| 1 | // @vitest-environment jsdom |
| 2 | import { act } from "react"; |
| 3 | import { createRoot, type Root } from "react-dom/client"; |
| 4 | import { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 6 | |
| 7 | const 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 | })); |
| 15 | vi.mock("../settings/SelfHostedServer", () => ({ RemoteConnectionFlow: () => null })); |
| 16 | vi.mock("../../state/session", () => ({ |
| 17 | useEnvironmentScope: () => true, |
| 18 | useEnvironmentsWithScope: (environments: Array<{ environmentId: string }>) => |
| 19 | new Set(environments.map((entry) => entry.environmentId)), |
| 20 | readEnvironmentScope: () => true, |
| 21 | })); |
| 22 | vi.mock("../../state/agentSessions", () => ({ agentSessionImport: "import" })); |
| 23 | vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "create" } })); |
| 24 | vi.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 | })); |
| 32 | vi.mock("../../onboarding/firstRun", () => ({ useCompleteOnboarding: () => mocks.complete })); |
| 33 | vi.mock("../../state/entities", () => ({ |
| 34 | useProjects: () => mocks.projects, |
| 35 | readProjects: () => mocks.projects, |
| 36 | })); |
| 37 | vi.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 | }); |
| 49 | vi.mock("../../state/server", () => ({ |
| 50 | serverEnvironment: { |
| 51 | providersValueAtom: () => [], |
| 52 | configValueAtom: () => null, |
| 53 | refreshProviders: "refresh", |
| 54 | }, |
| 55 | })); |
| 56 | vi.mock("@effect/atom-react", () => ({ useAtomValue: (value: unknown) => value })); |
| 57 | vi.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 | })); |
| 80 | vi.mock("../../connection/onboarding", () => ({ connectPairing: vi.fn() })); |
| 81 | vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} })); |
| 82 | vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() })); |
| 83 | vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false })); |
| 84 | vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: () => null })); |
| 85 | vi.mock("../settings/ChatGptWelcomeCoordinator", () => ({ ChatGptWelcomeCoordinator: () => null })); |
| 86 | vi.mock("../settings/CodexSetupSection", () => ({ |
| 87 | CodexSetupSection: () => null, |
| 88 | AddManagedCodexAccountDialog: () => null, |
| 89 | })); |
| 90 | vi.mock("../cloud/CloudEnvironmentConnectList", () => ({ |
| 91 | CloudEnvironmentConnectRows: () => null, |
| 92 | })); |
| 93 | vi.mock("../ui/toast", () => ({ |
| 94 | toastManager: { add: mocks.toast, close: vi.fn(), update: vi.fn() }, |
| 95 | })); |
| 96 | |
| 97 | import { WelcomeWizard } from "./WelcomeWizard"; |
| 98 | |
| 99 | let root: Root; |
| 100 | let container: HTMLDivElement; |
| 101 | |
| 102 | beforeEach(() => { |
| 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 | }); |
| 128 | afterEach(async () => { |
| 129 | await act(async () => root.unmount()); |
| 130 | container.remove(); |
| 131 | }); |
| 132 | |
| 133 | async 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 | |
| 141 | it("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 | |
| 168 | it.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 | |
| 202 | it("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 | }); |