mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 05:23:39 +02:00
3509 lines
158 KiB
TypeScript
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());
|
|
});
|
|
});
|
|
});
|