Files
PaperClipAI/ui/src/components/OnboardingWizard.test.tsx

3509 lines
158 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import type { LocalAiLoginStatus } from "@paperclipai/shared";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// --- Mocks (hoisted so vi.mock factories can close over them) ----------------
// The company list is keyed by account, so it holds until the session query
// *succeeds*. A seeded entry is stale under the test client and refetches, so
// the refetch has to answer too — otherwise the identity errors and the list
// never runs.
const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() }));
const localHealth = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("@/api/health", () => ({ healthApi: localHealth }));
const managedApi = vi.hoisted(() => ({
list: vi.fn(async () => ({ currentUserId: "user-1", connections: [] })),
startLocalLogin: vi.fn(async () => ({ sessionId: "local-attempt", expiresAt: "2099-01-01T00:00:00Z" })),
submitLocalLoginCode: vi.fn(async () => ({ ok: true })),
checkLocalLogin: vi.fn(async (): Promise<LocalAiLoginStatus> => ({ status: "sign_in_required" })),
cancelLocalLogin: vi.fn(async () => ({})),
connectLocal: vi.fn(async () => ({ connectionId: "local-connection", grantId: "local-grant" })),
create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })),
}));
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi }));
// Keep the wizard tests focused on binding/model/creation boundaries. The
// connection component has its own tests; the ordinary-install UI canary
// exercises its real token storage and default selection.
vi.mock("./ai-connections/AiConnectionField", async (importOriginal) => {
const actual = await importOriginal<typeof import("./ai-connections/AiConnectionField")>();
return { ...actual, AiConnectionField: (props: { environmentId?: string; onChange: (value: import("@paperclipai/shared").AiConnectionBinding) => void }) => (
<button data-environment={props.environmentId} onClick={() => props.onChange({ provider: "github", method: "api_key", mode: "responsible_user" })}>Use saved Copilot connection</button>
) };
});
vi.mock("../api/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("../api/auth")>();
return { ...actual, authApi: { ...actual.authApi, getSession: mockAuthApi.getSession } };
});
const mockDialog = vi.hoisted(() => ({
onboardingOpen: true,
onboardingOptions: {} as { initialStep?: number; companyId?: string },
closeOnboarding: vi.fn(),
onboardingRouteDismissed: false,
setOnboardingRouteDismissed: vi.fn(),
}));
const mockCompany = vi.hoisted(() => ({
companies: [] as Array<{ id: string; name: string; issuePrefix: string }>,
setSelectedCompanyId: vi.fn(),
loading: false,
error: null as Error | null,
}));
const mockCompaniesApi = vi.hoisted(() => ({
detachInflightList: vi.fn(),
create: vi.fn(),
update: vi.fn(),
// The gate fetches the list itself now, rather than reading the shared
// cache, so ownership cases are driven from here. `mockCompany.companies`
// below still feeds the *inner* wizard, which is a different question.
list: vi.fn(),
}));
const mockGoalsApi = vi.hoisted(() => ({
create: vi.fn(),
list: vi.fn(async () => []),
}));
const mockAgentsApi = vi.hoisted(() => ({
adapterModels: vi.fn(async () => [] as Array<{ id: string; label: string }>),
testEnvironment: vi.fn(
async (): Promise<import("@paperclipai/shared").AdapterEnvironmentTestResult> => ({
adapterType: "claude_local",
status: "pass",
checks: [],
testedAt: new Date().toISOString(),
}),
),
hire: vi.fn(async () => ({ agent: { id: "agent-1" }, approval: null })),
// The hire step lists the company's agents first and adopts one that already
// carries the typed name on the same source, so a wizard that reopens on the
// agent step cannot hire "Ada 2". Empty by default: the company is new.
list: vi.fn(async () => [] as Array<{ id: string; name: string; adapterType: string }>),
instructionsBundle: vi.fn(async () => ({ entryFile: "AGENTS.md" })),
saveInstructionsFile: vi.fn(async () => ({})),
// No default implementation: the top-level `beforeEach` sets the "no
// stored value" 404 rejection, using the real `ApiError` class the code
// under test checks with `instanceof`.
getClaudeOAuthTokenStatus: vi.fn(),
getAdapterAuthSignal: vi.fn(
async (): Promise<import("@paperclipai/shared").AdapterAuthSignalResponse> => ({
status: "present",
}),
),
// The sign-in routes. The connect step's Connect button starts a login rather
// than hiring when the signal says the source has no credential, so the two
// login shapes need enough of a server to reach the state the step is about:
// a session that is running, and a prompt to show for it.
startClaudeSetupTokenLogin: vi.fn(async () => ({
sessionId: "claude-session-1",
status: "pending",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
})),
getClaudeSetupTokenLoginStatus: vi.fn(async () => ({
sessionId: "claude-session-1",
status: "pending",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
})),
getClaudeSetupTokenLoginPrompt: vi.fn(async () => ({
authorizationUrl: "https://claude.ai/oauth/authorize?code=true",
transportAdvisory: null,
})),
cancelClaudeSetupTokenLogin: vi.fn(async () => ({})),
submitClaudeSetupTokenBrowserCode: vi.fn(async () => ({})),
completeClaudeSetupTokenLogin: vi.fn(async () => ({ storedSessionId: "stored-1" })),
startAdapterAuthLogin: vi.fn(async () => ({
sessionId: "codex-session-1",
status: "pending",
})),
getAdapterAuthLoginStatus: vi.fn(async () => ({
sessionId: "codex-session-1",
status: "pending",
prompt: { url: "https://auth.openai.com/codex/device", code: "Q2RJ-E1YIF" },
})),
cancelAdapterAuthLogin: vi.fn(async () => ({})),
// No default implementation: the top-level `beforeEach` sets the "no active
// session" 404 rejection, matching the real route.
getActiveAdapterAuthLoginSession: vi.fn(),
getActiveClaudeSetupTokenLoginSession: vi.fn(),
}));
// The adapter registry mock below always returns this function, so a test
// can shape the built adapter config (e.g. a configured ANTHROPIC_API_KEY)
// without a real adapter package.
const mockAdapterBuild = vi.hoisted(() => ({
buildAdapterConfig: vi.fn(() => ({}) as Record<string, unknown>),
}));
// The Connect path loads environment settings before probing; without these
// the probe dies on "Could not load environment settings" and the hire never
// runs — which reads as a mysterious 0-call assertion, not an error.
const mockEnvironmentsApi = vi.hoisted(() => ({
list: vi.fn(async () => [] as Array<Record<string, unknown>>),
capabilities: vi.fn(
async (): Promise<import("@paperclipai/shared").EnvironmentCapabilities> =>
(await import("@paperclipai/shared")).getEnvironmentCapabilities([]),
),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
get: vi.fn(async () => ({ defaultEnvironmentId: null as string | null })),
getExperimental: vi.fn(async (): Promise<{ enableManagedSandboxOnly: boolean; enableNativeRunner?: boolean }> => ({ enableManagedSandboxOnly: false })),
}));
const mockApprovalsApi = vi.hoisted(() => ({
create: vi.fn(),
}));
const mockSecretsApi = vi.hoisted(() => ({
list: vi.fn(),
removeUserSecretDefinition: vi.fn(),
listMyUserSecrets: vi.fn(),
createUserSecretDefinition: vi.fn(),
createMyUserSecret: vi.fn(),
rotateMyUserSecret: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
create: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({
create: vi.fn(),
list: vi.fn(async () => []),
}));
// The real adapter registry eagerly imports every adapter package. The
// model/harness picker internals are out of scope here, so stub the adapter
// layer entirely and drive it through this knob.
const mockAdapterRegistry = vi.hoisted(() => ({
list: [] as Array<{ type: string; recommended?: boolean }>,
disabled: new Set<string>(),
}));
vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: "/", search: "", hash: "", state: null }),
useNavigate: () => vi.fn(),
useParams: () => ({}),
}));
vi.mock("../context/DialogContext", () => ({
useDialog: () => mockDialog,
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => mockCompany,
}));
vi.mock("../api/companies", () => ({ companiesApi: mockCompaniesApi }));
vi.mock("../api/goals", () => ({ goalsApi: mockGoalsApi }));
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("../api/approvals", () => ({ approvalsApi: mockApprovalsApi }));
vi.mock("../api/secrets", () => ({ secretsApi: mockSecretsApi }));
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("../api/environments", () => ({ environmentsApi: mockEnvironmentsApi }));
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi }));
vi.mock("../adapters", () => ({
listUIAdapters: () => mockAdapterRegistry.list,
getUIAdapter: () => ({ buildAdapterConfig: mockAdapterBuild.buildAdapterConfig }),
}));
vi.mock("../adapters/metadata", () => ({ isVisualAdapterChoice: () => true }));
vi.mock("../adapters/adapter-display-registry", () => ({
getAdapterDisplay: (type: string) => ({
type,
// Mirrors the real registry, where these two and only these two are
// `recommended`. A blanket `false` used to be harmless because every adapter
// then sat in the "Advanced settings" disclosure and was reachable anyway;
// with the step down to a tile row built from this flag, it made that row
// empty in every test and hid the surface under it.
recommended: type === "claude_local" || type === "codex_local" || mockAdapterRegistry.list.some(entry => entry.type === type && entry.recommended),
label: type,
description: "",
icon: () => null,
}),
getAdapterLabel: (type: string) => type,
getAdapterLabels: () => ({}) as Record<string, string>,
isKnownAdapterType: () => true,
}));
vi.mock("../adapters/use-disabled-adapters", () => ({
useDisabledAdaptersSync: () => mockAdapterRegistry.disabled,
// Added by #11371's adapter snap. A module mock replaces the whole module,
// so every export the component reaches for has to be here — omitting one
// makes it undefined and the call throws.
useAdapterRegistryLoaded: () => true,
}));
// Adapters with a declared login capability, mirroring the real registry:
// `claude_local` and `codex_local` both support a sandbox login, and every
// other type has none, matching the real `useAdapterCapabilities` fallback for
// an unlisted type.
//
// The panel modes are the real ones rather than one mode for both. They used to
// be, back when nothing outside the panel dispatcher read them. The connect
// step reads them now — the two logins end in different places, so its button
// waits differently for each — and a mock that called Claude's login
// `displayed_code` would have the step testing the wrong half of that.
// Reconcile with KNOWN_DEFAULTS in `adapters/use-adapter-capabilities.ts`.
const ADAPTER_LOGIN_MODES: Record<string, "displayed_code" | "submitted_browser_code"> = {
claude_local: "submitted_browser_code",
codex_local: "displayed_code",
};
vi.mock("../adapters/use-adapter-capabilities", () => ({
useAdapterCapabilities: () => (type: string) => ({
supportsInstructionsBundle: false,
supportsSkills: false,
supportsLocalAgentJwt: false,
requiresMaterializedRuntimeSkills: false,
login: ADAPTER_LOGIN_MODES[type]
? { panelMode: ADAPTER_LOGIN_MODES[type]!, timeoutPolicy: "fixed" as const }
: undefined,
}),
}));
// Animation / canvas-ish children that add nothing to the logic under test.
vi.mock("./AsciiArtAnimation", () => ({ AsciiArtAnimation: () => null }));
vi.mock("./AgentCapsule", () => ({ AgentCapsule: () => null }));
import { ApiError } from "../api/client";
import { queryKeys } from "../lib/queryKeys";
import { ADAPTER_AUTH_MISSING_CHECK_CODE, getEnvironmentCapabilities } from "@paperclipai/shared";
import { CLAUDE_OAUTH_TOKEN_ENV_KEY } from "./environment-variables-editor/model";
import { ONBOARDING_STORAGE_KEY, OnboardingWizard } from "./OnboardingWizard";
import { CONNECTED_HOLD_MS } from "./onboarding/onboarding-motion";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
/** React tracks input value on the DOM node; set it the way React will see. */
function setControlledValue(el: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)!.set!;
setter.call(el, value);
el.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
const SESSION_USER_ID = "user-b";
function render() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// The company list is keyed by account, so it stays disabled until the
// session is known. Seeding it is how these tests say "signed in as B".
queryClient.setQueryData(queryKeys.auth.session, {
session: { id: "session-b", userId: SESSION_USER_ID },
user: { id: SESSION_USER_ID, name: "B", email: "b@example.com", image: null },
});
return { container, root, queryClient };
}
/**
* Press the first model-source tile.
*
* By `aria-checked` rather than by label: the display registry is mocked in this
* suite, so a tile reads "claude_localSubscription" rather than "Claude Code",
* and a test that selected on the visible name would be asserting the mock.
*
* The connect step arrives with nothing chosen, so this is what opens the input
* surface and lets the step advance.
*/
async function pickFirstSource(
click: (match: (text: string) => boolean) => Promise<void>,
): Promise<void> {
const tile = [...document.body.querySelectorAll("button[aria-checked]")][0];
const label = tile?.textContent?.trim() ?? "";
await click((text) => text === label);
}
/**
* The arc footer's primary button, whatever this step calls it.
*
* Step 4 calls it "Connect", because there it starts a sign-in rather than
* simply advancing; every other arc step calls it "Next". These tests are about
* what the press does, not what it reads, so they match either — restating the
* label at twenty call sites would make a copy change look like a behaviour
* regression. The label itself is pinned once, in the step test that is about
* the label.
*/
function isArcPrimary(text: string): boolean {
return text.startsWith("Next") || text.startsWith("Connect");
}
describe("OnboardingWizard restore-gate (stale localStorage across accounts)", () => {
beforeEach(() => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false });
localHealth.get.mockResolvedValue({ deploymentMode: "authenticated" });
managedApi.checkLocalLogin.mockReset().mockResolvedValue({
status: "sign_in_required",
authorizationUrl: "https://claude.ai/oauth/authorize?code=true",
code: "TEST-CODE",
});
managedApi.connectLocal.mockReset().mockResolvedValue({ connectionId: "local-connection", grantId: "local-grant" });
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-b", userId: SESSION_USER_ID },
user: { id: SESSION_USER_ID, name: "B", email: "b@example.com", image: null },
});
mockSecretsApi.list.mockResolvedValue([]);
mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
mockSecretsApi.removeUserSecretDefinition.mockResolvedValue({ ok: true });
window.localStorage.clear();
mockDialog.onboardingOpen = true;
mockDialog.onboardingOptions = {};
mockDialog.onboardingRouteDismissed = false;
mockCompany.companies = [];
mockCompany.loading = false;
mockCompany.error = null;
mockCompaniesApi.list.mockResolvedValue([]);
mockAdapterRegistry.list = [];
mockAdapterRegistry.disabled = new Set<string>();
mockAdapterBuild.buildAdapterConfig.mockReset();
mockAdapterBuild.buildAdapterConfig.mockReturnValue({});
// Default: no stored Claude login for the owner. The route returns a
// fixed 404 for a missing value, so the client treats a real `ApiError`
// with that status as "no stored value" rather than a hard failure.
mockAgentsApi.getClaudeOAuthTokenStatus.mockReset();
mockAgentsApi.getClaudeOAuthTokenStatus.mockRejectedValue(
new ApiError("Not found", 404, null),
);
// Default: no active login session for the caller. A resume test
// overrides this with a resolved session body.
mockAgentsApi.getActiveAdapterAuthLoginSession.mockReset();
mockAgentsApi.getActiveAdapterAuthLoginSession.mockRejectedValue(
new ApiError("Not found", 404, null),
);
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockReset();
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockRejectedValue(
new ApiError("Not found", 404, null),
);
// Reset to each mock's original default. `mockResolvedValue` /
// `mockReturnValue` overrides a mock's implementation permanently — it
// is not undone by `afterEach`'s `vi.clearAllMocks()`, which only clears
// call history — so a test that customizes one of these must not leak
// its override into the next test.
mockAgentsApi.testEnvironment.mockReset();
mockAgentsApi.adapterModels.mockReset().mockResolvedValue([]);
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "claude_local",
status: "pass" as const,
checks: [],
testedAt: new Date().toISOString(),
});
mockAgentsApi.hire.mockReset();
mockAgentsApi.hire.mockResolvedValue({ agent: { id: "agent-1" }, approval: null });
mockCompaniesApi.create.mockResolvedValue({
id: "created",
name: "Created Co",
issuePrefix: "CRE",
});
mockCompaniesApi.update.mockResolvedValue({
id: "c1",
name: "Acme Rockets",
issuePrefix: "PAP",
});
mockGoalsApi.create.mockResolvedValue({ id: "goal-1" });
mockGoalsApi.list.mockResolvedValue([]);
});
afterEach(() => {
document.body.innerHTML = "";
vi.clearAllMocks();
});
describe("qualified Copilot onboarding", () => {
async function openCopilot() {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false, enableNativeRunner: true });
mockCompany.companies = [{ id: "c1", name: "Canary", issuePrefix: "CAN" }];
mockCompaniesApi.list.mockResolvedValue(mockCompany.companies);
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "paperclip_runner" }];
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 4, createdCompanyId: "c1", agentName: "Copilot canary" }));
mockAgentsApi.adapterModels.mockResolvedValue([{ id: "gpt-5.6-luna", label: "GPT 5.6 Luna" }]);
mockAdapterBuild.buildAdapterConfig.mockImplementation((...args: unknown[]) => {
const values = args[0] as { adapterSchemaValues: Record<string, unknown>; model: string };
return { ...values.adapterSchemaValues, model: values.model };
});
const { root, queryClient } = render();
queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableManagedSandboxOnly: false, enableNativeRunner: true });
await act(async () => root.render(<QueryClientProvider client={queryClient}><OnboardingWizard /></QueryClientProvider>));
await flushReact();
const click = async (label: string) => {
const button = [...document.body.querySelectorAll("button")].find((entry) => entry.textContent?.includes(label))!;
expect(button).toBeTruthy();
await act(async () => button.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await flushReact();
};
await click("GitHub Copilot");
await flushReact();
return { root, click };
}
it.each([null, "copilot-daytona"])("discovers models and tests the same saved-token configuration it hires in environment %s", async (environmentId) => {
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: environmentId });
mockEnvironmentsApi.list.mockResolvedValue(environmentId ? [{ id: environmentId, name: "Daytona", driver: "sandbox", status: "active", config: { provider: "daytona" } }] : []);
const { root, click } = await openCopilot();
const claudeReadsBeforeCopilot = mockAgentsApi.getClaudeOAuthTokenStatus.mock.calls.length;
expect((mockAgentsApi.adapterModels.mock.calls as unknown[][]).some((call) => call[1] === "paperclip_runner")).toBe(false);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await click("Use saved Copilot connection");
expect(document.body.querySelector('[data-environment]')?.getAttribute("data-environment") ?? null).toBe(environmentId);
expect(mockAgentsApi.adapterModels).toHaveBeenCalledWith("c1", "paperclip_runner", { environmentId, provider: "acpx", acpxAgent: "copilot", aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } });
const select = document.body.querySelector('[aria-label="Copilot model"]') as HTMLSelectElement;
expect(select.value).toBe("");
await act(async () => { select.value = "gpt-5.6-luna"; select.dispatchEvent(new Event("change", { bubbles: true })); });
await flushReact();
await click("Connect");
const tested = mockAgentsApi.testEnvironment.mock.calls[0] as unknown as [string, string, { adapterConfig: Record<string, unknown>; aiConnection: unknown }];
const hired = mockAgentsApi.hire.mock.calls[0] as unknown[];
expect(tested).toMatchObject(["c1", "paperclip_runner", { adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" }, aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } }]);
expect(hired).toMatchObject(["c1", { adapterType: "paperclip_runner", adapterConfig: tested[2].adapterConfig, runtimeConfig: { aiConnection: tested[2].aiConnection } }]);
expect(mockAgentsApi.getClaudeOAuthTokenStatus.mock.calls.length).toBe(claudeReadsBeforeCopilot);
expect(JSON.stringify(hired)).not.toContain("COPILOT_GITHUB_TOKEN");
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ adapterType: "paperclip_runner", onboardingCopilot: true, model: "gpt-5.6-luna" });
await act(async () => root.unmount());
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockEnvironmentsApi.list.mockResolvedValue([]);
});
it("refuses an unavailable model after refresh without substituting or hiring", async () => {
const { root, click } = await openCopilot();
await click("Use saved Copilot connection");
const select = document.body.querySelector('[aria-label="Copilot model"]') as HTMLSelectElement;
await act(async () => { select.value = "gpt-5.6-luna"; select.dispatchEvent(new Event("change", { bubbles: true })); });
await flushReact();
mockAgentsApi.adapterModels.mockResolvedValue([{ id: "other-model", label: "Other model" }]);
await click("Refresh models");
expect(select.value).toBe("gpt-5.6-luna");
expect(select.textContent).toContain("gpt-5.6-luna (unavailable)");
const primary = [...document.body.querySelectorAll("button")].find((entry) => entry.textContent?.trim() === "Connect");
expect(primary?.disabled).toBe(true);
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
});
describe("step 1 leads straight to the agent — there is no mission step 2", () => {
// One path now: Name your organization → Name your agent → Connect → Get
// started. The Build / Grow front door and both mission screens are gone,
// so "Continue" on step 1 creates the organization and lands on the agent
// step with no mission question in between.
async function openStepOne() {
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({ step: 1, companyName: "Initech" }),
);
mockDialog.onboardingOptions = {};
mockCompany.companies = [];
mockCompany.loading = false;
mockCompaniesApi.list.mockResolvedValue([]);
// The model step needs tiles to pick from, and this suite's default
// registry is empty.
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
const { root, queryClient } = render();
const renderTree = () =>
act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderTree();
await flushReact();
return { root, renderTree };
}
async function clickByText(match: (text: string) => boolean) {
const el = [...document.body.querySelectorAll("button")].find((b) =>
match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
}
it("creates the organization on Continue and lands on the agent step, no mission", async () => {
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne();
await clickByText((t) => t.startsWith("Continue"));
expect(mockCompaniesApi.create).toHaveBeenCalledWith({ name: "Initech" });
expect(document.body.textContent).toContain("Create your first agent");
expect(document.body.textContent).not.toContain("Define your mission");
expect(document.body.textContent).not.toContain("Tell us about your team");
await act(async () => root.unmount());
});
it("shows no environment-check card on the model step, and no Mission row on review", async () => {
// Round-3 walk feedback: the adapter environment check still runs —
// Connect probes before hiring and blocks on a fail — but its idle card
// (explainer plus "Test now") is gone. And the review checklist lost its
// "Mission" row: onboarding stopped asking, so the row could only ever
// render unchecked. Both asserted against positive anchors so an
// unrendered step cannot pass as an absence.
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne();
await clickByText((t) => t.startsWith("Continue"));
expect(document.body.textContent).toContain("Create your first agent");
// Step 3 → 4 needs an agent name — the one field the step has now.
const agentField = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement;
await act(async () => {
setControlledValue(agentField, "Ada");
});
await flushReact();
await clickByText((t) => isArcPrimary(t));
expect(document.body.textContent).toContain("Connect a model");
await pickFirstSource(clickByText);
expect(document.body.textContent).not.toContain("Adapter environment check");
expect(document.body.textContent).not.toContain("Test now");
// Through Connect to Review, so the Mission-row assertion runs against
// the checklist that actually renders it — stopping at the model step
// would let a Mission regression pass unseen.
await clickByText((t) => isArcPrimary(t));
// The review step is the heading and the woken agent, nothing else: the
// checklist that restated the walk in three rows is gone, and with it
// the Mission row that could only render unchecked.
expect(document.body.textContent).toContain("Let's get started...");
expect(document.body.textContent).toContain("Ada is ready to work!");
expect(document.body.textContent).not.toContain("Organization name");
expect(document.body.textContent).not.toContain("Model connected");
expect(document.body.textContent).not.toContain("Mission");
await act(async () => root.unmount());
});
it("adopts an agent the company already has under that name instead of hiring it twice", async () => {
// The wizard can reopen on the agent step for a company that just got
// its first agent — the dashboard's agentless offer on a stale list is
// one way — with nothing in its state to say the hire happened. The
// server numbers a repeat name, so without this the walk produced
// "Ada" and "Ada 2". Same name on the same source is the same agent.
mockDialog.onboardingOptions = {};
mockCompany.companies = [];
mockCompany.loading = false;
mockCompaniesApi.list.mockResolvedValue([]);
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
mockAgentsApi.list.mockResolvedValueOnce([
{ id: "agent-existing", name: "Ada", adapterType: "claude_local" },
]);
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
const clickText = async (match: (t: string) => boolean) => {
const el = [...document.body.querySelectorAll("button")].find((b) =>
match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
};
const nameField = document.body.querySelector(
"#onboarding-company-name",
) as HTMLInputElement | null;
if (nameField) {
await act(async () => {
setControlledValue(nameField, "Initech");
});
await flushReact();
} else {
const anyName = document.body.querySelector(
'input[placeholder="e.g. Northwind Labs"]',
) as HTMLInputElement;
await act(async () => {
setControlledValue(anyName, "Initech");
});
await flushReact();
}
await clickText((t) => t.startsWith("Continue"));
const agentField = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement;
await act(async () => {
setControlledValue(agentField, "ada ");
});
await flushReact();
await clickText((t) => isArcPrimary(t));
await pickFirstSource(clickText);
await clickText((t) => isArcPrimary(t));
expect(mockAgentsApi.list).toHaveBeenCalledWith("company-new");
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("ada is ready to work!");
await act(async () => root.unmount());
});
it("hires from a legacy draft that saved an empty role", async () => {
// `agentRole: ""` was this field's default before the arc stopped asking
// for a role, so every draft saved by an earlier build carries it. `??`
// would pass the empty string straight through to the hire's silent
// return — the same no-op the default exists to prevent, arriving
// through a restored draft instead of a fresh one.
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({ step: 1, companyName: "Initech", agentRole: "" }),
);
mockDialog.onboardingOptions = {};
mockCompany.companies = [];
mockCompany.loading = false;
mockCompaniesApi.list.mockResolvedValue([]);
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
// The model step needs tiles to pick from, and this suite's default
// registry is empty.
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
const { root, queryClient } = render();
const renderTree = () =>
act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderTree();
await flushReact();
const clickText = async (match: (t: string) => boolean) => {
const el = [...document.body.querySelectorAll("button")].find((b) =>
match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
};
await clickText((t) => t.startsWith("Continue"));
const agentField = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement;
await act(async () => {
setControlledValue(agentField, "Ada");
});
await flushReact();
await clickText((t) => isArcPrimary(t));
await pickFirstSource(clickText);
await clickText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).toHaveBeenCalled();
// The mock is declared with no parameters, so index the call rather than
// destructuring a zero-length tuple type.
const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[];
expect((hireArgs[1] as { role: string }).role).toBe("general");
await act(async () => root.unmount());
});
it("hires one agent when Connect fires twice in one breath", async () => {
// The Connect handler re-runs a cached failed probe now that "Test now"
// is gone — so two overlapping submissions could both pass the fresh
// probe and both hire. `loading` cannot stop the second caller: it is
// state, unwritten while the first call is still awaiting. The ref
// guard must make the second submission a no-op.
let resolveHire: (v: { agent: { id: string }; approval: null }) => void = () => {};
mockAgentsApi.hire.mockReturnValue(
new Promise((resolve) => {
resolveHire = resolve;
}),
);
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne();
await clickByText((t) => t.startsWith("Continue"));
const agentField = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement;
await act(async () => {
setControlledValue(agentField, "Ada");
});
await flushReact();
await clickByText((t) => isArcPrimary(t));
expect(document.body.textContent).toContain("Connect a model");
await pickFirstSource(clickByText);
const connect = [...document.body.querySelectorAll("button")].find((b) =>
isArcPrimary(b.textContent?.trim() ?? ""),
)!;
await act(async () => {
connect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
connect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => resolveHire({ agent: { id: "agent-1" }, approval: null }));
await flushReact();
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
});
it("creates one company for one keystroke, modifier or not", async () => {
// The name field handles Enter itself and does not check for a modifier,
// so Cmd+Enter in that field reaches the field's handler *and* the
// wizard's step-level one. Both would start creating. The step-level
// `loading` guard cannot stop it — `setLoading(true)` has not landed
// while the same event is still bubbling — so the second caller reads a
// value the first has not written. Two companies, one keystroke.
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne();
const nameInput = document.body.querySelector(
'input[placeholder="e.g. Northwind Labs"]',
) as HTMLInputElement;
await act(async () => {
nameInput.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }),
);
});
await flushReact();
expect(mockCompaniesApi.create).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("Create your first agent");
await act(async () => root.unmount());
});
it("creates one company however many times Enter repeats", async () => {
// Holding Enter down fires keydown repeatedly. Each one is a separate
// event, so `defaultPrevented` says nothing about the others, and neither
// `loading` nor `createdCompanyId` has been written by the time the next
// arrives — the first is state, the second is not set until the request
// it guards resolves. Only a ref written before the request goes out is
// visible to the caller behind it.
let resolveCreate: (c: { id: string; issuePrefix: string }) => void = () => {};
mockCompaniesApi.create.mockReturnValue(
new Promise<{ id: string; issuePrefix: string }>((resolve) => {
resolveCreate = resolve;
}),
);
const { root } = await openStepOne();
const nameInput = document.body.querySelector(
'input[placeholder="e.g. Northwind Labs"]',
) as HTMLInputElement;
await act(async () => {
for (let i = 0; i < 4; i++) {
nameInput.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
}
});
expect(mockCompaniesApi.create).toHaveBeenCalledTimes(1);
await act(async () => resolveCreate({ id: "company-new", issuePrefix: "INI" }));
await flushReact();
expect(document.body.textContent).toContain("Create your first agent");
await act(async () => root.unmount());
});
it("sends Back to the screen the run actually came from", async () => {
// A create run reached the agent step from step 1, so Back owes it step 1 —
// not the mission screen it never saw.
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
const { root } = await openStepOne();
await clickByText((t) => t.startsWith("Continue"));
expect(document.body.textContent).toContain("Create your first agent");
await clickByText((t) => t.includes("Back"));
expect(document.body.textContent).toContain("What is the name of your organization?");
expect(document.body.textContent).not.toContain("Define your mission");
await act(async () => root.unmount());
});
});
describe("hire gate: adapter authentication (claude_local, the default onboarding adapter)", () => {
/** Drives the wizard to the Connect step, agent name already filled in. */
async function openConnectStep({ useApiKeys = false } = {}) {
// The tile row is built from this registry, and the suite's default is
// empty. That was survivable while the step preselected a source; now that
// nothing is chosen until a tile is pressed, a step with no tiles is a step
// that can never advance.
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({ step: 1, companyName: "Initech" }),
);
mockDialog.onboardingOptions = {};
mockCompany.companies = [];
mockCompany.loading = false;
mockCompaniesApi.list.mockResolvedValue([]);
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
const clickByText = async (match: (text: string) => boolean) => {
const el = [...document.body.querySelectorAll("button")].find((b) =>
match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
};
await clickByText((t) => t.startsWith("Continue"));
const agentField = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement;
await act(async () => {
setControlledValue(agentField, "Ada");
});
await flushReact();
await clickByText((t) => isArcPrimary(t));
expect(document.body.textContent).toContain("Connect a model");
// The credential mode is chosen *before* a source, because picking a
// source starts the sequence and the mode link fades out with the row —
// after that it is inert, and switching would mean changing the card out
// from under a running sign-in.
if (useApiKeys) {
await clickByText((t) => t.startsWith("Use API key"));
}
// Pick a source. The step arrives with nothing chosen — `adapterType`
// carries a value for the hire, but that is not the same as the customer
// having answered — so the input surface stays closed and the step will
// not advance until a tile is pressed. Every case below is about what
// happens *after* that choice, so the helper makes it.
await pickFirstSource(clickByText);
return { root, clickByText };
}
it("blocks the hire on a warn result that holds adapter_auth_missing, and shows the returned checks", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "claude_local",
status: "warn" as const,
checks: [
{
code: ADAPTER_AUTH_MISSING_CHECK_CODE,
level: "warn" as const,
message: "No stored Claude login was found for this agent.",
},
],
testedAt: new Date().toISOString(),
});
const { root, clickByText } = await openConnectStep();
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain(
"No stored Claude login was found for this agent.",
);
await act(async () => root.unmount());
});
it("still hires on a warn result that carries no adapter_auth_missing check", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "claude_local",
status: "warn" as const,
checks: [
{
code: "claude_anthropic_api_key_overrides_subscription",
level: "warn" as const,
message: "ANTHROPIC_API_KEY overrides the subscription login.",
},
],
testedAt: new Date().toISOString(),
});
const { root, clickByText } = await openConnectStep();
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).toHaveBeenCalled();
await act(async () => root.unmount());
});
// The Connect handler reuses a passing probe instead of re-running it, so the
// effect that clears the cache has to name every input to the configuration
// the probe tested. `credentialMode` and `apiKey` were missing from it, and
// the gap is reachable: the probe and the hire share one try/catch, so a hire
// that throws leaves the pass in state. Switching to a key and pressing
// Connect again then hired against a key nothing had tested.
/**
* Typing a key into this step must not put the key into the agent's stored
* configuration. That configuration is persisted and revisioned, so a plain
* value there is a live credential at rest in every copy of it — which is
* what this step did before, and what the Claude token path has always
* avoided by holding a `user_secret_ref` instead.
*/
it.each(["personal", "organization"])("defaults to a saved %s API key and uses the same reference for probe and hire", async (scope) => {
const key = "ANTHROPIC_API_KEY";
const binding = scope === "personal"
? { type: "user_secret_ref", key, version: "latest" }
: { type: "secret_ref", secretId: "saved-org-key", version: "latest" };
if (scope === "personal") {
mockSecretsApi.listMyUserSecrets.mockResolvedValue([{
definition: { id: "saved-key", companyId: "company-new", key, name: "Saved key", status: "active" },
secret: { companyId: "company-new", status: "active" },
}]);
} else {
mockSecretsApi.list.mockResolvedValue([{
id: "saved-org-key", companyId: "company-new", key, name: "Saved key", scope: "company", status: "active",
}]);
}
const { root, clickByText } = await openConnectStep();
const picker = document.body.querySelector('select[aria-label="Saved API key"]') as HTMLSelectElement;
expect(picker.value).toBe(scope === "personal" ? "user:saved-key" : "company:saved-org-key");
await clickByText((t) => isArcPrimary(t));
expect((mockAgentsApi.testEnvironment.mock.calls.at(-1) as unknown[])[2]).toMatchObject({ adapterConfig: { env: { [key]: binding } } });
expect((mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1]).toMatchObject({ adapterConfig: { env: { [key]: binding } } });
expect(mockSecretsApi.createMyUserSecret).not.toHaveBeenCalled();
expect(mockSecretsApi.rotateMyUserSecret).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
describe("an API key typed on the step", () => {
const KEY = "sk-ant-typed-by-the-customer";
// The canvas holding the key field only opens once a source is selected,
// and the tile row that selects one is built from this registry. The
// suite's default is empty, which leaves the step with no tiles, no
// canvas, and no field to type into.
beforeEach(() => {
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
// No definition and no stored value yet: the first customer to type a key.
mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
mockSecretsApi.createUserSecretDefinition.mockResolvedValue({ id: "def-1" });
mockSecretsApi.createMyUserSecret.mockResolvedValue({ id: "secret-abc" });
mockSecretsApi.rotateMyUserSecret.mockResolvedValue({ id: "secret-existing" });
});
async function connectWithApiKey() {
const handles = await openConnectStep({ useApiKeys: true });
const field = document.body.querySelector(
'input[type="password"]',
) as HTMLInputElement;
await act(async () => {
setControlledValue(field, KEY);
});
await flushReact();
await handles.clickByText((t) => isArcPrimary(t));
return handles;
}
it("is stored as a personal connection and referenced, never carried in the hire", async () => {
const { root } = await connectWithApiKey();
expect(managedApi.create).toHaveBeenCalledTimes(1);
expect(managedApi.create).toHaveBeenCalledWith("company-new", expect.objectContaining({ provider: "anthropic", method: "api_key", ownership: "personal", apiKey: KEY }));
const hireBody = (mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1] as { runtimeConfig: { aiConnection: unknown }; adapterConfig: { env?: Record<string, unknown> } };
expect(hireBody.runtimeConfig.aiConnection).toEqual({ provider: "anthropic", method: "api_key", mode: "responsible_user" });
expect(hireBody.adapterConfig.env?.ANTHROPIC_API_KEY).toBeUndefined();
// The whole payload, not just that one field: the point is that the key
// is nowhere in what gets persisted, however it might be nested.
expect(JSON.stringify(hireBody)).not.toContain(KEY);
await act(async () => root.unmount());
});
it("creates a managed connection without rotating an existing saved key", async () => {
mockSecretsApi.listMyUserSecrets.mockResolvedValue([
{ definition: { id: "old-def", key: "ANTHROPIC_API_KEY" }, secret: { id: "secret-existing" } },
]);
const { root } = await connectWithApiKey();
expect(managedApi.create).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ apiKey: KEY }));
expect(mockSecretsApi.rotateMyUserSecret).not.toHaveBeenCalled();
expect(managedApi.create).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
});
// The one outcome that must never happen is a hire that falls back to
// embedding the key because storing it failed.
it("blocks the hire when the key cannot be stored", async () => {
managedApi.create.mockRejectedValueOnce(new Error("vault unreachable"));
const { root } = await connectWithApiKey();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Could not store the API key");
await act(async () => root.unmount());
});
it("stores one connection when Connect is pressed twice with the same key", async () => {
mockAgentsApi.hire.mockRejectedValueOnce(new Error("network went away"));
const { root, clickByText } = await connectWithApiKey();
await clickByText((t) => isArcPrimary(t));
expect(managedApi.create).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
});
});
it("re-probes rather than reusing a pass when the credential mode changes", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "claude_local",
status: "pass" as const,
checks: [],
testedAt: new Date().toISOString(),
});
// The hire fails, which is what leaves the passing probe behind.
mockAgentsApi.hire.mockRejectedValueOnce(new Error("network went away"));
const { root, clickByText } = await openConnectStep();
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
// Switching the credential mode means backing out first: the mode link
// fades away with the row once a source is chosen, and is inert after
// that, so it cannot be used to change the card out from under a running
// sign-in. Back unwinds to the question, and the answer is given again.
await clickByText((t) => t.startsWith("Back"));
await clickByText((t) => t.startsWith("Use API key"));
await pickFirstSource(clickByText);
const field = document.body.querySelector(
'input[type="password"]',
) as HTMLInputElement;
await act(async () => {
setControlledValue(field, "sk-ant-rekey");
});
await flushReact();
await clickByText((t) => isArcPrimary(t));
// A different configuration, so the passing probe from before it cannot
// stand in for one against this one.
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
await act(async () => root.unmount());
});
it("does not open the create path on a cached warn result that holds adapter_auth_missing", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "claude_local",
status: "warn" as const,
checks: [
{
code: ADAPTER_AUTH_MISSING_CHECK_CODE,
level: "warn" as const,
message: "No stored Claude login was found for this agent.",
},
],
testedAt: new Date().toISOString(),
});
const { root, clickByText } = await openConnectStep();
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
// A second Connect must not treat the first (cached) blocking result as
// reusable — it re-probes, and the create path stays closed.
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
it("sends the fixed Claude binding and applyStoredClaudeLogin when a stored login exists", async () => {
mockAgentsApi.getClaudeOAuthTokenStatus.mockReset();
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({
secretId: "11111111-1111-1111-1111-111111111111",
latestVersion: 1,
});
const { root } = await openConnectStep();
// Selecting the provider automatically verifies the saved subscription.
await flushReact();
expect(mockAgentsApi.hire).toHaveBeenCalled();
const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[];
const hireBody = hireArgs[1] as {
adapterConfig: { env?: Record<string, unknown> };
applyStoredClaudeLogin?: boolean;
};
expect(hireBody.applyStoredClaudeLogin).toBe(true);
expect(hireBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toEqual({
type: "user_secret_ref",
key: CLAUDE_OAUTH_TOKEN_ENV_KEY,
version: "latest",
required: true,
});
await act(async () => root.unmount());
});
it("sends no binding and no flag when the Claude status route returns 404", async () => {
// The default `beforeEach` mock already rejects with a 404 `ApiError`,
// matching "no stored value" — asserted explicitly here to pin the
// scenario this test is named for.
const { root, clickByText } = await openConnectStep();
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).toHaveBeenCalled();
const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[];
const hireBody = hireArgs[1] as {
adapterConfig: { env?: Record<string, unknown> };
applyStoredClaudeLogin?: boolean;
};
expect(hireBody.applyStoredClaudeLogin).toBeUndefined();
expect(hireBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toBeUndefined();
await act(async () => root.unmount());
});
it("sends no binding when the adapter configuration holds a non-empty ANTHROPIC_API_KEY", async () => {
mockAgentsApi.getClaudeOAuthTokenStatus.mockReset();
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({
secretId: "11111111-1111-1111-1111-111111111111",
latestVersion: 1,
});
mockAdapterBuild.buildAdapterConfig.mockReturnValue({
env: { ANTHROPIC_API_KEY: { type: "plain", value: "sk-ant-configured" } },
});
const { root } = await openConnectStep();
// Selecting the provider automatically verifies the saved subscription.
await flushReact();
expect(mockAgentsApi.hire).toHaveBeenCalled();
// Discovery reads saved-login metadata once; the hire does not re-read
// or apply it when the configuration already has an API key.
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(1);
const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[];
const hireBody = hireArgs[1] as {
adapterConfig: { env?: Record<string, unknown> };
applyStoredClaudeLogin?: boolean;
};
expect(hireBody.applyStoredClaudeLogin).toBeUndefined();
expect(hireBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toBeUndefined();
await act(async () => root.unmount());
});
it("carries no token value in the hire payload", async () => {
mockAgentsApi.getClaudeOAuthTokenStatus.mockReset();
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({
secretId: "11111111-1111-1111-1111-111111111111",
latestVersion: 1,
});
const { root } = await openConnectStep();
// Selecting the provider automatically verifies the saved subscription.
await flushReact();
expect(mockAgentsApi.hire).toHaveBeenCalled();
const hireArgs = mockAgentsApi.hire.mock.calls.at(-1) as unknown[];
const hireBody = hireArgs[1] as { adapterConfig: { env?: Record<string, unknown> } };
const binding = hireBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY] as
| { type: string }
| undefined;
// A reference, never a value: no `value` field, no `secretId` field
// either — the fixed binding names the env var, not the status
// response's secret id.
expect(binding?.type).toBe("user_secret_ref");
expect(JSON.stringify(hireBody)).not.toContain("secretId");
await act(async () => root.unmount());
});
it("sends the fixed binding in the environment test request when the status route returns 200", async () => {
mockAgentsApi.getClaudeOAuthTokenStatus.mockReset();
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({
secretId: "11111111-1111-1111-1111-111111111111",
latestVersion: 1,
});
const { root } = await openConnectStep();
// Selecting the provider automatically verifies the saved subscription.
await flushReact();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalled();
const testArgs = mockAgentsApi.testEnvironment.mock.calls.at(-1) as unknown[];
const testBody = testArgs[2] as { adapterConfig: { env?: Record<string, unknown> } };
expect(testBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toEqual({
type: "user_secret_ref",
key: CLAUDE_OAUTH_TOKEN_ENV_KEY,
version: "latest",
required: true,
});
await act(async () => root.unmount());
});
it("sends no binding in the environment test request when the status route returns 404", async () => {
// The default `beforeEach` mock already rejects with a 404 `ApiError`.
const { root, clickByText } = await openConnectStep();
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.testEnvironment).toHaveBeenCalled();
const testArgs = mockAgentsApi.testEnvironment.mock.calls.at(-1) as unknown[];
const testBody = testArgs[2] as { adapterConfig: { env?: Record<string, unknown> } };
expect(testBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY]).toBeUndefined();
await act(async () => root.unmount());
});
it("hires when a stored login exists, even though a probe without the binding would warn adapter_auth_missing", async () => {
mockAgentsApi.getClaudeOAuthTokenStatus.mockReset();
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({
secretId: "11111111-1111-1111-1111-111111111111",
latestVersion: 1,
});
// Answers like the real sandbox probe: `warn` with `adapter_auth_missing`
// for a configuration with no binding, `pass` once the binding is
// present. This proves the wizard sends the probe the SAME configuration
// it hires with — a probe still sent without the binding would warn and
// block the hire below.
mockAgentsApi.testEnvironment.mockImplementation(
(async (...args: unknown[]) => {
const request = args[2] as {
adapterConfig: { env?: Record<string, unknown> };
};
const hasBinding = Boolean(
request.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY],
);
return hasBinding
? {
adapterType: "claude_local" as const,
status: "pass" as const,
checks: [],
testedAt: new Date().toISOString(),
}
: {
adapterType: "claude_local" as const,
status: "warn" as const,
checks: [
{
code: ADAPTER_AUTH_MISSING_CHECK_CODE,
level: "warn" as const,
message: "No stored Claude login was found for this agent.",
},
],
testedAt: new Date().toISOString(),
};
}) as unknown as () => Promise<
import("@paperclipai/shared").AdapterEnvironmentTestResult
>,
);
const { root } = await openConnectStep();
// Selecting the provider automatically verifies the saved subscription.
await flushReact();
expect(mockAgentsApi.hire).toHaveBeenCalled();
await act(async () => root.unmount());
});
it("blocks the hire when the probe reports warn with adapter_auth_missing and no stored login exists", async () => {
// The default `beforeEach` mock already rejects with a 404 `ApiError`.
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "claude_local",
status: "warn" as const,
checks: [
{
code: ADAPTER_AUTH_MISSING_CHECK_CODE,
level: "warn" as const,
message: "No stored Claude login was found for this agent.",
},
],
testedAt: new Date().toISOString(),
});
const { root, clickByText } = await openConnectStep();
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain(
"No working authentication was found",
);
await act(async () => root.unmount());
});
it("reads the stored-login status once for each create attempt", async () => {
mockAgentsApi.getClaudeOAuthTokenStatus.mockReset();
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({
secretId: "11111111-1111-1111-1111-111111111111",
latestVersion: 1,
});
// Fails after the gate opens, so the button stays clickable for a
// second attempt instead of advancing past step 4.
mockAgentsApi.hire.mockRejectedValue(new Error("hire failed"));
const { root, clickByText } = await openConnectStep();
const discoveryReads = mockAgentsApi.getClaudeOAuthTokenStatus.mock.calls.length;
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(discoveryReads + 1);
await clickByText((t) => isArcPrimary(t));
expect(mockAgentsApi.getClaudeOAuthTokenStatus).toHaveBeenCalledTimes(discoveryReads + 2);
await act(async () => root.unmount());
});
});
it("re-syncs a restored draft once companies resolve asynchronously (companies start empty/loading)", async () => {
// Regression for the initializer-only restore bug: the inner wizard's
// ~20 useState(saved?.x ?? default) initializers only read `saved` on
// their very first render. useCompany() starts with companies=[] and
// loading=true and resolves later; if the inner component mounted before
// that resolution, restoreOnboardingState would see an empty companies
// list and the whole draft would lock to defaults forever, even after
// companies arrive. The fix defers mounting the inner wizard until
// companies settle.
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({
step: 3,
companyName: "Saved Co",
agentName: "Ops Lead",
createdCompanyId: "c1",
}),
);
mockDialog.onboardingOptions = {};
mockCompany.companies = [];
mockCompany.loading = true;
// Deferred rather than swapped later: the gate's fetch fires on the first
// render, so replacing the mock afterwards would never reach it.
let resolveList: (companies: Array<{ id: string; name: string; issuePrefix: string }>) => void =
() => {};
mockCompaniesApi.list.mockReturnValue(
new Promise<Array<{ id: string; name: string; issuePrefix: string }>>((resolve) => {
resolveList = resolve;
}),
);
const { container, root, queryClient } = render();
const renderTree = () =>
act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await renderTree();
await flushReact();
// Nothing mounts yet: no premature guess, and the draft is not touched.
expect(container.textContent).toBe("");
expect(document.body.textContent).toBe("");
expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).not.toBeNull();
// Companies resolve asynchronously, owning the saved company.
mockCompany.companies = [{ id: "c1", name: "Saved Co", issuePrefix: "SC" }];
mockCompany.loading = false;
await act(async () => {
resolveList([{ id: "c1", name: "Saved Co", issuePrefix: "SC" }]);
});
await renderTree();
await flushReact();
// The draft is restored once companies settle: step 3 (Create your first
// agent) with the saved agent name in the input, not the defaults
// (step 0, "Chief of staff").
expect(document.body.textContent).toContain("Create your first agent");
const nameInput = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement | null;
expect(nameInput?.value).toBe("Ops Lead");
// The run entered on the agent arc, so the arc strip is the progress
// indicator and counts 1-3 over the wizard's steps 3-5. Segments are
// labelled by destination: the wizard has its own numbering, and two
// controls both announcing "Step 1" would mean different things.
const currentStep = document.body.querySelector('[aria-current="step"]');
expect(currentStep?.getAttribute("aria-label")).toBe("Create your first agent");
expect(document.body.textContent).toContain("Step 1 of 3");
await act(async () => {
root.unmount();
});
});
it("keeps the live wizard mounted while a post-create company-list refetch runs", async () => {
// The authorization fetch needs to delay the first mount when a draft
// exists. Once the customer has typed, though, invalidating that query is
// normal background work. Unmounting for the refetch remounted the wizard
// from this page-load draft and made a successful create look like a reset.
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({ step: 1, companyName: "", createdCompanyId: null }),
);
let resolveRefetch: (companies: Array<{ id: string; name: string; issuePrefix: string }>) => void =
() => {};
mockCompaniesApi.list
.mockResolvedValueOnce([])
.mockImplementationOnce(
() =>
new Promise<Array<{ id: string; name: string; issuePrefix: string }>>((resolve) => {
resolveRefetch = resolve;
}),
);
mockCompaniesApi.create.mockResolvedValue({
id: "created",
name: "Created Co",
issuePrefix: "CRE",
});
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
const companyNameInput = document.querySelector("input") as HTMLInputElement;
setControlledValue(companyNameInput, "Created Co");
await act(async () => {
[...document.body.querySelectorAll("button")]
.find((button) => button.textContent?.trim() === "Continue")!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockCompaniesApi.create).toHaveBeenCalledWith({ name: "Created Co" });
expect(mockCompaniesApi.list).toHaveBeenCalledTimes(2);
expect(document.querySelector("#onboarding-agent-name")).not.toBeNull();
await act(async () => {
resolveRefetch([{ id: "created", name: "Created Co", issuePrefix: "CRE" }]);
});
await act(async () => {
root.unmount();
});
});
it("restores a draft after its initial ownership check fails and a retry succeeds", async () => {
// A failed first request cannot authorize the draft, so the wizard opens
// with safe defaults. The original gate remounted on a later successful
// retry so the now-verified draft could be restored; keep that recovery
// while preserving the post-create refetch fix above.
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({
step: 3,
companyName: "Saved Co",
agentName: "Ops Lead",
createdCompanyId: "c1",
}),
);
const company = { id: "c1", name: "Saved Co", issuePrefix: "SC" };
let resolveRetry: (companies: Array<{ id: string; name: string; issuePrefix: string }>) => void =
() => {};
mockCompaniesApi.list
.mockRejectedValueOnce(new Error("company list unavailable"))
.mockImplementationOnce(
() =>
new Promise<Array<{ id: string; name: string; issuePrefix: string }>>((resolve) => {
resolveRetry = resolve;
}),
);
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
expect(document.querySelector("#onboarding-agent-name")).toBeNull();
mockCompany.companies = [company];
await act(async () => {
void queryClient.invalidateQueries({
queryKey: queryKeys.companies.list(SESSION_USER_ID),
});
});
await flushReact();
// The retry is intentionally still in flight. The wrapper removes the
// safe-default wizard so a completed validation can mount the saved draft.
expect(document.body.textContent).toBe("");
await act(async () => {
resolveRetry([company]);
});
await flushReact();
expect(mockCompaniesApi.list).toHaveBeenCalledTimes(2);
expect(queryClient.getQueryData(queryKeys.companies.list(SESSION_USER_ID))).toEqual({
companies: [company],
unauthorized: false,
});
const agentNameInput = document.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement | null;
expect(agentNameInput?.value).toBe("Ops Lead");
await act(async () => {
root.unmount();
});
});
it("discards a saved draft for a company the signed-in account does not own, and wipes the stale blob", async () => {
// The actual vulnerability this fix closes: localStorage is per-origin,
// not per-account, so a browser that already onboarded "company-old" for
// a different account hands its id straight to a brand-new session.
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({
step: 4,
companyName: "Someone Else's Co",
createdCompanyId: "company-old",
}),
);
mockCompany.companies = [{ id: "company-new", name: "My Co", issuePrefix: "MC" }];
mockCompany.loading = false;
mockCompaniesApi.list.mockResolvedValue([
{ id: "company-new", name: "My Co", issuePrefix: "MC" },
]);
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
// Falls back to the wizard's default first step, not the stale step 4
// draft for a company this account does not own.
expect(document.body.textContent).not.toContain("Someone Else's Co");
// The stale blob must not linger to confuse the next onboarding attempt.
expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBeNull();
await act(async () => {
root.unmount();
});
});
it("opens, and keeps the draft, when the initial company query fails", async () => {
// React Query reports a failed list as `isLoading === false` with `data`
// defaulted to `[]`, which is indistinguishable from "settled, and this
// account owns nothing" — the verdict that wipes the blob. Deleting a
// customer's onboarding because their company request timed out is the one
// outcome here that cannot be undone.
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({
step: 3,
companyName: "Saved Co",
agentName: "Ops Lead",
createdCompanyId: "c1",
}),
);
mockCompany.companies = [];
mockCompany.loading = false;
mockCompany.error = new Error("company list unavailable");
mockCompaniesApi.list.mockRejectedValue(new Error("company list unavailable"));
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
// It mounts: the companies query sets `retry: false`, and with no
// companies the dashboard's "Get Started" button opens onboarding — a gate
// that rendered nothing here would make that button dead.
expect(document.querySelector('[data-testid="onboarding-wizard"]')).not.toBeNull();
// The draft is not restored, because ownership cannot be verified...
const nameInput = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement | null;
expect(nameInput?.value ?? "").not.toBe("Ops Lead");
// ...and not deleted either. The wizard is open in this harness, so the
// persist effect does supersede it - the point is that the ownership check
// did not *discard* it. The closed case below is where it survives intact.
expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).not.toBeNull();
await act(async () => {
root.unmount();
});
});
it("renders instead of throwing when the browser denies storage access", async () => {
// Safari's private mode and blocked third-party contexts make getItem
// throw outright. This runs during render, so an escaping exception takes
// the whole app down rather than just losing a draft.
// A browser that refuses the read refuses the write too, so deny both —
// otherwise the cleanup effect's removeItem is never exercised.
const deny = () => {
throw new DOMException("The operation is insecure.", "SecurityError");
};
// Deny the browser boundary itself. Spying on a Storage object or
// prototype is not portable: DOM implementations may return a fresh
// wrapper and Node exposes a separate experimental Storage global.
const localStorage = vi.spyOn(window, "localStorage", "get").mockImplementation(deny);
mockCompany.companies = [{ id: "c1", name: "My Co", issuePrefix: "MC" }];
mockCompany.loading = false;
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
expect(localStorage).toHaveBeenCalled();
// It mounted: the wizard is open with no draft, rather than the render
// throwing on the way in.
expect(document.querySelector('[data-testid="onboarding-wizard"]')).not.toBeNull();
localStorage.mockRestore();
await act(async () => {
root.unmount();
});
});
it("opens but does not restore when a refetch fails over a cached list", async () => {
// The companies cache is not account-scoped and survives sign-out, so a
// failed refetch after an account switch can leave the *previous*
// account's companies in hand. Trusting a non-empty list there would find
// the old company id in it and hand that draft to the new account — the
// leak this whole change closes, through a different door.
//
// So: mount (no dead end), do not restore, do not delete.
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({
step: 3,
companyName: "Saved Co",
agentName: "Ops Lead",
createdCompanyId: "c1",
}),
);
mockCompany.companies = [{ id: "c1", name: "Saved Co", issuePrefix: "SC" }];
mockCompany.loading = false;
mockCompany.error = new Error("refetch failed");
mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed"));
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
// Mounted rather than blank...
expect(document.querySelector('[data-testid="onboarding-wizard"]')).not.toBeNull();
// ...but the draft was not restored, because the list cannot be trusted.
const nameInput = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement | null;
expect(nameInput?.value ?? "").not.toBe("Ops Lead");
await act(async () => {
root.unmount();
});
});
// The close-on-storage-denial case is gone with the control that reached it.
// Onboarding is a gate now, not a dialog: there is no X, Escape does not
// dismiss it, and nothing in the wizard calls `handleClose`. A test that
// clicked Close was asserting a path a customer no longer has.
//
// The open side of the same hazard is still covered by "renders instead of
// throwing when the browser denies storage access" directly above, which is
// the half that can still happen.
it("leaves the draft untouched when the company query fails and onboarding is closed", async () => {
// Mounting is safe for the draft precisely because the persist effect is
// gated on the wizard being open. A closed wizard writes nothing, so the
// blob survives for a later load that can actually verify ownership.
const draft = JSON.stringify({
step: 3,
companyName: "Saved Co",
agentName: "Ops Lead",
createdCompanyId: "c1",
});
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, draft);
mockDialog.onboardingOpen = false;
mockDialog.onboardingRouteDismissed = true;
mockCompany.companies = [];
mockCompany.loading = false;
mockCompany.error = new Error("company list unavailable");
mockCompaniesApi.list.mockRejectedValue(new Error("company list unavailable"));
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBe(draft);
await act(async () => {
root.unmount();
});
});
it("does not hand one account's draft to the next when a refetch fails after a switch", async () => {
// The attack path in full. Account A onboards and leaves a draft naming
// its company. The account then changes without this component's company
// cache being cleared, and the refetch fails, so A's list is still in
// hand. A list that still contains A's company must not be read as proof
// that B owns it.
//
// `useSignOut` now resets account-scoped caches, which closes the
// sign-out-button route into this state (see its own regression test). The
// gate is asserted here independently of that: it must hold for any route
// that leaves a stale list behind, not only the one that has been fixed.
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({
// Step 3 so the agent-name input renders and can be asserted on —
// step 4 has no such field, which would make this pass for the wrong
// reason whether or not the draft was restored.
step: 3,
companyName: "Account A Co",
agentName: "A's Lead",
createdCompanyId: "company-a",
}),
);
mockCompany.companies = [{ id: "company-a", name: "Account A Co", issuePrefix: "AAC" }];
mockCompany.loading = false;
mockCompany.error = new Error("refetch failed for the new account");
mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed for the new account"));
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
// Account A's agent name must not appear in account B's wizard.
expect(document.body.textContent).not.toContain("A's Lead");
const nameInput = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement | null;
expect(nameInput?.value ?? "").not.toBe("A's Lead");
await act(async () => {
root.unmount();
});
});
it("does not judge ownership against a warm cache that never refetched", async () => {
// The door this whole change exists to shut, and the one the previous
// version missed. `main.tsx` sets `staleTime: 30_000`, so for thirty
// seconds after a sign-in the company list is served straight from cache
// with no request — and `Auth.tsx` invalidates on sign-in but invalidation
// keeps serving the old data while refetching. Either way account A's
// companies arrive with *no loading state and no error*, so a gate keyed
// on "not loading, no error" reads them as authoritative.
//
// Here the context reports exactly that healthy-looking stale state, and
// the fetch for this session has not answered. A's draft must not be
// restored on the strength of A's cached list.
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({
step: 3,
companyName: "Account A Co",
agentName: "A's Lead",
createdCompanyId: "company-a",
}),
);
// The shared cache still holds A's list, and looks entirely healthy.
mockCompany.companies = [{ id: "company-a", name: "Account A Co", issuePrefix: "AAC" }];
mockCompany.loading = false;
mockCompany.error = null;
// The list fetched for *this* session answers with B's companies, which
// do not include A's. Note the fetch must actually complete: leaving it
// pending would keep the wizard unmounted and the assertion below would
// hold for the wrong reason.
mockCompaniesApi.list.mockResolvedValue([
{ id: "company-b", name: "Account B Co", issuePrefix: "BBC" },
]);
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
// Mounted — so this is a real observation, not an unmounted false pass.
expect(document.querySelector('[data-testid="onboarding-wizard"]')).not.toBeNull();
expect(document.body.textContent).not.toContain("A's Lead");
const nameInput = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement | null;
expect(nameInput?.value ?? "").not.toBe("A's Lead");
await act(async () => {
root.unmount();
});
});
it("does not delete a draft when the company list comes back unauthorized", async () => {
// `companiesListQueryOptions` folds 401/403 into
// `{ companies: [], unauthorized: true }` rather than throwing, so an auth
// blip arrives as a successful fetch of an empty list — which reads as
// "this account owns nothing" and would delete the draft.
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({ step: 3, companyName: "Saved Co", createdCompanyId: "c1" }),
);
mockCompaniesApi.list.mockRejectedValue(
new ApiError("forbidden", 403, null),
);
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).not.toBeNull();
await act(async () => {
root.unmount();
});
});
it("does not restore against data retained from a failed post-mount refetch", async () => {
// The gate's weak point if it only asks "was there a fetch after mount,
// and is there data". React Query keeps the last successful `data` when a
// refetch fails — so after an account switch the retained value is the
// *previous* account's list, and accepting it restores their draft.
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({
step: 3,
companyName: "Account A Co",
agentName: "A's Lead",
createdCompanyId: "company-a",
}),
);
const { root, queryClient } = render();
// A's list, already in the cache from their session.
queryClient.setQueryData(queryKeys.companies.list(SESSION_USER_ID), {
companies: [{ id: "company-a", name: "Account A Co", issuePrefix: "AAC" }],
unauthorized: false,
});
// B's session: the refetch fails, so A's data is retained.
mockCompaniesApi.list.mockRejectedValue(new Error("refetch failed"));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
// Mounted, so this observes the real thing rather than an empty document.
expect(document.querySelector('[data-testid="onboarding-wizard"]')).not.toBeNull();
expect(document.body.textContent).not.toContain("A's Lead");
const nameInput = document.body.querySelector(
"#onboarding-agent-name",
) as HTMLInputElement | null;
expect(nameInput?.value ?? "").not.toBe("A's Lead");
await act(async () => {
root.unmount();
});
});
it("does not mount undecided over a warm cache, which would overwrite the draft", async () => {
// The customer's own draft, their own companies already cached, and a
// refetch in flight. `isLoading` is false whenever retained data exists,
// so a gate keyed on it would mount the wizard while ownership was still
// undecidable — and with the wizard open, the persist effect writes the
// wizard's state back on every change, overwriting their draft with
// defaults before the answer arrives.
const draft = JSON.stringify({
step: 3,
companyName: "Saved Co",
agentName: "Ops Lead",
createdCompanyId: "c1",
});
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, draft);
const { root, queryClient } = render();
queryClient.setQueryData(queryKeys.companies.list(SESSION_USER_ID), {
companies: [{ id: "c1", name: "Saved Co", issuePrefix: "SC" }],
unauthorized: false,
});
mockCompaniesApi.list.mockReturnValue(new Promise(() => {}));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
// Nothing mounted, so nothing could have written over the draft.
expect(document.body.textContent).toBe("");
expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBe(draft);
await act(async () => {
root.unmount();
});
});
it("clears an unreadable draft without waiting on the company endpoint", async () => {
// Junk is junk whoever owns what, so judging it must not queue behind a
// request that cannot change the answer — nor issue one at all.
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, "{not json");
const { root, queryClient } = render();
mockCompaniesApi.list.mockReturnValue(new Promise(() => {}));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
await flushReact();
expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBeNull();
expect(mockCompaniesApi.list).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
describe("the adapter step login panel (cheap auth signal, no adapter test)", () => {
const SANDBOX_ENVIRONMENT = {
id: "env-sandbox-1",
driver: "sandbox" as const,
status: "active" as const,
config: { provider: "daytona" },
metadata: {},
};
const LOCAL_ENVIRONMENT = {
id: "env-local-1",
driver: "local" as const,
status: "active" as const,
config: { provider: "daytona" },
metadata: { defaultForInstance: true },
};
beforeEach(() => {
mockEnvironmentsApi.list.mockReset();
mockEnvironmentsApi.list.mockResolvedValue([SANDBOX_ENVIRONMENT]);
mockEnvironmentsApi.capabilities.mockReset();
mockEnvironmentsApi.capabilities.mockResolvedValue(
getEnvironmentCapabilities([], {
sandboxProviders: { daytona: { supportsLoginPty: true } },
}),
);
mockInstanceSettingsApi.get.mockReset();
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: "env-sandbox-1" });
mockInstanceSettingsApi.getExperimental.mockReset();
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false });
mockAgentsApi.getAdapterAuthSignal.mockReset();
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "present" });
// The row has to actually offer the adapter these drafts name. The login
// panel lives inside the input canvas, and the canvas opens for a chosen
// source — a saved adapter the row cannot show is not a chosen one, so
// without this the panel is absent for a reason that has nothing to do
// with the auth signal these tests are about.
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
});
/** Drives the wizard to the Connect step with a company already created. */
async function openStep4(overrides: Record<string, unknown> = {}) {
mockCompany.companies = [{ id: "company-new", name: "Initech", issuePrefix: "INI" }];
mockCompany.loading = false;
mockCompaniesApi.list.mockResolvedValue(mockCompany.companies);
window.localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({
step: 4,
companyName: "Initech",
agentName: "Ada",
createdCompanyId: "company-new",
adapterType: "claude_local",
...overrides,
}),
);
mockDialog.onboardingOptions = {};
const { root, queryClient } = render();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizard />
</QueryClientProvider>,
);
});
// The login-panel gate chains several dependent queries (environments,
// instance settings, environment capabilities, then the auth signal
// itself), each settling on its own render. One flush is not always
// enough to reach the end of that chain.
for (let i = 0; i < 5; i++) await flushReact();
expect(document.body.textContent).toContain("Connect a model");
// No pick needed here: the draft this helper restores already names an
// adapter, which is what a run returning to this step actually carries.
return { root, queryClient };
}
it.each([
["claude_local", "anthropic", /Claude/, "claude-session-1", "claude-setup-token-status"],
["codex_local", "openai", /OpenAI/, "codex-session-1", "adapter-login-status"],
] as const)("finishes %s sign-in when its connection becomes visible before the completion poll", async (adapterType, provider, label, sessionId, statusKey) => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
mockAgentsApi.hire.mockRejectedValueOnce(new Error("Temporary hire failure"));
const { root, queryClient } = await openStep4({ adapterType });
await pickSource(label);
for (let i = 0; i < 6; i++) await flushReact();
try {
// The connection activity event arrives before the login poll. It must
// not replace/unmount the controller that still owns the completion.
await act(async () => {
queryClient.setQueryData(["ai-connections", "company-new"], {
currentUserId: "user-1",
connections: [{ id: "managed-connection", grantId: "managed-grant", companyId: "company-new", provider, method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
});
});
for (let i = 0; i < 4; i++) await flushReact();
expect(document.body.textContent).toContain(adapterType === "claude_local" ? "authorization code" : "Q2RJ-E1YIF");
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await act(async () => {
queryClient.setQueryData(
adapterType === "claude_local" ? [statusKey, "company-new", sessionId] : [statusKey, "company-new", adapterType, sessionId],
{ sessionId, status: "authenticated", expiresAt: new Date(Date.now() + 600_000).toISOString() },
);
});
for (let i = 0; i < 120 && !mockAgentsApi.hire.mock.calls.length; i++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 25)); });
}
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).toHaveBeenCalledWith("company-new", expect.objectContaining({ runtimeConfig: expect.objectContaining({ aiConnection: { provider, method: "subscription", mode: "responsible_user" } }) }));
for (let i = 0; i < 4; i++) await flushReact();
expect(document.body.textContent).toContain("Temporary hire failure");
const retry = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Connect");
expect(retry).toBeTruthy();
expect(retry!.disabled).toBe(false);
await act(async () => { retry!.click(); });
for (let i = 0; i < 6; i++) await flushReact();
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(2);
expect(mockAgentsApi.startClaudeSetupTokenLogin.mock.calls.length + mockAgentsApi.startAdapterAuthLogin.mock.calls.length).toBe(1);
} finally {
await act(async () => root.unmount());
}
});
it("names the tiles for the provider, not the adapter type", async () => {
// `MODEL_SOURCE_NAMES` exists so this row says "Claude" and "OpenAI" —
// which provider you are signing in to, the question the step's heading
// asks — rather than the display registry's tool names, which the agent
// config screens want. It was added with a long comment justifying it and
// then never read, so the row went on rendering whatever the registry
// supplied: "Claude Code" and "Codex" in the app, and the bare type here,
// since this suite's registry mock returns `label: type`.
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
const { root } = await openStep4({ adapterType: "claude_local" });
const labels = [...document.body.querySelectorAll("button[aria-checked]")].map(
(tile) => tile.textContent ?? "",
);
expect(labels.length, "both recommended sources should render").toBe(2);
expect(labels.some((l) => l.includes("Claude"))).toBe(true);
expect(labels.some((l) => l.includes("OpenAI"))).toBe(true);
// The negative half is the one that fails on the unwired version: the
// registry label is the adapter type, and it must not reach the tile.
expect(labels.join(" ")).not.toContain("claude_local");
expect(labels.join(" ")).not.toContain("codex_local");
await act(async () => root.unmount());
});
it("will not advance on a saved adapter the step no longer offers", async () => {
// A draft can name an adapter this registry does not carry — a cloud
// sandbox without claude_local, an adapter since disabled. The row hides
// it, so the step shows an unanswered question: no tile filled, no input
// canvas. The CTA has to agree with that.
//
// It did not. The gate asked `sourcePicked`, which only means "a draft
// named something", so Next stayed live on a step that had visibly asked
// nothing and would hire against the hidden name. Reported by Greptile on
// #12726.
mockAdapterRegistry.list = [{ type: "codex_local" }];
const { root } = await openStep4({ adapterType: "some_retired_adapter" });
const tiles = [...document.body.querySelectorAll("button[aria-checked]")];
expect(tiles.length, "the row should still offer what it has").toBeGreaterThan(0);
expect(
tiles.some((t) => t.getAttribute("aria-checked") === "true"),
"no tile should read as chosen",
).toBe(false);
const cta = [...document.body.querySelectorAll("button")].find(
(b) => isArcPrimary(b.textContent?.trim() ?? ""),
);
expect(cta, "the step should render its forward button").toBeTruthy();
expect(
cta!.hasAttribute("disabled"),
"Connect must not advance a question the step has not visibly asked",
).toBe(true);
// And it opens again the moment the customer answers it themselves.
await act(async () => {
tiles[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 5; i++) await flushReact();
const ctaAfter = [...document.body.querySelectorAll("button")].find(
(b) => isArcPrimary(b.textContent?.trim() ?? ""),
);
expect(ctaAfter!.hasAttribute("disabled")).toBe(false);
await act(async () => root.unmount());
});
it("will not advance on a saved adapter the row does not show", async () => {
// The other shape of the same defect, and the one the snap cannot cover.
//
// The tile row is `recommendedAdapters`; the snap's idea of "visible" is
// recommended *plus* the advanced list. An adapter in the second but not
// the first — a saved `opencode_local`, say — therefore satisfies the
// snap, which leaves it alone, while the row it is supposed to be chosen
// in never shows it. Nothing is highlighted, the canvas is shut, and with
// the gate on `sourcePicked` the CTA was live: one press hires against an
// adapter the customer has not seen on this screen.
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "opencode_local" }];
const { root } = await openStep4({ adapterType: "opencode_local" });
const tiles = [...document.body.querySelectorAll("button[aria-checked]")];
expect(
tiles.some((t) => t.getAttribute("aria-checked") === "true"),
"the saved adapter is not in this row, so nothing should read as chosen",
).toBe(false);
const cta = [...document.body.querySelectorAll("button")].find(
(b) => isArcPrimary(b.textContent?.trim() ?? ""),
);
expect(
cta!.hasAttribute("disabled"),
"Connect must not hire an adapter the row never offered",
).toBe(true);
await act(async () => root.unmount());
});
it("will not hire from the keyboard on a source nobody selected", async () => {
// The step has two ways forward, and gating only the visible one leaves
// the defect intact behind a keystroke. Cmd+Enter called
// `handleGiveHeartbeat` directly with its own, older list of conditions,
// so with the button correctly disabled the same screen still hired on
// Cmd+Enter. Reported by Greptile on #12726 after the button was fixed.
//
// The saved adapter is one the registry no longer carries, so the snap
// replaces it with `claude_local` and clears the pick: the row shows two
// tiles, neither chosen. A keystroke that gets through hires claude_local
// — a real, offerable adapter that nobody on this screen selected, which
// is what makes the bypass worth a test rather than a comment.
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
const { root } = await openStep4({ adapterType: "some_retired_adapter" });
const tiles = [...document.body.querySelectorAll("button[aria-checked]")];
expect(
tiles.some((t) => t.getAttribute("aria-checked") === "true"),
"the snap must not leave a tile reading as chosen",
).toBe(false);
const wizard = document.querySelector('[data-testid="onboarding-wizard"]');
expect(wizard, "the wizard should be mounted").not.toBeNull();
for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) {
await act(async () => {
wizard!.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, ...modifier }),
);
});
for (let i = 0; i < 6; i++) await flushReact();
}
expect(
mockAgentsApi.hire,
"no keystroke may hire a source nobody selected",
).not.toHaveBeenCalled();
// And it works once the question is answered, so this is a gate rather
// than a dead shortcut.
await act(async () => {
tiles[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 6; i++) await flushReact();
await act(async () => {
wizard!.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, metaKey: true }),
);
});
for (let i = 0; i < 6; i++) await flushReact();
expect(mockAgentsApi.hire).toHaveBeenCalled();
await act(async () => root.unmount());
});
it("starts no call to the test-environment route on adapter selection", async () => {
const { root } = await openStep4();
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
/**
* Press a source tile. This is what starts the sign-in — the row is the
* question, and answering it is the whole of the trigger. Nothing is
* selected on arrival, including from a draft that names an adapter.
*/
async function pickSource(match: RegExp) {
const tile = [...document.body.querySelectorAll('[role="radio"]')].find((t) =>
match.test(t.textContent ?? ""),
);
expect(tile, "the row should offer that source").toBeTruthy();
await act(async () => {
tile!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 10; i++) await flushReact();
}
/** Press the step's forward button and let the sign-in queries settle. */
async function pressArcPrimary() {
const cta = [...document.body.querySelectorAll("button")].find((b) =>
isArcPrimary(b.textContent?.trim() ?? ""),
);
expect(cta, "the step should render its forward button").toBeTruthy();
await act(async () => {
cta!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 8; i++) await flushReact();
}
it("starts the claude_local sign-in on Connect when the signal reports no ready credential", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
// Nothing before the press. The card *is* the sign-in now, so it does not
// exist until the step has been asked to start one — which is the whole
// difference between this step and the one it replaced.
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
await pickSource(/Claude/);
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalled();
// Connect started a sign-in rather than hiring. The ordering is the point:
// a hire here would create an agent with no credential to run on.
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain(
"Sign in to Claude then come back and enter authorization code",
);
await act(async () => root.unmount());
});
it("collapses the row to the chosen source and walks the button through the sign-in", async () => {
// The sequence, end to end, as the step actually runs it: the row is the
// question, answering it starts the sign-in, and the button reports where
// that has got to rather than offering an action it cannot perform.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
// Collapse is read off the row's own layout, not off how many tiles are
// in the DOM: the leaving tile exits through AnimatePresence, and in
// jsdom an exit never completes, so it stays mounted either way.
const rowCentred = () =>
document.body
.querySelector('[role="radiogroup"]')!
.className.includes("justify-center");
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
// Nothing chosen yet on a fresh arrival: the row is a question.
expect(rowCentred()).toBe(false);
await pickSource(/Claude/);
// The row has been answered, so it now shows only the answer — leaving
// the alternative up would invite a press that has to cancel a live
// session to honour.
expect(rowCentred()).toBe(true);
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalled();
// And the button has become the sign-in rather than a step advance.
expect(cta()).toBe("Sign in to Claude");
// Back unwinds rather than leaving the step: the row is a question again.
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 10; i++) await flushReact();
expect(rowCentred()).toBe(false);
expect(cta()).toBe("Next");
expect(document.body.textContent).toContain("Connect a model");
await act(async () => root.unmount());
});
it("submits the browser code on the paste, not on the first keystroke", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
const field = document.body.querySelector(
'input[aria-label="Authorization code"]',
) as HTMLInputElement | null;
expect(field, "the Claude card should offer a code field").toBeTruthy();
// The trap this pins. `isValidBrowserCode` reads like a completeness
// check and is not one — it accepts any printable ASCII from a single
// character up — so an auto-submit keyed off the value fires here, on the
// first character of anyone typing the code rather than pasting it, and
// clears the field they are typing into.
await act(async () => {
setControlledValue(field!, "Q");
});
for (let i = 0; i < 4; i++) await flushReact();
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).not.toHaveBeenCalled();
// The paste is the answer, so it goes without a press. Order matches a
// real paste: the event lands before the value changes.
await act(async () => {
field!.dispatchEvent(new Event("paste", { bubbles: true }));
setControlledValue(field!, "Q2RJ-E1YIF-authorization-code");
});
for (let i = 0; i < 4; i++) await flushReact();
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledWith(
"company-new",
"claude-session-1",
"Q2RJ-E1YIF-authorization-code",
);
// And it stays on screen. Clearing the field on submit emptied it in the
// same frame the paste landed, so the only feedback for the seconds that
// followed was an input that had just gone blank — reported from staging
// as the paste looking dropped, or the step looking stuck.
expect(field!.value).toBe("Q2RJ-E1YIF-authorization-code");
// As dots. The code is kept so the customer can see the paste landed,
// and that is all the field needs to show of it.
expect(field!.type).toBe("password");
// And the button answers the paste itself. The status here never reaches
// authenticated, so this is "Connecting" before any server confirmation —
// waiting for that left about a second of a button still reading
// "Waiting for code" after the code had gone in.
expect(
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim(),
).toBe("Connecting");
await act(async () => root.unmount());
});
it("does not hire on the paste alone, before the login is stored", async () => {
// "Connecting" appears at the paste now, ahead of the server confirming
// anything. The two-second hold used to start at that same moment, so
// moving one without the other would hire at the paste plus two seconds
// whether or not a credential existed. The status here stays pending, so
// the login is never stored.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
const field = document.body.querySelector(
'input[aria-label="Authorization code"]',
) as HTMLInputElement;
await act(async () => {
field.dispatchEvent(new Event("paste", { bubbles: true }));
setControlledValue(field, "Q2RJ-E1YIF-authorization-code");
});
for (let i = 0; i < 4; i++) await flushReact();
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
// The paste really did start Connecting; without this the assertion
// below would hold for a flow that never got that far.
expect(cta(), "the paste should have started Connecting").toBe("Connecting");
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, CONNECTED_HOLD_MS + 400));
});
for (let i = 0; i < 4; i++) await flushReact();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(cta()).toBe("Connecting");
await act(async () => root.unmount());
});
it("gives the button back when the pasted code is refused", async () => {
// The other half of answering the paste early: a button that says
// "Connecting" before the server answers has to stop saying it when the
// answer is no, or it spins on a login that is not coming.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
mockAgentsApi.submitClaudeSetupTokenBrowserCode.mockRejectedValueOnce(
new Error("That authorization code was not accepted."),
);
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
expect(cta()).toBe("Sign in to Claude");
const field = document.body.querySelector(
'input[aria-label="Authorization code"]',
) as HTMLInputElement;
await act(async () => {
field.dispatchEvent(new Event("paste", { bubbles: true }));
setControlledValue(field, "Q2RJ-E1YIF-authorization-code");
});
for (let i = 0; i < 8; i++) await flushReact();
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("That authorization code was not accepted.");
expect(cta()).toBe("Sign in to Claude");
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
it("does not reopen the card when a pasted code fails after Back", async () => {
// The panel stays mounted through Back's exit, so its report of a failed
// submit can land mid-exit. Restoring the button there reopened the card
// the customer was leaving, without the address Back had cleared.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
let refuse: (error: Error) => void = () => {};
mockAgentsApi.submitClaudeSetupTokenBrowserCode.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
refuse = reject;
}),
);
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
const field = document.body.querySelector(
'input[aria-label="Authorization code"]',
) as HTMLInputElement;
await act(async () => {
field.dispatchEvent(new Event("paste", { bubbles: true }));
setControlledValue(field, "Q2RJ-E1YIF-authorization-code");
});
for (let i = 0; i < 4; i++) await flushReact();
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledTimes(1);
expect(cta(), "the paste should have started Connecting").toBe("Connecting");
// Hold the exit open so the refusal lands inside it. Without a
// `matchMedia` to ask, every beat collapses to zero and the exit would be
// over before the refusal arrived — which would pass for the wrong reason.
const realMatchMedia = window.matchMedia;
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
try {
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
refuse(new Error("That authorization code was not accepted."));
});
for (let i = 0; i < 4; i++) await flushReact();
// Still leaving: the button shows the step's resting face, not the
// sign-in it would have reopened.
expect(cta()).toBe("Next");
// And the exit finishes — the row is a question again. Waited in short
// slices, each its own `act`. One long `act` defers React's commits to
// its end, so a beat's timer fires on time but its phase only commits
// when the wait is over — and the next beat is scheduled only then. The
// exit crawls one step per wait and never gets back to the question.
for (let i = 0; i < 30; i++) {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 50));
});
}
expect(
document.body
.querySelector('[role="radiogroup"]')!
.className.includes("justify-center"),
).toBe(false);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
} finally {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: realMatchMedia,
});
}
await act(async () => root.unmount());
});
it("does not hire when the login finishes after Back", async () => {
// The same window from the other side. A login can complete while Back's
// exit is still running, and reporting that success pulled the step back
// into "Connecting" and on into a hire the customer had backed away from.
// No paste needed: here the server has already authenticated, and the
// completion read is simply slow.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
status: "authenticated",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
let finishCompletion: (value: { storedSessionId: string }) => void = () => {};
mockAgentsApi.completeClaudeSetupTokenLogin.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishCompletion = resolve;
}),
);
const realMatchMedia = window.matchMedia;
try {
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
for (let i = 0; i < 6; i++) await flushReact();
// The completion read is out and has not answered, and the card is up.
expect(mockAgentsApi.completeClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
expect(cta()).toBe("Sign in to Claude");
// Hold the exit open, as above, so the success lands inside it.
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
finishCompletion({ storedSessionId: "stored-1" });
});
for (let i = 0; i < 4; i++) await flushReact();
expect(cta()).toBe("Next");
// Past the exit, and past the full hold a late success would have
// started. In short slices, each its own `act`, for the reason given in
// the test above — and here it matters twice: a hire scheduled by a late
// "Connecting" is only scheduled once that phase commits, so one long
// `act` would hide the very hire this is looking for.
const slices = Math.ceil((CONNECTED_HOLD_MS + 1200) / 50);
for (let i = 0; i < slices; i++) {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 50));
});
}
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(
document.body
.querySelector('[role="radiogroup"]')!
.className.includes("justify-center"),
).toBe(false);
await act(async () => root.unmount());
} finally {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: realMatchMedia,
});
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
status: "pending",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
}
}, 15_000);
it("starts the sign-in on the first press, even when it changes the adapter", async () => {
// The regression this is here for. Picking a source sets the phase *and*
// the adapter, and a reset keyed on the adapter then put the phase
// straight back — so the first press did nothing and only a second one,
// which changed no adapter and so woke no effect, was allowed to stand.
//
// It only showed when the chosen source differed from the one the draft
// carried: picking the adapter already in state is a no-op React bails
// out of, so every existing case here happened to miss it.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/OpenAI/);
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
expect(
document.body
.querySelector('[role="radiogroup"]')!
.className.includes("justify-center"),
"one press should have answered the row",
).toBe(true);
await act(async () => root.unmount());
});
it("opens the OpenAI card's sign-in once the prompt lands", async () => {
// The panel that shows a code owns its session and the wizard learns the
// prompt's URL only by being told. When it was not told, the step waited
// on a card that had already opened: the code sat on screen under a
// button still reading "Next", disabled, with nothing left to wait for.
//
// The sibling test above stops at the login having started, which is why
// this went unseen — and the harness could not see it either, since it
// supplies the prompt itself rather than going through the panel.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/OpenAI/);
for (let i = 0; i < 6; i++) await flushReact();
// The displayed-code panel is the one under test: if the row picked a
// source the other panel serves, the rest of this proves nothing.
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalled();
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
const cta = [...document.body.querySelectorAll("button")].pop()!;
expect(cta.textContent?.trim()).toBe("Sign in to OpenAI");
// The code is the card's own content, and it arrives with the URL.
expect(document.body.textContent).toContain("Q2RJ-E1YIF");
// The button is the whole point: its destination is the URL the panel
// reports, so an unreported prompt leaves it reading like an offer and
// refusing the press. The label alone does not catch that — the phase can
// reach `ready` before the signal resolves, which names the button
// without enabling it.
expect(cta.hasAttribute("disabled"), "the sign-in should be pressable").toBe(
false,
);
await act(async () => root.unmount());
});
it("does not hire after Back interrupts the hold before step 5", async () => {
// "Connecting" is held for two seconds so it reads as a state rather than
// a flicker, and Back stays live throughout. The hire behind that hold
// knows nothing about the phase, so a timer left running took a customer
// who had just backed out to Review with an agent hired anyway.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
// A session the server has already authenticated, so the panel completes
// and reports success on its own. The paste that normally gets it there
// is the sibling test's subject; this one is about what the success does.
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
status: "authenticated",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
for (let i = 0; i < 8; i++) await flushReact();
const cta = () =>
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim();
expect(cta(), "success should have taken the button to the hold").toBe(
"Connecting",
);
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 10; i++) await flushReact();
// Past the hold: whatever it was going to do, it has had its chance.
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, CONNECTED_HOLD_MS + 400));
});
for (let i = 0; i < 6; i++) await flushReact();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Connect a model");
await act(async () => root.unmount());
});
it("shows no probe diagnostics between the sign-in succeeding and the step advancing", async () => {
// Reported from staging: a block of amber diagnostics flashed up right
// after a successful sign-in. They are the identity and target INFO
// checks every run reports, which make the result a `warn` without
// blocking anything — so they rendered for the window between the probe
// returning and the step advancing, reading as an error thrown by the
// sign-in that had just succeeded.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
status: "authenticated",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
});
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "claude_local",
status: "warn",
checks: [
{
code: "environment_identity",
level: "info",
message: 'Environment test identity for "Paperclip Computer".',
detail: "paperclipLeaseId=ff9e58e9; provider=daytona",
},
],
testedAt: new Date().toISOString(),
});
// Hold the hire open, which is the window the diagnostics appeared in:
// the probe has returned but `loading` is not cleared until the `finally`
// that runs after the step advances.
let finishHire: (v: { agent: { id: string }; approval: null }) => void = () => {};
mockAgentsApi.hire.mockReturnValue(
new Promise((resolve) => {
finishHire = resolve;
}),
);
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
for (let i = 0; i < 8; i++) await flushReact();
// Past the deliberate hold, so the hire is running and its probe is done.
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, CONNECTED_HOLD_MS + 400));
});
for (let i = 0; i < 8; i++) await flushReact();
expect(mockAgentsApi.hire).toHaveBeenCalled();
expect(document.body.textContent).not.toContain("Environment test identity");
expect(document.body.textContent).not.toContain("Warnings");
// And the button goes on saying what is happening rather than going quiet.
expect(
[...document.body.querySelectorAll("button")].pop()?.textContent?.trim(),
).toBe("Connecting…");
await act(async () => finishHire({ agent: { id: "agent-1" }, approval: null }));
for (let i = 0; i < 6; i++) await flushReact();
await act(async () => root.unmount());
});
it("starts no login when Back interrupts the collapse", async () => {
// Backing out before the card has opened has nothing to close. Unwinding
// through the card beat regardless mounted the panel — which starts a
// server login on mount — only for the unmount to cancel it, and a cancel
// that fails holds the per-owner reservation until the server deadline,
// so the retry the customer is about to make cannot start.
//
// The beats collapse to zero without a `matchMedia` to ask, which is why
// this stubs one: the collapse has to still be running when Back lands.
const realMatchMedia = window.matchMedia;
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
try {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
// Still collapsing: the flushes above are microtasks and 0ms timers,
// far inside the collapse's own duration.
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 10; i++) await flushReact();
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 1200));
});
for (let i = 0; i < 6; i++) await flushReact();
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Connect a model");
await act(async () => root.unmount());
} finally {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: realMatchMedia,
});
}
});
it("starts the codex_local sign-in on Connect when the signal cannot decide", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" });
const { root } = await openStep4({ adapterType: "codex_local" });
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
await pickSource(/OpenAI/);
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
// The displayed-code login hands a code over rather than taking one back,
// so both halves of it have to reach the screen.
expect(document.body.textContent).toContain(
"Sign in to OpenAI by providing the authorization code below",
);
expect(document.body.textContent).toContain("Q2RJ-E1YIF");
// The link lives inside the sentence now rather than in a row of its own,
// and points at the same address the step's button opens — it is the
// path for anyone finishing the sign-in in another browser.
const link = document.body.querySelector('a[href*="auth.openai.com"]');
expect(link, "the sign-in link should be in the instruction").toBeTruthy();
expect(link!.textContent).toBe("Sign in to OpenAI");
// And the code sits below the sentence carrying it.
const code = [...document.body.querySelectorAll("span")].find(
(el) => el.textContent?.trim() === "Q2RJ-E1YIF",
);
expect(code, "the code row should render").toBeTruthy();
expect(
link!.compareDocumentPosition(code!) & Node.DOCUMENT_POSITION_FOLLOWING,
"the code should follow the sentence that links to the sign-in",
).toBeTruthy();
await act(async () => root.unmount());
});
it("shows the failure instead of starting a codex_local login when the active-session read fails", async () => {
// A transient active-session lookup failure is not proof that no
// session exists — only a successful lookup that resolves to null is.
// The step must not start a second login the server would reject
// against the per-owner cap; it shows the failure instead.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" });
mockAgentsApi.getActiveAdapterAuthLoginSession.mockReset();
mockAgentsApi.getActiveAdapterAuthLoginSession.mockRejectedValue(
new ApiError("Service unavailable", 503, null),
);
const { root } = await openStep4({ adapterType: "codex_local" });
await pickSource(/OpenAI/);
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Service unavailable");
await act(async () => root.unmount());
});
it("shows the failure instead of starting a claude_local login when the active-session read fails", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockReset();
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockRejectedValue(
new ApiError("Service unavailable", 503, null),
);
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Service unavailable");
await act(async () => root.unmount());
});
it("resumes the same claude_local session after Back, rather than starting a second", async () => {
// This is the behaviour that makes the card's Cancel removable. Back only
// hides the card — it deliberately does not release the session — so
// coming back has to adopt the one already running. If it started a
// fresh one instead, the removed Cancel would have been the only way out
// of a login the customer could no longer reach, and the per-owner cap
// would reject the second start.
const session = {
sessionId: "claude-session-1",
status: "pending",
expiresAt: new Date(Date.now() + 600_000).toISOString(),
};
let started = false;
mockAgentsApi.startClaudeSetupTokenLogin.mockImplementation(async () => {
started = true;
return session;
});
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockReset();
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockImplementation(async () =>
started ? session : null,
);
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
// The login is genuinely running: without this the assertion below holds
// for the wrong reason.
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 12; i++) await flushReact();
await pickSource(/Claude/);
for (let i = 0; i < 8; i++) await flushReact();
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.getActiveClaudeSetupTokenLoginSession).toHaveBeenCalled();
await act(async () => root.unmount());
});
it("resumes the same codex_local session after Back, rather than starting a second", async () => {
const session = { sessionId: "codex-session-1", status: "pending" };
let started = false;
mockAgentsApi.startAdapterAuthLogin.mockImplementation(async () => {
started = true;
return session;
});
mockAgentsApi.getActiveAdapterAuthLoginSession.mockReset();
mockAgentsApi.getActiveAdapterAuthLoginSession.mockImplementation(async () =>
started ? session : null,
);
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/OpenAI/);
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 12; i++) await flushReact();
await pickSource(/OpenAI/);
for (let i = 0; i < 8; i++) await flushReact();
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.getActiveAdapterAuthLoginSession).toHaveBeenCalled();
await act(async () => root.unmount());
});
it("starts the other source's login after backing out of the first", async () => {
// The abandonment case, raised in review against removing the card's
// Cancel: with no explicit release, does a source switch still get a
// login? It does. The server's lease is keyed on the adapter type as
// well as the company and environment, so the abandoned Claude session
// does not stand in the way of a Codex one — and it is collected on its
// own five-minute timer regardless (DEVICE_LOGIN_TIMEOUT_MS), with the
// reaper as the restart-safe backstop.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
const back = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.trim().startsWith("Back"),
);
await act(async () => {
back!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 12; i++) await flushReact();
await pickSource(/OpenAI/);
for (let i = 0; i < 8; i++) await flushReact();
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
await act(async () => root.unmount());
});
it("hires on Connect, with no sign-in, when the signal reports a ready credential", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "present" });
const { root } = await openStep4({ adapterType: "claude_local" });
// Answer the row, then press: with a credential already in place there is
// no sign-in to run, so the button goes straight to the hire.
await pickSource(/Claude/);
await pressArcPrimary();
// The positive half is what makes this a test: a source that is already
// signed in goes straight to the hire, so Connect keeps its old meaning
// wherever there is no sign-in to do. Asserting only the absence of a
// card would pass just as well if the button had stopped working.
expect(mockAgentsApi.hire).toHaveBeenCalled();
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
it("renders no 'Use saved login' control", async () => {
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
expect(document.body.textContent).not.toContain("Use saved login");
await act(async () => root.unmount());
});
it("reads the signal again after an adapter change", async () => {
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
expect(mockAgentsApi.getAdapterAuthSignal).toHaveBeenCalledWith(
"company-new",
"claude_local",
"env-sandbox-1",
);
const clickByText = async (match: (text: string) => boolean) => {
const el = [...document.body.querySelectorAll("button")].find((b) =>
match(b.getAttribute("aria-label")?.startsWith("Use ") ? b.getAttribute("aria-label")! : b.textContent?.trim() ?? ""),
)!;
await act(async () => {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
};
// Straight to the tile. The adapter change used to be reached through an
// "Advanced settings" disclosure listing every non-recommended adapter;
// the step now offers Claude and Codex as tiles and spends that line on
// the credential switch instead. What is asserted below is unchanged —
// changing the source re-reads the signal — only the route there is.
// The tile's text is the label plus its credential tag, hence the prefix.
// That label is the provider name now, not the adapter type: this row
// asks which provider you are signing in to, so it reads through
// `MODEL_SOURCE_NAMES` rather than the display registry.
await clickByText((t) => t.startsWith("OpenAI"));
expect(mockAgentsApi.getAdapterAuthSignal).toHaveBeenCalledWith(
"company-new",
"codex_local",
"env-sandbox-1",
);
await act(async () => root.unmount());
});
describe("automatic subscription verification", () => {
const providers = [
["claude_local", "anthropic", /Claude/],
["codex_local", "openai", /OpenAI/],
] as const;
const passed = { adapterType: "codex_local", status: "pass" as const, checks: [], testedAt: new Date().toISOString() };
beforeEach(() => {
localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" });
mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]);
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
});
async function settle() {
for (let i = 0; i < 10; i++) await flushReact();
}
function button(label: string) {
const found = [...document.body.querySelectorAll("button")].find(b => b.textContent?.trim() === label);
expect(found, label).toBeTruthy();
return found!;
}
async function click(label: string) {
await act(async () => button(label).click());
await settle();
}
async function detected() {
managedApi.checkLocalLogin.mockResolvedValue({ status: "ready" });
await act(async () => window.dispatchEvent(new Event("focus")));
await settle();
}
function expectTesting() {
expect(button("Testing…").disabled).toBe(true);
expect(button("Testing…").querySelector("svg.animate-spin")).not.toBeNull();
expect([...document.body.querySelectorAll('[role="status"]')].some(e => e.textContent === "Testing connection…")).toBe(true);
expect(document.body.querySelector('[aria-label="Saved subscription"]')).toBeNull();
expect(document.body.textContent).not.toContain("Run this in a terminal");
expect(document.body.textContent).not.toContain("Start sign-in again");
}
it.each(providers)("automatically verifies detected %s once through connection refreshes and advances", async (adapterType, provider, label) => {
let finish!: (result: typeof passed) => void;
mockAgentsApi.testEnvironment.mockReturnValue(new Promise(resolve => { finish = resolve; }));
const { root, queryClient } = await openStep4({ adapterType });
try {
await pickSource(label);
expect(document.body.textContent).toContain("Sign in to");
expect(document.body.textContent).not.toContain("Run this in a terminal");
expect(button("Connect").disabled).toBe(false);
await detected();
expectTesting();
// The saved account can become visible while its hello test is pending.
await act(async () => {
queryClient.setQueryData(["ai-connections", "company-new"], {
currentUserId: "user-1",
connections: [{ id: "local-connection", grantId: "local-grant", companyId: "company-new", provider, method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
});
window.dispatchEvent(new Event("focus"));
});
await settle();
expectTesting();
expect(managedApi.connectLocal).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await act(async () => finish(passed));
await settle();
expect(document.body.textContent).toContain("is ready to work!");
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).toHaveBeenCalledWith("company-new", expect.objectContaining({ runtimeConfig: expect.objectContaining({ aiConnection: { provider, method: "subscription", mode: "responsible_user" } }) }));
} finally { await act(async () => root.unmount()); }
});
it.each(providers)("automatically verifies a saved %s subscription only after selecting its provider", async (adapterType, provider, label) => {
const { root, queryClient } = await openStep4({ adapterType });
try {
await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], {
currentUserId: "user-1",
connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider, method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
}));
await settle();
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
await pickSource(label);
await settle();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(managedApi.connectLocal).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("is ready to work!");
} finally { await act(async () => root.unmount()); }
});
it.each(providers)("prefers the personal default %s subscription over an earlier shared account", async (adapterType, provider, label) => {
const { root, queryClient } = await openStep4({ adapterType });
try {
await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], {
currentUserId: "user-1",
connections: [
{ id: "shared", grantId: "shared-grant", companyId: "company-new", provider, method: "subscription", name: "Shared", ownership: "shared", status: "connected" },
{ id: "personal", grantId: "personal-grant", companyId: "company-new", provider, method: "subscription", name: "Personal", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true },
],
}));
await pickSource(label);
await settle();
expect(mockAgentsApi.hire).toHaveBeenCalledWith("company-new", expect.objectContaining({
runtimeConfig: expect.objectContaining({ aiConnection: { provider, method: "subscription", mode: "responsible_user" } }),
}));
expect(managedApi.connectLocal).not.toHaveBeenCalled();
} finally { await act(async () => root.unmount()); }
});
it.each(providers)("does not choose an arbitrary %s account when several shared subscriptions exist", async (adapterType, provider, label) => {
const { root, queryClient } = await openStep4({ adapterType });
try {
await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], {
currentUserId: "user-1",
connections: ["first", "second"].map(id => ({ id, grantId: `${id}-grant`, companyId: "company-new", provider, method: "subscription", name: id, ownership: "shared", status: "connected" })),
}));
await pickSource(label);
await settle();
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Sign in to");
expect(button("Connect").disabled).toBe(false);
} finally { await act(async () => root.unmount()); }
});
it.each(providers)("keeps %s verification pending through environment query updates", async (adapterType, _provider, label) => {
let finish!: (result: typeof passed) => void;
mockAgentsApi.testEnvironment.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
const { root, queryClient } = await openStep4({ adapterType });
try {
await pickSource(label);
await detected();
expectTesting();
await act(async () => queryClient.setQueryData(queryKeys.environments.list("company-new"), [
{ ...LOCAL_ENVIRONMENT, id: "new-local-default" },
]));
await settle();
expectTesting();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await act(async () => finish(passed));
await settle();
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("is ready to work!");
} finally { await act(async () => root.unmount()); }
});
it.each(["back", "unmount"] as const)("ignores an already-submitted hire response after %s", async navigation => {
let finishHire!: (value: { agent: { id: string }; approval: null }) => void;
mockAgentsApi.hire.mockReturnValueOnce(new Promise(resolve => { finishHire = resolve; }));
const { root } = await openStep4({ adapterType: "codex_local" });
let mounted = true;
try {
await pickSource(/OpenAI/);
await detected();
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
if (navigation === "back") await click("Back");
else {
await act(async () => root.unmount());
mounted = false;
}
await act(async () => finishHire({ agent: { id: "agent-1" }, approval: null }));
await settle();
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
expect(document.body.textContent).not.toContain("is ready to work!");
} finally { if (mounted) await act(async () => root.unmount()); }
});
it.each(providers)("offers a manual retry after failed %s verification without signing in again", async (adapterType, _provider, label) => {
mockAgentsApi.testEnvironment.mockRejectedValueOnce(new Error("Provider request timed out. Try again."));
const { root } = await openStep4({ adapterType });
try {
await pickSource(label);
await detected();
expect(document.body.textContent).toContain("Provider request timed out. Try again.");
expect(button("Connect").disabled).toBe(false);
await detected();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
await click("Connect");
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
expect(managedApi.connectLocal).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("is ready to work!");
} finally { await act(async () => root.unmount()); }
});
it.each(providers)("keeps Connect usable when %s detection has not completed", async (adapterType, _provider, label) => {
const { root } = await openStep4({ adapterType });
try {
await pickSource(label);
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
await click("Connect");
expect(managedApi.connectLocal).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("is ready to work!");
} finally { await act(async () => root.unmount()); }
});
it("shows Connecting while saving, then Testing while verifying", async () => {
let saved!: (result: { connectionId: string; grantId: string }) => void;
managedApi.connectLocal.mockReturnValue(new Promise(resolve => { saved = resolve; }));
mockAgentsApi.testEnvironment.mockReturnValue(new Promise(() => {}));
const { root } = await openStep4({ adapterType: "codex_local" });
try {
await pickSource(/OpenAI/);
await detected();
expect(button("Connecting…").disabled).toBe(true);
expect(document.body.textContent).not.toContain("Run this in a terminal");
await act(async () => saved({ connectionId: "local-connection", grantId: "local-grant" }));
await settle();
expectTesting();
} finally { await act(async () => root.unmount()); }
});
it.each(["back", "provider", "company", "unmount"] as const)("ignores verification completion after %s navigation", async navigation => {
let finish!: (result: typeof passed) => void;
mockAgentsApi.testEnvironment.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
const { root, queryClient } = await openStep4({ adapterType: "codex_local" });
let mounted = true;
try {
await pickSource(/OpenAI/);
await detected();
expectTesting();
if (navigation === "company") {
mockCompany.companies.push({ id: "company-other", name: "Other", issuePrefix: "OTH" });
mockDialog.onboardingOptions = { initialStep: 4, companyId: "company-other" };
managedApi.checkLocalLogin.mockResolvedValue({ status: "sign_in_required" });
await act(async () => root.render(<QueryClientProvider client={queryClient}><OnboardingWizard /></QueryClientProvider>));
await settle();
} else if (navigation === "unmount") {
await act(async () => root.unmount());
mounted = false;
} else {
await click("Back");
if (navigation === "provider") {
managedApi.checkLocalLogin.mockResolvedValue({ status: "sign_in_required" });
await pickSource(/Claude/);
}
}
await act(async () => finish(passed));
await settle();
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
expect(document.body.textContent).not.toContain("is ready to work!");
if (navigation === "provider") expect(document.body.textContent).toContain("Preparing browser sign-in");
} finally { if (mounted) await act(async () => root.unmount()); }
});
});
it("keeps Gemini local login out of managed Claude subscription setup", async () => {
localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" });
mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]);
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockAdapterRegistry.list.push({ type: "gemini_local", recommended: true });
const { root } = await openStep4({ adapterType: "gemini_local" });
try {
await pickSource(/Gemini|Google|gemini_local/);
expect(managedApi.startLocalLogin).not.toHaveBeenCalled();
expect(managedApi.connectLocal).not.toHaveBeenCalled();
expect(document.body.textContent).not.toContain("claude auth login");
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
} finally { await act(async () => root.unmount()); }
});
it("shows the Claude browser sign-in card and saves its connection before hiring", async () => {
localHealth.get.mockResolvedValue({ deploymentMode: "local_trusted" });
mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]);
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
const { root } = await openStep4({ adapterType: "claude_local" });
await pickSource(/Claude/);
expect(document.body.textContent).toContain("Sign in to Claude then come back and enter authorization code");
expect(document.body.textContent).not.toContain("claude auth login");
expect(document.body.textContent).not.toContain("No managed sandbox");
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
const connect = [...document.body.querySelectorAll("button")].find(b => b.textContent?.trim().startsWith("Connect"));
expect(connect).toBeTruthy();
await act(async () => connect!.click());
for (let i = 0; i < 6; i++) await flushReact();
expect(managedApi.connectLocal).toHaveBeenCalledWith("company-new", expect.objectContaining({ provider: "anthropic", method: "subscription", ownership: "personal" }));
expect(mockAgentsApi.hire).toHaveBeenCalled();
const hire = (mockAgentsApi.hire.mock.calls.at(-1) as unknown[])[1] as { runtimeConfig: { aiConnection: unknown } };
expect(hire.runtimeConfig.aiConnection).toEqual({ provider: "anthropic", method: "subscription", mode: "responsible_user" });
await act(async () => root.unmount());
});
it("hides the panel when the resolved login environment driver is not sandbox", async () => {
mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]);
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
const { root } = await openStep4({ adapterType: "claude_local" });
expect(document.body.textContent).not.toContain("Sign in to Anthropic");
expect(mockAgentsApi.getAdapterAuthSignal).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
it("restores the started-login state after a reload, resuming the active session with no press", async () => {
// A reload loses `connectPhase` — the draft deliberately does not carry
// it, because a login is a live server session with a deadline, not
// wizard state to replay blindly. The step must instead re-derive it
// from the caller's active session, so a customer who reloads mid-login
// sees their sign-in still running rather than the tile row again.
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "env-sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
panelMode: "submitted_browser_code",
prompt: { authorizationUrl: "https://claude.ai/oauth/authorize?code=true" },
});
const { root } = await openStep4({ adapterType: "claude_local" });
// A reload's resume now runs one layer deeper than a fresh press: the
// step's own active-session read has to land before it opens the
// sequence, and only then does the mounted panel run its own resume
// read. Each is a further round trip `flushReact` has to catch up to.
for (let i = 0; i < 10; i += 1) await flushReact();
// No press: the resumed session is discovered on load and the card
// shows the login already running.
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
expect(document.body.textContent).toContain(
"Sign in to Claude then come back and enter authorization code",
);
await act(async () => root.unmount());
});
});
});