Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -87,6 +88,7 @@ vi.mock(import("../../state/entities"), async (original) => {
...(await original()),
useProjects: (() => useAtomValue(state.projects as Atom.Atom<Project[]>)) as never,
readProjects: (() => appAtomRegistry.get(state.projects as Atom.Atom<Project[]>)) as never,
waitForProject: ((ref: unknown) => state.waitForProject(ref)) as never,
};
});
vi.mock(import("../../state/projects"), async (original) => ({
Expand Down Expand Up @@ -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<Project>((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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((complete) => {
Expand Down Expand Up @@ -260,6 +283,7 @@ function setup() {
state.onDone.mockReset();
state.scan.mockReset();
state.refreshScan.mockReset();
state.waitForProject.mockReset().mockImplementation(waitForRecordedProject);
return now;
}

Expand Down Expand Up @@ -314,3 +338,23 @@ vi.mock("@goat/shared/goatConnect", async (importOriginal) => ({
...(await importOriginal<typeof import("@goat/shared/goatConnect")>()),
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);
});
41 changes: 18 additions & 23 deletions apps/web/src/components/onboarding/WelcomeWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ReadonlySet<string> | null>(null);
const importWarningRef = useRef("");
const importedThreadCountRef = useRef(0);
const [landingProject, setLandingProject] = useState<ScopedProjectRef | null>(null);
const importedProjectsRef = useRef(new Map<string, ScopedProjectRef>());
const projectsWithImportedHistoryRef = useRef(new Map<string, ScopedProjectRef>());
const lastImportSelectionRef = useRef<ReadonlyArray<string>>([]);
Expand All @@ -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) => ({
Expand Down Expand Up @@ -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) => {
Expand Down
Loading