mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
Adds a chat surface to the Phase 7 browser explorer where the board sends prompts to a scenario and sees exactly how the mock Paperclip control plane was used on every turn — the point of the phase is that the activity stays visible rather than hidden behind the conversation (PAP-16916). Runtime owns the turn model, so the UI derives nothing: - `Phase7ChatSession` keeps one long-lived mock core across turns, so the state a turn changes is still there for the next one. Turn 0 is the control-plane seed (checkout, wake routing, exposure) that exists before anyone can type. - Timeline entries gain a `turn`; the artifact gains `turns[]` with per-turn exposure, authorization records, state diff, reconciliation events, counts, and a turn-scoped verdict. A new `user_message` kind joins the five existing ones. - `phase7TurnParity` judges a turn on what that turn did. A session-level expectation about work nobody has asked for yet would read as a failure of turn 1 rather than as work still to come. - Recording machinery moves to `scenario-execution.ts`, shared with the 7F single-shot runner so a scenario reads the same either way. Two scenarios carry authored multi-turn scripts: `ap-mcp-gate-01` (allowed turn, then a denied one) and `ix-checkbox-01` (open an interaction, then the board answers it — a control-plane-owned action with no agent tool, which is what makes the control plane schedule the continuation wake). Every other corpus scenario falls back to its 7F plan as one scripted turn. The surface implements the approved 7I interaction map: turn strips bridging each turn to its evidence, the six-section activity stream, the mobile Chat/Activity segments with a deny count chip, reset and scenario-switch confirmation, and deterministic deep links including a staged streaming hold. Deviations taken during implementation are recorded in §11a of the map rather than left for a reviewer to find. No ACPX implementation, no real control-plane integration, no credential in the browser: Codex mode stays disabled with a named reason and scripted mode drives the same in-page mock core offline. Tests: 30 new runtime/component tests and 18 browser tests; 79 phase-7 unit and 43 phase-7 browser tests pass. Ten §11 screenshots recorded by `record:phase7i`, which fails the run on any horizontal page scroll. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
252 lines
12 KiB
TypeScript
252 lines
12 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
/**
|
|
* Phase 7I browser acceptance.
|
|
*
|
|
* The chat is where the board actually uses the mock control plane, so these
|
|
* cover the paths a person takes: send a prompt, get a denial, follow a turn to
|
|
* its evidence, reset, and do it again on a phone. Screen captures for the §11
|
|
* matrix are recorded by `scripts/record-phase7i-evidence.mjs`, which reuses
|
|
* these routes.
|
|
*/
|
|
|
|
const BASE = "/phase7-explorer/";
|
|
const MOBILE = { width: 390, height: 844 };
|
|
|
|
async function open(page: Page, hash: string, state = "settled"): Promise<void> {
|
|
await page.goto(`${BASE}${hash}`);
|
|
await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute(
|
|
"data-chat-state",
|
|
state,
|
|
{ timeout: 30_000 },
|
|
);
|
|
}
|
|
|
|
async function send(page: Page, prompt: string): Promise<void> {
|
|
await page.fill('[data-testid="composer-input"]', prompt);
|
|
await page.press('[data-testid="composer-input"]', "Enter");
|
|
await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute(
|
|
"data-chat-state",
|
|
"settled",
|
|
{ timeout: 30_000 },
|
|
);
|
|
}
|
|
|
|
test.describe("chat shell", () => {
|
|
test("chat home offers the picker and an intro, with no dead panel", async ({ page }) => {
|
|
await page.goto(`${BASE}#/chat`);
|
|
await expect(page.locator('[data-testid="explorer-intro"]')).toContainText("scenario chat");
|
|
await expect(page.locator("nav[aria-label='Scenario picker']")).toBeVisible();
|
|
await expect(page.locator("aside[aria-label='Turn activity']")).toBeVisible();
|
|
await expect(page.locator("h1")).toHaveCount(1);
|
|
});
|
|
|
|
test("an unknown chat case is named rather than silently redirected", async ({ page }) => {
|
|
await page.goto(`${BASE}#/chat/not-a-real-case`);
|
|
await expect(page.locator('[data-testid="unknown-scenario"]')).toContainText("not-a-real-case");
|
|
});
|
|
|
|
test("the seeded session shows control-plane work before any message", async ({ page }) => {
|
|
await open(page, "#/chat/ix-checkbox-01");
|
|
await expect(page.locator('[data-testid="chat-seed-label"]')).toBeVisible();
|
|
await expect(page.locator('[data-testid="chat-turn-0"]')).toContainText("Control plane");
|
|
await expect(page.locator('[data-testid="session-status"]')).toHaveText("Idle · seeded");
|
|
await expect(page.locator('[data-testid="turn-activity-strip-0"]')).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe("driving a session from the composer", () => {
|
|
test("a typed prompt runs a turn and a denial lands in place", async ({ page }) => {
|
|
await open(page, "#/chat/ap-mcp-gate-01");
|
|
await send(page, "Where does the MCP tool approval stand?");
|
|
await send(page, "Raise the approval request yourself.");
|
|
|
|
await expect(page.locator('[data-testid="turn-activity-strip-2"]')).toBeVisible();
|
|
const denial = page.locator('[data-outcome="denied"]').first();
|
|
await expect(denial).toBeVisible();
|
|
await expect(denial).toContainText("required_claim_missing");
|
|
// A denial must never carry protected state.
|
|
await expect(denial).toContainText("not returned");
|
|
await expect(page.locator('[data-testid="denial-announcement"]').first()).toHaveAttribute(
|
|
"aria-live",
|
|
"assertive",
|
|
);
|
|
// The session survives a denial; it is an outcome, not a failure.
|
|
await expect(page.locator('[data-testid="session-status"]')).toHaveText(
|
|
"Scripted · deterministic",
|
|
);
|
|
});
|
|
|
|
test("a turn strip opens that turn's evidence and mirrors it to the route", async ({ page }) => {
|
|
await open(page, "#/chat/ap-mcp-gate-01?replay=fake");
|
|
await page.click('[data-testid="turn-activity-strip-2"]');
|
|
await expect(page).toHaveURL(/view=activity&turn=2/);
|
|
await expect(page.locator('[data-testid="activity-turn-2"]')).toHaveAttribute(
|
|
"data-open",
|
|
"true",
|
|
);
|
|
await expect(page.locator('[data-testid="activity-section-2-authorization"]')).toBeVisible();
|
|
await expect(page.locator('[data-testid="activity-diff-2"]')).toBeVisible();
|
|
});
|
|
|
|
test("reset confirms, returns to the seed, and leaks nothing", async ({ page }) => {
|
|
await open(page, "#/chat/ap-mcp-gate-01?replay=fake");
|
|
await expect(page.locator('[data-testid="turn-activity-strip-2"]')).toBeVisible();
|
|
|
|
await page.click('[data-testid="reset-session"]');
|
|
await page.click('[data-testid="confirm-cancel"]');
|
|
await expect(page.locator('[data-testid="turn-activity-strip-2"]')).toBeVisible();
|
|
|
|
await page.click('[data-testid="reset-session"]');
|
|
await page.click('[data-testid="confirm-accept"]');
|
|
await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute(
|
|
"data-chat-state",
|
|
"settled",
|
|
{ timeout: 30_000 },
|
|
);
|
|
await expect(page.locator('[data-testid="turn-activity-strip-1"]')).toHaveCount(0);
|
|
await expect(page.locator('[data-outcome="denied"]')).toHaveCount(0);
|
|
await expect(page.locator('[data-testid="session-status"]')).toHaveText("Idle · seeded");
|
|
});
|
|
|
|
test("switching scenario mid-session asks before discarding the mock state", async ({ page }) => {
|
|
await open(page, "#/chat/ap-mcp-gate-01?replay=fake");
|
|
await page.fill('[data-testid="scenario-search"]', "ix-checkbox-01");
|
|
await page.click('[data-testid="case-row-ix-checkbox-01"]');
|
|
await expect(page.locator("dialog[open]")).toContainText("Switch scenario?");
|
|
await page.click('[data-testid="confirm-accept"]');
|
|
await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute(
|
|
"data-chat-state",
|
|
"settled",
|
|
{ timeout: 30_000 },
|
|
);
|
|
await expect(page.locator('[data-testid="session-header"]')).toContainText("ix-checkbox-01");
|
|
await expect(page.locator('[data-outcome="denied"]')).toHaveCount(0);
|
|
});
|
|
});
|
|
|
|
test.describe("determinism and evidence", () => {
|
|
test("the scripted route settles to a byte-identical transcript on reload", async ({ page }) => {
|
|
const read = async (): Promise<string> => {
|
|
await open(page, "#/chat/ix-checkbox-01?replay=fake");
|
|
return (await page.locator('[data-testid="chat-conversation"]').innerText()).trim();
|
|
};
|
|
const first = await read();
|
|
await page.goto("about:blank");
|
|
expect(await read()).toBe(first);
|
|
});
|
|
|
|
test("every turn group carries the six evidence sections in order", async ({ page }) => {
|
|
await open(page, "#/chat/ix-checkbox-01?replay=fake&view=activity&turn=3");
|
|
const group = page.locator('[data-testid="activity-turn-3"]');
|
|
await expect(group.locator('[data-testid="activity-section-3-exposure"]')).toBeVisible();
|
|
await expect(group.locator('[data-testid="activity-section-3-calls"]')).toBeVisible();
|
|
await expect(group.locator('[data-testid="activity-section-3-control"]')).toBeVisible();
|
|
await expect(group.locator('[data-testid="activity-section-3-diff"]')).toBeVisible();
|
|
await expect(group.locator('[data-testid="activity-section-3-parity"]')).toBeVisible();
|
|
await expect(group.locator('[data-testid="activity-reconciliation-3"]')).toContainText(
|
|
"wake.scheduled",
|
|
);
|
|
});
|
|
|
|
test("a filter collapses sections without deleting turn groups", async ({ page }) => {
|
|
await open(page, "#/chat/ap-mcp-gate-01?replay=fake&view=activity&turn=2&filter=authorization");
|
|
await expect(page.locator('[data-testid="activity-turn-0"]')).toBeVisible();
|
|
await expect(page.locator('[data-testid="activity-turn-2"]')).toBeVisible();
|
|
await expect(page.locator('[data-testid="activity-section-2-authorization"]')).toBeVisible();
|
|
await expect(page.locator('[data-testid="activity-section-2-diff"]')).toHaveCount(0);
|
|
});
|
|
|
|
test("the staged streaming route holds a running turn", async ({ page }) => {
|
|
await open(page, "#/chat/ap-mcp-gate-01?replay=fake&stage=streaming", "streaming");
|
|
await expect(page.locator('[data-slot="composer"]')).toHaveAttribute("data-state", "active-turn");
|
|
await expect(page.locator('[data-testid^="turn-pending-"]')).toBeVisible();
|
|
// Controls that would corrupt the running turn state their reason.
|
|
await expect(page.locator('[data-testid="reset-session"]')).toBeDisabled();
|
|
await expect(page.locator('[data-testid="replay-session"]')).toBeDisabled();
|
|
});
|
|
|
|
test("no credential or raw secret reaches the chat page", async ({ page }) => {
|
|
await open(page, "#/chat/rs-secret-hygiene-01?replay=fake");
|
|
const body = await page.locator("body").innerText();
|
|
expect(body).not.toContain("fixture-secret-value-not-a-real-credential");
|
|
expect(await page.evaluate(() => window.localStorage.length)).toBe(0);
|
|
});
|
|
});
|
|
|
|
test.describe("mobile and accessibility", () => {
|
|
test.use({ viewport: MOBILE });
|
|
|
|
test("the activity segment carries the whole stream and counts denials", async ({ page }) => {
|
|
await open(page, "#/chat/ap-mcp-gate-01?replay=fake");
|
|
await expect(page.locator('[data-testid="segment-activity-denials"]')).toContainText("deny");
|
|
await page.click('[data-testid="segment-activity"]');
|
|
await expect(page.locator('[data-testid="activity-stream"]')).toBeVisible();
|
|
await expect(page.locator('[data-testid="activity-section-2-authorization"]')).toBeVisible();
|
|
await expect(page.locator('[data-testid="chat-conversation"]')).toBeHidden();
|
|
});
|
|
|
|
test("back to chat returns focus to the turn strip that opened the group", async ({ page }) => {
|
|
await open(page, "#/chat/ap-mcp-gate-01?replay=fake");
|
|
await page.click('[data-testid="turn-activity-strip-2"]');
|
|
await expect(page.locator('[data-testid="activity-stream"]')).toBeVisible();
|
|
await page.click('[data-testid="activity-back-to-chat-2"]');
|
|
await expect(page.locator('[data-testid="chat-conversation"]')).toBeVisible();
|
|
await expect(page.locator('[data-testid="turn-activity-strip-2"]')).toBeFocused();
|
|
});
|
|
|
|
test("long payloads never scroll the page sideways", async ({ page }) => {
|
|
for (const hash of [
|
|
"#/chat/ix-checkbox-01?replay=fake",
|
|
"#/chat/ap-mcp-gate-01?replay=fake&view=activity&turn=2",
|
|
"#/chat/mh-subtask-tree-01?replay=fake&view=activity",
|
|
]) {
|
|
await open(page, hash);
|
|
const overflow = await page.evaluate(
|
|
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
);
|
|
expect(overflow, `${hash} scrolls horizontally`).toBeLessThanOrEqual(0);
|
|
}
|
|
});
|
|
|
|
test("touch targets on the chat controls meet the minimum", async ({ page }) => {
|
|
await open(page, "#/chat/ix-checkbox-01?replay=fake");
|
|
for (const testId of ["segment-chat", "segment-activity", "turn-activity-strip-1"]) {
|
|
const box = await page.locator(`[data-testid="${testId}"]`).first().boundingBox();
|
|
expect(box, testId).not.toBeNull();
|
|
expect(box!.height, testId).toBeGreaterThanOrEqual(40);
|
|
}
|
|
});
|
|
|
|
test("the composer stays in the thumb zone while the transcript scrolls", async ({ page }) => {
|
|
await open(page, "#/chat/ix-checkbox-01?replay=fake");
|
|
await page.evaluate(() => window.scrollTo(0, 0));
|
|
const composer = page.locator('[data-testid="composer"]');
|
|
const top = (await composer.boundingBox())!.y;
|
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
const bottom = (await composer.boundingBox())!.y;
|
|
expect(bottom).not.toBe(top);
|
|
// Still on screen after the scroll, not left behind at the top.
|
|
const box = (await composer.boundingBox())!;
|
|
expect(box.y).toBeLessThan(MOBILE.height);
|
|
expect(box.y + box.height).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("the composer keyboard contract holds", async ({ page }) => {
|
|
await open(page, "#/chat/ix-checkbox-01");
|
|
const input = page.locator('[data-testid="composer-input"]');
|
|
await input.fill("first line");
|
|
await input.press("Shift+Enter");
|
|
await input.type("second line");
|
|
expect(await input.inputValue()).toContain("\n");
|
|
await expect(page.locator('[data-testid="turn-activity-strip-1"]')).toHaveCount(0);
|
|
await input.press("Enter");
|
|
await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute(
|
|
"data-chat-state",
|
|
"settled",
|
|
{ timeout: 30_000 },
|
|
);
|
|
await expect(page.locator('[data-testid="turn-activity-strip-1"]')).toBeVisible();
|
|
});
|
|
});
|