paperclip/tests/release-smoke/docker-auth-onboarding.spec.ts

220 lines
9.6 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
const ADMIN_EMAIL =
process.env.PAPERCLIP_RELEASE_SMOKE_EMAIL ??
process.env.SMOKE_ADMIN_EMAIL ??
"smoke-admin@paperclip.local";
const ADMIN_PASSWORD =
process.env.PAPERCLIP_RELEASE_SMOKE_PASSWORD ??
process.env.SMOKE_ADMIN_PASSWORD ??
"paperclip-smoke-password";
const COMPANY_NAME = `Release-Smoke-${Date.now()}`;
const AGENT_NAME = "Release Smoke Lead";
// The arc asks for a name, not a role, so every onboarding hire is filed under
// the neutral role (DEFAULT_AGENT_ROLE in ui/src/lib/onboarding-agent-role.ts).
const AGENT_ROLE = "general";
// Seeded by the wizard's launch step (DEFAULT_TASK_TITLE in
// ui/src/components/OnboardingWizard.tsx).
const FIRST_TASK_TITLE = "Paperclip onboarding";
async function signIn(page: Page) {
await page.goto("/");
await expect(page).toHaveURL(/\/auth/);
await page.locator('input[type="email"]').fill(ADMIN_EMAIL);
await page.locator('input[type="password"]').fill(ADMIN_PASSWORD);
await page.getByRole("button", { name: "Sign In" }).click();
await expect(page).not.toHaveURL(/\/auth/, { timeout: 20_000 });
}
async function getJson<T>(page: Page, url: string): Promise<T> {
const response = await page.request.get(url);
expect(response.ok()).toBe(true);
return (await response.json()) as T;
}
// ONBOARDING_STORAGE_KEY in ui/src/components/OnboardingWizard.tsx.
const ONBOARDING_DRAFT_STORAGE_KEY = "paperclip-onboarding-state";
/**
* Open the wizard on its first step and hand back the organization-name field.
*
* `/onboarding` resolves to `{ initialStep: 1 }` on a self-hosted instance
* (`resolveRouteOnboardingOptions`) and the route keeps the wizard open, so
* this lands on "name your organization" whether or not the instance already
* holds a company. Navigating explicitly is what keeps the spec re-runnable:
* the release-smoke config retries once in CI, and by the second attempt the
* instance is no longer company-less, so sign-in lands on a dashboard instead.
*
* The saved draft is dropped first. Sign-in on an instance that already holds
* an agentless company redirects into *that* company's onboarding, which
* persists its id into the draft; the restored draft then makes step 1 skip
* creating a company and hire into the old one instead. That is an artifact of
* re-running against a re-used instance, not behaviour this spec is asserting,
* and a fresh release-smoke container never has it.
*
* The field is located by role. Step 1 has no id and its `<label>` is not
* associated with the input, so the alternative is its placeholder copy — the
* exact coupling that let this spec drift. The wizard's first screen has
* exactly one text box, and a second one appearing there would fail Playwright's
* strict mode loudly rather than silently matching the wrong control.
*/
async function openOnboarding(page: Page) {
await page.evaluate((key) => {
window.localStorage.removeItem(key);
}, ONBOARDING_DRAFT_STORAGE_KEY);
await page.goto("/onboarding");
const orgNameField = page.getByRole("textbox");
await expect(orgNameField).toBeVisible({ timeout: 20_000 });
return orgNameField;
}
test.describe("Docker authenticated onboarding smoke", () => {
test("logs in, completes onboarding, and hires the lead agent", async ({
page,
}) => {
await signIn(page);
const baseUrl = new URL(page.url()).origin;
// A board with no company routes sign-in straight into onboarding rather
// than a dashboard — the first-run experience this suite exists to guard.
// Asserted only when the instance really is company-less, because a retry
// (or a re-used smoke container) runs against one that is not.
const companiesBeforeOnboarding = await getJson<Array<{ id: string }>>(
page,
`${baseUrl}/api/companies`
);
if (companiesBeforeOnboarding.length === 0) {
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 20_000 });
}
// Step 1: name the organization. "Continue" creates the company itself and
// routes straight to the agent step — onboarding no longer asks for the
// mission (it is collected later, in the app), so step 2 is skipped.
const orgNameField = await openOnboarding(page);
await orgNameField.fill(COMPANY_NAME);
await page.getByRole("button", { name: "Continue", exact: true }).click();
// Step 3: name the team lead. The name is the step's only question and it
// gates the CTA; the role picker is gone, so the hire is filed as `general`.
const agentNameField = page.locator("#onboarding-agent-name");
await expect(agentNameField).toBeVisible({ timeout: 20_000 });
await agentNameField.fill(AGENT_NAME);
const nextButton = page.getByRole("button", { name: "Next", exact: true });
await expect(nextButton).toBeEnabled({ timeout: 10_000 });
await nextButton.click();
// Step 4: answer the model-source question, then connect (hire) the lead.
// The step now opens as a row of source tiles and the footer button has
// nothing to do until one is picked (#12796/#12801 rebuilt the step around
// that question); picking Claude collapses the row and turns the button
// into "Connect". In the smoke container the resolved login environment is
// the local host, not a sandbox, so there is no sign-in to run and Connect
// goes straight to the hire, with the missing agent CLI reported by the
// probe as a warning rather than an error — a genuine failure here means
// the published artifact cannot hire on a clean machine. Allow generous
// time for the probe + hire + auto-approval.
await expect(
page.getByRole("heading", { name: "Connect a model" })
).toBeVisible({ timeout: 20_000 });
const claudeSourceTile = page
.getByRole("radiogroup", { name: "Model source" })
.getByRole("radio", { name: /Claude/ });
await expect(claudeSourceTile).toBeVisible({ timeout: 10_000 });
await claudeSourceTile.click();
const connectButton = page.getByRole("button", {
name: "Connect",
exact: true,
});
await expect(connectButton).toBeVisible({ timeout: 10_000 });
await expect(connectButton).toBeEnabled({ timeout: 30_000 });
await connectButton.click();
// Step 5: review, then launch. "Get started" provisions the onboarding
// project and first task and, only on success, drops the user into the
// seeded first task's thread (not the dashboard).
const getStartedButton = page.getByRole("button", {
name: "Get started",
exact: true,
});
await expect(getStartedButton).toBeVisible({ timeout: 60_000 });
await expect(getStartedButton).toBeEnabled({ timeout: 10_000 });
await getStartedButton.click();
await expect(page).toHaveURL(/\/issues\//, { timeout: 30_000 });
const companies = await getJson<Array<{ id: string; name: string }>>(
page,
`${baseUrl}/api/companies`
);
const company = companies.find((entry) => entry.name === COMPANY_NAME);
expect(company).toBeTruthy();
const agents = await getJson<
Array<{ id: string; name: string; role: string; adapterType: string }>
>(page, `${baseUrl}/api/companies/${company!.id}/agents`);
const leadAgent = agents.find((entry) => entry.name === AGENT_NAME);
expect(leadAgent).toBeTruthy();
expect(leadAgent!.role).toBe(AGENT_ROLE);
expect(leadAgent!.adapterType).not.toBe("process");
// Onboarding deliberately writes no goal: the mission is collected later in
// the app, so a fresh company must come out of the wizard with an empty
// goal list rather than an unchosen one.
const goals = await getJson<Array<{ id: string }>>(
page,
`${baseUrl}/api/companies/${company!.id}/goals`
);
expect(goals).toEqual([]);
const issues = await getJson<
Array<{
id: string;
identifier: string | null;
title: string;
assigneeAgentId: string | null;
}>
>(page, `${baseUrl}/api/companies/${company!.id}/issues`);
const seededIssue = issues.find((entry) => entry.title === FIRST_TASK_TITLE);
expect(seededIssue).toBeTruthy();
expect(seededIssue!.assigneeAgentId).toBe(leadAgent!.id);
// The launch must have landed on the seeded task itself, not merely on
// some issue route.
const seededRef = seededIssue!.identifier ?? seededIssue!.id;
expect(new URL(page.url()).pathname.endsWith(`/issues/${seededRef}`)).toBe(
true
);
// #13068 rebuilt the seeded first task as a chat with the lead: launch
// posts a deterministic, server-owned greeting plus an opening question
// card, and deliberately does not wake the assignee — "no run until the
// user answers". Assert the chat actually opened (the greeting and the
// card are seeded without an LLM, so their absence means the launch
// half-finished) …
await expect(
page.getByText("Welcome to Paperclip!").first()
).toBeVisible({ timeout: 20_000 });
await expect(page.getByText("What would you like to do?")).toBeVisible();
// … and that the no-run contract holds. This spec used to poll for an
// assignment-triggered heartbeat run here; a run appearing before the
// user's first answer is now the regression, not the success. Wake
// dispatch is asynchronous, so watch the endpoint over a bounded window
// rather than sampling it once — a launch-time wake that slips through
// lands well within this window.
const runsUrl = `${baseUrl}/api/companies/${company!.id}/heartbeat-runs?agentId=${leadAgent!.id}`;
const noRunDeadline = Date.now() + 15_000;
while (Date.now() < noRunDeadline) {
expect(await getJson<Array<{ id: string }>>(page, runsUrl)).toEqual([]);
await page.waitForTimeout(1_000);
}
});
});