import { test, expect } from "@playwright/test"; /** * E2E: Onboarding wizard flow (NUX Phase 2 expanded wizard). * * The wizard now opens on a front door (path picker) and the "Create a new * company" path runs: * Step 0 — Front door (Create a new company / Level up existing) * Step 1a — Name your organization (creates the company) * Step 2 — Hire your team lead (adapter picker) * Step 3+ — Launch celebration → CEO chat → hiring plan → orientation * * This test covers the deterministic, LLM-free core: it drives the front door * through company naming (which creates the company) and verifies the wizard * advances to the team-lead step without asking for a mission. * * The tail (CEO chat at step 4, hiring-plan generation at step 5, final * landing) depends on a live LLM and is verified separately during manual / * LLM-backed QA — see PAP-50. Surface-level rendering of every step is * snapshotted by nux-phase4-screenshots.spec.ts. */ const COMPANY_NAME = `E2E-Test-${Date.now()}`; test.describe("Onboarding wizard", () => { test("create-company path: naming creates the company, and no goal is invented", async ({ page, }) => { const pageErrors: string[] = []; page.on("pageerror", (err) => pageErrors.push(err.message)); // `--data-dir` starts a new server, not a new browser profile. Keep a // resumable draft here so this ordinary browser condition is covered when // the company-list invalidation runs after the create request. await page.addInitScript(() => { localStorage.setItem("paperclip-onboarding-state", JSON.stringify({ step: 1, companyName: "", createdCompanyId: null, })); }); let delayCompanyListRefetch = false; await page.route("**/api/companies", async (route) => { if (delayCompanyListRefetch && route.request().method() === "GET") { // Keep the invalidation observable: a background fetch must not reset // the in-progress wizard. await new Promise((resolve) => setTimeout(resolve, 250)); } await route.continue(); }); // New-NUX surfaces are flag-gated default-OFF (PAP-136/137/138): turn the // experimental flag on for this throwaway instance before driving them. const flagRes = await page.request.patch("/api/instance/settings/experimental", { data: { enableConferenceRoomChat: true }, }); expect(flagRes.ok()).toBe(true); await page.goto("/onboarding"); // The wizard may open on a launcher card or directly on the capsule // wizard; the front door (step 0) requires a click into the create path. const startBtn = page.getByRole("button", { name: /Start Onboarding|New Organization|Add Agent/, }); if (await startBtn.count()) { await startBtn.first().click(); } const createCard = page.getByRole("button", { name: /Build a new organization/ }); if (await createCard.count()) { await createCard.first().click(); } // Step 1 — Name your organization. await expect( page.getByRole("heading", { name: "What is the name of your organization?" }), ).toBeVisible({ timeout: 15_000 }); await page.getByPlaceholder("e.g. Northwind Labs").fill(COMPANY_NAME); delayCompanyListRefetch = true; await page.getByRole("button", { name: /^Continue/ }).click(); // Step 1's "Next" now creates the company and goes straight to the agent. // The mission step used to sit between them and do the creating; onboarding // no longer asks for the mission, which is collected later in the app. await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000, }); // Verify the company + company-level goal were persisted. const baseUrl = page.url().split("/").slice(0, 3).join("/"); const companiesRes = await page.request.get(`${baseUrl}/api/companies`); expect(companiesRes.ok()).toBe(true); const companies = await companiesRes.json(); const company = companies.find( (c: { name: string }) => c.name === COMPANY_NAME, ); expect(company, `company ${COMPANY_NAME} should exist`).toBeTruthy(); // And no company-level goal, which is the point rather than an omission. // Onboarding no longer asks for a mission, so writing one here would mean // inventing a goal the customer never chose. The mission is collected later // in the app, and the absence is what leaves room for it. const goalsRes = await page.request.get( `${baseUrl}/api/companies/${company.id}/goals`, ); expect(goalsRes.ok()).toBe(true); const goals = await goalsRes.json(); const companyGoal = (Array.isArray(goals) ? goals : []).find( (g: { level?: string }) => g.level === "company", ); expect( companyGoal, "onboarding must not invent a mission the customer never gave", ).toBeFalsy(); // The expanded wizard must not crash the app (Rules-of-Hooks regression). expect(pageErrors, pageErrors.join("\n")).toHaveLength(0); }); test("connect step starts the sign-in on Connect, rather than hiring, when the signal reports no credential", async ({ page, }) => { const pageErrors: string[] = []; page.on("pageerror", (err) => pageErrors.push(err.message)); const flagRes = await page.request.patch("/api/instance/settings/experimental", { data: { enableConferenceRoomChat: true }, }); expect(flagRes.ok()).toBe(true); // The login panel's capability gate requires a sandbox environment with a // login-capable provider, and this throwaway instance has neither: it // only auto-creates the local environment. Add one fake sandbox // environment to the real list, make it the instance default, and declare // its provider's login pseudo-terminal capability — this reproduces the // gate a real sandbox-backed instance would already pass, without // changing any other field the rest of the page depends on. const FAKE_SANDBOX_ENVIRONMENT_ID = "e2e-fake-sandbox-environment"; const FAKE_SANDBOX_PROVIDER = "e2e-fake-provider"; await page.route("**/environments", async (route) => { const response = await route.fetch(); const environments = await response.json(); environments.push({ id: FAKE_SANDBOX_ENVIRONMENT_ID, name: "E2E fake sandbox", description: null, driver: "sandbox", status: "active", config: { provider: FAKE_SANDBOX_PROVIDER }, envVars: {}, metadata: {}, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); await route.fulfill({ response, json: environments }); }); await page.route("**/environments/capabilities", async (route) => { const response = await route.fetch(); const capabilities = await response.json(); capabilities.sandboxProviders[FAKE_SANDBOX_PROVIDER] = { status: "supported", supportsSavedProbe: true, supportsUnsavedProbe: true, supportsRunExecution: true, supportsReusableLeases: false, supportsInteractiveSetup: false, interactiveSetupConnectionTypes: [], supportsTemplateCapture: false, supportsTemplateDelete: false, supportsLoginPty: true, source: "plugin", }; await route.fulfill({ response, json: capabilities }); }); await page.route("**/instance/settings", async (route) => { const response = await route.fetch(); const settings = await response.json(); settings.defaultEnvironmentId = FAKE_SANDBOX_ENVIRONMENT_ID; await route.fulfill({ response, json: settings }); }); // Report no ready credential, so the wizard shows the login panel right // after the adapter is picked, before it ever runs an adapter test. await page.route("**/adapters/*/auth-signal*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ status: "absent" }), }), ); // The sign-in Connect now starts. Stubbed so the card is deterministic: // the session start answers, and the guarded prompt read hands back an // authorization URL for the card's link row. await page.route("**/setup-token-login-sessions", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ sessionId: "e2e-setup-token-session", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(), }), }), ); await page.route("**/setup-token-login-sessions/*/prompt", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ authorizationUrl: "https://claude.ai/oauth/authorize?code=true&client=e2e", transportAdvisory: null, }), }), ); await page.route("**/setup-token-login-sessions/e2e-setup-token-session", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ sessionId: "e2e-setup-token-session", status: "pending", expiresAt: new Date(Date.now() + 600_000).toISOString(), }), }), ); // Fail the adapter test the "Connect" button runs, so the hire gate // blocks the create and this test can prove no agent is hired. await page.route("**/test-environment", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ adapterType: "claude_local", status: "fail", checks: [ { code: "claude_cli_not_found", level: "fail", message: "The claude CLI was not found on this host.", }, ], testedAt: new Date().toISOString(), }), }), ); let hireCalled = false; await page.route("**/agent-hires", (route) => { hireCalled = true; return route.continue(); }); await page.goto("/onboarding"); const startBtn = page.getByRole("button", { name: /Start Onboarding|New Organization|Add Agent/, }); if (await startBtn.count()) { await startBtn.first().click(); } const createCard = page.getByRole("button", { name: /Build a new organization/ }); if (await createCard.count()) { await createCard.first().click(); } await expect( page.getByRole("heading", { name: "What is the name of your organization?" }), ).toBeVisible({ timeout: 15_000 }); await page.getByPlaceholder("e.g. Northwind Labs").fill(`${COMPANY_NAME}-auth-signal`); await page.getByRole("button", { name: /^Continue/ }).click(); await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 }); await page.locator("#onboarding-agent-name").fill("Ada"); await page.getByRole("button", { name: "Next" }).click(); // Step 4 (Connect a model): pick a source. Nothing to assert yet — the card // *is* the sign-in now, so it does not exist until Connect is pressed. By // role rather than by label, because which adapters the row offers depends // on the registry this environment reports. const source = page.getByRole("radio").first(); await source.waitFor({ timeout: 30_000 }); await source.click(); const cardInstruction = page.getByText("Open Claude link then come back and enter code"); await expect(cardInstruction).toHaveCount(0); // Connect starts the sign-in instead of hiring, which is the ordering the // step exists to enforce: a hire here would file an agent with no // credential to run on. Waited on for *enabled* rather than for visible — // it is already on screen, and clicking a disabled button does nothing. const connect = page.getByRole("button", { name: "Connect", exact: true }); await expect(connect).toBeEnabled({ timeout: 30_000 }); await connect.click(); await expect(cardInstruction).toBeVisible({ timeout: 15_000 }); await expect( page.getByRole("link", { name: /claude\.ai\/oauth\/authorize/ }), ).toBeVisible({ timeout: 15_000 }); // No "Use saved login" here: the hire step applies a stored login itself. await expect(page.getByRole("button", { name: "Use saved login" })).toHaveCount(0); expect(hireCalled).toBe(false); expect(pageErrors, pageErrors.join("\n")).toHaveLength(0); }); test("connect step blocks the hire when the environment probe fails and no sign-in is needed", async ({ page, }) => { // The other half of what the test above used to cover. The two claims are // different situations now: there, an absent credential makes Connect start // a sign-in; here there is no sandbox to sign in against — this throwaway // instance only auto-creates the local environment, and nothing below adds // one — so Connect goes straight to the probe, and the probe is the gate. const pageErrors: string[] = []; page.on("pageerror", (err) => pageErrors.push(err.message)); const flagRes = await page.request.patch("/api/instance/settings/experimental", { data: { enableConferenceRoomChat: true }, }); expect(flagRes.ok()).toBe(true); await page.route("**/test-environment", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ adapterType: "claude_local", status: "fail", checks: [ { code: "claude_cli_not_found", level: "fail", message: "The claude CLI was not found on this host.", }, ], testedAt: new Date().toISOString(), }), }), ); let hireCalled = false; await page.route("**/agent-hires", (route) => { hireCalled = true; return route.continue(); }); await page.goto("/onboarding"); const startBtn = page.getByRole("button", { name: /Start Onboarding|New Organization|Add Agent/, }); if (await startBtn.count()) { await startBtn.first().click(); } const createCard = page.getByRole("button", { name: /Build a new organization/ }); if (await createCard.count()) { await createCard.first().click(); } await expect( page.getByRole("heading", { name: "What is the name of your organization?" }), ).toBeVisible({ timeout: 15_000 }); await page.getByPlaceholder("e.g. Northwind Labs").fill(`${COMPANY_NAME}-probe-gate`); await page.getByRole("button", { name: /^Continue/ }).click(); await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 }); await page.locator("#onboarding-agent-name").fill("Ada"); await page.getByRole("button", { name: "Next" }).click(); const source = page.getByRole("radio").first(); await source.waitFor({ timeout: 30_000 }); await source.click(); const connect = page.getByRole("button", { name: "Connect", exact: true }); await expect(connect).toBeEnabled({ timeout: 30_000 }); await connect.click(); // The failed probe blocks the hire and shows its own checks. await expect(page.getByText("The claude CLI was not found on this host.")).toBeVisible({ timeout: 15_000, }); expect(hireCalled).toBe(false); expect(pageErrors, pageErrors.join("\n")).toHaveLength(0); }); });