diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx index 642e396609..aa562f7f4a 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.browser.test.tsx @@ -35,6 +35,7 @@ const state = vi.hoisted(() => ({ onDone: vi.fn(), scan: vi.fn(), refreshScan: vi.fn(), + waitForProject: vi.fn(), })); vi.mock(import("@goat/client-runtime/state/session"), async (original) => { @@ -87,6 +88,7 @@ vi.mock(import("../../state/entities"), async (original) => { ...(await original()), useProjects: (() => useAtomValue(state.projects as Atom.Atom)) as never, readProjects: (() => appAtomRegistry.get(state.projects as Atom.Atom)) as never, + waitForProject: ((ref: unknown) => state.waitForProject(ref)) as never, }; }); vi.mock(import("../../state/projects"), async (original) => ({ @@ -187,6 +189,27 @@ function recordProject({ environmentId, input }: CreateProjectInput) { return AsyncResult.success(undefined); } +// Resolves once the project reaches the mocked projects atom; the real helper's timeout is +// replaced per test with a rejection. +function waitForRecordedProject(ref: { environmentId: EnvironmentId; projectId: string }) { + const find = () => + appAtomRegistry + .get(projectsAtom()) + .find( + (project) => project.id === ref.projectId && project.environmentId === ref.environmentId, + ); + return new Promise((resolve) => { + const current = find(); + if (current) return resolve(current); + const unsubscribe = appAtomRegistry.subscribe(projectsAtom(), () => { + const project = find(); + if (!project) return; + unsubscribe(); + resolve(project); + }); + }); +} + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((complete) => { @@ -260,6 +283,7 @@ function setup() { state.onDone.mockReset(); state.scan.mockReset(); state.refreshScan.mockReset(); + state.waitForProject.mockReset().mockImplementation(waitForRecordedProject); return now; } @@ -314,3 +338,23 @@ vi.mock("@goat/shared/goatConnect", async (importOriginal) => ({ ...(await importOriginal()), isGoatConnectEnabled: () => true, })); + +it("finishes setup without landing when the imported project never reaches the client", async () => { + using _now = setup(); + state.candidates = state.candidates.slice(0, 1); + // The server accepts the project, but it never shows up in the client's projects. + state.createProject.mockResolvedValue(AsyncResult.success(undefined)); + state.waitForProject.mockRejectedValue(new Error("The project did not appear.")); + state.completeOnboarding.mockRejectedValueOnce(new Error("offline")); + await using _screen = await mountImport(); + + await click("Import 1 project"); + await vi.waitFor(() => expect(state.completeOnboarding).toHaveBeenCalledOnce()); + // A failed completion hands the step back instead of leaving it importing. + await vi.waitFor(() => expect(button("Import 1 project").disabled).toBe(false)); + expect(state.onDone).not.toHaveBeenCalled(); + + await click("Import 1 project"); + await vi.waitFor(() => expect(state.onDone).toHaveBeenCalledOnce()); + expect(state.onDone).toHaveBeenCalledWith(undefined); +}); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 71d4b990c2..e3a86211c4 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -57,7 +57,7 @@ import { } from "../../onboarding/providerReadiness.logic"; import { newProjectId, randomUUID } from "../../lib/utils"; import { agentSessionImport } from "../../state/agentSessions"; -import { readProjects, useProjects } from "../../state/entities"; +import { readProjects, waitForProject } from "../../state/entities"; import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; import { isOnboardingRelayEnvironment, @@ -1220,11 +1220,9 @@ function ImportStep({ const writableEnvironments = useEnvironmentsWithScope(scans, AuthOrchestrationOperateScope); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const importThreads = useAtomCommand(agentSessionImport, { reportFailure: false }); - const projects = useProjects(); const [selectedPaths, setSelectedPaths] = useState | null>(null); const importWarningRef = useRef(""); const importedThreadCountRef = useRef(0); - const [landingProject, setLandingProject] = useState(null); const importedProjectsRef = useRef(new Map()); const projectsWithImportedHistoryRef = useRef(new Map()); const lastImportSelectionRef = useRef>([]); @@ -1240,25 +1238,6 @@ function ImportStep({ }; }, []); - useEffect(() => { - if ( - landingProject !== null && - projects.some( - (project) => - project.id === landingProject.projectId && - project.environmentId === landingProject.environmentId, - ) - ) { - // oxlint-disable-next-line react/set-state-in-effect -- The landing project is known only once the projects subscription delivers it. - setLandingProject(null); - void onDone(landingProject, importWarningRef.current, importedThreadCountRef.current).then( - (completed) => { - if (!completed) setIsImporting(false); - }, - ); - } - }, [landingProject, onDone, projects, setIsImporting]); - const { available: candidates, recent } = partitionOnboardingProjects( scans.flatMap((scan) => (scan.data?.candidates ?? []).map((candidate) => ({ @@ -1292,7 +1271,23 @@ function ImportStep({ return; } setIsImporting(true); - setLandingProject(projectRef); + const importGeneration = importGenerationRef.current; + // waitForProject gives up after its timeout, so a project that never reaches the client + // still finishes setup, just without landing in it. + void waitForProject(projectRef) + .then( + () => projectRef, + () => undefined, + ) + .then(async (landing) => { + if (importGeneration !== importGenerationRef.current) return; + const completed = await onDone( + landing, + importWarningRef.current, + importedThreadCountRef.current, + ); + if (!completed) setIsImporting(false); + }); }; const runImport = async (selection: typeof candidates) => {