paperclip/tests/e2e/planning-mode-visual-verifi...

207 lines
8.6 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { mockOnboardingLocalAiConnection } from "./helpers/onboarding-ai-connection";
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";
/**
* The first task opens with the chief of staff's opening card sitting where
* the composer is. Cancel hands the plain composer back (the card stays
* pending), and the composer is where the mode toggle lives.
*
* The card arrives with the interactions fetch, after the composer's first
* paint, so a bare `count()` right after navigation sees no card and skips
* the click; the card then lands on top of the composer and hides the mode
* toggle. Wait for the card (or, if it is already dismissed, the pending
* strip it leaves behind) before deciding, and only return once the plain
* composer is back.
*/
async function dismissOpeningCard(page: import("@playwright/test").Page) {
const takeover = page.getByTestId("task-chat-composer-takeover");
const pendingStrip = page.getByTestId("task-chat-pending-input-indicator");
await expect(takeover.or(pendingStrip).first()).toBeVisible({ timeout: 30_000 });
const cancel = takeover.getByRole("button", { name: "Cancel", exact: true });
if (await cancel.count()) await cancel.first().click();
await expect(page.getByTestId("task-chat-composer-mode")).toBeVisible({ timeout: 30_000 });
}
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 mockOnboardingLocalAiConnection(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 stays disabled until one is
// pressed. It reads "Connect", not "Next": the button starts the sign-in
// where there is one to start. The test simulates successful local account
// connection, then exercises the real first-task creation flow.
//
// 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: /^Connect$/ });
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 dismissOpeningCard(page);
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 dismissOpeningCard(page);
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 dismissOpeningCard(page);
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,
});
});