| 1 | import { RegistryContext } from "@effect/atom-react"; |
| 2 | import { |
| 3 | AuthOrchestrationOperateScope, |
| 4 | AuthOrchestrationReadScope, |
| 5 | EnvironmentId, |
| 6 | ProjectId, |
| 7 | type AgentSessionProjectCandidate, |
| 8 | type AuthEnvironmentScope, |
| 9 | type AuthSessionState, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; |
| 12 | import { act, create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; |
| 13 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 14 | |
| 15 | type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>; |
| 16 | type Project = { id: ProjectId; environmentId: EnvironmentId; workspaceRoot: string }; |
| 17 | type CreateProjectInput = { |
| 18 | environmentId: EnvironmentId; |
| 19 | input: { projectId: ProjectId; workspaceRoot: string }; |
| 20 | }; |
| 21 | const 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 | |
| 39 | vi.mock("../settings/SelfHostedServer", () => ({ RemoteConnectionFlow: () => null })); |
| 40 | vi.mock("../../connection/runtime", () => ({ connectionAtomRuntime: undefined })); |
| 41 | vi.mock("@t3tools/client-runtime/state/session", () => ({ |
| 42 | createEnvironmentSessionAtoms: () => ({ |
| 43 | sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!, |
| 44 | }), |
| 45 | })); |
| 46 | vi.mock("../../rpc/atomRegistry", () => ({ |
| 47 | get appAtomRegistry() { |
| 48 | return state.registry; |
| 49 | }, |
| 50 | })); |
| 51 | vi.mock("@clerk/react", () => ({ useAuth: () => ({ isLoaded: true, isSignedIn: false }) })); |
| 52 | vi.mock("../../hooks/useTheme", () => ({ mountOnboardingTheme: () => () => {} })); |
| 53 | vi.mock("../../hooks/useLocalStorage", () => ({ useLocalStorage: () => [false, () => {}] })); |
| 54 | vi.mock("../../hooks/useCopyToClipboard", () => ({ |
| 55 | useCopyToClipboard: () => ({ copyToClipboard: vi.fn(), copied: false }), |
| 56 | })); |
| 57 | vi.mock("../../cloud/publicConfig", () => ({ hasCloudPublicConfig: () => false })); |
| 58 | vi.mock("../clerk/useT3ConnectAuthPrompt", () => ({ useT3ConnectAuthPrompt: vi.fn() })); |
| 59 | vi.mock("../../onboarding/firstRun", () => ({ |
| 60 | useCompleteOnboarding: () => state.completeOnboarding, |
| 61 | })); |
| 62 | vi.mock("../../env", () => ({ isElectron: false })); |
| 63 | vi.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 | }); |
| 76 | vi.mock("../../state/server", () => ({ |
| 77 | serverEnvironment: { |
| 78 | providersValueAtom: () => state.providers, |
| 79 | configValueAtom: () => state.config, |
| 80 | refreshProviders: "refreshProviders", |
| 81 | }, |
| 82 | })); |
| 83 | vi.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 | }); |
| 90 | vi.mock("../../state/projects", () => ({ projectEnvironment: { create: "createProject" } })); |
| 91 | vi.mock("../../state/agentSessions", () => ({ |
| 92 | agentSessionScan: (query: { environmentId: EnvironmentId }) => query, |
| 93 | agentSessionImport: "importThreads", |
| 94 | })); |
| 95 | vi.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 | })); |
| 108 | vi.mock("../ui/dialog", () => ({ |
| 109 | Dialog: "div", |
| 110 | DialogPopup: "div", |
| 111 | DialogTitle: "div", |
| 112 | DialogHeader: "div", |
| 113 | })); |
| 114 | vi.mock("../ui/wizard", () => ({ |
| 115 | WizardPanel: "div", |
| 116 | WizardSteps: "div", |
| 117 | WizardPopup: "div", |
| 118 | WizardHeader: "div", |
| 119 | })); |
| 120 | vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); |
| 121 | vi.mock("../ui/tooltip", () => ({ Tooltip: "div", TooltipTrigger: "span", TooltipPopup: "span" })); |
| 122 | vi.mock("../../connection/onboarding", () => ({ connectPairing: "connectPairing" })); |
| 123 | vi.mock("../../state/use-atom-command", () => ({ |
| 124 | useAtomCommand: ( |
| 125 | command: "createProject" | "importThreads" | "refreshProviders" | "connectPairing", |
| 126 | ) => state[command], |
| 127 | })); |
| 128 | vi.mock("../../state/terminal", () => ({ terminalEnvironment: {} })); |
| 129 | vi.mock("../ThreadTerminalDrawer", () => ({ TerminalViewport: "div" })); |
| 130 | vi.mock("../cloud/CloudEnvironmentConnectList", () => ({ CloudEnvironmentConnectRows: "div" })); |
| 131 | vi.mock("../settings/ChatGptWelcomeCoordinator", () => ({ ChatGptWelcomeCoordinator: () => null })); |
| 132 | vi.mock("../settings/CodexSetupSection", () => ({ |
| 133 | CodexSetupSection: () => null, |
| 134 | AddManagedCodexAccountDialog: () => null, |
| 135 | })); |
| 136 | vi.mock("../settings/providerDriverMeta", () => ({ |
| 137 | providerClients: { get: () => ({ label: "Agent" }) }, |
| 138 | })); |
| 139 | vi.mock("../settings/providerStatus", () => ({ |
| 140 | getProviderSummary: () => ({ headline: "Checking", detail: null }), |
| 141 | })); |
| 142 | vi.mock("../ui/button", () => ({ Button: "button" })); |
| 143 | vi.mock("../ui/input", () => ({ Input: "input" })); |
| 144 | vi.mock("../ui/checkbox", () => ({ Checkbox: "input" })); |
| 145 | vi.mock("../ui/collapsible", () => ({ |
| 146 | Collapsible: "div", |
| 147 | CollapsiblePanel: "div", |
| 148 | CollapsibleTrigger: "button", |
| 149 | })); |
| 150 | vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn(), close: vi.fn(), update: vi.fn() } })); |
| 151 | |
| 152 | import { WelcomeWizard } from "./WelcomeWizard"; |
| 153 | |
| 154 | const primaryId = EnvironmentId.make("primary"); |
| 155 | const remoteId = EnvironmentId.make("remote"); |
| 156 | const permissionMessage = "This connection cannot import projects or thread history."; |
| 157 | let renderer: ReactTestRenderer | undefined; |
| 158 | |
| 159 | function 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 | |
| 172 | function setGrant(environmentId: EnvironmentId, allowed: boolean) { |
| 173 | state.registry!.set( |
| 174 | state.sessions.get(environmentId)!, |
| 175 | AsyncResult.success(session(allowed ? [AuthOrchestrationOperateScope] : [])), |
| 176 | ); |
| 177 | } |
| 178 | |
| 179 | function 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 | |
| 190 | function 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 | |
| 198 | function 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 | |
| 206 | function text(node: ReactTestInstance | string): string { |
| 207 | return typeof node === "string" ? node : node.children.map(text).join(""); |
| 208 | } |
| 209 | |
| 210 | function 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 | |
| 216 | async 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 | |
| 222 | async 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 | |
| 236 | beforeEach(() => { |
| 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 | |
| 266 | afterEach(async () => { |
| 267 | await act(async () => renderer?.unmount()); |
| 268 | renderer = undefined; |
| 269 | state.registry?.dispose(); |
| 270 | state.sessions.clear(); |
| 271 | vi.unstubAllGlobals(); |
| 272 | }); |
| 273 | |
| 274 | it("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 | |
| 288 | it("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 | |
| 299 | it("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 | |
| 327 | it("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 | |
| 353 | it.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 | |
| 389 | it("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 | }); |