1import { RegistryContext } from "@effect/atom-react";
2import {
3 AuthOrchestrationOperateScope,
4 AuthOrchestrationReadScope,
5 AuthRelayReadScope,
6 AuthRelayWriteScope,
7 EnvironmentId,
8 ProjectId,
9 ProviderDriverKind,
10 ProviderInstanceId,
11 type AgentSessionProjectCandidate,
12 type AuthEnvironmentScope,
13 type AuthSessionState,
14 type EnvironmentCloudLinkStateResult,
15 type ServerProvider,
16} from "@t3tools/contracts";
17import { AsyncResult, Atom } from "effect/reactivity";
18import { expect, it, vi } from "vite-plus/test";
19
20import "~/index.css";
21import { act, render } from "../../../test/render";
22
23type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>;
24type Project = { id: ProjectId; environmentId: EnvironmentId; workspaceRoot: string };
25type CreateProjectInput = {
26 environmentId: EnvironmentId;
27 input: { projectId: ProjectId; workspaceRoot: string };
28};
29const state = vi.hoisted(() => ({
30 sessions: new Map<string, unknown>(),
31 projects: null as unknown,
32 providers: null as unknown,
33 config: null as unknown,
34 candidates: null as unknown,
35 createProject: vi.fn(),
36 importThreads: vi.fn(),
37 refreshProviders: vi.fn(),
38 completeOnboarding: vi.fn(),
39 onDone: vi.fn(),
40 scan: vi.fn(),
41 refreshScan: vi.fn(),
42 waitForProject: vi.fn(),
43 cloud: false,
44 auth: { isLoaded: true, isSignedIn: false, userId: null as string | null },
45 linkState: null as unknown,
46 linkError: null as string | null,
47 refreshLinkState: vi.fn(),
48 scanError: null as string | null,
49 scanPending: false,
50}));
51
52vi.mock(import("@t3tools/client-runtime/state/session"), async (original) => {
53 const actual = await original();
54 return {
55 ...actual,
56 createEnvironmentSessionAtoms: ((runtime: never) => ({
57 ...actual.createEnvironmentSessionAtoms(runtime),
58 sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id),
59 })) as never,
60 };
61});
62vi.mock(import("../../cloud/publicConfig"), async (original) => ({
63 ...(await original()),
64 hasCloudPublicConfig: () => state.cloud,
65}));
66vi.mock(import("@clerk/react"), async (original) => ({
67 ...(await original()),
68 useAuth: (() => state.auth) as never,
69}));
70vi.mock(import("../clerk/useT3ConnectAuthPrompt"), async (original) => ({
71 ...(await original()),
72 useT3ConnectAuthPrompt: (() => ({ openAuthPrompt: () => {} })) as never,
73}));
74vi.mock(import("../../cloud/cloudLinkState"), async (original) => {
75 const { useAtomValue } = await import("@effect/atom-react");
76 return {
77 ...(await original()),
78 useCloudLinkState: (() => ({
79 data: useAtomValue(state.linkState as Atom.Atom<EnvironmentCloudLinkStateResult | null>),
80 error: state.linkError,
81 isPending: false,
82 refresh: state.refreshLinkState,
83 })) as never,
84 };
85});
86vi.mock(import("../../onboarding/firstRun"), async (original) => ({
87 ...(await original()),
88 completeOnboarding: state.completeOnboarding,
89}));
90vi.mock(import("../../state/environments"), async (original) => ({
91 ...(await original()),
92 useEnvironments: (() => ({
93 environments: [{ environmentId: "primary", label: "primary" }],
94 })) as never,
95 usePrimaryEnvironmentId: (() => "primary") as never,
96}));
97vi.mock(import("../../state/server"), async (original) => {
98 const actual = await original();
99 return {
100 ...actual,
101 serverEnvironment: {
102 ...actual.serverEnvironment,
103 providersValueAtom: (() => state.providers) as never,
104 configValueAtom: (() => state.config) as never,
105 refreshProviders: "refreshProviders" as never,
106 },
107 };
108});
109vi.mock(import("../../state/entities"), async (original) => {
110 const { useAtomValue } = await import("@effect/atom-react");
111 return {
112 ...(await original()),
113 useProjects: (() => useAtomValue(state.projects as Atom.Atom<Project[]>)) as never,
114 readProjects: (() => appAtomRegistry.get(state.projects as Atom.Atom<Project[]>)) as never,
115 waitForProject: ((ref: unknown) => state.waitForProject(ref)) as never,
116 };
117});
118vi.mock(import("../../state/projects"), async (original) => ({
119 ...(await original()),
120 projectEnvironment: { create: "createProject" } as never,
121}));
122vi.mock(import("../../state/agentSessions"), async (original) => ({
123 ...(await original()),
124 agentSessionImport: "importThreads" as never,
125}));
126vi.mock(import("../../onboarding/useProjectScans"), async (original) => {
127 const { useAtomValue } = await import("@effect/atom-react");
128 return {
129 ...(await original()),
130 useProjectScans: (ids: readonly EnvironmentId[]) => {
131 const candidates = useAtomValue(
132 state.candidates as Atom.Atom<AgentSessionProjectCandidate[]>,
133 );
134 return ids.map((environmentId) => {
135 state.scan({ environmentId });
136 return {
137 environmentId,
138 data: (state.scanPending ? null : { candidates, truncated: false }) as never,
139 isPending: state.scanPending,
140 error: state.scanError,
141 refresh: state.refreshScan,
142 };
143 });
144 },
145 };
146});
147vi.mock(import("../../state/use-atom-command"), async (original) => ({
148 ...(await original()),
149 useAtomCommand: ((command: "createProject" | "importThreads" | "refreshProviders") =>
150 state[command]) as never,
151}));
152
153import { welcomeImportSelectionKey } from "../../onboarding/welcomeState";
154import { appAtomRegistry } from "../../rpc/atomRegistry";
155import { toastManager } from "../ui/toast";
156import { WelcomePage } from "./WelcomePage";
157
158const primaryId = EnvironmentId.make("primary");
159const readyClaude: ServerProvider = {
160 instanceId: ProviderInstanceId.make("claudeAgent"),
161 driver: ProviderDriverKind.make("claudeAgent"),
162 enabled: true,
163 installed: true,
164 version: "1.0.0",
165 status: "ready",
166 auth: { status: "authenticated" },
167 checkedAt: "2026-01-01T00:00:00.000Z",
168 models: [],
169 slashCommands: [],
170 skills: [],
171};
172const permissionMessage = "This connection cannot import projects or thread history.";
173
174function session(scopes: ReadonlyArray<AuthEnvironmentScope>): AuthSessionState {
175 return {
176 authenticated: true,
177 scopes: [AuthOrchestrationReadScope, ...scopes],
178 auth: {
179 policy: "remote-reachable",
180 bootstrapMethods: ["one-time-token"],
181 sessionMethods: ["bearer-access-token"],
182 sessionCookieName: "t3_session",
183 },
184 };
185}
186
187function sessionAtom(environmentId: EnvironmentId) {
188 return state.sessions.get(environmentId) as Atom.Writable<SessionResult>;
189}
190
191function projectsAtom() {
192 return state.projects as Atom.Writable<ReadonlyArray<Project>>;
193}
194
195function candidatesAtom() {
196 return state.candidates as Atom.Writable<ReadonlyArray<AgentSessionProjectCandidate>>;
197}
198
199function setGrant(environmentId: EnvironmentId, allowed: boolean) {
200 appAtomRegistry.set(
201 sessionAtom(environmentId),
202 AsyncResult.success(session(allowed ? [AuthOrchestrationOperateScope] : [])),
203 );
204}
205
206// Default selection only picks recently active projects, so pin "now".
207const NOW = Date.parse("2026-01-01T00:00:00.000Z");
208
209function candidate(
210 path: string,
211 overrides: Partial<AgentSessionProjectCandidate> = {},
212): AgentSessionProjectCandidate {
213 return {
214 path,
215 title: path.split("/").at(-1)!,
216 sources: ["codex"],
217 threadCount: 10,
218 lastActiveAt: new Date(NOW - 60_000).toISOString(),
219 alreadyImported: false,
220 ...overrides,
221 };
222}
223
224function recordProject({ environmentId, input }: CreateProjectInput) {
225 appAtomRegistry.set(projectsAtom(), [
226 ...appAtomRegistry.get(projectsAtom()),
227 { id: input.projectId, environmentId, workspaceRoot: input.workspaceRoot },
228 ]);
229 return AsyncResult.success(undefined);
230}
231
232// Resolves once the project reaches the mocked projects atom; the real helper's timeout is
233// replaced per test with a rejection.
234function waitForRecordedProject(ref: { environmentId: EnvironmentId; projectId: string }) {
235 const find = () =>
236 appAtomRegistry
237 .get(projectsAtom())
238 .find(
239 (project) => project.id === ref.projectId && project.environmentId === ref.environmentId,
240 );
241 return new Promise<Project>((resolve) => {
242 const current = find();
243 if (current) return resolve(current);
244 const unsubscribe = appAtomRegistry.subscribe(projectsAtom(), () => {
245 const project = find();
246 if (!project) return;
247 unsubscribe();
248 resolve(project);
249 });
250 });
251}
252
253function deferred<T>() {
254 let resolve!: (value: T) => void;
255 const promise = new Promise<T>((complete) => {
256 resolve = complete;
257 });
258 return { promise, resolve };
259}
260
261function pageText() {
262 return document.body.textContent ?? "";
263}
264
265function button(label: string) {
266 const found = [...document.querySelectorAll("button")].find(
267 (element) => element.textContent?.trim() === label,
268 );
269 if (!found) throw new Error(`Missing button: ${label}`);
270 return found;
271}
272
273async function click(label: string) {
274 const target = button(label);
275 expect(target.disabled).toBe(false);
276 await act(async () => target.click());
277}
278
279async function mountWelcome() {
280 return render(
281 <RegistryContext.Provider value={appAtomRegistry}>
282 <WelcomePage environmentId={primaryId} onDone={state.onDone} />
283 </RegistryContext.Provider>,
284 );
285}
286
287// The mocked modules read `state`, so every test starts by replacing all of it.
288// Returns the clock spy so each test can `using` it; the spy would otherwise outlive the test.
289function setup() {
290 const now = vi.spyOn(Date, "now").mockReturnValue(NOW);
291 state.sessions.clear();
292 for (const id of [primaryId]) {
293 state.sessions.set(
294 id,
295 Atom.make<SessionResult>(AsyncResult.success(session([AuthOrchestrationOperateScope]))).pipe(
296 Atom.keepAlive,
297 ),
298 );
299 }
300 state.projects = Atom.make<ReadonlyArray<Project>>([]).pipe(Atom.keepAlive);
301 state.providers = Atom.make<ReadonlyArray<ServerProvider>>([readyClaude]).pipe(Atom.keepAlive);
302 state.config = Atom.make(null).pipe(Atom.keepAlive);
303 state.candidates = Atom.make<ReadonlyArray<AgentSessionProjectCandidate>>([
304 candidate("/projects/first"),
305 candidate("/projects/second"),
306 ]).pipe(Atom.keepAlive);
307 state.createProject
308 .mockReset()
309 .mockImplementation(async (input: CreateProjectInput) => recordProject(input));
310 state.importThreads
311 .mockReset()
312 .mockResolvedValue(AsyncResult.success({ importedCount: 1, skippedCount: 0 }));
313 state.refreshProviders.mockReset().mockResolvedValue(AsyncResult.success(undefined));
314 state.completeOnboarding.mockReset().mockResolvedValue(undefined);
315 state.onDone.mockReset();
316 state.scan.mockReset();
317 state.refreshScan.mockReset();
318 state.waitForProject.mockReset().mockImplementation(waitForRecordedProject);
319 state.cloud = false;
320 state.auth = { isLoaded: true, isSignedIn: false, userId: null };
321 state.linkState = Atom.make<EnvironmentCloudLinkStateResult | null>(null).pipe(Atom.keepAlive);
322 state.linkError = null;
323 state.refreshLinkState.mockReset();
324 state.scanError = null;
325 state.scanPending = false;
326 localStorage.removeItem(welcomeImportSelectionKey(primaryId));
327 return now;
328}
329
330it("rechecks after project creation and can retry its history without recreating the project", async () => {
331 using _now = setup();
332 const creation = deferred<void>();
333 state.createProject.mockImplementationOnce(async (input: CreateProjectInput) => {
334 await creation.promise;
335 return recordProject(input);
336 });
337 await using _screen = await mountWelcome();
338 await click("Import 2 projects and start");
339 expect(state.createProject).toHaveBeenCalledOnce();
340 await act(async () => {
341 setGrant(primaryId, false);
342 creation.resolve();
343 await creation.promise;
344 });
345 expect(state.importThreads).not.toHaveBeenCalled();
346 expect(state.createProject).toHaveBeenCalledOnce();
347 expect(state.onDone).not.toHaveBeenCalled();
348 await vi.waitFor(() => expect(pageText()).toContain(permissionMessage));
349
350 await act(async () => setGrant(primaryId, true));
351 // The first project now exists, so only the second counts as new.
352 await click("Import 1 project and start");
353 expect(state.createProject).toHaveBeenCalledTimes(2);
354 expect(state.importThreads).toHaveBeenCalledTimes(2);
355 expect(state.onDone).toHaveBeenCalledOnce();
356});
357
358it("completes accepted imports when permission is revoked after the final server write", async () => {
359 using _now = setup();
360 appAtomRegistry.set(candidatesAtom(), [candidate("/projects/first")]);
361 const imported = deferred<{ importedCount: number; skippedCount: number }>();
362 state.importThreads.mockImplementationOnce(async () =>
363 AsyncResult.success(await imported.promise),
364 );
365 await using _screen = await mountWelcome();
366 await click("Import 1 project and start");
367 await act(async () => {
368 setGrant(primaryId, false);
369 imported.resolve({ importedCount: 1, skippedCount: 0 });
370 await imported.promise;
371 });
372 expect(state.createProject).toHaveBeenCalledOnce();
373 expect(state.importThreads).toHaveBeenCalledOnce();
374 expect(state.completeOnboarding).toHaveBeenCalledOnce();
375 expect(state.onDone).toHaveBeenCalledOnce();
376});
377
378it("finishes setup without landing when the imported project never reaches the client", async () => {
379 using _now = setup();
380 appAtomRegistry.set(candidatesAtom(), [candidate("/projects/first")]);
381 // The server accepts the project, but it never shows up in the client's projects.
382 state.createProject.mockResolvedValue(AsyncResult.success(undefined));
383 state.waitForProject.mockRejectedValue(new Error("The project did not appear."));
384 state.completeOnboarding.mockRejectedValueOnce(new Error("offline"));
385 await using _screen = await mountWelcome();
386 using toastAdd = vi.spyOn(toastManager, "add");
387
388 await click("Import 1 project and start");
389 await vi.waitFor(() => expect(state.completeOnboarding).toHaveBeenCalledOnce());
390 // A failed completion hands the step back instead of leaving it importing.
391 await vi.waitFor(() => expect(button("Import 1 project and start").disabled).toBe(false));
392 expect(state.onDone).not.toHaveBeenCalled();
393
394 await click("Import 1 project and start");
395 await vi.waitFor(() => expect(state.onDone).toHaveBeenCalledOnce());
396 expect(state.onDone).toHaveBeenCalledWith(undefined);
397 // The retry skips the project it already imported, but still counts its thread.
398 await vi.waitFor(() =>
399 expect(toastAdd).toHaveBeenCalledWith(
400 expect.objectContaining({ type: "success", title: "Imported 1 thread" }),
401 ),
402 );
403});
404
405it("waits for a ready agent to start, and Start without an agent still finishes setup", async () => {
406 using _now = setup();
407 appAtomRegistry.set(state.providers as Atom.Writable<ReadonlyArray<ServerProvider>>, []);
408 await using _screen = await mountWelcome();
409 expect(button("Import 2 projects and start").disabled).toBe(true);
410
411 await click("Start without an agent");
412 await vi.waitFor(() => expect(state.onDone).toHaveBeenCalledOnce());
413 expect(state.createProject).toHaveBeenCalledTimes(2);
414});
415
416it("blocks leaving the page until the imported projects have landed", async () => {
417 using _now = setup();
418 const creation = deferred<void>();
419 state.createProject.mockImplementationOnce(async (input: CreateProjectInput) => {
420 await creation.promise;
421 return recordProject(input);
422 });
423 const landing = deferred<void>();
424 state.waitForProject.mockImplementation(
425 async (ref: { environmentId: EnvironmentId; projectId: string }) => {
426 await landing.promise;
427 return waitForRecordedProject(ref);
428 },
429 );
430 await using screen = await mountWelcome();
431 await click("Import 2 projects and start");
432
433 // A blocked navigation never settles, so it is not awaited.
434 const leave = () =>
435 act(async () => {
436 void screen.router?.navigate({ to: "/settings" });
437 });
438 await leave();
439 await act(async () => {
440 creation.resolve();
441 await creation.promise;
442 });
443 await vi.waitFor(() => expect(state.waitForProject).toHaveBeenCalledOnce());
444 await leave();
445 expect(screen.router?.state.location.pathname).toBe("/");
446
447 const saved = deferred<void>();
448 state.completeOnboarding.mockImplementationOnce(() => saved.promise);
449 await act(async () => {
450 landing.resolve();
451 await landing.promise;
452 });
453 await vi.waitFor(() => expect(state.completeOnboarding).toHaveBeenCalledOnce());
454 await leave();
455 expect(screen.router?.state.location.pathname).toBe("/");
456
457 await act(async () => {
458 saved.resolve();
459 await saved.promise;
460 });
461 await vi.waitFor(() => expect(state.onDone).toHaveBeenCalledOnce());
462 expect(screen.router?.state.location.pathname).toBe("/");
463 expect(state.createProject).toHaveBeenCalledTimes(2);
464});
465
466it("gives projects a later scan finds the default, unless the main box was unchecked", async () => {
467 using _now = setup();
468 await using _screen = await mountWelcome();
469 // Turning on another agent rescans and finds a recent project the user has not seen.
470 const findProject = (path: string) =>
471 act(async () =>
472 appAtomRegistry.set(candidatesAtom(), [
473 ...appAtomRegistry.get(candidatesAtom()),
474 candidate(path),
475 ]),
476 );
477
478 await click("Review all 2");
479 await click("Select none");
480 await findProject("/projects/third");
481 await vi.waitFor(() => expect(button("Import 1 project and start").disabled).toBe(false));
482
483 const importProjects = document.querySelector<HTMLElement>('[role="checkbox"]');
484 await act(async () => importProjects?.click());
485 await findProject("/projects/fourth");
486 await vi.waitFor(() => expect(button("Start").disabled).toBe(false));
487});
488
489it("keeps the project choice across a visit to Settings until setup finishes", async () => {
490 using _now = setup();
491 {
492 await using _first = await mountWelcome();
493 const importProjects = document.querySelector<HTMLElement>('[role="checkbox"]');
494 await act(async () => importProjects?.click());
495 await vi.waitFor(() => expect(button("Start").disabled).toBe(false));
496 }
497 // Coming back from Settings mounts the page again.
498 {
499 await using _second = await mountWelcome();
500 expect(button("Start").disabled).toBe(false);
501 await click("Start");
502 await vi.waitFor(() => expect(state.onDone).toHaveBeenCalledOnce());
503 expect(state.createProject).not.toHaveBeenCalled();
504 }
505 await using _third = await mountWelcome();
506 expect(button("Import 2 projects and start").disabled).toBe(false);
507});
508
509it("stays busy while setup saves after an import with nothing to open", async () => {
510 using _now = setup();
511 appAtomRegistry.set(candidatesAtom(), [candidate("/projects/first")]);
512 state.importThreads.mockResolvedValue(AsyncResult.success({ importedCount: 0, skippedCount: 1 }));
513 const saved = deferred<void>();
514 state.completeOnboarding.mockImplementationOnce(() => saved.promise);
515 await using _screen = await mountWelcome();
516
517 await click("Import 1 project and start");
518 await vi.waitFor(() => expect(state.completeOnboarding).toHaveBeenCalledOnce());
519 expect(button("Importing…").disabled).toBe(true);
520 await act(async () => {
521 saved.resolve();
522 await saved.promise;
523 });
524 await vi.waitFor(() => expect(state.onDone).toHaveBeenCalledOnce());
525 expect(state.createProject).toHaveBeenCalledOnce();
526});
527
528it("leaves projects already in T3 Code out of the default import, unless the box is checked", async () => {
529 using _now = setup();
530 const existing = ProjectId.make("existing");
531 const justAdded = ProjectId.make("just-added");
532 appAtomRegistry.set(projectsAtom(), [
533 { id: existing, environmentId: primaryId, workspaceRoot: "/projects/first" },
534 // Added a moment ago, so the cached scan does not know it exists yet.
535 { id: justAdded, environmentId: primaryId, workspaceRoot: "/projects/second" },
536 ]);
537 appAtomRegistry.set(candidatesAtom(), [
538 candidate("/projects/first", { alreadyImported: true, projectId: existing }),
539 candidate("/projects/second"),
540 candidate("/projects/third"),
541 ]);
542 {
543 await using _screen = await mountWelcome();
544 await vi.waitFor(() => expect(pageText()).toContain("Import 1 project and their threads"));
545 }
546
547 // With every project already in T3 Code, checking the box still picks them.
548 appAtomRegistry.set(candidatesAtom(), appAtomRegistry.get(candidatesAtom()).slice(0, 2));
549 await using _screen = await mountWelcome();
550 const importProjects = document.querySelector<HTMLElement>('[role="checkbox"]');
551 await act(async () => importProjects?.click());
552 await vi.waitFor(() => expect(pageText()).toContain("Import 2 projects and their threads"));
553 expect(button("Import threads and start").disabled).toBe(false);
554});
555
556it("keeps the started projects checked when the page opens again after a quit", async () => {
557 using _now = setup();
558 const imported = deferred<{ importedCount: number; skippedCount: number }>();
559 state.importThreads.mockImplementationOnce(async () =>
560 AsyncResult.success(await imported.promise),
561 );
562 {
563 await using _first = await mountWelcome();
564 await click("Import 2 projects and start");
565 await vi.waitFor(() => expect(state.importThreads).toHaveBeenCalledOnce());
566 }
567 // The app quit during the first project's history; it now exists in T3 Code.
568 const [first] = appAtomRegistry.get(projectsAtom());
569 appAtomRegistry.set(candidatesAtom(), [
570 candidate("/projects/first", { alreadyImported: true, projectId: first!.id }),
571 candidate("/projects/second"),
572 ]);
573 await using _second = await mountWelcome();
574 await vi.waitFor(() => expect(pageText()).toContain("Import 2 projects and their threads"));
575});
576
577it("keeps the project controls when a later scan fails", async () => {
578 using _now = setup();
579 await using _screen = await mountWelcome();
580 state.scanError = "The scan timed out.";
581 await act(async () =>
582 appAtomRegistry.set(candidatesAtom(), [...appAtomRegistry.get(candidatesAtom())]),
583 );
584 await vi.waitFor(() => expect(pageText()).toContain("Could not check projects again."));
585 expect(button("Import 2 projects and start").disabled).toBe(false);
586 expect(button("Retry").disabled).toBe(false);
587});
588
589it("keeps saved picks when Start comes before the project list loads", async () => {
590 using _now = setup();
591 const saved = JSON.stringify({ knownKeys: ["a"], keys: ["a"], optedOut: false });
592 localStorage.setItem(welcomeImportSelectionKey(primaryId), saved);
593 state.scanPending = true;
594 await using _screen = await mountWelcome();
595
596 await click("Start");
597 await vi.waitFor(() => expect(state.onDone).toHaveBeenCalledOnce());
598 // An import a quit cut short is still checked on the next visit.
599 expect(localStorage.getItem(welcomeImportSelectionKey(primaryId))).toBe(saved);
600});
601
602it("offers Update for a signed-in agent whose version is broken, and keeps Start off", async () => {
603 using _now = setup();
604 appAtomRegistry.set(state.providers as Atom.Writable<ReadonlyArray<ServerProvider>>, [
605 {
606 ...readyClaude,
607 compatibilityAdvisory: {
608 status: "broken",
609 message: "Update Claude Code to 2.0 or later.",
610 recommendedVersion: "2.0.0",
611 recommendedRange: null,
612 },
613 },
614 ]);
615 await using _screen = await mountWelcome();
616 await vi.waitFor(() => expect(pageText()).toContain("Update Claude Code to 2.0 or later."));
617 const update = [...document.querySelectorAll("a")].find(
618 (element) => element.textContent?.trim() === "Update",
619 );
620 expect(update?.getAttribute("href")).toContain("/settings/providers");
621 expect(button("Import 2 projects and start").disabled).toBe(true);
622});
623
624it("keeps an import opt-out on the page when storage cannot save it", async () => {
625 using _now = setup();
626 await using _screen = await mountWelcome();
627 using _full = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
628 throw new DOMException("The quota has been exceeded.", "QuotaExceededError");
629 });
630 const importProjects = document.querySelector<HTMLElement>('[role="checkbox"]');
631 await act(async () => importProjects?.click());
632
633 await click("Start");
634 await vi.waitFor(() => expect(state.onDone).toHaveBeenCalledOnce());
635 expect(state.createProject).not.toHaveBeenCalled();
636});
637
638it("opens the list of agents to add once the checks find none, even after a wrong first status", async () => {
639 using _now = setup();
640 const pi = {
641 ...readyClaude,
642 instanceId: ProviderInstanceId.make("pi"),
643 driver: ProviderDriverKind.make("pi"),
644 };
645 // Pi's first status says installed before its check runs.
646 appAtomRegistry.set(state.providers as Atom.Writable<ReadonlyArray<ServerProvider>>, [
647 { ...pi, status: "warning", auth: { status: "unknown" } },
648 ]);
649 await using _screen = await mountWelcome();
650 await act(async () =>
651 appAtomRegistry.set(state.providers as Atom.Writable<ReadonlyArray<ServerProvider>>, [
652 { ...pi, installed: false, status: "error", auth: { status: "unknown" } },
653 ]),
654 );
655 const fold = () =>
656 [...document.querySelectorAll("button")].find((element) =>
657 element.textContent?.startsWith("Set up an agent"),
658 );
659 await vi.waitFor(() => expect(fold()?.getAttribute("aria-expanded")).toBe("true"));
660
661 // Turning an agent on starts a new check; the open list stays where the user is working.
662 await act(async () =>
663 appAtomRegistry.set(state.providers as Atom.Writable<ReadonlyArray<ServerProvider>>, [
664 { ...pi, installed: false, status: "warning", auth: { status: "unknown" } },
665 ]),
666 );
667 expect(fold()?.getAttribute("aria-expanded")).toBe("true");
668});
669
670it("starts without importing when this connection cannot import projects", async () => {
671 using _now = setup();
672 setGrant(primaryId, false);
673 await using _screen = await mountWelcome();
674 expect(pageText()).not.toContain("Review all");
675
676 await click("Start");
677 await vi.waitFor(() => expect(state.onDone).toHaveBeenCalledOnce());
678 expect(state.createProject).not.toHaveBeenCalled();
679});
680
681it("says the phone is set up only once this computer is shared with the signed-in account", async () => {
682 using _now = setup();
683 state.cloud = true;
684 state.auth = { isLoaded: true, isSignedIn: true, userId: "user_1" };
685 appAtomRegistry.set(
686 sessionAtom(primaryId),
687 AsyncResult.success(
688 session([AuthOrchestrationOperateScope, AuthRelayReadScope, AuthRelayWriteScope]),
689 ),
690 );
691 state.linkError = "Could not read T3 Connect link state.";
692 await using _screen = await mountWelcome();
693 await click("Set up");
694 // A failed read says so instead of asking to share the computer again.
695 await vi.waitFor(() => expect(pageText()).toContain("Could not check T3 Connect."));
696 expect(pageText()).not.toContain("Set up T3 Connect");
697
698 state.linkError = null;
699 await act(async () =>
700 appAtomRegistry.set(state.linkState as Atom.Writable<EnvironmentCloudLinkStateResult | null>, {
701 linked: false,
702 cloudUserId: null,
703 relayUrl: null,
704 relayIssuer: null,
705 publishAgentActivity: false,
706 }),
707 );
708 // Signed in, but "Not now" on the share step left this computer unreachable.
709 await vi.waitFor(() => expect(pageText()).toContain("Set up T3 Connect"));
710
711 const share = (reachability: EnvironmentCloudLinkStateResult["reachability"]) =>
712 act(async () =>
713 appAtomRegistry.set(
714 state.linkState as Atom.Writable<EnvironmentCloudLinkStateResult | null>,
715 {
716 linked: true,
717 cloudUserId: "user_1",
718 relayUrl: null,
719 relayIssuer: null,
720 managedTunnelActive: true,
721 publishAgentActivity: true,
722 reachability,
723 },
724 ),
725 );
726 const checkedAt = "2026-01-01T00:00:00.000Z";
727 await share({
728 status: "unreachable",
729 checkedAt,
730 cause: "connector_not_running",
731 reason: "The tunnel is not running.",
732 hint: null,
733 fix: "Turn T3 Connect off and on.",
734 });
735 await vi.waitFor(() => expect(pageText()).toContain("Check T3 Connect"));
736 expect(pageText()).not.toContain("Your phone can reach this computer.");
737
738 await share({ status: "reachable", checkedAt, readyConnections: 4, protocol: "quic" });
739 await vi.waitFor(() => expect(pageText()).toContain("Your phone can reach this computer."));
740 expect(pageText()).not.toContain("Check T3 Connect");
741});