// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "../lib/queryKeys";
import {
ONBOARDING_AGENT_STEP,
ONBOARDING_MISSION_STEP,
} from "../lib/onboarding-route";
/**
* Which step the onboarding wizard *lands on*, and what is allowed to move it
* afterwards.
*
* These are seam tests on purpose. `initialStep` is derived from two queries
* and consumed by an effect that calls `setStep`, and every defect this file
* guards lived in that seam rather than in either side of it — the pure
* helpers in `onboarding-route.test.ts` passed while the wizard was moving a
* customer off the step they were typing on. So the real component is rendered
* here, with the real route resolver and the real mission hook, and only the
* network and the surrounding contexts are stubbed.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const mockGoalsApi = vi.hoisted(() => ({
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
}));
const mockAdaptersApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockAgentsApi = vi.hoisted(() => ({
create: vi.fn(),
adapterModels: vi.fn(),
hire: vi.fn(),
instructionsBundle: vi.fn(),
saveInstructionsFile: vi.fn(),
testEnvironment: vi.fn(),
}));
const mockCompaniesApi = vi.hoisted(() => ({ create: vi.fn() }));
// The hire path resolves the Test environment before it probes: it reads the
// environment list, the instance settings, and the experimental settings. The
// test stubs these so the resolution settles on the local default, the same as
// a real run with no instance default.
const mockEnvironmentsApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockInstanceSettingsApi = vi.hoisted(() => ({
get: vi.fn(),
getExperimental: vi.fn(),
}));
const routerState = vi.hoisted(() => ({ pathname: "/" }));
const dialogState = vi.hoisted(() => ({
onboardingOpen: false,
onboardingOptions: {} as { initialStep?: number; companyId?: string },
onboardingRouteDismissed: false,
closeOnboarding: vi.fn(),
setOnboardingRouteDismissed: vi.fn(),
}));
const companyState = vi.hoisted(() => ({
companies: [
{ id: "company-1", name: "Acme", issuePrefix: "PC1" },
{ id: "company-2", name: "Globex", issuePrefix: "PC2" },
],
loading: false,
setSelectedCompanyId: vi.fn(),
}));
vi.mock("../api/goals", () => ({ goalsApi: mockGoalsApi }));
vi.mock("@/api/adapters", () => ({ adaptersApi: mockAdaptersApi }));
vi.mock("../api/companies", () => ({ companiesApi: mockCompaniesApi }));
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("../api/approvals", () => ({ approvalsApi: { create: vi.fn() } }));
vi.mock("../api/issues", () => ({ issuesApi: { create: vi.fn() } }));
vi.mock("../api/projects", () => ({ projectsApi: { list: vi.fn(), create: vi.fn() } }));
vi.mock("../api/environments", () => ({ environmentsApi: mockEnvironmentsApi }));
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi }));
vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: routerState.pathname }),
useNavigate: () => vi.fn(),
useParams: () => ({}),
}));
vi.mock("../context/DialogContext", () => ({
useDialog: () => dialogState,
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => companyState,
}));
// Canvas/animation leaves — nothing to do with the step machinery.
vi.mock("./AsciiArtAnimation", () => ({ AsciiArtAnimation: () => null }));
vi.mock("./AgentCapsule", () => ({ AgentCapsule: () => null }));
vi.mock("./FrontDoor", () => ({ FrontDoor: () => null }));
const { OnboardingWizard } = await import("./OnboardingWizard");
/** The mission step renders this heading; the agent step renders this input. */
function currentStep(): "mission" | "agent" | "closed" | "other" {
const body = document.body;
if (!body.querySelector("[role='dialog'], .fixed.inset-0")) return "closed";
const headings = [...body.querySelectorAll("h3")].map((h) => h.textContent);
if (headings.includes("Define your mission")) return "mission";
// Keyed on the role control rather than the name field: the name is optional
// and starts empty, so its placeholder is the generic "Name".
if (body.querySelector("#onboarding-agent-role")) return "agent";
return "other";
}
function confirmMissionButton(): HTMLButtonElement | null {
return (
[...document.body.querySelectorAll("button")].find((button) =>
button.textContent?.includes("Confirm mission"),
) ?? null
);
}
function missionTextarea(): HTMLTextAreaElement | null {
return document.body.querySelector("textarea");
}
/** Type into a controlled React input without a full user-event dependency. */
function setControlledValue(el: HTMLTextAreaElement | HTMLInputElement, value: string) {
const prototype =
el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
}
const COMPANY_GOAL = {
id: "goal-1",
companyId: "company-1",
title: "Ship the thing",
description: null,
level: "company",
status: "active",
parentId: null,
ownerAgentId: null,
createdAt: new Date("2026-03-02T00:00:00Z"),
updatedAt: new Date("2026-03-02T00:00:00Z"),
};
describe("OnboardingWizard — which step it lands on", () => {
let container: HTMLDivElement;
let queryClient: QueryClient;
let root: Root | null = null;
async function render() {
root = createRoot(container);
await act(async () => {
root!.render(
,
);
});
}
/** Re-render after mutating the stubbed contexts or the location. */
async function rerender() {
await act(async () => {
root!.render(
,
);
});
}
// React Query resolves through microtasks and React schedules the re-render
// after them, so a single tick is not reliably enough under load. Several
// ticks cost microseconds and remove the ordering sensitivity.
async function settle(ticks = 12) {
for (let i = 0; i < ticks; i++) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
// The wizard restores its step from localStorage, so a step left behind by
// an earlier case would decide the next one.
localStorage.clear();
routerState.pathname = "/";
dialogState.onboardingOpen = false;
dialogState.onboardingOptions = {};
dialogState.onboardingRouteDismissed = false;
mockAdaptersApi.list.mockResolvedValue([]);
mockGoalsApi.list.mockResolvedValue([]);
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAgentsApi.hire.mockResolvedValue({ agent: { id: "agent-1" }, approval: null });
mockAgentsApi.instructionsBundle.mockResolvedValue({ entryFile: "AGENTS.md" });
mockAgentsApi.saveInstructionsFile.mockResolvedValue({});
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "claude_local",
status: "pass",
checks: [],
testedAt: new Date("2026-03-02T00:00:00Z").toISOString(),
});
mockEnvironmentsApi.list.mockResolvedValue([]);
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableManagedSandboxOnly: false,
});
});
afterEach(async () => {
await act(async () => root?.unmount());
root = null;
queryClient.clear();
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("opens a company that already has its mission on the agent step", async () => {
// The point of the change: Cloud collected the mission at signup and the
// seed wrote it as a company-level goal, so asking for it again asks a
// question the customer answered minutes earlier on another origin.
routerState.pathname = "/PC1/onboarding";
mockGoalsApi.list.mockResolvedValue([COMPANY_GOAL]);
await render();
await settle();
expect(currentStep()).toBe("agent");
});
it("stays closed until the mission lookup settles", async () => {
// The step is applied once. Opening before the answer is in would land the
// customer on the mission step and leave them there.
routerState.pathname = "/PC1/onboarding";
mockGoalsApi.list.mockReturnValue(new Promise(() => {}));
await render();
expect(currentStep()).toBe("closed");
});
it("opens on the mission step when the lookup fails, rather than not at all", async () => {
// Fail-open. A goals request that exhausts its retries must cost the step,
// not the whole flow.
routerState.pathname = "/PC1/onboarding";
mockGoalsApi.list.mockRejectedValue(new Error("goals unavailable"));
await render();
await settle();
expect(currentStep()).toBe("mission");
});
it("does not move an open wizard when a later refetch finds a mission", async () => {
// The defect this file exists for. The lookup fails, the wizard opens on
// the mission step, the customer starts typing — and a refetch then
// succeeds. The derived step flips from 2 to 3. Before the fix, the sync
// effect took that as a dependency and moved the customer to the agent
// step mid-sentence.
routerState.pathname = "/PC1/onboarding";
mockGoalsApi.list.mockRejectedValue(new Error("goals unavailable"));
await render();
await settle();
expect(currentStep()).toBe("mission");
await act(async () => {
queryClient.setQueryData(queryKeys.goals.list("company-1"), [COMPANY_GOAL]);
});
await settle();
expect(currentStep()).toBe("mission");
});
it("does not move an open wizard when the dialog is re-opened with a new step", async () => {
// The dashboard's auto-open sits behind queries too, so a refetch can call
// `openOnboarding` again with a different step for the same company. The
// wizard belongs to the customer by then.
dialogState.onboardingOpen = true;
dialogState.onboardingOptions = {
companyId: "company-1",
initialStep: ONBOARDING_MISSION_STEP,
};
await render();
await settle();
expect(currentStep()).toBe("mission");
dialogState.onboardingOptions = {
companyId: "company-1",
initialStep: ONBOARDING_AGENT_STEP,
};
await rerender();
await settle();
expect(currentStep()).toBe("mission");
});
it("re-decides the step when the route names a different company", async () => {
// The guard must hold the step against a *stale value settling*, not
// against a genuinely new request. Navigating to another company's
// onboarding is a new request, and its answer is a different one.
routerState.pathname = "/PC1/onboarding";
mockGoalsApi.list.mockImplementation((companyId: string) =>
companyId === "company-2" ? Promise.resolve([COMPANY_GOAL]) : Promise.resolve([]),
);
await render();
await settle();
expect(currentStep()).toBe("mission");
routerState.pathname = "/PC2/onboarding";
await rerender();
await settle();
expect(currentStep()).toBe("agent");
});
describe("the mission step, reached with a company that already exists", () => {
// Nothing sent an existing company here until the dashboard started
// opening agentless ones on this step. Both defects below were reachable
// the moment it did.
async function openOnMissionStepForExistingCompany() {
dialogState.onboardingOpen = true;
dialogState.onboardingOptions = {
companyId: "company-1",
initialStep: ONBOARDING_MISSION_STEP,
};
await render();
await settle();
expect(currentStep()).toBe("mission");
}
async function click(el: Element) {
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
it("names the company it is asking about, so the step can be completed", async () => {
// `companyName` is only ever typed on step 1. Without a backfill it is
// empty here, the step's own copy has a blank where the name goes, and
// "Confirm mission" stays disabled — a customer sent to this step could
// not leave it.
await openOnMissionStepForExistingCompany();
expect(document.body.textContent).toContain("Acme");
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Ship the thing");
await settle();
expect(confirmMissionButton()?.disabled).toBe(false);
});
it("saves the mission it asked for", async () => {
// Confirming used to advance to the agent step and write nothing, so the
// company kept no mission — the exact state this change exists to remove.
mockGoalsApi.create.mockResolvedValue({ id: "goal-new" });
await openOnMissionStepForExistingCompany();
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Ship the thing");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(mockGoalsApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ title: "Ship the thing", level: "company", status: "active" }),
);
expect(currentStep()).toBe("agent");
});
it("does not write a second mission when Enter is pressed twice", async () => {
// The buttons are all disabled while a request is in flight; the
// keyboard has to be too. A second Enter re-enters the handler before
// the first has set the goal id its own guard reads, so both requests
// see "no mission yet" and the company ends up with two.
let resolveCreate: (goal: { id: string }) => void = () => {};
mockGoalsApi.create.mockReturnValue(
new Promise<{ id: string }>((resolve) => {
resolveCreate = resolve;
}),
);
await openOnMissionStepForExistingCompany();
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Ship the thing");
await settle();
const surface = document.body.querySelector(".fixed.inset-0.z-50.flex")!;
const submit = () =>
act(async () => {
surface.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }),
);
});
await submit();
await submit();
await act(async () => resolveCreate({ id: "goal-new" }));
await settle();
expect(mockGoalsApi.create).toHaveBeenCalledTimes(1);
});
it("updates the mission it could not see, rather than adding a second", async () => {
// The cost of failing open. The lookup could not answer, so the customer
// was asked for a mission the company already had. Adding a goal would
// leave two active company-level goals, and the earlier one would keep
// winning `selectDefaultCompanyGoalId` outside this wizard — so the
// mission the customer just typed would lose. Their answer wins instead.
// The dashboard's lookup failed, which is why this company is on the
// mission step at all. By the time the customer confirms, the goal list
// reads — and it has a mission.
mockGoalsApi.list.mockResolvedValue([COMPANY_GOAL]);
mockGoalsApi.update.mockResolvedValue({ id: COMPANY_GOAL.id });
await openOnMissionStepForExistingCompany();
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "The mission they just typed");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(mockGoalsApi.create).not.toHaveBeenCalled();
expect(mockGoalsApi.update).toHaveBeenCalledWith(
COMPANY_GOAL.id,
expect.objectContaining({ title: "The mission they just typed" }),
);
expect(currentStep()).toBe("agent");
});
it("still writes the mission when the pre-write read also fails", async () => {
// Fail-open all the way down. If it cannot tell whether a mission
// exists, an unwritten mission is the worse error.
mockGoalsApi.list.mockRejectedValue(new Error("goals unavailable"));
mockGoalsApi.create.mockResolvedValue({ id: "goal-new" });
await openOnMissionStepForExistingCompany();
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Ship the thing");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(mockGoalsApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ title: "Ship the thing" }),
);
expect(currentStep()).toBe("agent");
});
it("does not carry a mission across a switch to another company", async () => {
// Confirming for one company sets the goal id that `handleConfirmMission`
// reads as "already written". Carried across a company switch it makes
// the next company skip saving its own mission, and the launch path then
// links that company's project to the previous company's goal.
mockGoalsApi.create.mockResolvedValue({ id: "goal-company-1" });
routerState.pathname = "/PC1/onboarding";
dialogState.onboardingOpen = false;
await render();
await settle();
expect(currentStep()).toBe("mission");
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Acme's mission");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(currentStep()).toBe("agent");
routerState.pathname = "/PC2/onboarding";
await rerender();
await settle();
expect(currentStep()).toBe("mission");
const direct2 = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct2);
setControlledValue(missionTextarea()!, "Globex's mission");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(mockGoalsApi.create).toHaveBeenCalledTimes(2);
expect(mockGoalsApi.create).toHaveBeenLastCalledWith(
"company-2",
expect.objectContaining({ title: "Globex's mission" }),
);
});
it("does not hand a new company the mission written for the old one", async () => {
// A route change can switch companies while the write is in flight, and
// the switch clears exactly the state the write is about to set. The
// goal is written and correct either way — but attributing it to the
// company now in hand would undo the clearing and let that company skip
// its own mission.
let resolveCreate: (goal: { id: string }) => void = () => {};
mockGoalsApi.create.mockReturnValue(
new Promise<{ id: string }>((resolve) => {
resolveCreate = resolve;
}),
);
routerState.pathname = "/PC1/onboarding";
await render();
await settle();
expect(currentStep()).toBe("mission");
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Acme's mission");
await settle();
await click(confirmMissionButton()!);
// Switch companies before the write lands, then let it land.
routerState.pathname = "/PC2/onboarding";
await rerender();
await settle();
await act(async () => resolveCreate({ id: "goal-company-1" }));
await settle();
// Globex must still be asked, and must write its own mission.
expect(currentStep()).toBe("mission");
mockGoalsApi.create.mockResolvedValue({ id: "goal-company-2" });
const direct2 = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct2);
setControlledValue(missionTextarea()!, "Globex's mission");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(mockGoalsApi.create).toHaveBeenLastCalledWith(
"company-2",
expect.objectContaining({ title: "Globex's mission" }),
);
});
it("does not carry a mission through a route that withdraws the company", async () => {
// Withdrawing a company and replacing one are the same event: this
// company is no longer the wizard's. Clearing only on replacement leaves
// a goal id behind, and the company created next would read it as
// "mission already written" and never be asked for one.
mockGoalsApi.create.mockResolvedValue({ id: "goal-company-1" });
routerState.pathname = "/PC1/onboarding";
await render();
await settle();
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Acme's mission");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(currentStep()).toBe("agent");
// Navigate to the unprefixed route, which names no company.
routerState.pathname = "/onboarding";
await rerender();
await settle();
// The wizard is back at company creation with nothing carried over.
const nameInput = document.body.querySelector("input") as HTMLInputElement | null;
expect(nameInput?.value).toBe("");
expect(document.body.textContent).not.toContain("Acme's mission");
});
it("withdraws a company the wizard created once the route stops naming it", async () => {
// The route only introduces a company when it names one the wizard is
// not already holding, so a company the wizard *created* was never
// recorded as route-owned and was never withdrawn. Visiting its own
// onboarding path and then `/onboarding` left the wizard showing
// "create a company" while still holding it — and the next confirmation
// wrote that customer's new mission into the old company.
mockCompaniesApi.create.mockResolvedValue({ id: "company-1", issuePrefix: "PC1" });
mockGoalsApi.create.mockResolvedValue({ id: "goal-company-1" });
routerState.pathname = "/onboarding";
await render();
await settle();
const nameInput = document.body.querySelector("input")! as HTMLInputElement;
setControlledValue(nameInput, "Acme");
await settle();
await click(
[...document.body.querySelectorAll("button")].find(
(b) => b.textContent?.trim() === "Next",
)!,
);
await settle();
setControlledValue(missionTextarea()!, "Acme's mission");
await settle();
await click(confirmMissionButton()!);
await settle();
expect(mockCompaniesApi.create).toHaveBeenCalled();
expect(currentStep()).toBe("agent");
// Its own onboarding path, then back to the unprefixed one.
routerState.pathname = "/PC1/onboarding";
await rerender();
await settle();
routerState.pathname = "/onboarding";
await rerender();
await settle();
const nameAfter = document.body.querySelector("input") as HTMLInputElement | null;
expect(nameAfter?.value).toBe("");
expect(document.body.textContent).not.toContain("Acme's mission");
});
it("does not write a second mission when the step is confirmed twice", async () => {
mockGoalsApi.create.mockResolvedValue({ id: "goal-new" });
await openOnMissionStepForExistingCompany();
const direct = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("I know my mission"),
)!;
await click(direct);
setControlledValue(missionTextarea()!, "Ship the thing");
await settle();
await click(confirmMissionButton()!);
await settle();
// Back to the mission step, then forward again.
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Back"),
)!;
await click(back);
await settle();
await click(confirmMissionButton()!);
await settle();
expect(mockGoalsApi.create).toHaveBeenCalledTimes(1);
expect(currentStep()).toBe("agent");
});
});
it("does not adopt a company it created once a route has supplied one", async () => {
// The same guard from the other end. Nothing was in hand when the create
// started, so "unchanged" means still nothing. A route that supplied a
// company while the request was open has taken over the wizard, and
// adopting the new company would fight it — and would leave the customer
// on a company they never navigated to.
let resolveCreate: (company: { id: string; issuePrefix: string }) => void = () => {};
mockCompaniesApi.create.mockReturnValue(
new Promise<{ id: string; issuePrefix: string }>((resolve) => {
resolveCreate = resolve;
}),
);
routerState.pathname = "/onboarding";
await render();
await settle();
// Step 1: name a new company, then confirm the mission to create it.
const nameInput = document.body.querySelector("input")! as HTMLInputElement;
setControlledValue(nameInput, "Initech");
await settle();
const next = [...document.body.querySelectorAll("button")].find(
(b) => b.textContent?.trim() === "Next",
)!;
await act(async () => {
next.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await settle();
setControlledValue(missionTextarea()!, "Initech's mission");
await settle();
await act(async () => {
confirmMissionButton()!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// A route supplies an existing company before the create lands.
routerState.pathname = "/PC1/onboarding";
await rerender();
await settle();
expect(mockCompaniesApi.create).toHaveBeenCalledWith({ name: "Initech" });
await act(async () => resolveCreate({ id: "company-created", issuePrefix: "INI" }));
await settle();
// Adopting the created company would select it globally and take the
// customer off the one they navigated to. Asserted on the selection call
// rather than on the rendered name: the name reads "Acme" either way,
// because the switch reset clears it and the backfill refills it from the
// company list, which has no entry for the company just created.
expect(companyState.setSelectedCompanyId).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Acme");
});
it("applies the step again when the wizard is re-opened", async () => {
// Same guard, from the other side: closing and re-opening is a new
// request, so a freeze that outlived the open would be its own defect.
dialogState.onboardingOpen = true;
dialogState.onboardingOptions = {
companyId: "company-1",
initialStep: ONBOARDING_MISSION_STEP,
};
await render();
await settle();
expect(currentStep()).toBe("mission");
dialogState.onboardingOpen = false;
await rerender();
expect(currentStep()).toBe("closed");
dialogState.onboardingOpen = true;
dialogState.onboardingOptions = {
companyId: "company-1",
initialStep: ONBOARDING_AGENT_STEP,
};
await rerender();
await settle();
expect(currentStep()).toBe("agent");
});
describe("a company that already has its mission", () => {
// It opens on the agent step, so steps 1 and 2 never run. Everything the
// mission feeds has to come from the company instead of the form.
const MISSION_GOAL = {
...COMPANY_GOAL,
title: "Scale the marketplace",
description: "Reach 1000 sellers",
};
async function openOnAgentStep() {
routerState.pathname = "/PC1/onboarding";
mockGoalsApi.list.mockResolvedValue([MISSION_GOAL]);
await render();
await settle();
expect(currentStep()).toBe("agent");
}
/**
* Choose a role, which the step now requires before it will advance — it
* asks rather than assuming one. Driven by keyboard because the control is
* a Radix listbox: its pointer path needs `hasPointerCapture`, which jsdom
* does not implement, while its keyboard path does not.
*/
async function pickRole(label = "CEO") {
const trigger = document.getElementById("onboarding-agent-role")!;
await act(async () => {
trigger.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
});
await settle();
const option = [...document.body.querySelectorAll('[role="option"]')].find(
(o) => o.textContent?.trim() === label,
) as HTMLElement | undefined;
expect(option).toBeDefined();
await act(async () => {
option!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
await settle();
}
it("seeds the lead agent's instructions with the mission it was never asked for", async () => {
// The regression this exists for. The agent step feeds
// `composeCeoInstructions` from the mission field, and a company entered
// here never types one — so the agent was hired knowing nothing of the
// mission the customer gave at signup, and nothing reported it.
await openOnAgentStep();
await pickRole();
const next = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Next"),
)!;
await act(async () => {
next.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await settle();
const connect = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Connect"),
)!;
expect(connect.hasAttribute("disabled")).toBe(false);
await act(async () => {
connect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await settle();
expect(mockAgentsApi.saveInstructionsFile).toHaveBeenCalled();
const [, file] = mockAgentsApi.saveInstructionsFile.mock.calls[0];
expect(file.content).toContain("Scale the marketplace");
expect(file.content).toContain("Reach 1000 sellers");
});
it("will not hire while the mission is being re-read", async () => {
// Cached goals plus an in-flight refetch: the field holds the right
// company's mission, but not necessarily its current one. Hiring inside
// that window seeds the agent from a value about to change, and reports
// nothing — the same "retained data is not an answer" rule the draft
// ownership gate follows.
await openOnAgentStep();
await pickRole();
const next = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Next"),
)!;
await act(async () => {
next.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await settle();
expect(
[...document.body.querySelectorAll("button")]
.find((b) => b.textContent?.includes("Connect"))!
.hasAttribute("disabled"),
).toBe(false);
mockGoalsApi.list.mockReturnValue(new Promise(() => {}));
await act(async () => {
queryClient.invalidateQueries({
queryKey: queryKeys.goals.list("company-1"),
});
});
await settle(2);
const connect = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Connect"),
)!;
expect(connect.hasAttribute("disabled")).toBe(true);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
});
it("hydrates again when the same company comes back through onboarding", async () => {
// The hydration marker is a ref, so it outlives the state it describes.
// `reset()` clears the mission field; leaving the marker set would make
// the second run believe a mission it no longer holds was already
// fetched — and hire the agent without it, exactly as before this fix.
await openOnAgentStep();
const close = [...document.body.querySelectorAll("button")].find((b) =>
b.querySelector(".sr-only")?.textContent?.includes("Close"),
);
expect(close).toBeDefined();
await act(async () => {
close!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await settle();
// `reset()` ran: the wizard is back at the front door with a cleared
// mission field, which is precisely the state the marker must not
// outlive.
expect(currentStep()).not.toBe("agent");
routerState.pathname = "/";
await rerender();
await settle();
routerState.pathname = "/PC1/onboarding";
dialogState.onboardingRouteDismissed = false;
await rerender();
await settle();
expect(currentStep()).toBe("agent");
await pickRole();
const next = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Next"),
)!;
await act(async () => {
next.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await settle();
const connect = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Connect"),
)!;
await act(async () => {
connect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await settle();
expect(mockAgentsApi.saveInstructionsFile).toHaveBeenCalled();
const [, file] = mockAgentsApi.saveInstructionsFile.mock.calls.at(-1)!;
expect(file.content).toContain("Scale the marketplace");
});
it("hires the agent with the role the customer picked", async () => {
// The role was hardcoded to "ceo" before the role select existed. A
// dropdown that renders but does not reach the hire call would look
// entirely correct on screen and silently mis-file every agent.
await openOnAgentStep();
// Deliberately not the first option: "ceo" is what the hardcoded value
// was, so a test that picked it could not tell a wired dropdown from an
// ignored one.
await pickRole("Engineer");
const next = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Next"),
)!;
await act(async () => {
next.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await settle();
const connect = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Connect"),
)!;
await act(async () => {
connect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await settle();
expect(mockAgentsApi.hire).toHaveBeenCalled();
const [, payload] = mockAgentsApi.hire.mock.calls.at(-1)!;
expect(payload.role).toBe("engineer");
// Picking a role also renamed the agent, since the field still held the
// name the wizard supplied.
expect(payload.name).toBe("Engineer");
});
it("does not offer a way back behind the step it entered on", async () => {
// Step 1 creates a company. A run that already holds one must not be
// able to walk into it, by the Back button or the progress bar.
await openOnAgentStep();
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Back"),
);
expect(back).toBeUndefined();
// The progress strip's segments are the only jump controls on this
// screen. Entering here means there is nowhere behind to return to, so
// every one of them is inert — asserted over the whole set rather than
// one segment, since a single enabled one is the whole defect.
const segments = [...document.body.querySelectorAll("button")].filter((b) =>
["Create your first agent", "Connect a model", "Review"].includes(
b.getAttribute("aria-label") ?? "",
),
) as HTMLButtonElement[];
expect(segments).toHaveLength(3);
expect(segments.every((segment) => segment.disabled)).toBe(true);
// And company creation is genuinely unreachable, not merely unlinked.
expect(document.body.textContent).not.toContain("Name your company");
});
});
});