1// @vitest-environment jsdom
2import { act } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { EnvironmentId, ProjectId } from "@t3tools/contracts";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6
7const mocks = vi.hoisted(() => ({
8 importThreads: vi.fn(),
9 createProject: vi.fn(),
10 complete: vi.fn(),
11 refresh: vi.fn(),
12 toast: vi.fn(),
13 projects: [] as Array<{ id: string; environmentId: string; workspaceRoot: string }>,
14}));
15vi.mock("../settings/SelfHostedServer", () => ({ RemoteConnectionFlow: () => null }));
16vi.mock("../../state/session", () => ({
17 useEnvironmentScope: () => true,
18 useEnvironmentsWithScope: (environments: Array<{ environmentId: string }>) =>
19 new Set(environments.map((entry) => entry.environmentId)),
20 readEnvironmentScope: () => true,
21}));
22vi.mock("../../state/agentSessions", () => ({ agentSessionImport: "import" }));
23vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "create" } }));
24vi.mock("../../state/use-atom-command", () => ({
25 useAtomCommand: (command: string) =>
26 command === "import"
27 ? mocks.importThreads
28 : command === "create"
29 ? mocks.createProject
30 : mocks.refresh,
31}));
32vi.mock("../../onboarding/firstRun", () => ({ useCompleteOnboarding: () => mocks.complete }));
33vi.mock("../../state/entities", () => ({
34 useProjects: () => mocks.projects,
35 readProjects: () => mocks.projects,
36}));
37vi.mock("../../state/environments", () => {
38 const environment = {
39 environmentId: "test-env",
40 label: "Computer",
41 connection: { phase: "connected" },
42 entry: { enabled: true },
43 };
44 return {
45 useEnvironments: () => ({ environments: [environment] }),
46 usePrimaryEnvironment: () => environment,
47 };
48});
49vi.mock("../../state/server", () => ({
50 serverEnvironment: {
51 providersValueAtom: () => [],
52 configValueAtom: () => null,
53 refreshProviders: "refresh",
54 },
55}));
56vi.mock("@effect/atom-react", () => ({ useAtomValue: (value: unknown) => value }));
57vi.mock("../../onboarding/useProjectScans", () => ({
58 useProjectScans: () => [
59 {
60 environmentId: "test-env",
61 isPending: false,
62 error: null,
63 refresh: mocks.refresh,
64 data: {
65 truncated: false,
66 candidates: [
67 {
68 path: "/project",
69 title: "project",
70 projectId: "test-project",
71 threadCount: 29,
72 lastActiveAt: new Date().toISOString(),
73 sources: ["codex"],
74 },
75 ],
76 },
77 },
78 ],
79}));
80vi.mock("../../connection/onboarding", () => ({ connectPairing: vi.fn() }));
81vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} }));
82vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() }));
83vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false }));
84vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: () => null }));
85vi.mock("../settings/ChatGptWelcomeCoordinator", () => ({ ChatGptWelcomeCoordinator: () => null }));
86vi.mock("../settings/CodexSetupSection", () => ({
87 CodexSetupSection: () => null,
88 AddManagedCodexAccountDialog: () => null,
89}));
90vi.mock("../cloud/CloudEnvironmentConnectList", () => ({
91 CloudEnvironmentConnectRows: () => null,
92}));
93vi.mock("../ui/toast", () => ({
94 toastManager: { add: mocks.toast, close: vi.fn(), update: vi.fn() },
95}));
96
97import { WelcomeWizard } from "./WelcomeWizard";
98
99let root: Root;
100let container: HTMLDivElement;
101
102beforeEach(() => {
103 vi.clearAllMocks();
104 vi.stubGlobal(
105 "ResizeObserver",
106 class {
107 observe() {}
108 unobserve() {}
109 disconnect() {}
110 },
111 );
112 Object.defineProperty(Element.prototype, "getAnimations", {
113 configurable: true,
114 value: () => [],
115 });
116 mocks.projects = [{ id: "test-project", environmentId: "test-env", workspaceRoot: "/project" }];
117 mocks.complete.mockResolvedValue(undefined);
118 mocks.refresh.mockResolvedValue(undefined);
119 mocks.importThreads.mockResolvedValue({
120 _tag: "Success",
121 value: { importedCount: 28, skippedCount: 1 },
122 });
123 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
124 container = document.createElement("div");
125 document.body.append(container);
126 root = createRoot(container);
127});
128afterEach(async () => {
129 await act(async () => root.unmount());
130 container.remove();
131});
132
133async function click(label: string) {
134 const button = [...document.querySelectorAll("button")].find(
135 (element) => element.textContent?.trim() === label,
136 );
137 expect(button, `button ${label}`).toBeDefined();
138 await act(async () => button!.click());
139}
140
141it("enters the workspace after a partial import and warns after navigation finishes", async () => {
142 let finishNavigation = () => {};
143 const navigation = new Promise<void>((resolve) => {
144 finishNavigation = resolve;
145 });
146 const onDone = vi.fn(() => navigation);
147 await act(async () => root.render(<WelcomeWizard localAvailable onDone={onDone} />));
148 await click("Continue");
149 await click("Continue");
150 await click("Import 1 project");
151 expect(onDone).toHaveBeenCalledWith({
152 environmentId: EnvironmentId.make("test-env"),
153 projectId: ProjectId.make("test-project"),
154 });
155 expect(mocks.toast).not.toHaveBeenCalled();
156 await act(async () => finishNavigation());
157 expect(mocks.toast).toHaveBeenCalledWith(
158 expect.objectContaining({
159 type: "warning",
160 description: "Imported 28 threads. 1 thread could not be imported.",
161 }),
162 );
163 expect(mocks.toast.mock.invocationCallOrder[0]).toBeGreaterThan(
164 onDone.mock.invocationCallOrder[0]!,
165 );
166});
167
168it.each([
169 [0, 0, null],
170 [29, 0, null],
171 [1, 0, null],
172 [0, 1, "1 thread could not be imported."],
173 [0, 2, "2 threads could not be imported."],
174] as const)(
175 "finishes setup with %i imported and %i skipped threads",
176 async (importedCount, skippedCount, warning) => {
177 mocks.importThreads.mockResolvedValue({
178 _tag: "Success",
179 value: { importedCount, skippedCount },
180 });
181 const onDone = vi.fn();
182 await act(async () => root.render(<WelcomeWizard localAvailable onDone={onDone} />));
183 await click("Continue");
184 await click("Continue");
185 await click("Import 1 project");
186 expect(onDone).toHaveBeenCalledOnce();
187 if (warning === null && importedCount > 0) {
188 expect(mocks.toast).toHaveBeenCalledWith({
189 type: "success",
190 title: `Imported ${importedCount} ${importedCount === 1 ? "thread" : "threads"}`,
191 });
192 } else if (warning === null) {
193 expect(mocks.toast).not.toHaveBeenCalled();
194 } else {
195 expect(mocks.toast).toHaveBeenCalledWith(
196 expect.objectContaining({ type: "warning", description: warning }),
197 );
198 }
199 },
200);
201
202it("keeps setup open when saving completion fails and preserves the import warning on retry", async () => {
203 mocks.complete.mockRejectedValueOnce(new Error("settings unavailable"));
204 const onDone = vi.fn();
205 await act(async () => root.render(<WelcomeWizard localAvailable onDone={onDone} />));
206 await click("Continue");
207 await click("Continue");
208 await click("Import 1 project");
209 expect(onDone).not.toHaveBeenCalled();
210 expect(mocks.toast).toHaveBeenCalledWith(
211 expect.objectContaining({ type: "error", title: "Could not finish setup" }),
212 );
213 await click("Do not import projects");
214 expect(onDone).toHaveBeenCalledOnce();
215 expect(mocks.importThreads).toHaveBeenCalledOnce();
216 expect(mocks.toast).toHaveBeenLastCalledWith(
217 expect.objectContaining({
218 type: "warning",
219 description: "Imported 28 threads. 1 thread could not be imported.",
220 }),
221 );
222});