Files
PaperClipAI/tests/e2e/planning-mode-visual-verification.spec.ts
T
TonioandClaude Fable 5 141c5b1340 feat(onboarding): Figma pass over the tenant arc (#12726)
Takes the four tenant onboarding steps to the design, and makes the model
choice explicit.

**Nothing is preselected on the connect step.** It arrived with a source
already chosen, which made the row read as a confirmation rather than a
question and let a customer pass the step having touched none of it. The
step now opens unanswered and cannot advance until a source is selected in
the visible row.

Two defects fell out of that, both found by Greptile in review:

- A saved draft can name an adapter the row does not show — one the registry
  dropped, or one in the advanced list. 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. It now
  asks `sourceSelected`, read off the visible row, and the snap that replaces
  an unofferable adapter clears the pick rather than presenting its
  replacement as chosen.
- Cmd+Enter bypassed that gate entirely, because the condition was written
  out twice and drifted. Both paths now ask one predicate.

Also: selection is a fill rather than a border; the input canvas opens only
for a chosen source; the close control is gone from every step (nothing
downstream of the connect step works until a model is connected); the arc
draws to the design's 424px column with a filled 44px name field; the CTA
reads "Next" through the arc and "Get started" at the end; the login spinner
reads "Preparing..."; the OAuth panels number their fields and show the URL
above the code.

Storybook: the arc stories waited for a button named "Connect" and had been
silently rendering the wrong step since the CTA was renamed. They now wait on
the destination heading. Three e2e specs had the same fault and now select a
source before advancing.

One test was removed rather than replaced — `hydrates again when the same
company comes back through onboarding` drove the close control, and three
substitutes each passed against a wizard with the behaviour deleted. The
reason is recorded where it stood.

Known and not fixed here: SidebarCompanyMenu opens the wizard at step 1 for
"create a new organization", and with no exit an existing user who changes
their mind is trapped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 12:19:15 -07:00

178 lines
7.0 KiB
TypeScript

import { expect, test } from "@playwright/test";
import {
expectLandsOnFirstTaskWithoutDashboardBounce,
instrumentNavLog,
} from "./helpers/onboarding-landing";
/** The name the CEO role fills in — see AGENT_ROLE_LABELS. */
const AGENT_NAME = "CEO";
const TASK_TITLE = "Paperclip onboarding";
test("captures planning mode UI for desktop and mobile", async ({ page }) => {
const timestamp = Date.now();
const companyName = `PAP-3413-${timestamp}`;
const screenshotDir = "test-results/planning-mode";
await instrumentNavLog(page);
await page.route("**/test-environment", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ status: "pass", checks: [] }),
}),
);
await page.route("**/agent-hires", async (route) => {
const req = route.request();
const body = JSON.parse(req.postData() || "{}");
const auth = req.headers().authorization;
const real = await fetch(new URL(req.url()).toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
...(auth ? { Authorization: auth } : {}),
},
body: JSON.stringify({
name: body.name,
role: body.role,
adapterType: "http",
adapterConfig: { url: "http://127.0.0.1:1/dead" },
runtimeConfig: { heartbeat: { enabled: false } },
}),
});
await route.fulfill({
status: real.status,
contentType: "application/json",
body: await real.text(),
});
});
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.locator('input[placeholder="e.g. Northwind Labs"]').fill(companyName);
await page.getByRole("button", { name: /^Continue/ }).click();
// Naming the company creates it and goes straight to the agent step.
// The agent step asks for a name and nothing else; the name is what gates
// "Next", and the hire is filed under the neutral `general` role.
await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 });
await page.locator("#onboarding-agent-name").fill(AGENT_NAME);
await page.getByRole("button", { name: /^Next$/ }).click();
// The connect step arrives with no source selected — the tile row is a
// question, not a confirmation — so its CTA, which reads "Next" here too,
// stays disabled until one is pressed. Waited on for enabled rather than
// visible: it is already on screen, and clicking a disabled button raises
// nothing and does nothing.
const source = page.getByRole("radio").first();
await source.waitFor({ timeout: 30_000 });
await source.click();
const connectNext = page.getByRole("button", { name: /^Next$/ });
await expect(connectNext).toBeEnabled({ timeout: 30_000 });
await connectNext.click();
// The review step names the agent rather than the step.
await expect(
page.getByRole("heading", { name: "Let's get started..." }),
).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: /Get started/ }).click();
// The wizard now drops the user straight onto the first task's detail page,
// and must not bounce through the dashboard (PAP-404).
await expectLandsOnFirstTaskWithoutDashboardBounce(page);
const baseOrigin = new URL(page.url()).origin;
const companyRes = await page.request.get(`${baseOrigin}/api/companies`);
expect(companyRes.ok()).toBe(true);
const companies = await companyRes.json();
const company = companies.find((c: { name: string }) => c.name === companyName);
expect(company).toBeTruthy();
const issueRes = await page.request.get(`${baseOrigin}/api/companies/${company.id}/issues`);
expect(issueRes.ok()).toBe(true);
const issues = await issueRes.json();
const planningSeedIssue = issues.find(
(candidate: { id: string; identifier?: string; title: string }) =>
candidate.title === TASK_TITLE,
);
expect(planningSeedIssue).toBeTruthy();
const issue = planningSeedIssue;
const issueIdentifier = issue.identifier ?? issue.id;
const issuePath = `/${company.issuePrefix ?? company.id}/issues/${issueIdentifier}`;
const companyPrefix = company.issuePrefix ?? company.id;
const issueLinkSelector = `a[href$="/issues/${issueIdentifier}"]`;
const setMode = async (mode: "standard" | "planning") => {
const patchRes = await page.request.patch(`${baseOrigin}/api/issues/${issue.id}`, {
data: { workMode: mode },
});
expect(patchRes.ok()).toBe(true);
await expect
.poll(async () => {
const currentRes = await page.request.get(`${baseOrigin}/api/issues/${issue.id}`);
expect(currentRes.ok()).toBe(true);
const current = await currentRes.json();
return current.workMode;
}, { timeout: 10_000 })
.toBe(mode);
};
await setMode("planning");
await page.goto(issuePath);
await expect(page.getByText("Plan mode").first()).toBeVisible();
const desktopPlanningToggle = page.getByTestId("task-chat-composer-mode");
await expect(desktopPlanningToggle).toBeVisible();
await expect(desktopPlanningToggle).toHaveAttribute("data-pending-work-mode", "planning");
await page.screenshot({
path: `${screenshotDir}/desktop-planning-detail-${timestamp}.png`,
fullPage: true,
});
await page.goto(`/${companyPrefix}/issues`);
await expect(page.locator(issueLinkSelector)).toBeVisible();
await expect(page.locator(issueLinkSelector)).not.toContainText("Plan mode");
await page.screenshot({
path: `${screenshotDir}/desktop-planning-row-${timestamp}.png`,
fullPage: true,
});
await page.goto(issuePath);
await page.getByTestId("task-chat-composer-mode").click();
await page.getByRole("menuitem", { name: /Auto mode/ }).click();
await expect(page.getByTestId("task-chat-composer-mode")).toHaveAttribute("data-pending-work-mode", "standard");
await page.screenshot({
path: `${screenshotDir}/desktop-standard-toggle-${timestamp}.png`,
fullPage: true,
});
await setMode("planning");
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(issuePath);
await expect(page.getByText("Plan mode").first()).toBeVisible();
const mobilePlanningToggle = page.getByTestId("task-chat-composer-mode");
await expect(mobilePlanningToggle).toBeVisible();
await expect(mobilePlanningToggle).toHaveAttribute("data-pending-work-mode", "planning");
await page.screenshot({
path: `${screenshotDir}/mobile-planning-detail-${timestamp}.png`,
fullPage: true,
});
await page.goto(`/${companyPrefix}/issues`);
await expect(page.locator(issueLinkSelector)).toBeVisible();
await expect(page.locator(issueLinkSelector)).not.toContainText("Plan mode");
await page.screenshot({
path: `${screenshotDir}/mobile-planning-row-${timestamp}.png`,
fullPage: true,
});
});