Files
PaperClipAI/ui/src/components/OnboardingWizard.step.test.tsx
T
TonioandClaude Opus 5 3d366ba15f Rebuild the onboarding agent arc on the prototype's step design (#11905)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The onboarding wizard in `ui/` hires that first agent. It runs three
steps: create the agent, connect a model, and review
> - A standalone prototype holds the agreed design for these steps.
#10786 ported that prototype, but #11067 reverted it in full because the
port deleted `OnboardingWizard.tsx` while four pull requests were
editing that file
> - Those four pull requests have since merged. The revert said the port
can "re-land incrementally", and this is that re-land
> - This pull request takes the presentational layer from the prototype
only. It keeps master's wizard as the source of behaviour, so the eight
onboarding fixes merged since the revert stay in place
> - The benefit is that the three agent steps match the agreed design,
and no merged fix is lost to get there

## Linked Issues or Issue Description

Refs #10786 — the first attempt to land this design.
Refs #11067 — the revert that asked for it to re-land in smaller steps.

No public issue exists for the re-land. The problem is described below.

**Subsystem affected**

The `ui` package. The change touches the onboarding wizard, the agent
capsule,
and one Storybook story. It adds four small presentational components
under
`ui/src/components/onboarding/`.

**Current behavior**

The wizard's agent steps do not match the prototype. Each step shows a
small
heading beside an icon, above a form. The agent capsule sits below that
heading and does not animate. The agent gets a name but no role, so
every
first agent is created as `ceo`.

The wizard also shows a five-segment progress bar on these steps. A
walker who
enters on the agent step cannot reach the first two segments, so two of
the
five can never be filled.

**Proposed behavior**

The three steps use the prototype's card, its centred display heading,
and its
footer. One capsule sits above the heading and stays mounted across all
three
steps, so it reads as one object being built rather than three screens
that
each show their own.

A three-segment strip counts these steps for a walker who enters on
them. The
full-length bar stays for a walker who starts at step one, so that count
never
restarts partway.

The agent step gains a role. The options come from the agent role enum,
not
from the prototype's mock list.

**Reason and benefit**

The design is agreed and already built once. Re-landing it
presentation-first
keeps the behaviour that master gained after the revert.

Sourcing roles from the enum matters. The prototype offers "Coder",
which is
not a valid role — the enum uses `engineer` — so a walker who picked it
would
fail validation at hire time.

**Breaking changes**

None. The wizard keeps its routes, its draft format, and its hire call.
The
draft gains one optional field, `agentRole`. A draft saved before this
change
loads without it and falls back to the default.

## What Changed

- Add `ui/src/components/onboarding/`: `Stepper`, `OnboardingCard`,
`OnboardingHeading`, `FooterNav`, `AgentPreview`, and shared motion
constants
- Rebuild wizard steps 3–5 on those parts: one card, the capsule above a
  centred heading, and one footer
- Hold one `AgentCapsule` across the three steps. It springs in once,
then
  morphs from dashed slot to traced outline to filled
- Add `strokeDraw` to `AgentCapsule`. It traces the outline instead of
  cross-fading it. The dashed layer holds until the trace ends
- Add a role select to the agent step. Choosing a role fills the name,
unless
  the walker typed one
- Show one progress indicator per run, not two
- Label strip segments by destination, not by number
- Add `motion` to the `ui` package
- Add a Storybook story for the strip and the capsule states

## Verification

Run the tests:

```
pnpm --filter @paperclipai/ui exec vitest run
pnpm --filter @paperclipai/ui exec tsc -p tsconfig.json --noEmit
```

4235 tests pass. The typecheck is clean.

To see the steps, start the app and open `/<PREFIX>/onboarding` for a
company
that has a company-level goal. The wizard opens on the agent step. Step
three
requires a hire.

Three absence assertions were checked by fault injection. Each one fails
when
the old behaviour returns:

- put the step counter back, and the "shows no step counter" test fails
- default `strokeDraw` to true, and the cross-fade test fails
- restore the timer gate on the strip, and the indicator test fails

## Risks

Low to medium.

`motion` is one new dependency in `ui`. #11067 gave dependency weight as
one
of three reasons to revert #10786, so this branch carries the smallest
set
that works. `motion` drives the step transitions and the capsule
choreography,
and three files import it.

An earlier revision of this branch also added `three` and
`@types/three`. Both
are removed. They existed for the 3D backdrop, which belongs to the auth
and
welcome screens rather than to these three steps, so nothing on this
branch
imported them.

The role select changes what the wizard sends. Before this change every
first
agent was hired as `ceo`. Now the walker chooses. The values come from
the
enum, so the server accepts all of them.

Steps 1 and 2 keep the older design. They do not run on the Cloud-first
path,
where the company already exists.

## Model Used

Claude Opus 5 (`claude-opus-5`), with extended thinking, tool use, and
code
execution. Used for the code, the tests, and this description.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:41:53 -07:00

960 lines
37 KiB
TypeScript

// @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(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
}
/** Re-render after mutating the stubbed contexts or the location. */
async function rerender() {
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
}
// 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");
});
});
});