mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
Back during pending subscription sign-in could accept a late response and
reopen agent setup. Abort pending reads, release abandoned sessions, and
ignore stale responses. Wait for cleanup before starting a replacement,
and preserve sessions belonging to another account or environment.
Cover deferred resume/start/prompt responses for both providers, local
login cancellation, quick reselection, and browser request abort. Stabilize
Runner fixtures and normalization assertions exposed by integration CI.
Verified: 483 focused UI tests, six onboarding browser cases, UI typecheck
and token gates, Runner fixture checks, and the complete CI run on
221f2e3eb7. Greptile scored 5/5;
all review threads are resolved. Earlier failures and recovery evidence
remain in the PR description.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
4989 lines
271 KiB
TypeScript
4989 lines
271 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
import { act, StrictMode } from "react";
|
||
import { ONBOARDING_CONNECTION_TEST_TIMEOUT_MS } from "../lib/onboarding-connection-test";
|
||
import { buildClaudeLocalConfig } from "../../../packages/adapters/claude-local/src/ui/build-config";
|
||
import { buildCodexRunnerConfig } from "../adapters/codex-local/config-fields";
|
||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||
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 mockAdaptersApi = vi.hoisted(() => ({ list: vi.fn(async (_context?: { companyId: string; environmentId?: string | null }) => [] as import("../api/adapters").AdapterInfo[]) }));
|
||
vi.mock("../api/adapters", () => ({ adaptersApi: mockAdaptersApi }));
|
||
|
||
function setupAdapterInfo(input: Pick<import("../api/adapters").AdapterInfo, "type"> & Partial<import("../api/adapters").AdapterInfo>): import("../api/adapters").AdapterInfo {
|
||
return { label: input.type, source: "builtin", modelsCount: 0, loaded: true, disabled: false,
|
||
capabilities: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true,
|
||
requiresMaterializedRuntimeSkills: false, supportsAcp: true }, ...input };
|
||
}
|
||
|
||
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" })),
|
||
setDefault: vi.fn(async (_companyId: string, _grantId: string) => ({})),
|
||
loginResult: vi.fn(async () => ({ connectionId: "subscription-account", grantId: "subscription-grant" })),
|
||
}));
|
||
vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi }));
|
||
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(() => ({
|
||
get: vi.fn(),
|
||
create: vi.fn(async (_company: string, input: Record<string, unknown>) => ({ ...input, id: input.setupId, pauseReason: "setup" })),
|
||
update: vi.fn(async (_id: string, input: Record<string, unknown>) => input),
|
||
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; adapterConfig?: Record<string, unknown> }>),
|
||
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((_values?: CreateConfigValues) => ({}) 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(() => ({
|
||
probe: vi.fn(async (): Promise<import("@paperclipai/shared").EnvironmentProbeResult> => ({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } })),
|
||
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>(),
|
||
}));
|
||
|
||
const mockRouterState = vi.hoisted(() => ({ pathname: "/" }));
|
||
|
||
vi.mock("@/lib/router", () => ({
|
||
useLocation: () => ({ pathname: mockRouterState.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";
|
||
import { TooltipProvider } from "./ui/tooltip";
|
||
|
||
// 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));
|
||
});
|
||
}
|
||
|
||
function renderWizardView(root: ReturnType<typeof createRoot>, queryClient: QueryClient) {
|
||
return act(async () => {
|
||
root.render(
|
||
<QueryClientProvider client={queryClient}>
|
||
<TooltipProvider><OnboardingWizard /></TooltipProvider>
|
||
</QueryClientProvider>,
|
||
);
|
||
});
|
||
}
|
||
|
||
const SESSION_USER_ID = "user-b";
|
||
|
||
function render(staleTime = 0) {
|
||
const container = document.createElement("div");
|
||
document.body.appendChild(container);
|
||
const root = createRoot(container);
|
||
const queryClient = new QueryClient({
|
||
defaultOptions: { queries: { retry: false, staleTime } },
|
||
});
|
||
// 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(() => {
|
||
managedApi.list.mockReset().mockResolvedValue({ currentUserId: "user-1", connections: [] });
|
||
managedApi.create.mockReset().mockResolvedValue({ connectionId: "managed-connection", grantId: "managed-grant" });
|
||
mockAdaptersApi.list.mockReset().mockResolvedValue([]);
|
||
mockEnvironmentsApi.probe.mockReset().mockResolvedValue({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } });
|
||
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();
|
||
mockRouterState.pathname = "/";
|
||
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>();
|
||
mockAdaptersApi.list.mockReset().mockResolvedValue(["claude_local", "codex_local"].map(type => setupAdapterInfo({ type, loaded: true, disabled: false, supportedRunners: ["paperclip", "legacy"], defaultRunner: "legacy" })));
|
||
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),
|
||
);
|
||
for (const mock of [mockAgentsApi.startClaudeSetupTokenLogin, mockAgentsApi.getClaudeSetupTokenLoginStatus,
|
||
mockAgentsApi.getClaudeSetupTokenLoginPrompt, mockAgentsApi.cancelClaudeSetupTokenLogin,
|
||
mockAgentsApi.submitClaudeSetupTokenBrowserCode, mockAgentsApi.completeClaudeSetupTokenLogin,
|
||
mockAgentsApi.startAdapterAuthLogin, mockAgentsApi.getAdapterAuthLoginStatus, mockAgentsApi.cancelAdapterAuthLogin]) {
|
||
mock.mockReset();
|
||
}
|
||
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.get.mockReset();
|
||
managedApi.setDefault.mockReset().mockResolvedValue({});
|
||
managedApi.loginResult.mockReset().mockResolvedValue({ connectionId: "subscription-account", grantId: "subscription-grant" });
|
||
mockAgentsApi.list.mockReset().mockResolvedValue([]);
|
||
mockAgentsApi.create.mockReset().mockImplementation(async (_company, input) => ({ ...input, id: input.setupId, pauseReason: "setup" }));
|
||
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("first-agent provider choices", () => {
|
||
async function openProviderChoices(savedOverrides: Record<string, unknown> = {}) {
|
||
mockCompany.companies = [{ id: "c1", name: "Acme", issuePrefix: "ACM" }];
|
||
mockCompaniesApi.list.mockResolvedValue(mockCompany.companies);
|
||
mockAdapterRegistry.list = [
|
||
{ type: "claude_local" }, { type: "codex_local" },
|
||
{ type: "paperclip_runner" }, { type: "copilot_runtime", recommended: true },
|
||
{ type: "gemini_local", recommended: true }, { type: "opencode_local" },
|
||
];
|
||
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify({
|
||
step: 4, createdCompanyId: "c1", agentName: "Ada", ...savedOverrides,
|
||
}));
|
||
const { root, queryClient } = render();
|
||
await renderWizardView(root, queryClient);
|
||
await flushReact();
|
||
return root;
|
||
}
|
||
|
||
it.each([true, false, undefined].flatMap(enableNativeRunner =>
|
||
(["paperclip", "legacy"] as const).map(defaultRunner => ({ enableNativeRunner, defaultRunner })),
|
||
))("offers the same Claude and Codex choices with $defaultRunner availability and native runner setting $enableNativeRunner", async ({ enableNativeRunner, defaultRunner }) => {
|
||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false, enableNativeRunner });
|
||
mockAdaptersApi.list.mockResolvedValue(["claude_local", "codex_local"].map(type =>
|
||
setupAdapterInfo({ type, defaultRunner, supportedRunners: defaultRunner === "paperclip" ? ["paperclip", "legacy"] : ["legacy"] }),
|
||
));
|
||
const root = await openProviderChoices();
|
||
try {
|
||
const sources = [...document.body.querySelectorAll("button[aria-checked]")];
|
||
expect(sources).toHaveLength(2);
|
||
expect(sources.map(source => source.textContent)).toEqual([
|
||
expect.stringContaining("Claude"), expect.stringContaining("OpenAI"),
|
||
]);
|
||
expect(document.body.textContent).not.toContain("GitHub Copilot");
|
||
expect(document.body.textContent).not.toContain("Windows");
|
||
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!).runner).toBe("auto");
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it("still hides disabled providers from first-agent setup", async () => {
|
||
mockAdapterRegistry.disabled = new Set(["claude_local"]);
|
||
const root = await openProviderChoices();
|
||
try {
|
||
const sources = [...document.body.querySelectorAll("button[aria-checked]")];
|
||
expect(sources).toHaveLength(1);
|
||
expect(sources[0]?.textContent).toContain("OpenAI");
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it("requires a new choice when resuming a Copilot draft", async () => {
|
||
const root = await openProviderChoices({
|
||
adapterType: "paperclip_runner", onboardingCopilot: true, model: "copilot-model",
|
||
});
|
||
try {
|
||
expect(document.body.querySelector('button[aria-checked="true"]')).toBeNull();
|
||
expect(document.body.textContent).not.toContain("Connect GitHub Copilot");
|
||
const connect = [...document.body.querySelectorAll("button")].find(button => isArcPrimary(button.textContent?.trim() ?? ""));
|
||
expect(connect?.disabled).toBe(true);
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({
|
||
adapterType: "claude_local", runner: "auto", model: "",
|
||
});
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it("clears a saved Gemini model when only Codex is available", async () => {
|
||
mockAdapterRegistry.disabled = new Set(["claude_local"]);
|
||
const root = await openProviderChoices({ adapterType: "gemini_local", model: "gemini-model" });
|
||
try {
|
||
expect(document.body.querySelector('button[aria-checked="true"]')).toBeNull();
|
||
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({
|
||
adapterType: "codex_local", model: "",
|
||
});
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it.each([false, true])("explains unavailable onboarding providers and clears an old Copilot draft: %s", async savedCopilot => {
|
||
mockAdapterRegistry.disabled = new Set(["claude_local", "codex_local"]);
|
||
const root = await openProviderChoices(savedCopilot ? {
|
||
adapterType: "paperclip_runner", onboardingCopilot: true, runner: "paperclip", model: "copilot-model",
|
||
} : {});
|
||
try {
|
||
expect(document.body.querySelectorAll("button[aria-checked]")).toHaveLength(0);
|
||
expect(document.body.textContent).toContain("Claude and Codex are unavailable on this instance.");
|
||
expect(document.body.textContent).toContain("choose New Agent on the Agents page");
|
||
const next = [...document.body.querySelectorAll("button")].find(button => isArcPrimary(button.textContent?.trim() ?? ""));
|
||
expect(next?.disabled).toBe(true);
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
const saved = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!);
|
||
expect(saved).toMatchObject({ adapterType: "claude_local", runner: "auto", model: "" });
|
||
expect(saved).not.toHaveProperty("onboardingCopilot");
|
||
} finally { 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(savedOverrides: Record<string, unknown> = {}) {
|
||
window.localStorage.setItem(
|
||
ONBOARDING_STORAGE_KEY,
|
||
JSON.stringify({ step: 1, companyName: "Initech", ...savedOverrides }),
|
||
);
|
||
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 = () =>
|
||
renderWizardView(root, queryClient);
|
||
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("defers the native runner until the name-only setup has a chosen provider", async () => {
|
||
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
|
||
const { root } = await openStepOne({ adapterType: "codex_local", runner: "paperclip" });
|
||
try {
|
||
await clickByText((text) => text.startsWith("Continue"));
|
||
const field = document.body.querySelector("#onboarding-agent-name") as HTMLInputElement;
|
||
await act(async () => setControlledValue(field, "Chief of Staff"));
|
||
await flushReact();
|
||
await clickByText((text) => isArcPrimary(text));
|
||
|
||
expect(document.body.textContent).toContain("Connect a model");
|
||
expect(mockAgentsApi.create).toHaveBeenCalledTimes(1);
|
||
const setup = mockAgentsApi.create.mock.calls[0][1];
|
||
expect(setup).toMatchObject({ adapterType: "process", metadata: { setupAdapterPending: true } });
|
||
expect(setup).not.toHaveProperty("runner");
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
|
||
await clickByText((text) => /OpenAI.*Subscription/.test(text));
|
||
for (let i = 0; i < 3; i++) await flushReact();
|
||
expect(mockAgentsApi.update).toHaveBeenCalledWith(setup.setupId, expect.objectContaining({
|
||
adapterType: "codex_local", runner: "paperclip", metadata: { setupAdapterPending: false },
|
||
}), "company-new");
|
||
expect(mockAgentsApi.create).toHaveBeenCalledTimes(1);
|
||
} finally {
|
||
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(mockCompaniesApi.create).toHaveBeenCalledWith({ name: "Initech" });
|
||
expect(document.body.textContent).not.toContain("Define your mission");
|
||
expect(document.body.textContent).not.toContain("Tell us about your team");
|
||
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.mockResolvedValue([
|
||
{ id: "agent-existing", name: "Ada", adapterType: "claude_local" },
|
||
]);
|
||
mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }];
|
||
const { root, queryClient } = render();
|
||
await renderWizardView(root, queryClient);
|
||
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.each([false, true])("shows manual CLI debugging only outside managed Cloud (managed: %s)", async managed => {
|
||
localHealth.get.mockResolvedValue({ deploymentMode: "authenticated", ...(managed ? { cloud: { managed: true, managedBy: "paperclip-cloud" } } : {}) });
|
||
mockAgentsApi.testEnvironment.mockResolvedValue({ adapterType: "claude_local", status: "fail", testedAt: new Date().toISOString(),
|
||
checks: [{ code: "claude_hello_probe_failed", level: "error", message: "The runtime test did not finish.",
|
||
detail: "The provider exited with code 1.",
|
||
hint: "Exit code 1. Run `claude --print - --output-format stream-json --verbose` manually in this directory and prompt `Respond with hello` to debug." }] });
|
||
mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" });
|
||
const { root } = await openStepOne();
|
||
try {
|
||
await clickByText(t => t.startsWith("Continue"));
|
||
await act(async () => setControlledValue(document.body.querySelector("#onboarding-agent-name") as HTMLInputElement, "Ada"));
|
||
await flushReact();
|
||
await clickByText(isArcPrimary);
|
||
await pickFirstSource(clickByText);
|
||
await clickByText(isArcPrimary);
|
||
expect(document.body.textContent).toContain("The runtime test did not finish.");
|
||
expect(document.body.textContent).toContain("The provider exited with code 1.");
|
||
expect(document.body.textContent?.includes("manually in this directory")).toBe(!managed);
|
||
expect(document.body.textContent?.includes("Manual debug")).toBe(!managed);
|
||
expect(document.body.textContent?.includes("--output-format stream-json")).toBe(!managed);
|
||
expect(document.body.textContent?.includes("Fix the reported checks")).toBe(!managed);
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally {
|
||
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 = () =>
|
||
renderWizardView(root, queryClient);
|
||
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 renderWizardView(root, queryClient);
|
||
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.",
|
||
);
|
||
|
||
expect(document.body.textContent).toContain("No working authentication was found");
|
||
|
||
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 }));
|
||
expect(managedApi.setDefault).toHaveBeenLastCalledWith("company-new", "managed-grant");
|
||
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("retries selecting the saved API account without creating another connection", async () => {
|
||
managedApi.setDefault.mockRejectedValueOnce(new Error("default selection failed"));
|
||
const { root, clickByText } = await connectWithApiKey();
|
||
try {
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
expect(document.body.textContent).toContain("default selection failed");
|
||
await clickByText(t => isArcPrimary(t));
|
||
expect(managedApi.create).toHaveBeenCalledTimes(1);
|
||
expect(managedApi.setDefault).toHaveBeenCalledTimes(2);
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||
} finally { 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.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,
|
||
});
|
||
|
||
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;
|
||
};
|
||
|
||
const binding = hireBody.adapterConfig.env?.[CLAUDE_OAUTH_TOKEN_ENV_KEY] as
|
||
| { type: string }
|
||
| undefined;
|
||
expect(binding?.type).toBe("user_secret_ref");
|
||
expect(JSON.stringify(hireBody)).not.toContain("secretId");
|
||
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.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();
|
||
|
||
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("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("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());
|
||
});
|
||
});
|
||
|
||
describe("restored route progress", () => {
|
||
function savedReview(runner = "auto") {
|
||
return { step: 5, companyName: "Saved Co", createdCompanyId: "c1", createdCompanyPrefix: "SC",
|
||
createdAgentId: "saved-agent", agentName: "Saved lead", adapterType: "codex_local", runner,
|
||
model: "gpt-5.6-sol", credentialMode: "api" };
|
||
}
|
||
|
||
async function openSavedReview(pathname: string, runner = "auto", options: { strict?: boolean; companiesLoading?: boolean; dialogStep?: number } = {}) {
|
||
const draft = savedReview(runner);
|
||
window.localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(draft));
|
||
mockRouterState.pathname = pathname;
|
||
mockDialog.onboardingOpen = options.dialogStep !== undefined;
|
||
mockDialog.onboardingOptions = options.dialogStep === undefined ? {} : { initialStep: options.dialogStep, companyId: "c1" };
|
||
const companies = [{ id: "c1", name: "Saved Co", issuePrefix: "SC" },
|
||
{ id: "c2", name: "Other Co", issuePrefix: "OTHER" }];
|
||
mockCompany.companies = companies;
|
||
mockCompany.loading = options.companiesLoading ?? false;
|
||
mockCompaniesApi.list.mockResolvedValue(companies);
|
||
const handles = render();
|
||
if (options.strict) {
|
||
await act(async () => handles.root.render(
|
||
<QueryClientProvider client={handles.queryClient}><StrictMode><OnboardingWizard /></StrictMode></QueryClientProvider>,
|
||
));
|
||
} else await renderWizardView(handles.root, handles.queryClient);
|
||
for (let tick = 0; tick < 8; tick++) await flushReact();
|
||
return { ...handles, draft };
|
||
}
|
||
|
||
it.each([
|
||
["/onboarding", "auto"], ["/SC/onboarding", "auto"],
|
||
["/onboarding", "legacy"], ["/SC/onboarding", "legacy"],
|
||
])("restores the authorized review draft after route reload %s with %s runner", async (pathname, runner) => {
|
||
const { root, draft } = await openSavedReview(pathname, runner);
|
||
try {
|
||
expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(true);
|
||
expect(document.body.textContent).not.toContain("What is the name of your organization?");
|
||
const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!);
|
||
expect(persisted).toMatchObject(draft);
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it("keeps restored review progress when initial effects replay in StrictMode", async () => {
|
||
const { root, draft } = await openSavedReview("/SC/onboarding", "auto", { strict: true });
|
||
try {
|
||
expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(true);
|
||
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject(draft);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it("waits for route company loading before deciding the initial restored step", async () => {
|
||
const { root, queryClient, draft } = await openSavedReview("/SC/onboarding", "auto", { companiesLoading: true });
|
||
try {
|
||
mockCompany.loading = false;
|
||
await renderWizardView(root, queryClient);
|
||
for (let tick = 0; tick < 8; tick++) await flushReact();
|
||
expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(true);
|
||
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject(draft);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it("does not restore another company's review into a different route", async () => {
|
||
const { root } = await openSavedReview("/OTHER/onboarding");
|
||
try {
|
||
expect(document.querySelector("#onboarding-agent-name")).not.toBeNull();
|
||
const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!);
|
||
expect(persisted.createdCompanyId).toBe("c2");
|
||
expect(persisted.createdAgentId).toBeNull();
|
||
expect(persisted.step).toBe(3);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it("does not bind an unmatched company prefix to the restored company", async () => {
|
||
const { root } = await openSavedReview("/MISSING/onboarding");
|
||
try {
|
||
const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!);
|
||
expect(persisted.step).toBe(1);
|
||
expect(persisted.createdCompanyId).toBeNull();
|
||
expect(persisted.createdAgentId).toBeNull();
|
||
expect(persisted.companyName).toBe("");
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it.each([2, 3])("honors explicit prefilled-company dialog step %s on initial mount", async initialStep => {
|
||
const { root } = await openSavedReview("/SC/onboarding", "auto", { dialogStep: initialStep });
|
||
try {
|
||
const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!);
|
||
expect(persisted.step).toBe(initialStep);
|
||
expect(persisted.createdCompanyId).toBe("c1");
|
||
expect([...document.querySelectorAll("button")].some(button => button.textContent?.trim() === "Get started")).toBe(false);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it("honors an explicit dialog step instead of resuming the route draft", async () => {
|
||
const { root, queryClient } = await openSavedReview("/SC/onboarding");
|
||
try {
|
||
mockDialog.onboardingOpen = true;
|
||
mockDialog.onboardingOptions = { companyId: "c2", initialStep: 3 };
|
||
await renderWizardView(root, queryClient);
|
||
await flushReact();
|
||
expect(document.querySelector("#onboarding-agent-name")).not.toBeNull();
|
||
const persisted = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!);
|
||
expect(persisted.createdCompanyId).toBe("c2");
|
||
expect(persisted.createdAgentId).toBeNull();
|
||
expect(persisted.step).toBe(3);
|
||
} finally { 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 = () =>
|
||
renderWizardView(root, queryClient);
|
||
|
||
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 renderWizardView(root, queryClient);
|
||
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 renderWizardView(root, queryClient);
|
||
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 renderWizardView(root, queryClient);
|
||
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 renderWizardView(root, queryClient);
|
||
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 renderWizardView(root, queryClient);
|
||
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 renderWizardView(root, queryClient);
|
||
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 renderWizardView(root, queryClient);
|
||
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 renderWizardView(root, queryClient);
|
||
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 renderWizardView(root, queryClient);
|
||
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 renderWizardView(root, queryClient);
|
||
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 renderWizardView(root, queryClient);
|
||
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 renderWizardView(root, queryClient);
|
||
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 renderWizardView(root, queryClient);
|
||
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.probe.mockReset().mockResolvedValue({ ok: true, driver: "sandbox", summary: "Measured image",
|
||
details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } });
|
||
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> = {}, staleTime = 0) {
|
||
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(staleTime);
|
||
await renderWizardView(root, queryClient);
|
||
// 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((["success", "failed probe", "failed save"] as const).flatMap(outcome => [false, true].map(native => ({ outcome, native }))))("saves the API connection after returning from a completed subscription setup: $outcome, native $native", async ({ outcome, native }) => {
|
||
managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [{
|
||
id: "subscription-account", grantId: "subscription-grant", companyId: "company-new",
|
||
provider: "openai", method: "subscription", ownership: "personal", ownerUserId: "user-1",
|
||
isDefault: true, status: "connected", name: "OpenAI subscription",
|
||
}] } as never);
|
||
mockEnvironmentsApi.list.mockResolvedValue([SANDBOX_ENVIRONMENT, { ...SANDBOX_ENVIRONMENT, id: "saved-environment" }]);
|
||
mockAgentsApi.get.mockResolvedValue({ id: "agent-1", companyId: "company-new", adapterType: native ? "paperclip_runner" : "codex_local", defaultEnvironmentId: "saved-environment",
|
||
adapterConfig: { ...(native ? { provider: "codex" } : {}), model: "gpt-5.4", customSetting: "preserve", env: { CODEX_HOME: { type: "secret_ref", secretId: "old-home" }, KEEP: { type: "plain", value: "keep" } } },
|
||
runtimeConfig: { aiConnection: { provider: "openai", method: "subscription", mode: "responsible_user" }, customRuntime: "preserve", heartbeat: { intervalSec: 300, enabled: false } },
|
||
});
|
||
const { root } = await openStep4({ adapterType: "codex_local", model: "gpt-5.4" });
|
||
try {
|
||
await pickSource(/OpenAI/);
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||
expect(document.body.textContent).toContain("Let's get started...");
|
||
if (outcome === "failed probe") mockAgentsApi.testEnvironment.mockResolvedValue({ adapterType: "codex_local", status: "fail", checks: [], testedAt: new Date().toISOString() });
|
||
if (outcome === "failed save") mockAgentsApi.update.mockRejectedValueOnce(new Error("account save failed"));
|
||
const back = () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!;
|
||
await act(async () => back().click());
|
||
for (let i = 0; i < 10; i++) await flushReact();
|
||
await act(async () => (document.querySelector('[aria-label="Use API key instead"]') as HTMLButtonElement).click());
|
||
await pickSource(/OpenAI.*API/);
|
||
await act(async () => setControlledValue(document.querySelector('input[aria-label="API key"]')!, "example-api-key"));
|
||
await pressArcPrimary();
|
||
expect(managedApi.create).toHaveBeenCalledWith("company-new", expect.objectContaining({ provider: "openai", method: "api_key", apiKey: "example-api-key" }));
|
||
expect(managedApi.setDefault).toHaveBeenLastCalledWith("company-new", "managed-grant");
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenLastCalledWith("company-new", "codex_local", expect.objectContaining({ agentId: "agent-1", environmentId: "saved-environment", runner: native ? "paperclip" : "legacy" }), expect.anything());
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||
const update = mockAgentsApi.update.mock.calls.find(([id, body]) => id === "agent-1" && (body.runtimeConfig as Record<string, unknown>)?.aiConnection);
|
||
if (outcome === "failed probe") {
|
||
expect(update).toBeUndefined();
|
||
expect(document.body.textContent).toContain("Connect a model");
|
||
return;
|
||
}
|
||
expect(update).toBeTruthy();
|
||
expect(update![1]).toMatchObject({ runner: native ? "paperclip" : "legacy", runtimeConfig: { aiConnection: { provider: "openai", method: "api_key", mode: "responsible_user" }, customRuntime: "preserve", heartbeat: { intervalSec: 300, enabled: false } }, adapterConfig: { customSetting: "preserve", env: { KEEP: { type: "plain", value: "keep" } } } });
|
||
expect((update![1].adapterConfig as { env?: Record<string, unknown> }).env).not.toHaveProperty("CODEX_HOME");
|
||
expect(JSON.stringify(update![1])).not.toContain("example-api-key");
|
||
expect(document.body.textContent).toContain(outcome === "failed save" ? "Connect a model" : "Let's get started...");
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it.each(["completed", "before selection", "after selection", "failed response after selection"] as const)("selects the subscription again after a subscription to API to subscription round trip (API save: %s)", async completion => {
|
||
const interrupted = completion !== "completed";
|
||
const accounts: import("@paperclipai/shared").AiConnectionList = { currentUserId: "user-1", canManageConnections: true, connections: [{
|
||
id: "subscription-account", grantId: "subscription-grant", companyId: "company-new", provider: "openai", method: "subscription",
|
||
ownership: "personal", ownerUserId: "user-1", isDefault: true, status: "connected", name: "My subscription",
|
||
}] };
|
||
managedApi.list.mockImplementation(async () => structuredClone(accounts) as never);
|
||
managedApi.create.mockImplementationOnce(async () => {
|
||
accounts.connections.push({ ...accounts.connections[0]!, id: "managed-connection", grantId: "managed-grant", method: "api_key", isDefault: false, name: "My API key" });
|
||
return { connectionId: "managed-connection", grantId: "managed-grant" };
|
||
});
|
||
let finishApiDefault!: () => void;
|
||
let held = false;
|
||
managedApi.setDefault.mockImplementation(async (_companyId, grantId) => {
|
||
const apply = () => { for (const account of accounts.connections) account.isDefault = account.grantId === grantId; };
|
||
if (interrupted && grantId === "managed-grant" && !held) {
|
||
held = true;
|
||
return new Promise<Record<string, never>>((resolve, reject) => { finishApiDefault = () => {
|
||
apply();
|
||
if (completion === "failed response after selection") reject(new Error("API default response was lost"));
|
||
else resolve({});
|
||
}; });
|
||
}
|
||
apply();
|
||
return {};
|
||
});
|
||
mockAgentsApi.get.mockResolvedValue({ id: "agent-1", companyId: "company-new", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {} });
|
||
const { root } = await openStep4({ adapterType: "codex_local" });
|
||
const goBack = async () => {
|
||
await act(async () => [...document.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!.click());
|
||
for (let i = 0; i < 10; i++) await flushReact();
|
||
};
|
||
try {
|
||
await pickSource(/OpenAI/);
|
||
await goBack();
|
||
await act(async () => (document.querySelector('[aria-label="Use API key instead"]') as HTMLButtonElement).click());
|
||
await pickSource(/OpenAI.*API/);
|
||
await act(async () => setControlledValue(document.querySelector('input[aria-label="API key"]')!, "example-api-key"));
|
||
await pressArcPrimary();
|
||
if (!interrupted) expect(accounts.connections.find(account => account.isDefault)?.method).toBe("api_key");
|
||
await goBack();
|
||
if (completion === "before selection") await act(async () => finishApiDefault());
|
||
await act(async () => (document.querySelector('[aria-label="Use subscription instead"]') as HTMLButtonElement).click());
|
||
await pickSource(/OpenAI/);
|
||
if (completion === "after selection" || completion === "failed response after selection") {
|
||
expect(document.body.textContent).toContain("Connect a model");
|
||
expect(document.body.textContent).not.toContain("Let's get started...");
|
||
await act(async () => finishApiDefault());
|
||
await vi.waitFor(() => expect(document.body.textContent).toContain("Let's get started..."));
|
||
}
|
||
expect(accounts.connections.find(account => account.isDefault)?.grantId).toBe("subscription-grant");
|
||
expect(document.body.textContent).toContain("Let's get started...");
|
||
await goBack();
|
||
await act(async () => (document.querySelector('[aria-label="Use API key instead"]') as HTMLButtonElement).click());
|
||
await pickSource(/OpenAI.*API/);
|
||
await pressArcPrimary();
|
||
expect(accounts.connections.find(account => account.isDefault)?.grantId).toBe("managed-grant");
|
||
if (completion === "completed") for (const completeBeforeReselect of [true, false]) {
|
||
let finishSubscriptionDefault!: () => void;
|
||
managedApi.setDefault.mockImplementationOnce(async (_companyId, grantId) => new Promise<Record<string, never>>(resolve => {
|
||
finishSubscriptionDefault = () => { for (const account of accounts.connections) account.isDefault = account.grantId === grantId; resolve({}); };
|
||
}));
|
||
await goBack();
|
||
await act(async () => (document.querySelector('[aria-label="Use subscription instead"]') as HTMLButtonElement).click());
|
||
await pickSource(/OpenAI/);
|
||
await goBack();
|
||
if (completeBeforeReselect) await act(async () => finishSubscriptionDefault());
|
||
await act(async () => (document.querySelector('[aria-label="Use API key instead"]') as HTMLButtonElement).click());
|
||
await pickSource(/OpenAI.*API/);
|
||
await pressArcPrimary();
|
||
if (!completeBeforeReselect) {
|
||
expect(document.body.textContent).toContain("Connect a model");
|
||
expect(document.body.textContent).not.toContain("Let's get started...");
|
||
await act(async () => finishSubscriptionDefault());
|
||
}
|
||
await vi.waitFor(() => expect(document.body.textContent).toContain("Let's get started..."));
|
||
expect(accounts.connections.find(account => account.isDefault)?.grantId).toBe("managed-grant");
|
||
}
|
||
expect(managedApi.create).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it("selects the exact newly signed-in subscription when an API account was the default", async () => {
|
||
managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [{
|
||
id: "api-account", grantId: "api-grant", companyId: "company-new", provider: "openai", method: "api_key",
|
||
ownership: "personal", ownerUserId: "user-1", isDefault: true, status: "connected", name: "My API key",
|
||
}] } as never);
|
||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({ sessionId: "codex-session-1", status: "authenticated", prompt: { url: "https://auth.openai.com/codex/device", code: "TEST-CODE" } });
|
||
managedApi.loginResult.mockResolvedValue({ connectionId: "new-subscription", grantId: "new-subscription-grant" });
|
||
const { root } = await openStep4({ adapterType: "codex_local", credentialModeChoice: "subscription" });
|
||
try {
|
||
await pickSource(/OpenAI/);
|
||
await vi.waitFor(() => expect(managedApi.loginResult).toHaveBeenCalledWith("company-new", "codex-session-1"), { timeout: CONNECTED_HOLD_MS + 1500 });
|
||
expect(managedApi.setDefault).toHaveBeenLastCalledWith("company-new", "new-subscription-grant");
|
||
await vi.waitFor(() => expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1));
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it.each(["company", "user"])("keeps a managed agent on Connect when a saved %s key needs a connected API account", async scope => {
|
||
managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [{
|
||
id: "subscription-account", grantId: "subscription-grant", companyId: "company-new", provider: "openai", method: "subscription",
|
||
ownership: "personal", ownerUserId: "user-1", isDefault: true, status: "connected", name: "My subscription",
|
||
}] } as never);
|
||
if (scope === "company") mockSecretsApi.list.mockResolvedValue([{ id: "saved-key", companyId: "company-new", name: "Saved API key", key: "OPENAI_API_KEY", scope: "company", status: "active" }] as never);
|
||
else mockSecretsApi.listMyUserSecrets.mockResolvedValue([{ definition: { id: "saved-key", companyId: "company-new", name: "Saved API key", key: "OPENAI_API_KEY", status: "active" }, secret: { companyId: "company-new", status: "active" } }] as never);
|
||
mockAgentsApi.get.mockResolvedValue({ id: "agent-1", companyId: "company-new", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: { aiConnection: { provider: "openai", method: "subscription", mode: "responsible_user" } } });
|
||
const { root } = await openStep4({ adapterType: "codex_local" });
|
||
try {
|
||
await pickSource(/OpenAI/);
|
||
await act(async () => [...document.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!.click());
|
||
for (let i = 0; i < 10; i++) await flushReact();
|
||
await act(async () => (document.querySelector('[aria-label="Use API key instead"]') as HTMLButtonElement).click());
|
||
await pickSource(/OpenAI.*API/);
|
||
await pressArcPrimary();
|
||
expect(document.body.textContent).toContain("Connect a model");
|
||
expect(document.body.textContent).toContain("Enter a new API key");
|
||
expect(mockAgentsApi.update.mock.calls.filter(([id]) => id === "agent-1")).toHaveLength(0);
|
||
const select = document.querySelector('select[aria-label="Saved API key"]') as HTMLSelectElement;
|
||
await act(async () => { select.value = ""; select.dispatchEvent(new Event("change", { bubbles: true })); });
|
||
await act(async () => setControlledValue(document.querySelector('input[aria-label="API key"]')!, "example-api-key"));
|
||
await pressArcPrimary();
|
||
expect(document.body.textContent).toContain("Let's get started...");
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||
expect(managedApi.create).toHaveBeenCalledTimes(1);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
// N-16: the gateway is an advanced route, not the next thing after the key.
|
||
// It sat directly under the API key field, inside the key card; it belongs
|
||
// in the step's Advanced disclosure, hidden until that is opened.
|
||
it.each([["Claude API", "claude_local", /Claude.*API/], ["Codex API", "codex_local", /OpenAI.*API/]] as const)("keeps Use a custom gateway in the closed Advanced disclosure on the %s key step", async (_path, adapterType, source) => {
|
||
const handles = await openStep4({ adapterType, runner: "paperclip", credentialMode: "api" });
|
||
try {
|
||
await pickSource(source);
|
||
const keyField = document.body.querySelector('input[type="password"]') as HTMLInputElement;
|
||
expect(keyField, "the API key field should be showing").toBeTruthy();
|
||
const gateway = () => [...document.body.querySelectorAll("button")].find(entry => entry.textContent?.trim() === "Use a custom gateway");
|
||
expect(gateway(), "the custom gateway control should still exist").toBeTruthy();
|
||
expect(document.body.querySelector('[aria-label="Runner"]')).toBeNull();
|
||
const advanced = gateway()!.closest("details");
|
||
expect(advanced, "Use a custom gateway should live inside the Advanced disclosure").toBeTruthy();
|
||
expect(advanced!.querySelector(":scope > summary")?.textContent?.trim()).toBe("Advanced");
|
||
expect(advanced!.open, "Advanced should start closed").toBe(false);
|
||
expect(advanced!.contains(keyField), "the key field stays outside Advanced").toBe(false);
|
||
await act(async () => { advanced!.open = true; });
|
||
await flushReact();
|
||
await act(async () => gateway()!.click());
|
||
await flushReact();
|
||
expect(document.querySelector('input[aria-label="Provider URL"]'), "the gateway dialog should still open from Advanced").toBeTruthy();
|
||
} finally { await act(async () => handles.root.unmount()); }
|
||
});
|
||
|
||
it.each([["successful refresh", false], ["failed refresh and retry", true]] as const)("saves a custom gateway without probing, then explicitly tests and hires with its selected model: %s", async (_scenario, failRefresh) => {
|
||
const gatewayAccounts: import("@paperclipai/shared").AiConnectionList = {
|
||
currentUserId: "user-1", canManageConnections: true, connections: [],
|
||
};
|
||
let lookupFails = false;
|
||
managedApi.list.mockImplementation(async () => {
|
||
if (lookupFails) throw new Error("connection lookup failed");
|
||
return structuredClone(gatewayAccounts) as never;
|
||
});
|
||
managedApi.create.mockImplementationOnce(async () => {
|
||
gatewayAccounts.connections = [{
|
||
id: "managed-connection", grantId: "managed-grant", companyId: "company-new",
|
||
name: "My gateway.example.test", provider: "openai", method: "api_key", ownership: "personal",
|
||
ownerUserId: "user-1", isDefault: false, status: "connected",
|
||
routing: { kind: "gateway", protocol: "responses", auth: "bearer", baseUrl: "https://gateway.example.test/v1", models: [{ id: "gpt-5.4-mini" }] },
|
||
}];
|
||
lookupFails = failRefresh;
|
||
return { connectionId: "managed-connection", grantId: "managed-grant" };
|
||
});
|
||
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => ({ provider: "codex", model: values?.model }));
|
||
const handles = await openStep4({ adapterType: "codex_local", runner: "paperclip", credentialMode: "api" });
|
||
const click = async (label: string) => {
|
||
const button = [...document.body.querySelectorAll("button")].find(entry => entry.textContent?.trim() === label)!;
|
||
expect(button).toBeTruthy();
|
||
await act(async () => button.click());
|
||
await flushReact();
|
||
};
|
||
try {
|
||
await pickSource(/OpenAI.*API/);
|
||
await click("Use a custom gateway");
|
||
const gatewayDialog = document.querySelector('input[aria-label="Provider URL"]')!.closest('[role="dialog"]')!;
|
||
await act(async () => {
|
||
setControlledValue(gatewayDialog.querySelector('input[aria-label="Provider URL"]')!, "https://gateway.example.test/v1");
|
||
setControlledValue(gatewayDialog.querySelector('input[aria-label="API key"]')!, "example-gateway-key");
|
||
});
|
||
await click("Change");
|
||
await act(async () => setControlledValue(document.querySelector('input[aria-label="Model IDs"]')!, "gpt-5.4-mini"));
|
||
await act(async () => gatewayDialog.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true })));
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
await click("Save connection");
|
||
expect(managedApi.create).toHaveBeenCalledExactlyOnceWith("company-new", expect.objectContaining({
|
||
provider: "openai", method: "api_key", apiKey: "example-gateway-key", ownership: "personal",
|
||
agentIds: [expect.any(String)], routing: expect.objectContaining({ kind: "gateway", protocol: "responses", models: [{ id: "gpt-5.4-mini" }] }),
|
||
}));
|
||
if (failRefresh) {
|
||
await vi.waitFor(() => expect(document.body.textContent).toContain("Your saved gateway connection could not be loaded."));
|
||
expect(document.querySelector('select[aria-label="Model"]')).toBeNull();
|
||
expect([...document.body.querySelectorAll("button")].find(entry => entry.textContent?.trim() === "Connect")!.disabled).toBe(true);
|
||
expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
lookupFails = false;
|
||
await click("Retry loading connection");
|
||
}
|
||
await vi.waitFor(() => expect(document.querySelector('select[aria-label="Model"]'), document.body.textContent ?? "").toBeTruthy());
|
||
expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
expect(document.body.textContent).toContain("Gateway connection saved.");
|
||
const connect = [...document.body.querySelectorAll("button")].find(entry => entry.textContent?.trim() === "Connect")!;
|
||
expect(connect.disabled).toBe(true);
|
||
const select = document.querySelector('select[aria-label="Model"]') as HTMLSelectElement;
|
||
expect([...select.options].map(option => option.value)).toEqual(["", "gpt-5.4-mini"]);
|
||
await act(async () => { select.value = "gpt-5.4-mini"; select.dispatchEvent(new Event("change", { bubbles: true })); });
|
||
await flushReact();
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
expect(JSON.stringify(window.localStorage)).not.toContain("example-gateway-key");
|
||
await click("Connect");
|
||
const binding = { provider: "openai", method: "api_key", mode: "delegated", connectionId: "managed-connection", grantId: "managed-grant" };
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledExactlyOnceWith("company-new", "codex_local", expect.objectContaining({
|
||
environmentId: "env-sandbox-1", aiConnection: binding, adapterConfig: expect.objectContaining({ model: "gpt-5.4-mini" }),
|
||
}), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledExactlyOnceWith("company-new", expect.objectContaining({
|
||
runtimeConfig: expect.objectContaining({ aiConnection: binding }), adapterConfig: expect.objectContaining({ model: "gpt-5.4-mini" }),
|
||
}));
|
||
expect(JSON.stringify(mockAgentsApi.hire.mock.calls)).not.toContain("example-gateway-key");
|
||
} finally {
|
||
await act(async () => handles.root.unmount());
|
||
managedApi.list.mockResolvedValue({ currentUserId: "user-1", connections: [] });
|
||
}
|
||
}, 15_000);
|
||
|
||
it.each([["paperclip", "image"], ["legacy", "image"], ["paperclip", "snapshot"], ["legacy", "snapshot"]] as const)("qualifies managed Claude during Connect and tests/hire with measured %s configuration from %s", async (measured, source) => {
|
||
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!));
|
||
mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: {
|
||
provider: "daytona", ...(source === "snapshot" ? { snapshot: "managed-snapshot" } : { image: `registry.example.test/runtime@sha256:${"a".repeat(64)}` }),
|
||
} }]);
|
||
const discovery = (runner: "paperclip" | "legacy") => [
|
||
setupAdapterInfo({ type: "claude_local", loaded: true, disabled: false, defaultRunner: runner, supportedRunners: [runner] }),
|
||
setupAdapterInfo({ type: "paperclip_runner", loaded: true, disabled: false }),
|
||
];
|
||
let qualified = false;
|
||
mockAdaptersApi.list.mockImplementation(async () => discovery(qualified ? measured : "legacy"));
|
||
const handles = await openStep4({ adapterType: "claude_local", runner: "auto" });
|
||
await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], {
|
||
currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
|
||
}));
|
||
const calls: string[] = [];
|
||
mockAdaptersApi.list.mockImplementation(async context => {
|
||
expect(context).toEqual({ companyId: "company-new", environmentId: "env-sandbox-1" });
|
||
calls.push(qualified ? "discovery-after" : "discovery-before");
|
||
return discovery(qualified ? measured : "legacy");
|
||
});
|
||
mockEnvironmentsApi.probe.mockImplementation(async () => {
|
||
calls.push("platform"); qualified = true;
|
||
return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } };
|
||
});
|
||
mockAgentsApi.testEnvironment.mockImplementation(async () => {
|
||
calls.push("adapter"); return { adapterType: measured === "paperclip" ? "paperclip_runner" : "claude_local", status: "pass", checks: [], testedAt: "2026-10-13T00:00:00Z" };
|
||
});
|
||
mockAgentsApi.hire.mockImplementation(async () => { calls.push("hire"); return { agent: { id: "agent-1" }, approval: null }; });
|
||
try {
|
||
expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled();
|
||
await pickSource(/Claude/);
|
||
expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter", "hire"]);
|
||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new", expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||
const tested = (mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2] as { adapterConfig: Record<string, unknown> };
|
||
const saved = (mockAgentsApi.hire.mock.calls[0] as unknown[])[1] as { adapterConfig: Record<string, unknown> };
|
||
expect(saved).not.toHaveProperty("runner");
|
||
expect(saved.adapterConfig).toMatchObject(tested.adapterConfig);
|
||
if (measured === "paperclip") expect(tested.adapterConfig).not.toHaveProperty("maxTurnsPerRun");
|
||
else expect(tested.adapterConfig.maxTurnsPerRun).toBe(1000);
|
||
} finally { await act(async () => handles.root.unmount()); }
|
||
});
|
||
|
||
it("stores a new API key once before managed qualification and hires with the same native configuration", async () => {
|
||
const key = "example-scoped-api-key";
|
||
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!));
|
||
mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: {
|
||
provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}`,
|
||
} }]);
|
||
let qualified = false;
|
||
mockAdaptersApi.list.mockImplementation(async () => [
|
||
setupAdapterInfo({ type: "claude_local", defaultRunner: qualified ? "paperclip" : "legacy", supportedRunners: qualified ? ["paperclip"] : ["legacy"] }),
|
||
setupAdapterInfo({ type: "paperclip_runner" }),
|
||
]);
|
||
mockEnvironmentsApi.probe.mockImplementation(async () => {
|
||
qualified = true;
|
||
return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-platform-lease" } };
|
||
});
|
||
const handles = await openStep4({ adapterType: "claude_local", runner: "auto", credentialMode: "api" });
|
||
try {
|
||
await pickSource(/Claude.*API/);
|
||
const field = document.body.querySelector('input[type="password"]') as HTMLInputElement;
|
||
await act(async () => setControlledValue(field, key));
|
||
await flushReact();
|
||
await pressArcPrimary();
|
||
expect(managedApi.create).toHaveBeenCalledExactlyOnceWith("company-new", expect.objectContaining({ provider: "anthropic", method: "api_key", apiKey: key }));
|
||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new", expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||
const tested = (mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2] as { adapterConfig: object };
|
||
const saved = (mockAgentsApi.hire.mock.calls[0] as unknown[])[1] as { adapterConfig: object; runtimeConfig: object };
|
||
expect(saved.adapterConfig).toMatchObject(tested.adapterConfig);
|
||
expect(saved.adapterConfig).not.toHaveProperty("maxTurnsPerRun");
|
||
expect(saved.runtimeConfig).toMatchObject({ aiConnection: { provider: "anthropic", method: "api_key", mode: "responsible_user" } });
|
||
expect(JSON.stringify(saved)).not.toContain(key);
|
||
} finally { await act(async () => handles.root.unmount()); }
|
||
});
|
||
|
||
it.each(["back", "environment-config"])("abandons held managed Claude qualification after %s without another probe", async change => {
|
||
const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`;
|
||
mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: { provider: "daytona", image } }]);
|
||
mockAdaptersApi.list.mockResolvedValue([
|
||
setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy", supportedRunners: ["legacy"] }), setupAdapterInfo({ type: "paperclip_runner" }),
|
||
]);
|
||
const handles = await openStep4({ adapterType: "claude_local", runner: "auto" });
|
||
await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], {
|
||
currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
|
||
}));
|
||
let finish!: (value: import("@paperclipai/shared").EnvironmentProbeResult) => void;
|
||
mockEnvironmentsApi.probe.mockReturnValue(new Promise(resolve => { finish = resolve; }));
|
||
try {
|
||
await pickSource(/Claude/);
|
||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1);
|
||
await act(async () => document.querySelector('[data-testid="onboarding-wizard"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true })));
|
||
await flushReact();
|
||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1);
|
||
if (change === "back") await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Back")!.click());
|
||
else {
|
||
await act(async () => handles.queryClient.setQueryData(queryKeys.environments.list("company-new"), [{ ...SANDBOX_ENVIRONMENT, config: {
|
||
provider: "daytona", image: `registry.example.test/runtime@sha256:${"b".repeat(64)}`,
|
||
} }]));
|
||
await flushReact();
|
||
}
|
||
await act(async () => finish({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "owned-lease" } }));
|
||
for (let i = 0; i < 8; i++) await flushReact();
|
||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally { await act(async () => handles.root.unmount()); }
|
||
});
|
||
|
||
it("stops managed Claude onboarding at the existing platform-probe permission boundary", async () => {
|
||
mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config: {
|
||
provider: "daytona", image: `registry.example.test/runtime@sha256:${"a".repeat(64)}`,
|
||
} }]);
|
||
mockAdaptersApi.list.mockResolvedValue([
|
||
setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy", supportedRunners: ["legacy"] }), setupAdapterInfo({ type: "paperclip_runner" }),
|
||
]);
|
||
const handles = await openStep4({ adapterType: "claude_local", runner: "auto" });
|
||
await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], {
|
||
currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
|
||
}));
|
||
mockEnvironmentsApi.probe.mockRejectedValue(new ApiError("Instance admin access required", 403, null));
|
||
try {
|
||
await pickSource(/Claude/);
|
||
expect(document.body.textContent).toContain("Instance admin access required");
|
||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally { await act(async () => handles.root.unmount()); }
|
||
});
|
||
|
||
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) => {
|
||
managedApi.loginResult.mockResolvedValue({ connectionId: "managed-connection", grantId: "managed-grant" });
|
||
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 = [setupAdapterInfo({ 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 () => {
|
||
mockAdapterRegistry.list = [setupAdapterInfo({ 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 = [setupAdapterInfo({ 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("saves the submitted name before any provider choice or authentication", async () => {
|
||
const { root } = await openStep4();
|
||
expect(mockAgentsApi.create).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.create.mock.calls[0][1]).toMatchObject({ name: "Ada", adapterType: "process", metadata: { setupAdapterPending: true }, runtimeConfig: { heartbeat: { enabled: false } } });
|
||
expect(document.body.querySelector('[role="radio"][aria-checked="true"]')).toBeNull();
|
||
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
|
||
expect(managedApi.startLocalLogin).not.toHaveBeenCalled();
|
||
await pickSource(/OpenAI/);
|
||
expect(mockAgentsApi.create).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.update).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ adapterType: "codex_local", metadata: { setupAdapterPending: false } }), "company-new");
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it("saves a provisional enabled provider when process is disabled without choosing its tile", async () => {
|
||
mockAdapterRegistry.disabled = new Set(["process"]);
|
||
const { root } = await openStep4();
|
||
expect(mockAgentsApi.create.mock.calls[0][1]).toMatchObject({ adapterType: "claude_local", metadata: { setupAdapterPending: true } });
|
||
expect(document.body.querySelector('[role="radio"][aria-checked="true"]')).toBeNull();
|
||
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it("creates its own record instead of adopting another user's name-only setup", async () => {
|
||
mockAgentsApi.list.mockResolvedValue([{ id: "another-user-setup", companyId: "company-new", name: "Ada", pauseReason: "setup", createdByUserId: "other-user", adapterType: "process", adapterConfig: {}, metadata: { setupAdapterPending: true } } as never]);
|
||
mockAgentsApi.create.mockImplementationOnce(async (_company, input) => ({ ...input, id: input.setupId, name: "Ada 2", pauseReason: "setup" }));
|
||
const { root } = await openStep4();
|
||
expect(mockAgentsApi.create).toHaveBeenCalledTimes(1);
|
||
const setupId = mockAgentsApi.create.mock.calls[0][1].setupId;
|
||
expect(setupId).not.toBe("another-user-setup");
|
||
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ agentName: "Ada 2", setupAgentId: setupId });
|
||
await pickSource(/OpenAI/);
|
||
expect(mockAgentsApi.update.mock.calls.every(([id]) => id === setupId)).toBe(true);
|
||
expect(mockAgentsApi.update).toHaveBeenCalledWith(setupId, expect.objectContaining({ name: "Ada 2" }), "company-new");
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it("restores a name-only record without restoring its provisional runner or model", async () => {
|
||
const setupId = "00000000-0000-4000-8000-000000000094";
|
||
mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general", adapterType: "process", adapterConfig: { model: "provisional-model" }, runtimeConfig: {}, pauseReason: "setup", metadata: { setupAdapterPending: true } } as never);
|
||
const { root } = await openStep4({ adapterType: "process", runner: "legacy", model: "stale-model", setupAgentId: setupId });
|
||
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ runner: "auto", model: "", setupAgentId: setupId });
|
||
expect(document.body.querySelector('[role="radio"][aria-checked="true"]')).toBeNull();
|
||
await pickSource(/Claude/);
|
||
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!).runner).toBe("auto");
|
||
expect(mockAgentsApi.update).toHaveBeenCalledWith(setupId, expect.objectContaining({ runner: "auto" }), "company-new");
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it("creates and saves an inactive identity before starting sandbox sign-in", async () => {
|
||
let finish!: (agent: { id: string; pauseReason: string }) => void;
|
||
mockAgentsApi.create.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }) as never);
|
||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||
const { root } = await openStep4({ adapterType: "claude_local" });
|
||
await pickSource(/Claude/);
|
||
expect(mockAgentsApi.create).toHaveBeenCalledTimes(1);
|
||
const input = mockAgentsApi.create.mock.calls[0][1];
|
||
expect(input).toMatchObject({ name: "Ada", runtimeConfig: { heartbeat: { enabled: false } } });
|
||
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ setupAgentId: input.setupId });
|
||
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
await act(async () => finish({ id: String(input.setupId), pauseReason: "setup" }));
|
||
for (let i = 0; i < 10; i++) await flushReact();
|
||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it("reuses the persisted setup identity on a fresh onboarding mount", async () => {
|
||
const setupId = "00000000-0000-4000-8000-000000000081";
|
||
const { root } = await openStep4({ adapterType: "claude_local", setupAgentId: setupId });
|
||
await pickSource(/Claude/);
|
||
expect(mockAgentsApi.create).toHaveBeenCalledWith("company-new", expect.objectContaining({ setupId }));
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it("saves chosen model settings on the setup record before hiring", async () => {
|
||
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => ({ model: values?.model }));
|
||
const { root } = await openStep4({ adapterType: "codex_local", model: "gpt-5.4" });
|
||
await pickSource(/OpenAI/);
|
||
const setupId = mockAgentsApi.create.mock.calls[0][1].setupId;
|
||
expect(mockAgentsApi.update).toHaveBeenCalledWith(setupId, expect.objectContaining({
|
||
adapterConfig: { model: "gpt-5.4" }, runtimeConfig: expect.objectContaining({ heartbeat: expect.objectContaining({ enabled: false }) }),
|
||
}), "company-new");
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it("loads the server setup before an older local draft can autosave over it", async () => {
|
||
const setupId = "00000000-0000-4000-8000-000000000082";
|
||
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => ({ model: values?.model }));
|
||
mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general",
|
||
adapterType: "codex_local", adapterConfig: { model: "server-model", customSetting: "preserve" },
|
||
runtimeConfig: {}, pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never);
|
||
const { root } = await openStep4({ adapterType: "codex_local", model: "old-local-model", setupAgentId: setupId });
|
||
await pickSource(/OpenAI/);
|
||
const saves = mockAgentsApi.update.mock.calls.filter(call => call[0] === setupId);
|
||
expect(saves.length).toBeGreaterThan(0);
|
||
for (const [, body] of saves.filter(([, body]) => body.adapterConfig)) expect(body.adapterConfig).toMatchObject({ model: "server-model", customSetting: "preserve" });
|
||
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!).model).toBe("server-model");
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it.each([false, true])("migrates a stored Copilot setup without reusing its model or binding, with providers disabled: %s", async providersDisabled => {
|
||
const setupId = "00000000-0000-4000-8000-000000000095";
|
||
if (providersDisabled) mockAdapterRegistry.disabled = new Set(["claude_local", "codex_local"]);
|
||
mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general",
|
||
adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "copilot-model" },
|
||
runtimeConfig: { aiConnection: { provider: "github", method: "api_key", mode: "responsible_user" } },
|
||
pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never);
|
||
const { root } = await openStep4({ adapterType: "paperclip_runner", onboardingCopilot: true, setupAgentId: setupId });
|
||
try {
|
||
const saved = JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!);
|
||
expect(saved).toMatchObject({ adapterType: "claude_local", runner: "auto", model: "", setupAgentId: setupId });
|
||
expect(saved).not.toHaveProperty("onboardingCopilot");
|
||
expect(document.body.querySelector('button[aria-checked="true"]')).toBeNull();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
if (!providersDisabled) {
|
||
await pickSource(/Claude/);
|
||
const saves = mockAgentsApi.update.mock.calls.filter(([id, body]) => id === setupId && body.adapterConfig);
|
||
expect(saves.length).toBeGreaterThan(0);
|
||
for (const [, body] of saves) {
|
||
expect(body).toMatchObject({ adapterType: "claude_local", runner: "auto" });
|
||
expect(body.adapterConfig).not.toHaveProperty("acpxAgent", "copilot");
|
||
expect(body.adapterConfig).not.toHaveProperty("model", "copilot-model");
|
||
expect(body.runtimeConfig).not.toHaveProperty("aiConnection.provider", "github");
|
||
}
|
||
}
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it("tests and hires in the restored setup environment instead of the instance default", async () => {
|
||
const setupId = "00000000-0000-4000-8000-000000000083";
|
||
mockEnvironmentsApi.list.mockResolvedValue([SANDBOX_ENVIRONMENT, { ...SANDBOX_ENVIRONMENT, id: "saved-environment" }]);
|
||
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-claude", latestVersion: 1 });
|
||
mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general",
|
||
adapterType: "claude_local", adapterConfig: { model: "server-model" }, runtimeConfig: {},
|
||
pauseReason: "setup", defaultEnvironmentId: "saved-environment" } as never);
|
||
const { root } = await openStep4({ adapterType: "claude_local", setupAgentId: setupId });
|
||
await pickSource(/Claude/);
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-new", "claude_local", expect.objectContaining({ environmentId: "saved-environment" }), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledWith("company-new", expect.objectContaining({ defaultEnvironmentId: "saved-environment" }));
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it("drops the restored OpenAI connection after switching to a stored Claude login", async () => {
|
||
mockAgentsApi.testEnvironment.mockImplementation(async (...args: unknown[]) => ({ adapterType: args[1] as string,
|
||
status: args[1] === "codex_local" ? "fail" as const : "pass" as const, checks: [], testedAt: new Date().toISOString() }));
|
||
const setupId = "00000000-0000-4000-8000-000000000084";
|
||
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-claude", latestVersion: 1 });
|
||
mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general",
|
||
adapterType: "codex_local", adapterConfig: { model: "gpt-5.4" }, runtimeConfig: {
|
||
aiConnection: { provider: "openai", method: "subscription", mode: "responsible_user" }, customSetting: "preserve",
|
||
}, pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never);
|
||
const { root } = await openStep4({ adapterType: "codex_local", setupAgentId: setupId });
|
||
await pickSource(/OpenAI/);
|
||
const back = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!;
|
||
await act(async () => back.click());
|
||
for (let i = 0; i < 10; i++) await flushReact();
|
||
await pickSource(/Claude/);
|
||
const saves = mockAgentsApi.update.mock.calls.filter(([, body]) => body.adapterType === "claude_local");
|
||
expect(saves.length).toBeGreaterThan(0);
|
||
for (const [, body] of saves) expect(body.runtimeConfig).not.toHaveProperty("aiConnection");
|
||
const hire = (mockAgentsApi.hire.mock.calls as unknown as Array<[string, { adapterType: string; runtimeConfig: Record<string, unknown> }]>).find(([, body]) => body.adapterType === "claude_local")?.[1];
|
||
expect(hire).toBeTruthy();
|
||
expect(hire!.runtimeConfig).toMatchObject({ customSetting: "preserve" });
|
||
expect(hire!.runtimeConfig).not.toHaveProperty("aiConnection");
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it("tests restored redacted environment values using the setup record", async () => {
|
||
const setupId = "00000000-0000-4000-8000-000000000086";
|
||
mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general",
|
||
adapterType: "codex_local", adapterConfig: { env: { CODEX_HOME: { type: "plain", value: "***REDACTED***" }, OPENAI_API_KEY: { type: "secret_ref", secretId: "saved-key" } } },
|
||
runtimeConfig: {}, pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never);
|
||
const { root } = await openStep4({ adapterType: "codex_local", setupAgentId: setupId });
|
||
await pickSource(/OpenAI/);
|
||
await pressArcPrimary();
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-new", "codex_local", expect.objectContaining({ agentId: setupId,
|
||
adapterConfig: expect.objectContaining({ env: expect.objectContaining({ CODEX_HOME: { type: "plain", value: "***REDACTED***" } }) }),
|
||
}), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it.each(["api", "subscription"] as const)("removes old Claude credentials when resuming with %s authentication", async mode => {
|
||
let changedMode = false;
|
||
mockAgentsApi.testEnvironment.mockImplementation(async () => ({ adapterType: "claude_local",
|
||
status: changedMode ? "pass" as const : "fail" as const, checks: [], testedAt: new Date().toISOString() }));
|
||
const setupId = "00000000-0000-4000-8000-000000000087";
|
||
const secret = { type: "secret_ref", secretId: "saved-key" };
|
||
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({ secretId: "saved-claude", latestVersion: 1 });
|
||
mockSecretsApi.list.mockResolvedValue([{ id: "saved-key", companyId: "company-new", name: "Saved API key", key: "ANTHROPIC_API_KEY", scope: "company", status: "active" }] as never);
|
||
mockAgentsApi.create.mockResolvedValueOnce({ id: setupId, companyId: "company-new", name: "Ada", role: "general",
|
||
adapterType: "claude_local", adapterConfig: { env: { KEEP: { type: "plain", value: "***REDACTED***" },
|
||
[mode === "api" ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY"]: secret } },
|
||
runtimeConfig: {}, pauseReason: "setup", defaultEnvironmentId: "env-sandbox-1" } as never);
|
||
const { root } = await openStep4({ adapterType: "claude_local", setupAgentId: setupId, credentialModeChoice: mode });
|
||
await pickSource(/Claude/);
|
||
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!).credentialMode).toBe(mode === "api" ? "subscription" : "api");
|
||
const back = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!;
|
||
await act(async () => back.click());
|
||
for (let i = 0; i < 10; i++) await flushReact();
|
||
changedMode = true;
|
||
const switcher = document.body.querySelector(`[aria-label="Use ${mode === "api" ? "API key" : "subscription"} instead"]`) as HTMLButtonElement;
|
||
await act(async () => switcher.click());
|
||
await pickSource(/Claude/);
|
||
if (mode === "api") await pressArcPrimary();
|
||
const call = (mockAgentsApi.testEnvironment.mock.calls as unknown[][]).at(-1);
|
||
expect(call).toBeTruthy();
|
||
const config = (call![2] as { adapterConfig: { env: Record<string, unknown> } }).adapterConfig;
|
||
expect(config.env).toHaveProperty("KEEP");
|
||
expect(config.env).not.toHaveProperty(mode === "api" ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY");
|
||
expect(config.env).toHaveProperty(mode === "api" ? "ANTHROPIC_API_KEY" : "CLAUDE_CODE_OAUTH_TOKEN");
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it("starts setup for the new company while the previous company's creation is pending", async () => {
|
||
let finish!: (agent: unknown) => void;
|
||
mockAgentsApi.create.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }) as never);
|
||
const { root, queryClient } = await openStep4({ adapterType: "codex_local" });
|
||
await pickSource(/OpenAI/);
|
||
expect(mockAgentsApi.create).toHaveBeenCalledTimes(1);
|
||
mockCompany.companies.push({ id: "company-other", name: "Other", issuePrefix: "OTH" });
|
||
mockDialog.onboardingOptions = { initialStep: 4, companyId: "company-other" };
|
||
await act(async () => root.render(<QueryClientProvider client={queryClient}><OnboardingWizard /></QueryClientProvider>));
|
||
for (let i = 0; i < 10; i++) await flushReact();
|
||
expect(mockAgentsApi.create).toHaveBeenCalledWith("company-other", expect.objectContaining({ setupId: expect.any(String) }));
|
||
const nextId = mockAgentsApi.create.mock.calls.find(call => call[0] === "company-other")![1].setupId;
|
||
await act(async () => finish({ id: "old-company-setup", pauseReason: "setup" }));
|
||
for (let i = 0; i < 5; i++) await flushReact();
|
||
expect(JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)!)).toMatchObject({ createdCompanyId: "company-other", setupAgentId: nextId });
|
||
expect(document.body.textContent).not.toContain("Creating your agent…");
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
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.each([
|
||
["claude_local", /Claude/, "claude.ai", "link"],
|
||
["claude_local", /Claude/, "claude.ai", "button"],
|
||
["codex_local", /OpenAI/, "auth.openai.com", "link"],
|
||
["codex_local", /OpenAI/, "auth.openai.com", "button"],
|
||
] as const)("shows the provider waiting state after the %s %s %s %s opens sign-in", async (adapterType, source, host, control) => {
|
||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||
const { root } = await openStep4({ adapterType });
|
||
await pickSource(source);
|
||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||
try {
|
||
const link = document.body.querySelector(`a[href*="${host}"]`) as HTMLAnchorElement;
|
||
expect(link.target).toBe("_blank");
|
||
await act(async () => {
|
||
if (control === "link") link.click();
|
||
else [...document.body.querySelectorAll("button")].at(-1)!.click();
|
||
});
|
||
expect(open).toHaveBeenCalledExactlyOnceWith(link.href, "_blank", "noreferrer,noopener");
|
||
const primary = [...document.body.querySelectorAll("button")].at(-1)!;
|
||
expect(primary.textContent?.trim()).toBe(adapterType === "codex_local" ? "Waiting for auth…" : "Waiting for code…");
|
||
expect(primary.disabled).toBe(true);
|
||
await act(async () => primary.click());
|
||
await act(async () => document.querySelector('[data-testid="onboarding-wizard"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true, cancelable: true })));
|
||
expect(open).toHaveBeenCalledTimes(1);
|
||
await act(async () => link.click());
|
||
expect(open).toHaveBeenNthCalledWith(2, link.href, "_blank", "noreferrer,noopener");
|
||
expect(primary.textContent?.trim()).toBe(adapterType === "codex_local" ? "Waiting for auth…" : "Waiting for code…");
|
||
expect(primary.disabled).toBe(true);
|
||
if (adapterType === "claude_local") expect((document.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement).disabled).toBe(false);
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
expect(adapterType === "claude_local" ? mockAgentsApi.startClaudeSetupTokenLogin : mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
|
||
} finally {
|
||
open.mockRestore();
|
||
await act(async () => root.unmount());
|
||
}
|
||
});
|
||
|
||
it.each([false, true])("submits a code inserted without a paste event from the footer (opened link: %s)", async openedLink => {
|
||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||
const { root } = await openStep4({ adapterType: "claude_local" });
|
||
await pickSource(/Claude/);
|
||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||
try {
|
||
if (openedLink) await act(async () => (document.body.querySelector('a[href*="claude.ai"]') as HTMLAnchorElement).click());
|
||
open.mockClear();
|
||
const field = document.body.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement;
|
||
await act(async () => setControlledValue(field, "authorization-code"));
|
||
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).not.toHaveBeenCalled();
|
||
const primary = [...document.body.querySelectorAll("button")].at(-1)!;
|
||
expect(primary.textContent?.trim()).toBe("Submit code");
|
||
expect(primary.disabled).toBe(false);
|
||
await act(async () => { primary.click(); primary.click(); });
|
||
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledExactlyOnceWith("company-new", "claude-session-1", "authorization-code");
|
||
expect(open).not.toHaveBeenCalled();
|
||
expect([...document.body.querySelectorAll("button")].at(-1)!.textContent?.trim()).toBe("Connecting");
|
||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally {
|
||
open.mockRestore();
|
||
await act(async () => root.unmount());
|
||
}
|
||
});
|
||
|
||
it.each([{}, { metaKey: true }, { ctrlKey: true }])("submits the code once with Enter from its field (%j)", async modifier => {
|
||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||
const { root } = await openStep4({ adapterType: "claude_local" });
|
||
await pickSource(/Claude/);
|
||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||
try {
|
||
open.mockClear();
|
||
const field = document.body.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement;
|
||
await act(async () => setControlledValue(field, "authorization-code"));
|
||
await act(async () => field.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, ...modifier })));
|
||
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledExactlyOnceWith("company-new", "claude-session-1", "authorization-code");
|
||
expect(open).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally {
|
||
open.mockRestore();
|
||
await act(async () => root.unmount());
|
||
}
|
||
});
|
||
|
||
it.each([{ metaKey: true }, { ctrlKey: true }])("submits the code with the wizard shortcut while waiting (%j)", async modifier => {
|
||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||
const { root } = await openStep4({ adapterType: "claude_local" });
|
||
await pickSource(/Claude/);
|
||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||
try {
|
||
await act(async () => (document.body.querySelector('a[href*="claude.ai"]') as HTMLAnchorElement).click());
|
||
open.mockClear();
|
||
const field = document.body.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement;
|
||
await act(async () => setControlledValue(field, "authorization-code"));
|
||
const wizard = document.querySelector('[data-testid="onboarding-wizard"]')!;
|
||
await act(async () => wizard.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, ...modifier })));
|
||
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledExactlyOnceWith("company-new", "claude-session-1", "authorization-code");
|
||
expect(open).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally {
|
||
open.mockRestore();
|
||
await act(async () => root.unmount());
|
||
}
|
||
});
|
||
|
||
it("withdraws the footer submit action when the code is cleared or the card is closed", 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;
|
||
await act(async () => (document.body.querySelector('a[href*="claude.ai"]') as HTMLAnchorElement).click());
|
||
await act(async () => setControlledValue(field, "authorization-code"));
|
||
const primary = () => [...document.body.querySelectorAll("button")].at(-1)!;
|
||
expect(primary().textContent?.trim()).toBe("Submit code");
|
||
await act(async () => setControlledValue(field, ""));
|
||
expect(primary().textContent?.trim()).toBe("Waiting for code…");
|
||
expect(primary().disabled).toBe(true);
|
||
await act(async () => setControlledValue(field, "authorization-code"));
|
||
const back = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!;
|
||
await act(async () => back.click());
|
||
for (let i = 0; i < 10; i++) await flushReact();
|
||
expect(primary().textContent?.trim()).toBe("Next");
|
||
expect(primary().disabled).toBe(true);
|
||
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).not.toHaveBeenCalled();
|
||
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("Sign-in didn’t finish");
|
||
expect(document.body.textContent).not.toContain("That authorization code was not accepted.");
|
||
expect(cta()).toBe("Retry sign-in");
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it.each(["code", "start", "lookup", "prompt", "status", "failed", "timed_out", "gone", "completion"] as const)("retries Claude sign-in after a %s failure", async failure => {
|
||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||
if (failure === "code") mockAgentsApi.submitClaudeSetupTokenBrowserCode.mockRejectedValueOnce(new Error("Private provider detail"));
|
||
if (failure === "start") mockAgentsApi.startClaudeSetupTokenLogin.mockRejectedValueOnce(new Error("Sandbox unavailable"));
|
||
if (failure === "lookup") mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockRejectedValue(new Error("Lookup unavailable"));
|
||
if (failure === "gone" || failure === "status") mockAgentsApi.getClaudeSetupTokenLoginStatus.mockRejectedValueOnce(new ApiError("Session unavailable", failure === "gone" ? 404 : 503, {}));
|
||
if (failure === "prompt") mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockRejectedValueOnce(new ApiError("Sandbox unavailable", 503, {}));
|
||
if (failure === "failed" || failure === "timed_out" || failure === "completion") {
|
||
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValueOnce({
|
||
sessionId: "claude-session-1", status: failure === "completion" ? "authenticated" : failure,
|
||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||
});
|
||
}
|
||
if (failure === "completion") mockAgentsApi.completeClaudeSetupTokenLogin.mockRejectedValueOnce(new Error("Completion unavailable"));
|
||
const { root } = await openStep4({ adapterType: "claude_local" });
|
||
try {
|
||
await pickSource(/Claude/);
|
||
if (failure === "code") {
|
||
const field = document.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement;
|
||
await act(async () => {
|
||
field.dispatchEvent(new Event("paste", { bubbles: true }));
|
||
setControlledValue(field, "expired-code");
|
||
});
|
||
}
|
||
for (let i = 0; i < 8; i++) await flushReact();
|
||
const primary = () => [...document.body.querySelectorAll("button")].at(-1)!;
|
||
expect(document.body.textContent).toContain(failure === "timed_out" ? "This sign-in expired" : "Sign-in didn’t finish");
|
||
expect(document.querySelector('[role="alert"]')?.className).not.toContain("text-destructive");
|
||
expect(document.body.textContent).toContain("Retry sign-in to get a fresh code. Your setup is saved.");
|
||
expect(document.body.textContent).not.toContain("Private provider detail");
|
||
expect(document.querySelector('a[href*="claude.ai"]')).toBeNull();
|
||
expect(document.querySelector('input[aria-label="Authorization code"]')).toBeNull();
|
||
expect(primary().textContent?.trim()).toBe("Retry sign-in");
|
||
expect(primary().disabled).toBe(false);
|
||
const startsBeforeRetry = mockAgentsApi.startClaudeSetupTokenLogin.mock.calls.length;
|
||
let release: () => void = () => {};
|
||
if (failure !== "start" && failure !== "lookup") mockAgentsApi.cancelClaudeSetupTokenLogin.mockImplementationOnce(() => new Promise(resolve => { release = () => resolve({}); }));
|
||
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockRejectedValue(new ApiError("Not found", 404, null));
|
||
mockAgentsApi.startClaudeSetupTokenLogin.mockResolvedValueOnce({
|
||
sessionId: "claude-session-2", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||
});
|
||
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
|
||
sessionId: "claude-session-2", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||
});
|
||
mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockResolvedValue({ authorizationUrl: "https://claude.ai/oauth/authorize?attempt=2", transportAdvisory: null });
|
||
await act(async () => { primary().click(); primary().click(); });
|
||
if (failure !== "start" && failure !== "lookup") {
|
||
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).toHaveBeenCalledExactlyOnceWith("company-new", "claude-session-1");
|
||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(startsBeforeRetry);
|
||
expect(primary().disabled).toBe(true);
|
||
await act(async () => release());
|
||
}
|
||
for (let i = 0; i < 12; i++) await flushReact();
|
||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(startsBeforeRetry + 1);
|
||
expect(primary().textContent?.trim()).toBe("Sign in to Claude");
|
||
const field = document.querySelector('input[aria-label="Authorization code"]') as HTMLInputElement;
|
||
expect(field.value).toBe("");
|
||
expect(document.querySelector('[role="alert"]')).toBeNull();
|
||
await act(async () => setControlledValue(field, "fresh-code"));
|
||
await act(async () => primary().click());
|
||
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenLastCalledWith("company-new", "claude-session-2", "fresh-code");
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally {
|
||
await act(async () => root.unmount());
|
||
}
|
||
});
|
||
|
||
it.each(["claude_local", "codex_local"] as const)("keeps a missing resumed %s session retryable", async adapterType => {
|
||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||
const claude = adapterType === "claude_local";
|
||
const active = {
|
||
sessionId: claude ? "claude-session-1" : "codex-session-1", environmentId: "env-sandbox-1",
|
||
status: "waiting_for_user", expiresAt: null, failure: null,
|
||
aiConnection: { provider: claude ? "anthropic" : "openai", method: "subscription", ownership: "personal", agentIds: [], allAgents: true },
|
||
panelMode: claude ? "submitted_browser_code" : "displayed_code",
|
||
prompt: claude ? { authorizationUrl: "https://claude.ai/oauth/authorize?code=true" } : { url: "https://auth.openai.com/codex/device", code: "OLD-CODE" },
|
||
};
|
||
const activeApi = claude ? mockAgentsApi.getActiveClaudeSetupTokenLoginSession : mockAgentsApi.getActiveAdapterAuthLoginSession;
|
||
activeApi.mockResolvedValue(active);
|
||
const statusApi = claude ? mockAgentsApi.getClaudeSetupTokenLoginStatus : mockAgentsApi.getAdapterAuthLoginStatus;
|
||
statusApi.mockRejectedValueOnce(new ApiError("Session gone", 404, {}));
|
||
const { root } = await openStep4({ adapterType });
|
||
try {
|
||
for (let i = 0; i < 10; i++) await flushReact();
|
||
const primary = () => [...document.body.querySelectorAll("button")].at(-1)!;
|
||
expect(primary().textContent?.trim()).toBe("Retry sign-in");
|
||
expect(document.body.textContent).toContain("Sign-in didn’t finish");
|
||
activeApi.mockRejectedValue(new ApiError("Not found", 404, null));
|
||
await act(async () => primary().click());
|
||
for (let i = 0; i < 12; i++) await flushReact();
|
||
expect(primary().textContent?.trim()).toBe(claude ? "Sign in to Claude" : "Sign in to OpenAI");
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally {
|
||
await act(async () => root.unmount());
|
||
}
|
||
});
|
||
|
||
it("keeps retry available after cancellation fails, and accepts an already-removed session", async () => {
|
||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValueOnce({ sessionId: "claude-session-1", status: "failed", expiresAt: new Date(Date.now() + 600_000).toISOString() });
|
||
mockAgentsApi.cancelClaudeSetupTokenLogin.mockRejectedValueOnce(new Error("Temporary error"))
|
||
.mockRejectedValueOnce(new ApiError("Session gone", 404, {}));
|
||
const { root } = await openStep4({ adapterType: "claude_local" });
|
||
try {
|
||
await pickSource(/Claude/);
|
||
const primary = () => [...document.body.querySelectorAll("button")].at(-1)!;
|
||
await act(async () => primary().click());
|
||
for (let i = 0; i < 8; i++) await flushReact();
|
||
expect(primary().textContent?.trim()).toBe("Retry sign-in");
|
||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
|
||
mockAgentsApi.startClaudeSetupTokenLogin.mockResolvedValueOnce({ sessionId: "claude-session-2", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString() });
|
||
await act(async () => primary().click());
|
||
for (let i = 0; i < 12; i++) await flushReact();
|
||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(2);
|
||
expect(primary().textContent?.trim()).toBe("Sign in to Claude");
|
||
} finally {
|
||
await act(async () => root.unmount());
|
||
}
|
||
});
|
||
|
||
it.each(["timed_out", "gone"] as const)("retries OpenAI sign-in after the session is %s", async failure => {
|
||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||
if (failure === "gone") mockAgentsApi.getAdapterAuthLoginStatus.mockRejectedValueOnce(new ApiError("Session gone", 404, {}));
|
||
else mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValueOnce({ sessionId: "codex-session-1", status: "timed_out", prompt: { url: "https://auth.openai.com/codex/device", code: "OLD-CODE" } });
|
||
const { root } = await openStep4({ adapterType: "codex_local" });
|
||
try {
|
||
await pickSource(/OpenAI/);
|
||
const primary = () => [...document.body.querySelectorAll("button")].at(-1)!;
|
||
expect(primary().textContent?.trim()).toBe("Retry sign-in");
|
||
mockAgentsApi.startAdapterAuthLogin.mockResolvedValueOnce({ sessionId: "codex-session-2", status: "pending" });
|
||
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({ sessionId: "codex-session-2", status: "pending", prompt: { url: "https://auth.openai.com/codex/device", code: "FRESH-CODE" } });
|
||
await act(async () => { primary().click(); primary().click(); });
|
||
for (let i = 0; i < 12; i++) await flushReact();
|
||
expect(mockAgentsApi.cancelAdapterAuthLogin).toHaveBeenCalledExactlyOnceWith("company-new", "codex_local", "codex-session-1");
|
||
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(2);
|
||
expect(primary().textContent?.trim()).toBe("Sign in to OpenAI");
|
||
expect(document.body.textContent).toContain("FRESH-CODE");
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally {
|
||
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 Sandbox".',
|
||
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("Sign-in didn’t finish");
|
||
|
||
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("Sign-in didn’t finish");
|
||
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it.each(([
|
||
["claude_local", "resume"], ["codex_local", "resume"],
|
||
["claude_local", "start"], ["codex_local", "start"],
|
||
["claude_local", "prompt"], ["codex_local", "prompt"],
|
||
] as const).flatMap(([adapterType, stage]) => [false, true].map(afterExit => [adapterType, stage, afterExit] as const)))("keeps %s cancelled when its pending %s response arrives after Back (exit finished: %s)", async (adapterType, stage, afterExit) => {
|
||
const claude = adapterType === "claude_local";
|
||
const activeApi = claude ? mockAgentsApi.getActiveClaudeSetupTokenLoginSession : mockAgentsApi.getActiveAdapterAuthLoginSession;
|
||
const startApi = claude ? mockAgentsApi.startClaudeSetupTokenLogin : mockAgentsApi.startAdapterAuthLogin;
|
||
const promptApi = claude ? mockAgentsApi.getClaudeSetupTokenLoginPrompt : mockAgentsApi.getAdapterAuthLoginStatus;
|
||
const cancelApi = claude ? mockAgentsApi.cancelClaudeSetupTokenLogin : mockAgentsApi.cancelAdapterAuthLogin;
|
||
const session = {
|
||
sessionId: claude ? "claude-session-1" : "codex-session-1", environmentId: "env-sandbox-1",
|
||
status: "waiting_for_user", expiresAt: null, failure: null,
|
||
aiConnection: { provider: claude ? "anthropic" : "openai", method: "subscription", ownership: "personal", agentIds: [], allAgents: true },
|
||
panelMode: claude ? "submitted_browser_code" : "displayed_code",
|
||
prompt: claude ? { authorizationUrl: "https://claude.ai/oauth/authorize?cancelled=true" }
|
||
: { url: "https://auth.openai.com/codex/device", code: "CANCELLED-CODE" },
|
||
};
|
||
let finish!: (value: unknown) => void;
|
||
const pending = new Promise(resolve => { finish = resolve; });
|
||
const pendingApi = stage === "resume" ? activeApi : stage === "start" ? startApi : promptApi;
|
||
pendingApi.mockReturnValue(pending as never);
|
||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||
const { root } = await openStep4({ adapterType });
|
||
try {
|
||
await pickSource(claude ? /Claude/ : /OpenAI/);
|
||
expect(pendingApi).toHaveBeenCalled();
|
||
await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!.click());
|
||
if (afterExit) for (let i = 0; i < 12; i++) await flushReact();
|
||
if (stage !== "start") {
|
||
const signals = (pendingApi.mock.calls as unknown[][]).map(call => (call.at(-1) as { signal?: AbortSignal })?.signal).filter(signal => signal !== undefined);
|
||
expect(signals.length).toBeGreaterThan(0);
|
||
expect(signals.every(signal => signal.aborted)).toBe(true);
|
||
}
|
||
await act(async () => finish(stage === "prompt" && claude ? session.prompt : session));
|
||
for (let i = 0; i < 12; i++) await flushReact();
|
||
expect(document.querySelectorAll('[role="radio"][aria-checked="true"]')).toHaveLength(0);
|
||
expect(document.body.textContent).not.toContain("CANCELLED-CODE");
|
||
expect(document.querySelector('input[aria-label="Authorization code"]')).toBeNull();
|
||
expect(document.body.textContent).not.toContain("Let's get started...");
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
if (claude) expect(cancelApi).toHaveBeenCalledWith("company-new", session.sessionId);
|
||
else expect(cancelApi).toHaveBeenCalledWith("company-new", "codex_local", session.sessionId);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it.each([
|
||
["claude_local", false], ["codex_local", false], ["claude_local", true], ["codex_local", true],
|
||
] as const)("discovers and releases %s after its resume read rejects on abort (panel mounted: %s)", async (adapterType, panelMounted) => {
|
||
const claude = adapterType === "claude_local";
|
||
const activeApi = claude ? mockAgentsApi.getActiveClaudeSetupTokenLoginSession : mockAgentsApi.getActiveAdapterAuthLoginSession;
|
||
const cancelApi = claude ? mockAgentsApi.cancelClaudeSetupTokenLogin : mockAgentsApi.cancelAdapterAuthLogin;
|
||
const startApi = claude ? mockAgentsApi.startClaudeSetupTokenLogin : mockAgentsApi.startAdapterAuthLogin;
|
||
const session = { sessionId: "abandoned-session", environmentId: "env-sandbox-1", status: "waiting_for_user", expiresAt: null,
|
||
panelMode: claude ? "submitted_browser_code" : "displayed_code",
|
||
aiConnection: { provider: claude ? "anthropic" : "openai", method: "subscription", ownership: "personal", agentIds: [], allAgents: true } };
|
||
let serverActive = true;
|
||
let aborts = 0;
|
||
let finishDiscovery!: () => void;
|
||
let finishCancellation = () => {};
|
||
const discovery = new Promise<void>(resolve => { finishDiscovery = resolve; });
|
||
activeApi.mockImplementation((...args: unknown[]) => {
|
||
const options = args.at(-1) as { signal?: AbortSignal; fresh?: boolean };
|
||
if (options?.fresh) return discovery.then(() => serverActive ? session : null);
|
||
if (!serverActive) return Promise.resolve(null);
|
||
return new Promise((_resolve, reject) => options?.signal?.addEventListener("abort", () => {
|
||
aborts++;
|
||
reject(new DOMException("The request was aborted", "AbortError"));
|
||
}, { once: true }));
|
||
});
|
||
cancelApi.mockImplementation(() => new Promise(resolve => { finishCancellation = () => { serverActive = false; resolve({}); }; }));
|
||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||
const { root } = await openStep4({ adapterType });
|
||
const matchMedia = window.matchMedia;
|
||
const restoreMotion = () => Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, value: matchMedia });
|
||
try {
|
||
if (panelMounted) await pickSource(claude ? /Claude/ : /OpenAI/);
|
||
else {
|
||
Object.defineProperty(window, "matchMedia", { configurable: true, writable: true,
|
||
value: (media: string) => ({ matches: false, media, onchange: null, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent: () => false }) });
|
||
const tile = [...document.body.querySelectorAll('[role="radio"]')].find(tile => (claude ? /Claude/ : /OpenAI/).test(tile.textContent ?? ""))!;
|
||
await act(async () => (tile as HTMLElement).click());
|
||
expect(activeApi).toHaveBeenCalledTimes(1);
|
||
restoreMotion();
|
||
}
|
||
await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!.click());
|
||
for (let i = 0; i < 12; i++) await flushReact();
|
||
expect(aborts).toBeGreaterThan(0);
|
||
await vi.waitFor(() => expect((activeApi.mock.calls as unknown[][]).some(call => (call.at(-1) as { fresh?: boolean })?.fresh)).toBe(true));
|
||
expect(document.querySelectorAll('[role="radio"][aria-checked="true"]')).toHaveLength(0);
|
||
await pickSource(claude ? /Claude/ : /OpenAI/);
|
||
expect(startApi).not.toHaveBeenCalled();
|
||
await act(async () => finishDiscovery());
|
||
await vi.waitFor(() => expect(cancelApi).toHaveBeenCalled());
|
||
expect(startApi).not.toHaveBeenCalled();
|
||
await act(async () => finishCancellation());
|
||
for (let i = 0; i < 12; i++) await flushReact();
|
||
expect(serverActive).toBe(false);
|
||
expect(startApi).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally {
|
||
restoreMotion();
|
||
finishDiscovery();
|
||
finishCancellation();
|
||
await act(async () => root.unmount());
|
||
}
|
||
});
|
||
|
||
it.each((["claude_local", "codex_local"] as const).flatMap(adapterType =>
|
||
(["environment", "legacy", "connection", "ownership", "agents", "allAgents"] as const).map(difference => [adapterType, difference] as const)))("preserves another %s sign-in after Back when %s differs", async (adapterType, difference) => {
|
||
const claude = adapterType === "claude_local";
|
||
const activeApi = claude ? mockAgentsApi.getActiveClaudeSetupTokenLoginSession : mockAgentsApi.getActiveAdapterAuthLoginSession;
|
||
const cancelApi = claude ? mockAgentsApi.cancelClaudeSetupTokenLogin : mockAgentsApi.cancelAdapterAuthLogin;
|
||
const startApi = claude ? mockAgentsApi.startClaudeSetupTokenLogin : mockAgentsApi.startAdapterAuthLogin;
|
||
const intent = { provider: claude ? "anthropic" : "openai", method: "subscription", ownership: "personal", agentIds: [] as string[], allAgents: true };
|
||
const session = { sessionId: "settings-sign-in", environmentId: difference === "environment" ? "other-environment" : "env-sandbox-1",
|
||
status: "waiting_for_user", expiresAt: null, aiConnection: difference === "legacy" ? undefined : { ...intent,
|
||
...(difference === "connection" ? { connectionId: "other-connection" } : {}),
|
||
...(difference === "ownership" ? { ownership: "organization" } : {}),
|
||
...(difference === "agents" ? { agentIds: ["other-agent"] } : {}),
|
||
...(difference === "allAgents" ? { allAgents: false } : {}) } };
|
||
activeApi.mockResolvedValue(session);
|
||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||
const { root } = await openStep4({ adapterType });
|
||
try {
|
||
for (let i = 0; i < 12; i++) await flushReact();
|
||
expect(document.body.textContent).toContain("Sign-in didn’t finish");
|
||
await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!.click());
|
||
for (let i = 0; i < 12; i++) await flushReact();
|
||
expect(cancelApi).not.toHaveBeenCalled();
|
||
expect(startApi).not.toHaveBeenCalled();
|
||
expect(document.querySelectorAll('[role="radio"][aria-checked="true"]')).toHaveLength(0);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it.each(["claude_local", "codex_local"] as const)("preserves a conflicting %s resume response received after Back", async adapterType => {
|
||
const claude = adapterType === "claude_local";
|
||
const activeApi = claude ? mockAgentsApi.getActiveClaudeSetupTokenLoginSession : mockAgentsApi.getActiveAdapterAuthLoginSession;
|
||
const cancelApi = claude ? mockAgentsApi.cancelClaudeSetupTokenLogin : mockAgentsApi.cancelAdapterAuthLogin;
|
||
let finish!: (session: unknown) => void;
|
||
activeApi.mockReturnValue(new Promise(resolve => { finish = resolve; }));
|
||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||
const { root } = await openStep4({ adapterType });
|
||
try {
|
||
await pickSource(claude ? /Claude/ : /OpenAI/);
|
||
await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!.click());
|
||
await act(async () => finish({ sessionId: "settings-sign-in", environmentId: "another-environment", status: "waiting_for_user" }));
|
||
for (let i = 0; i < 12; i++) await flushReact();
|
||
expect(cancelApi).not.toHaveBeenCalled();
|
||
expect(document.querySelectorAll('[role="radio"][aria-checked="true"]')).toHaveLength(0);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it("cancels claude_local on Back and starts a fresh sign-in when selected again", async () => {
|
||
const session = {
|
||
sessionId: "claude-session-1",
|
||
status: "pending",
|
||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||
};
|
||
let started = false;
|
||
let finishCancellation!: () => void;
|
||
mockAgentsApi.cancelClaudeSetupTokenLogin.mockImplementation(() => new Promise(resolve => { finishCancellation = () => { started = false; resolve({}); }; }));
|
||
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/);
|
||
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();
|
||
|
||
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).toHaveBeenCalled();
|
||
await pickSource(/Claude/);
|
||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(1);
|
||
await act(async () => finishCancellation());
|
||
for (let i = 0; i < 8; i++) await flushReact();
|
||
|
||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledTimes(2);
|
||
expect(mockAgentsApi.getActiveClaudeSetupTokenLoginSession).toHaveBeenCalled();
|
||
|
||
await act(async () => root.unmount());
|
||
});
|
||
|
||
it("cancels codex_local on Back and starts a fresh sign-in when selected again", async () => {
|
||
const session = { sessionId: "codex-session-1", status: "pending" };
|
||
let started = false;
|
||
let finishCancellation!: () => void;
|
||
mockAgentsApi.cancelAdapterAuthLogin.mockImplementation(() => new Promise(resolve => { finishCancellation = () => { started = false; resolve({}); }; }));
|
||
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();
|
||
|
||
expect(mockAgentsApi.cancelAdapterAuthLogin).toHaveBeenCalled();
|
||
await pickSource(/OpenAI/);
|
||
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
|
||
await act(async () => finishCancellation());
|
||
for (let i = 0; i < 8; i++) await flushReact();
|
||
|
||
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(2);
|
||
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(document.body.textContent).toContain("Sign in to Claude then come back and enter authorization code");
|
||
expect(document.body.textContent).not.toContain("Use saved login");
|
||
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("reads the signal again after an adapter change", async () => {
|
||
mockAdapterRegistry.list = [setupAdapterInfo({ 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("managed Codex automatic setup qualification", () => {
|
||
const image = `registry.example.test/runtime@sha256:${"a".repeat(64)}`;
|
||
const discovery = (defaultRunner: "paperclip" | "legacy") => [{ type: "codex_local", defaultRunner,
|
||
supportedRunners: defaultRunner === "paperclip" ? ["paperclip", "legacy"] : ["legacy"] }] as import("../api/adapters").AdapterInfo[];
|
||
|
||
async function openManaged(overrides: Record<string, unknown> = {}, config: Record<string, unknown> = { provider: "daytona", image }, staleTime = 0) {
|
||
mockEnvironmentsApi.list.mockResolvedValue([{ ...SANDBOX_ENVIRONMENT, config }]);
|
||
mockAdaptersApi.list.mockResolvedValue(discovery("legacy"));
|
||
const handles = await openStep4({ adapterType: "codex_local", runner: "auto", ...overrides }, staleTime);
|
||
await act(async () => handles.queryClient.setQueryData(["ai-connections", "company-new"], {
|
||
currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "openai", method: "subscription", name: "My subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
|
||
}));
|
||
return handles;
|
||
}
|
||
|
||
it.each([["paperclip", 0], ["legacy", 0], ["paperclip", 30_000], ["legacy", 30_000]] as const)("qualifies the image on Connect and honors the measured %s default with %i ms cache", async (defaultRunner, staleTime) => {
|
||
const calls: string[] = [];
|
||
const handles = await openManaged({}, { provider: "daytona", image }, staleTime);
|
||
let qualified = false;
|
||
mockAdaptersApi.list.mockImplementation(async context => {
|
||
expect(context).toEqual({ companyId: "company-new", environmentId: "env-sandbox-1" });
|
||
calls.push(qualified ? "discovery-after" : "discovery-before");
|
||
return discovery(qualified ? defaultRunner : "legacy");
|
||
});
|
||
mockEnvironmentsApi.probe.mockImplementation(async () => {
|
||
calls.push("platform");
|
||
qualified = true;
|
||
return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } };
|
||
});
|
||
mockAgentsApi.testEnvironment.mockImplementation(async () => {
|
||
calls.push("adapter");
|
||
return { adapterType: defaultRunner === "paperclip" ? "paperclip_runner" : "codex_local", status: "pass", checks: [], testedAt: "2026-10-13T00:00:00Z" };
|
||
});
|
||
mockAgentsApi.hire.mockImplementation(async () => { calls.push("hire"); return { agent: { id: "agent-1" }, approval: null }; });
|
||
try {
|
||
expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled();
|
||
await pickSource(/OpenAI/);
|
||
expect(calls).toEqual(["discovery-before", "platform", "discovery-after", "adapter", "hire"]);
|
||
expect(handles.queryClient.getQueryData(queryKeys.adapters.availability("company-new", "env-sandbox-1"))).toEqual(discovery(defaultRunner));
|
||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledExactlyOnceWith("env-sandbox-1", "company-new", expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledWith("company-new", "codex_local", expect.objectContaining({ environmentId: "env-sandbox-1", aiConnection: { provider: "openai", method: "subscription", mode: "responsible_user" } }), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||
expect((mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2]).not.toHaveProperty("runner");
|
||
expect((mockAgentsApi.hire.mock.calls[0] as unknown[])[1]).not.toHaveProperty("runner");
|
||
expect(managedApi.connectLocal).not.toHaveBeenCalled();
|
||
} finally { await act(async () => handles.root.unmount()); }
|
||
});
|
||
|
||
it.each(["permission", "cleanup", "config-race", "missing-lease", "connection", "discovery"].flatMap(failure => [0, 30_000].map(staleTime => [failure, staleTime] as const)))("stops managed automatic setup after %s qualification failure with %i ms cache", async (failure, staleTime) => {
|
||
const handles = await openManaged({}, { provider: "daytona", image }, staleTime);
|
||
const diagnostic = failure === "permission" ? "Instance admin access required" : "Qualification could not be confirmed";
|
||
if (failure === "permission") mockEnvironmentsApi.probe.mockRejectedValue(new ApiError(diagnostic, 403, null));
|
||
else mockEnvironmentsApi.probe.mockImplementation(async () => {
|
||
if (failure === "discovery") mockAdaptersApi.list.mockRejectedValue(new Error(diagnostic));
|
||
return { ok: failure !== "connection", driver: "sandbox", summary: diagnostic,
|
||
details: { platformQualificationRecorded: !["cleanup", "config-race"].includes(failure),
|
||
...(failure === "missing-lease" ? {} : { leaseId: "platform-lease" }), platformQualificationDiagnostic: diagnostic } };
|
||
});
|
||
try {
|
||
await pickSource(/OpenAI/);
|
||
expect(document.body.textContent).toContain(diagnostic);
|
||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally { await act(async () => handles.root.unmount()); }
|
||
});
|
||
|
||
it.each(["legacy", "native-cache", "snapshot", "tag", "local", "saved"])("keeps the %s path free of an extra platform probe", async mode => {
|
||
const handles = await openManaged(mode === "legacy" ? { runner: "legacy" } : mode === "saved" ? { createdAgentId: "existing" } : {},
|
||
mode === "snapshot" ? { provider: "daytona", snapshot: "named" } : mode === "tag" ? { provider: "daytona", image: "ubuntu:24.04" } : { provider: "daytona", image });
|
||
if (mode === "native-cache") {
|
||
mockAdaptersApi.list.mockResolvedValue(discovery("paperclip"));
|
||
await act(async () => handles.queryClient.setQueryData(queryKeys.adapters.availability("company-new", "env-sandbox-1"), discovery("paperclip")));
|
||
}
|
||
if (mode === "local") {
|
||
await act(async () => handles.queryClient.setQueryData(queryKeys.environments.list("company-new"), [LOCAL_ENVIRONMENT]));
|
||
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
|
||
await act(async () => handles.queryClient.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: null }));
|
||
}
|
||
try {
|
||
await pickSource(/OpenAI/);
|
||
expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled();
|
||
} finally { await act(async () => handles.root.unmount()); }
|
||
});
|
||
|
||
it.each(["missing", "invalid-default", "unsupported-default"])("does not probe or hire with %s authoritative discovery", async mode => {
|
||
const handles = await openManaged();
|
||
mockAdaptersApi.list.mockResolvedValue(mode === "missing" ? [] : mode === "invalid-default"
|
||
? [{ type: "codex_local", supportedRunners: ["legacy"] }] as import("../api/adapters").AdapterInfo[]
|
||
: [{ type: "codex_local", defaultRunner: "paperclip", supportedRunners: ["legacy"] }] as import("../api/adapters").AdapterInfo[]);
|
||
try {
|
||
await pickSource(/OpenAI/);
|
||
expect(document.body.textContent).toContain("Could not confirm Codex runner availability");
|
||
expect(mockEnvironmentsApi.probe).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally { await act(async () => handles.root.unmount()); }
|
||
});
|
||
|
||
it.each([0, 30_000])("rechecks stale native discovery and stops a changed image from becoming an unnoticed Legacy hire with %i ms cache", async staleTime => {
|
||
const handles = await openManaged({}, { provider: "daytona", image }, staleTime);
|
||
await act(async () => handles.queryClient.setQueryData(queryKeys.adapters.availability("company-new", "env-sandbox-1"), discovery("paperclip")));
|
||
mockEnvironmentsApi.probe.mockResolvedValue({ ok: true, driver: "sandbox", summary: "Connected", details: {
|
||
platformQualificationRecorded: false, leaseId: "platform-lease", platformQualificationDiagnostic: "The environment changed during the probe. Probe its current configuration again." } });
|
||
try {
|
||
await pickSource(/OpenAI/);
|
||
expect(mockAdaptersApi.list).toHaveBeenCalledWith({ companyId: "company-new", environmentId: "env-sandbox-1" });
|
||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1);
|
||
expect(document.body.textContent).toContain("The environment changed during the probe");
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally { await act(async () => handles.root.unmount()); }
|
||
});
|
||
|
||
it.each(["authentication", "dependency", "model"])("keeps selected native %s setup failures actionable without retrying Legacy", async failure => {
|
||
const handles = await openManaged();
|
||
mockEnvironmentsApi.probe.mockImplementation(async () => {
|
||
mockAdaptersApi.list.mockResolvedValue(discovery("paperclip"));
|
||
return { ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } };
|
||
});
|
||
mockAgentsApi.testEnvironment.mockResolvedValue({ adapterType: "paperclip_runner", status: "fail", testedAt: "2026-10-13T00:00:00Z",
|
||
checks: [{ code: `native_${failure}`, level: "error", message: `Repair native ${failure}` }] });
|
||
try {
|
||
await pickSource(/OpenAI/);
|
||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||
expect((mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[2]).not.toHaveProperty("runner");
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
expect(document.body.textContent).toContain(`Repair native ${failure}`);
|
||
} finally { await act(async () => handles.root.unmount()); }
|
||
});
|
||
|
||
it.each(["back", "environment-config"])("does not test or hire after a held probe is abandoned by %s", async change => {
|
||
let finish!: (value: import("@paperclipai/shared").EnvironmentProbeResult) => void;
|
||
const handles = await openManaged();
|
||
mockEnvironmentsApi.probe.mockReturnValue(new Promise(resolve => { finish = resolve; }));
|
||
try {
|
||
await pickSource(/OpenAI/);
|
||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
await act(async () => document.querySelector('[data-testid="onboarding-wizard"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true })));
|
||
await flushReact();
|
||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1);
|
||
if (change === "back") await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Back")!.click());
|
||
else {
|
||
await act(async () => handles.queryClient.setQueryData(queryKeys.environments.list("company-new"), [{ ...SANDBOX_ENVIRONMENT, config: { provider: "daytona", image: `registry.example.test/runtime@sha256:${"b".repeat(64)}` } }]));
|
||
await flushReact();
|
||
}
|
||
await act(async () => finish({ ok: true, driver: "sandbox", summary: "Measured image", details: { platformQualificationRecorded: true, leaseId: "platform-lease" } }));
|
||
for (let i = 0; i < 8; i++) await flushReact();
|
||
expect(mockEnvironmentsApi.probe).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
} finally { await act(async () => handles.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.flatMap(([adapterType, provider, source]) =>
|
||
(["paperclip", "legacy"] as const).map(defaultRunner => ({ adapterType, provider, source, defaultRunner })),
|
||
))("waits for $adapterType discovery before verification and keeps $defaultRunner routing automatic", async ({ adapterType, provider, source, defaultRunner }) => {
|
||
let finish!: (value: import("../api/adapters").AdapterInfo[]) => void;
|
||
mockAdaptersApi.list.mockReturnValue(new Promise<import("../api/adapters").AdapterInfo[]>(resolve => { finish = resolve; }));
|
||
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => adapterType === "claude_local"
|
||
? buildClaudeLocalConfig(values!) : buildCodexRunnerConfig(values!));
|
||
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", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
|
||
}));
|
||
await pickSource(source);
|
||
await settle();
|
||
expect(document.body.querySelector('[aria-label="Runner"]')).toBeNull();
|
||
expect([...document.body.querySelectorAll("summary")].some(summary => summary.textContent === "Advanced")).toBe(false);
|
||
expect(button("Connect").disabled).toBe(true);
|
||
await act(async () => button("Connect").dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true })));
|
||
await settle();
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
await act(async () => finish([setupAdapterInfo({ type: adapterType, defaultRunner, supportedRunners: defaultRunner === "paperclip" ? ["paperclip", "legacy"] : ["legacy"] })]));
|
||
await settle();
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||
const tested = mockAgentsApi.testEnvironment.mock.calls[0] as unknown as [string, string, { adapterConfig: Record<string, unknown> }];
|
||
const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record<string, unknown> }];
|
||
expect(mockAdapterBuild.buildAdapterConfig).toHaveBeenCalledWith(expect.objectContaining({ adapterType, runner: "auto", resolvedRunner: defaultRunner }));
|
||
expect(tested[1]).toBe(adapterType);
|
||
expect(tested[2]).not.toHaveProperty("runner");
|
||
expect(hired[1]).toMatchObject({ adapterType, onboardingFirstAgent: true });
|
||
expect(hired[1]).not.toHaveProperty("runner");
|
||
expect(hired[1].adapterConfig).toEqual(tested[2].adapterConfig);
|
||
if (adapterType === "claude_local") {
|
||
if (defaultRunner === "paperclip") expect(hired[1].adapterConfig).not.toHaveProperty("maxTurnsPerRun");
|
||
else expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000);
|
||
}
|
||
expect(hired[1].adapterConfig).not.toHaveProperty("resolvedRunner");
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it.each(["environments", "settings", "policy"] as const)("does not use warm Claude discovery before %s resolves", async pending => {
|
||
let finish!: (value: any) => void;
|
||
const delayed = new Promise<any>(resolve => { finish = resolve; });
|
||
if (pending === "environments") mockEnvironmentsApi.list.mockReturnValue(delayed);
|
||
else if (pending === "settings") mockInstanceSettingsApi.get.mockReturnValue(delayed);
|
||
else mockInstanceSettingsApi.getExperimental.mockReturnValue(delayed);
|
||
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!));
|
||
const { root, queryClient } = await openStep4({ adapterType: "claude_local" });
|
||
try {
|
||
await act(async () => {
|
||
if (pending === "environments") queryClient.setQueryData(queryKeys.environments.list("company-new"), [LOCAL_ENVIRONMENT]);
|
||
else if (pending === "settings") queryClient.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: null });
|
||
else queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableManagedSandboxOnly: false });
|
||
for (const environmentId of [null, LOCAL_ENVIRONMENT.id]) queryClient.setQueryData(queryKeys.adapters.availability("company-new", environmentId), [{ type: "claude_local", defaultRunner: "paperclip" }]);
|
||
queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }] });
|
||
});
|
||
await pickSource(/Claude/);
|
||
await settle();
|
||
expect(mockAdapterBuild.buildAdapterConfig).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
await act(async () => finish(pending === "environments" ? [LOCAL_ENVIRONMENT] : pending === "settings" ? { defaultEnvironmentId: null } : { enableManagedSandboxOnly: false }));
|
||
await settle();
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||
const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record<string, unknown> }];
|
||
expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it.each(["environments", "settings", "policy"] as const)("reports failed Claude %s resolution and retries without early verification", async failed => {
|
||
if (failed === "environments") mockEnvironmentsApi.list.mockRejectedValue(new Error("Unavailable"));
|
||
else if (failed === "settings") mockInstanceSettingsApi.get.mockRejectedValue(new Error("Unavailable"));
|
||
else mockInstanceSettingsApi.getExperimental.mockRejectedValue(new Error("Unavailable"));
|
||
const { root, queryClient } = await openStep4({ adapterType: "claude_local" });
|
||
try {
|
||
await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }] }));
|
||
await pickSource(/Claude/);
|
||
await settle();
|
||
expect(document.body.textContent).toContain("Could not load execution environment settings");
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]);
|
||
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
|
||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false });
|
||
await click("Retry setup");
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it("does not hire from obsolete Claude discovery after the target changes during verification", async () => {
|
||
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!));
|
||
mockAdaptersApi.list.mockImplementation(async context => [setupAdapterInfo({ type: "claude_local", defaultRunner: context?.environmentId === "new-local-default" ? "paperclip" : "legacy" })]);
|
||
const finishes: Array<(value: typeof passed) => void> = [];
|
||
mockAgentsApi.testEnvironment.mockImplementation(() => new Promise(resolve => finishes.push(resolve)));
|
||
const { root, queryClient } = await openStep4({ adapterType: "claude_local" });
|
||
try {
|
||
await pickSource(/Claude/);
|
||
await detected();
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||
await act(async () => queryClient.setQueryData(queryKeys.environments.list("company-new"), [{ ...LOCAL_ENVIRONMENT, id: "new-local-default" }]));
|
||
await settle();
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||
await act(async () => finishes[0](passed));
|
||
await settle();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||
expect(document.body.textContent).toContain("Configuration changed during setup");
|
||
await click("Connect");
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
|
||
await act(async () => finishes[1](passed));
|
||
await settle();
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||
const tested = mockAgentsApi.testEnvironment.mock.calls as unknown as Array<[string, string, { environmentId: string; adapterConfig: Record<string, unknown> }]>;
|
||
expect(tested[0][2].adapterConfig.maxTurnsPerRun).toBe(1000);
|
||
expect(tested[1][2].environmentId).toBe("new-local-default");
|
||
expect(tested[1][2].adapterConfig).not.toHaveProperty("maxTurnsPerRun");
|
||
const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record<string, unknown> }];
|
||
expect(hired[1].adapterConfig).toEqual(tested[1][2].adapterConfig);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it("unlocks Claude setup when automatic runner availability changes during verification", async () => {
|
||
mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!));
|
||
mockAdaptersApi.list.mockResolvedValue([setupAdapterInfo({ type: "claude_local", defaultRunner: "paperclip" })]);
|
||
const finishes: Array<(value: typeof passed) => void> = [];
|
||
mockAgentsApi.testEnvironment.mockImplementation(() => new Promise(resolve => finishes.push(resolve)));
|
||
const { root, queryClient } = await openStep4({ adapterType: "claude_local" });
|
||
try {
|
||
await pickSource(/Claude/);
|
||
await detected();
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||
expectTesting();
|
||
mockAdaptersApi.list.mockResolvedValue([setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy" })]);
|
||
await act(async () => queryClient.setQueryData(queryKeys.adapters.availability("company-new", LOCAL_ENVIRONMENT.id), [setupAdapterInfo({ type: "claude_local", defaultRunner: "legacy" })]));
|
||
await settle();
|
||
expect(document.body.textContent).toContain("Configuration changed during setup");
|
||
expect(button("Connect").disabled).toBe(false);
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||
await act(async () => finishes[0](passed));
|
||
await settle();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
await click("Connect");
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
|
||
await act(async () => finishes[1](passed));
|
||
await settle();
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||
const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record<string, unknown> }];
|
||
expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
it("fails closed on Claude discovery rejection and retries availability before verification", async () => {
|
||
mockAdaptersApi.list.mockRejectedValue(new Error("Discovery unavailable"));
|
||
const { root, queryClient } = await openStep4({ adapterType: "claude_local" });
|
||
try {
|
||
await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], {
|
||
currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }],
|
||
}));
|
||
await pickSource(/Claude/);
|
||
await settle();
|
||
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
expect(document.body.textContent).toContain("Could not load runner availability");
|
||
mockAdaptersApi.list.mockResolvedValue([setupAdapterInfo({ type: "claude_local", defaultRunner: "paperclip" })]);
|
||
await click("Retry setup");
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||
} finally { await act(async () => root.unmount()); }
|
||
});
|
||
|
||
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, name: "Refreshed local environment" },
|
||
]));
|
||
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("releases a hung test, ignores its late result, and retries with the saved login", async () => {
|
||
let finish!: (result: typeof passed) => void;
|
||
mockAgentsApi.testEnvironment.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
|
||
const timerSpy = vi.spyOn(globalThis, "setTimeout");
|
||
const { root } = await openStep4({ adapterType: "claude_local" });
|
||
try {
|
||
await pickSource(/Claude/);
|
||
await detected();
|
||
expectTesting();
|
||
const deadline = timerSpy.mock.calls.find(([, ms]) => ms === ONBOARDING_CONNECTION_TEST_TIMEOUT_MS);
|
||
expect(deadline).toBeDefined();
|
||
const options = (mockAgentsApi.testEnvironment.mock.calls[0] as unknown[])[3] as { signal: AbortSignal };
|
||
await act(async () => (deadline![0] as () => void)());
|
||
await settle();
|
||
expect(options.signal.aborted).toBe(true);
|
||
expect(document.body.textContent).toContain("The connection test took too long.");
|
||
expect(button("Connect").disabled).toBe(false);
|
||
await act(async () => finish(passed));
|
||
await settle();
|
||
expect(mockAgentsApi.hire).not.toHaveBeenCalled();
|
||
await click("Connect");
|
||
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(2);
|
||
expect(managedApi.connectLocal).toHaveBeenCalledTimes(1);
|
||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||
expect(document.body.textContent).toContain("is ready to work!");
|
||
} finally {
|
||
timerSpy.mockRestore();
|
||
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("does not offer Gemini from a saved initial onboarding draft", 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 {
|
||
const sources = [...document.body.querySelectorAll("button[aria-checked]")];
|
||
expect(sources).toHaveLength(2);
|
||
expect(sources.some(source => /Gemini|Google|gemini_local/.test(source.textContent ?? ""))).toBe(false);
|
||
expect(document.body.querySelector('button[aria-checked="true"]')).toBeNull();
|
||
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",
|
||
aiConnection: { provider: "anthropic", method: "subscription", ownership: "personal", agentIds: [], allAgents: true },
|
||
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());
|
||
});
|
||
});
|
||
});
|