1import { RegistryContext } from "@effect/atom-react";
2import {
3 AuthOrchestrationOperateScope,
4 AuthOrchestrationReadScope,
5 EnvironmentId,
6 ProjectId,
7 type AgentSessionProjectCandidate,
8 type AuthEnvironmentScope,
9 type AuthSessionState,
10} from "@t3tools/contracts";
11import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
12import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer";
13import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
14
15type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>;
16type Project = { id: ProjectId; environmentId: EnvironmentId; workspaceRoot: string };
17type CreateProjectInput = {
18 environmentId: EnvironmentId;
19 input: { projectId: ProjectId; workspaceRoot: string };
20};
21const state = vi.hoisted(() => ({
22 selectedEnvironment: "primary",
23 registry: null as AtomRegistry.AtomRegistry | null,
24 sessions: new Map<EnvironmentId, Atom.Writable<SessionResult>>(),
25 projects: null as Atom.Writable<ReadonlyArray<Project>> | null,
26 providers: null as Atom.Atom<ReadonlyArray<never>> | null,
27 config: null as Atom.Atom<null> | null,
28 candidates: [] as AgentSessionProjectCandidate[],
29 createProject: vi.fn(),
30 importThreads: vi.fn(),
31 refreshProviders: vi.fn(),
32 connectPairing: vi.fn(),
33 completeOnboarding: vi.fn(),
34 onDone: vi.fn(),
35 scan: vi.fn(),
36 refreshScan: vi.fn(),
37}));
38
39vi.mock("../settings/SelfHostedServer", () => ({ RemoteConnectionFlow: () => null }));
40vi.mock("../../connection/runtime", () => ({ connectionAtomRuntime: undefined }));
41vi.mock("@t3tools/client-runtime/state/session", () => ({
42 createEnvironmentSessionAtoms: () => ({
43 sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!,
44 }),
45}));
46vi.mock("../../rpc/atomRegistry", () => ({
47 get appAtomRegistry() {
48 return state.registry;
49 },
50}));
51vi.mock("@clerk/react", () => ({ useAuth: () => ({ isLoaded: true, isSignedIn: false }) }));
52vi.mock("../../hooks/useTheme", () => ({ mountOnboardingTheme: () => () => {} }));
53vi.mock("../../hooks/useLocalStorage", () => ({ useLocalStorage: () => [false, () => {}] }));
54vi.mock("../../hooks/useCopyToClipboard", () => ({
55 useCopyToClipboard: () => ({ copyToClipboard: vi.fn(), copied: false }),
56}));
57vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false }));
58vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() }));
59vi.mock("../../onboarding/firstRun", () => ({
60 useCompleteOnboarding: () => state.completeOnboarding,
61}));
62vi.mock("../../env", () => ({ isElectron: false }));
63vi.mock("../../state/environments", () => {
64 const environment = (environmentId: string) => ({
65 environmentId,
66 label: environmentId,
67 connection: { phase: "connected" },
68 entry: { enabled: true, target: { _tag: "DirectConnectionTarget" } },
69 });
70 const primary = environment("primary");
71 return {
72 usePrimaryEnvironment: () => primary,
73 useEnvironments: () => ({ environments: [environment(state.selectedEnvironment)] }),
74 };
75});
76vi.mock("../../state/server", () => ({
77 serverEnvironment: {
78 providersValueAtom: () => state.providers,
79 configValueAtom: () => state.config,
80 refreshProviders: "refreshProviders",
81 },
82}));
83vi.mock("../../state/entities", async () => {
84 const { useAtomValue } = await import("@effect/atom-react");
85 return {
86 useProjects: () => useAtomValue(state.projects!),
87 readProjects: () => state.registry!.get(state.projects!),
88 };
89});
90vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "createProject" } }));
91vi.mock("../../state/agentSessions", () => ({
92 agentSessionScan: (query: { environmentId: EnvironmentId }) => query,
93 agentSessionImport: "importThreads",
94}));
95vi.mock("../../onboarding/useProjectScans", () => ({
96 useProjectScans: (ids: EnvironmentId[]) =>
97 ids.map((environmentId) => {
98 state.scan({ environmentId });
99 return {
100 environmentId,
101 data: { candidates: state.candidates, truncated: false },
102 isPending: false,
103 error: null,
104 refresh: state.refreshScan,
105 };
106 }),
107}));
108vi.mock("../ui/dialog", () => ({
109 Dialog: "div",
110 DialogPopup: "div",
111 DialogTitle: "div",
112 DialogHeader: "div",
113}));
114vi.mock("../ui/wizard", () => ({
115 WizardPanel: "div",
116 WizardSteps: "div",
117 WizardPopup: "div",
118 WizardHeader: "div",
119}));
120vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" }));
121vi.mock("../ui/tooltip", () => ({ Tooltip: "div", TooltipTrigger: "span", TooltipPopup: "span" }));
122vi.mock("../../connection/onboarding", () => ({ connectPairing: "connectPairing" }));
123vi.mock("../../state/use-atom-command", () => ({
124 useAtomCommand: (
125 command: "createProject" | "importThreads" | "refreshProviders" | "connectPairing",
126 ) => state[command],
127}));
128vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} }));
129vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: "div" }));
130vi.mock("../cloud/CloudEnvironmentConnectList", () => ({ CloudEnvironmentConnectRows: "div" }));
131vi.mock("../settings/ChatGptWelcomeCoordinator", () => ({ ChatGptWelcomeCoordinator: () => null }));
132vi.mock("../settings/CodexSetupSection", () => ({
133 CodexSetupSection: () => null,
134 AddManagedCodexAccountDialog: () => null,
135}));
136vi.mock("../settings/providerDriverMeta", () => ({
137 providerClients: { get: () => ({ label: "Agent" }) },
138}));
139vi.mock("../settings/providerStatus", () => ({
140 getProviderSummary: () => ({ headline: "Checking", detail: null }),
141}));
142vi.mock("../ui/button", () => ({ Button: "button" }));
143vi.mock("../ui/input", () => ({ Input: "input" }));
144vi.mock("../ui/checkbox", () => ({ Checkbox: "input" }));
145vi.mock("../ui/collapsible", () => ({
146 Collapsible: "div",
147 CollapsiblePanel: "div",
148 CollapsibleTrigger: "button",
149}));
150vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn(), close: vi.fn(), update: vi.fn() } }));
151
152import { WelcomeWizard } from "./WelcomeWizard";
153
154const primaryId = EnvironmentId.make("primary");
155const remoteId = EnvironmentId.make("remote");
156const permissionMessage = "This connection cannot import projects or thread history.";
157let renderer: ReactTestRenderer | undefined;
158
159function session(scopes: ReadonlyArray<AuthEnvironmentScope>): AuthSessionState {
160 return {
161 authenticated: true,
162 scopes: [AuthOrchestrationReadScope, ...scopes],
163 auth: {
164 policy: "remote-reachable",
165 bootstrapMethods: ["one-time-token"],
166 sessionMethods: ["bearer-access-token"],
167 sessionCookieName: "t3_session",
168 },
169 };
170}
171
172function setGrant(environmentId: EnvironmentId, allowed: boolean) {
173 state.registry!.set(
174 state.sessions.get(environmentId)!,
175 AsyncResult.success(session(allowed ? [AuthOrchestrationOperateScope] : [])),
176 );
177}
178
179function candidate(path: string): AgentSessionProjectCandidate {
180 return {
181 path,
182 title: path.split("/").at(-1)!,
183 sources: ["codex"],
184 threadCount: 10,
185 lastActiveAt: new Date(Date.now() - 60_000).toISOString(),
186 alreadyImported: false,
187 };
188}
189
190function recordProject({ environmentId, input }: CreateProjectInput) {
191 state.registry!.set(state.projects!, [
192 ...state.registry!.get(state.projects!),
193 { id: input.projectId, environmentId, workspaceRoot: input.workspaceRoot },
194 ]);
195 return AsyncResult.success(undefined);
196}
197
198function deferred<T>() {
199 let resolve!: (value: T) => void;
200 const promise = new Promise<T>((complete) => {
201 resolve = complete;
202 });
203 return { promise, resolve };
204}
205
206function text(node: ReactTestInstance | string): string {
207 return typeof node === "string" ? node : node.children.map(text).join("");
208}
209
210function button(label: string) {
211 const found = renderer!.root.findAllByType("button").find((node) => text(node) === label);
212 if (!found) throw new Error(`Missing button: ${label}`);
213 return found;
214}
215
216async function click(label: string) {
217 const target = button(label);
218 expect(target.props.disabled).not.toBe(true);
219 await act(async () => target.props.onClick());
220}
221
222async function mountImport(remote = false) {
223 state.selectedEnvironment = remote ? "remote" : "primary";
224 await act(async () => {
225 renderer = create(
226 <RegistryContext.Provider value={state.registry!}>
227 <WelcomeWizard localAvailable={!remote} onDone={state.onDone} />
228 </RegistryContext.Provider>,
229 );
230 });
231 await click("Continue");
232 await click("Continue");
233 expect(text(renderer!.root)).toContain("Import your projects");
234}
235
236beforeEach(() => {
237 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
238 vi.stubGlobal("document", { activeElement: null, body: {}, getElementById: () => null });
239 state.registry = AtomRegistry.make();
240 for (const id of [primaryId, remoteId]) {
241 state.sessions.set(
242 id,
243 Atom.make<SessionResult>(AsyncResult.success(session([AuthOrchestrationOperateScope]))).pipe(
244 Atom.keepAlive,
245 ),
246 );
247 }
248 state.projects = Atom.make<ReadonlyArray<Project>>([]).pipe(Atom.keepAlive);
249 state.providers = Atom.make<ReadonlyArray<never>>([]).pipe(Atom.keepAlive);
250 state.config = Atom.make(null).pipe(Atom.keepAlive);
251 state.candidates = [candidate("/projects/first"), candidate("/projects/second")];
252 state.createProject
253 .mockReset()
254 .mockImplementation(async (input: CreateProjectInput) => recordProject(input));
255 state.importThreads
256 .mockReset()
257 .mockResolvedValue(AsyncResult.success({ importedCount: 1, skippedCount: 0 }));
258 state.refreshProviders.mockReset().mockResolvedValue(AsyncResult.success(undefined));
259 state.connectPairing.mockReset().mockResolvedValue(AsyncResult.success(remoteId));
260 state.completeOnboarding.mockReset().mockResolvedValue(undefined);
261 state.onDone.mockReset();
262 state.scan.mockReset();
263 state.refreshScan.mockReset();
264});
265
266afterEach(async () => {
267 await act(async () => renderer?.unmount());
268 renderer = undefined;
269 state.registry?.dispose();
270 state.sessions.clear();
271 vi.unstubAllGlobals();
272});
273
274it("keeps scanning, choosing and skipping available to a paired read-only environment", async () => {
275 setGrant(remoteId, false);
276 await mountImport(true);
277 expect(state.scan).toHaveBeenCalledWith(expect.objectContaining({ environmentId: remoteId }));
278 expect(text(renderer!.root)).toContain("/projects/first");
279 expect(button("Import 2 projects").props.disabled).toBe(true);
280 expect(text(renderer!.root)).toContain(permissionMessage);
281 await click("Do not import projects");
282 expect(state.createProject).not.toHaveBeenCalled();
283 expect(state.importThreads).not.toHaveBeenCalled();
284 expect(state.completeOnboarding).toHaveBeenCalledOnce();
285 expect(state.onDone).toHaveBeenCalledOnce();
286});
287
288it("waits for the selected grant and enables import when it arrives", async () => {
289 state.registry!.set(state.sessions.get(primaryId)!, AsyncResult.initial());
290 await mountImport();
291 expect(button("Import 2 projects").props.disabled).toBe(true);
292 await act(async () => setGrant(primaryId, true));
293 await click("Import 2 projects");
294 expect(state.createProject).toHaveBeenCalledTimes(2);
295 expect(state.importThreads).toHaveBeenCalledTimes(2);
296 expect(state.onDone).toHaveBeenCalledWith(expect.objectContaining({ environmentId: primaryId }));
297});
298
299it("preserves selections and denies a retained action using the paired environment's fresh grant", async () => {
300 await mountImport(true);
301 await act(async () => {
302 renderer!.root.findAllByType("input")[1]!.props.onCheckedChange(false);
303 });
304 const retainedImport = button("Import 1 project").props.onClick;
305 await act(async () => {
306 setGrant(remoteId, false);
307 retainedImport();
308 });
309 expect(button("Import 1 project").props.disabled).toBe(true);
310 expect(renderer!.root.findAllByType("input")[1]!.props.checked).toBe(false);
311 expect(text(renderer!.root)).toContain(permissionMessage);
312 expect(state.createProject).not.toHaveBeenCalled();
313 expect(state.importThreads).not.toHaveBeenCalled();
314 expect(state.onDone).not.toHaveBeenCalled();
315
316 await act(async () => setGrant(remoteId, true));
317 expect(text(renderer!.root)).not.toContain(permissionMessage);
318 await click("Import 1 project");
319 expect(state.createProject).toHaveBeenCalledExactlyOnceWith(
320 expect.objectContaining({ environmentId: remoteId }),
321 );
322 expect(state.importThreads).toHaveBeenCalledExactlyOnceWith(
323 expect.objectContaining({ environmentId: remoteId }),
324 );
325});
326
327it("rechecks after project creation and can retry its history without recreating the project", async () => {
328 const creation = deferred<void>();
329 state.createProject.mockImplementationOnce(async (input: CreateProjectInput) => {
330 await creation.promise;
331 return recordProject(input);
332 });
333 await mountImport();
334 await click("Import 2 projects");
335 expect(state.createProject).toHaveBeenCalledOnce();
336 await act(async () => {
337 setGrant(primaryId, false);
338 creation.resolve();
339 await creation.promise;
340 });
341 expect(state.importThreads).not.toHaveBeenCalled();
342 expect(state.createProject).toHaveBeenCalledOnce();
343 expect(state.onDone).not.toHaveBeenCalled();
344 expect(text(renderer!.root)).toContain(permissionMessage);
345
346 await act(async () => setGrant(primaryId, true));
347 await click("Import 2 projects");
348 expect(state.createProject).toHaveBeenCalledTimes(2);
349 expect(state.importThreads).toHaveBeenCalledTimes(2);
350 expect(state.onDone).toHaveBeenCalledOnce();
351});
352
353it.each([false, true])(
354 "rechecks after history import before the next project write (existing: %s)",
355 async (existing) => {
356 if (existing) {
357 const projectId = ProjectId.make("second-existing");
358 state.candidates[1] = { ...state.candidates[1]!, projectId };
359 state.registry!.set(state.projects!, [
360 { id: projectId, environmentId: primaryId, workspaceRoot: "/projects/second" },
361 ]);
362 }
363 const imported = deferred<{ importedCount: number; skippedCount: number }>();
364 state.importThreads.mockImplementationOnce(async () =>
365 AsyncResult.success(await imported.promise),
366 );
367 await mountImport();
368 await click("Import 2 projects");
369 expect(state.createProject).toHaveBeenCalledOnce();
370 expect(state.importThreads).toHaveBeenCalledOnce();
371 await act(async () => {
372 setGrant(primaryId, false);
373 imported.resolve({ importedCount: 1, skippedCount: 0 });
374 await imported.promise;
375 });
376 expect(state.createProject).toHaveBeenCalledOnce();
377 expect(state.importThreads).toHaveBeenCalledOnce();
378 expect(state.onDone).not.toHaveBeenCalled();
379 expect(text(renderer!.root)).toContain(permissionMessage);
380
381 await act(async () => setGrant(primaryId, true));
382 await click("Import 2 projects");
383 expect(state.createProject).toHaveBeenCalledTimes(existing ? 1 : 2);
384 expect(state.importThreads).toHaveBeenCalledTimes(2);
385 expect(state.onDone).toHaveBeenCalledOnce();
386 },
387);
388
389it("completes accepted imports when permission is revoked after the final server write", async () => {
390 state.candidates = state.candidates.slice(0, 1);
391 const imported = deferred<{ importedCount: number; skippedCount: number }>();
392 state.importThreads.mockImplementationOnce(async () =>
393 AsyncResult.success(await imported.promise),
394 );
395 await mountImport();
396 await click("Import 1 project");
397 await act(async () => {
398 setGrant(primaryId, false);
399 imported.resolve({ importedCount: 1, skippedCount: 0 });
400 await imported.promise;
401 });
402 expect(state.createProject).toHaveBeenCalledOnce();
403 expect(state.importThreads).toHaveBeenCalledOnce();
404 expect(state.completeOnboarding).toHaveBeenCalledOnce();
405 expect(state.onDone).toHaveBeenCalledOnce();
406});