Files
PaperClipAI/packages/paperclip-runner/scripts/record-phase7-evidence.mjs
T
DottaandClaude Opus 5 fb006b75ca feat(runner): add Phase 7 browser scenario explorer
Implements the standalone, package-local Phase 7 scenario explorer against the
approved interaction map (docs/design/phase-7-scenario-explorer-ux.md).

Scenario runtime (src/phase7, no DOM, no network):
- Scenario index over all 106 eval cases and 16 groups, with actor role, task
  mode, fixture seed, and catalog claims declared in a profile table rather
  than inferred from case prose. Claims that cannot unlock an operation in a
  scenario's task mode or for its actor role are dropped, so no scenario shows
  a grant that unlocks nothing.
- Deterministic fixture seeds and fake-agent plans: twelve authored plans for
  the UX acceptance scenarios, derived plans for the rest. Derived plans only
  invoke operations the run actually exposes, so any denial in a derived
  transcript is a real policy result, not a scripting artefact.
- A run-artifact builder that executes each plan against the Phase 7C mock
  control plane through the Phase 7D authorization engine and records the
  ordered timeline (both channels), tool exposure, authorization records, an
  immutable ten-domain state diff, and a parity verdict. Descriptor redaction
  rules are applied at the artifact boundary so no raw secret can reach a
  rendered record.
- Parity is judged in the runtime, never in a component. Phase 7E's per-case
  result is carried through verbatim in its own labelled block and renders
  "Not run" when no report is bundled.

Explorer (examples/phase7-explorer):
- Read-only surface over the frozen SDK: hash routing, scenario picker with
  faceted filters, agent and control-plane transcript channels, and Context,
  Authorization, State diff, and Parity inspector tabs. Tokens only; the
  0.1.2 export surface is unchanged and the runtime is reached through the
  deliberately package-local @paperclip-runner-local/phase7 alias.
- Checked-in inputs arrive as declared virtual modules, so the example
  consumer keeps its boundary and fake mode does no network I/O.

Verification: 45 unit tests (index, plans, all-scenario runs and parity,
components, routes), 19 Playwright tests (route determinism, evidence routes,
credential boundary, keyboard paths, responsive), and the 24-image screenshot
acceptance set.

Fixes found while building: SDK badges overflowed at 390px inside disclosure
summaries; SDK badge capitalisation broke the label vocabulary; hash-change
deep links left the wrong mobile segment; screenshot capture leaked run state
between shots. All recorded as revisions in the interaction map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 13:30:32 +00:00

157 lines
5.6 KiB
JavaScript

#!/usr/bin/env node
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { createServer } from "node:net";
import { chromium } from "@playwright/test";
/**
* Records the Phase 7F screenshot acceptance matrix.
*
* The twelve slugs, routes, and viewports are the ones the approved
* interaction map fixes in §9; each shot is taken only after the shell
* reports `data-run-state="settled"`, so captures are reproducible.
*/
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const evidenceDirectory = resolve(packageRoot, "knowledge/evidence/phase-07");
/**
* Shared dev hosts routinely have the 418x/419x range occupied, so the
* recorder binds an ephemeral port rather than competing for a fixed one.
*/
async function freePort() {
const explicit = process.env.PHASE7_EVIDENCE_PORT;
if (explicit !== undefined) return Number(explicit);
return await new Promise((settle, fail) => {
const probe = createServer();
probe.on("error", fail);
probe.listen(0, "127.0.0.1", () => {
const { port } = probe.address();
probe.close(() => settle(port));
});
});
}
const VIEWPORTS = [
{ label: "1440x900", width: 1440, height: 900 },
{ label: "390x844", width: 390, height: 844 },
];
/** UX map §9 — the acceptance set reviewed before 7F is accepted. */
export const PHASE7_SHOTS = [
{ slug: "explorer-home", route: "#/" },
{
slug: "picker-filtered",
route: "#/?group=ix&disposition=always_agent_tool&parity=not_run",
},
{ slug: "hb-heartbeat", route: "#/case/hb-scoped-wake-01?run=fake&view=transcript" },
{ slug: "wk-planning", route: "#/case/wk-plan-directive-01?run=fake&view=transcript" },
{ slug: "bl-blockers-diff", route: "#/case/bl-create-blocked-01?run=fake&view=diff" },
{ slug: "ap-approval-denied", route: "#/case/ap-mcp-gate-01?run=fake&view=authorization" },
{ slug: "ar-artifacts", route: "#/case/ar-upload-before-done-01?run=fake&view=transcript" },
{ slug: "ix-interaction", route: "#/case/ix-checkbox-01?run=fake&view=transcript" },
{ slug: "rf-manager", route: "#/case/rf-api-mgr-heartbeat-01?run=fake&view=context" },
{ slug: "mh-multihop", route: "#/case/mh-subtask-tree-01?run=fake&view=parity" },
{ slug: "rs-restraint", route: "#/case/rs-question-only-01?run=fake&view=parity" },
{ slug: "rs-secret-redaction", route: "#/case/rs-secret-hygiene-01?run=fake&view=transcript" },
];
async function waitForServer(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
if (response.ok) return;
} catch {
// The dev server is still starting.
}
await new Promise((settle) => setTimeout(settle, 250));
}
throw new Error(`explorer did not start on ${url}`);
}
async function main() {
await mkdir(evidenceDirectory, { recursive: true });
const port = await freePort();
const origin = `http://127.0.0.1:${port}`;
const server = spawn(
"pnpm",
[
"exec",
"vite",
"--config",
"vite.phase7.config.ts",
"--host",
"127.0.0.1",
"--port",
String(port),
"--strictPort",
],
{ cwd: packageRoot, stdio: "ignore" },
);
const captured = [];
try {
await waitForServer(`${origin}/phase7-explorer/`, 60_000);
const browser = await chromium.launch();
try {
for (const viewport of VIEWPORTS) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
reducedMotion: "reduce",
});
const page = await context.newPage();
for (const shot of PHASE7_SHOTS) {
// A hash-only navigation would keep the previous shot's run results
// in memory, and their parity dots would leak into this shot's
// picker. Every capture starts from a clean document.
await page.goto("about:blank");
await page.goto(`${origin}/phase7-explorer/${shot.route}`);
await page.waitForSelector('[data-testid="explorer-shell"][data-run-state="settled"]', {
timeout: 30_000,
});
// Expand the run view and inspector so the matrix evidence is
// visible: argument payloads, decision records, redaction chips,
// diff rows. The picker's facets keep their default state — the
// rail is navigation, not evidence.
await page.evaluate(() => {
for (const element of document.querySelectorAll("main details, aside details")) {
element.setAttribute("open", "");
}
});
const path = resolve(evidenceDirectory, `${shot.slug}-${viewport.label}.png`);
await page.screenshot({ path, fullPage: true });
captured.push(`${shot.slug}-${viewport.label}.png`);
process.stdout.write(`captured ${shot.slug} at ${viewport.label}\n`);
}
await context.close();
}
} finally {
await browser.close();
}
} finally {
server.kill();
}
await writeFile(
resolve(evidenceDirectory, "screenshot-manifest.json"),
`${JSON.stringify(
{
schema: "paperclip.phase7.screenshot-manifest.v1",
source: "docs/design/phase-7-scenario-explorer-ux.md §9",
viewports: VIEWPORTS.map((viewport) => viewport.label),
shots: PHASE7_SHOTS,
files: captured.sort(),
},
null,
2,
)}\n`,
);
process.stdout.write(`Recorded ${captured.length} Phase 7 explorer screenshots.\n`);
}
await main();