Files
PaperClipAI/tests/runner-e2e/preview.ts
T
DottaandPaperclip 2b16262280 Improve runner E2E report navigation and local previews (#16996)
## Thinking Path

> - Paperclip is the open source app that people use to manage AI agents
for work.
> - Product E2E campaigns check user journeys across agent profiles and
environments.
> - Readers use the campaign history and reports to inspect results and
retained evidence.
> - The reports showed image summaries, many unselected suites, and a
long sticky index.
> - Individual case cards also showed routine diagnostics before the
visual evidence.
> - This pull request adds text summaries, feature filters, folding case
indexes, and simpler cards.
> - A local preview uses retained public data so designers can change
reports without another paid campaign.

## Linked Issues or Issue Description

**Current behavior**

The report is hard to scan as the journey catalog grows. Suite links can
point into hidden results. Case indexes do not show whether a case
passed across its configurations. Design changes also need a realistic
local preview.

**Proposed behavior**

Show the selected campaign as text. Link journeys to current feature-map
capabilities. Add folding case indexes with status indicators, reliable
section links, and a back-to-top control. Keep failed outcomes visible
and put routine diagnostics under Test details. Render retained public
campaign data with local templates.

**Reason and benefit**

Readers can find a feature or failed case faster. Designers can iterate
against real retained data without starting an application instance or
using provider credentials.

**Breaking changes**

No result schema or grading change. The report stops embedding the
generated summary PNG. The publisher still produces that artifact for
existing consumers.

Related work: #12889 added the existing matrix browser. #12846 concerns
the public image publication boundary. This PR changes report
presentation and local preview behavior.

## What Changed

- Replace image summaries with shared HTML suite summaries on the
history and campaign pages.
- Show selected executions and preserve missing results for selected
cells.
- Add explicit journey-to-capability associations, feature filters, and
a coverage view with unmapped journeys.
- Add folding case indexes, section case lists, and red, green, or amber
status indicators across matching configurations.
- Fix anchor navigation through filters and add a floating back-to-top
control.
- Simplify case cards. Keep failure reasons visible. Fold routine
checks, billing, and context into Test details.
- Add an original-image link to the gallery.
- Add a local preview utility with public data caching and offline mode.
Document the direct Node command.
- Test manifest-listed evidence, rejection of unlisted files and encoded
traversal, cache reuse, and offline misses with a real loopback server
and a stub downloader.

## Verification

- Focused report, history, and preview tests: 75 passed in 6 files.
- Report Playwright spec: 4 passed in Chrome with a standalone config
that does not start the application server.
- `node scripts/check-maps.mjs`: passed.
- `git diff --check`: passed.
- Manually checked retained campaign reports, feature filters, folding
indexes, failure links, card details, gallery links, mobile layout, and
keyboard navigation.
- `pnpm build`: passed.
- `pnpm -r typecheck`: passed.
- `pnpm test:e2e:runner:typecheck`: passed.
- `pnpm test:run`: stopped after 15 native session database cases
failed. The local Postgres package first lacked dynamic-library links.
After link hydration, the same cases still failed. A direct `initdb`
check then reported `could not create semaphores: No space left on
device` and identified the macOS semaphore limit. The complete local
suite has not passed.
- `pnpm test:e2e:runner:unit`: 3,250 passed, 6 failed, 2 skipped. All
six failures reproduced against unchanged `origin/master` report sources
with the same installed dependencies. They concern Copilot admission and
its Daytona image input, plus the launcher test that runs those
assertions. No report or history test failed.
- No live provider evaluation was run for this presentation change.
- CI on `b1a9c06823` first failed browser shard 3/8. The agent-chat
split-handoff test reached cleanup, where cancelling a run returned HTTP
500 from a Postgres deadlock. Cleanup did not restore the experiment
setting, and a later test failed its initial setting check. This shard
does not contain the report spec. One targeted rerun passed, and that
full workflow then passed. This shows recovery; no production fix was
made for the deadlock. The later preview-test commit requires its own
fresh checks.

To review the design, run `node --watch --import
./cli/node_modules/tsx/dist/loader.mjs tests/runner-e2e/preview.ts`.
Open the history address printed by the command, then open a campaign.
The README covers offline mode and cache refresh.

## Risks

- Feature associations use the current map. They describe linked
scenarios, not proof that a whole capability is covered. Historical
grades remain unchanged.
- New or unfamiliar cases remain unmapped until their assertions are
inspected.
- The preview needs the public history host on first access. Offline
mode requires cached files. It binds to the loopback interface and
serves manifest-listed public evidence.
- Hiding routine diagnostics adds one disclosure step. Failed or
incomplete outcomes remain visible.

This is report polish. It does not duplicate planned core work in
`ROADMAP.md`.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, tool use, code execution,
and browser automation. The exact serving model ID and context window
are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-20 14:02:19 -05:00

223 lines
10 KiB
TypeScript

import { createServer } from "node:http";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { runnerMatrix } from "./catalog.js";
import { renderRunnerE2EDashboard } from "./dashboard.js";
import { renderRunnerHistoryIndex } from "./history-index.js";
import type { RunnerE2ECampaign, RunnerE2EHistoryIndex, RunnerE2EResult } from "./types.js";
const repositoryRoot = path.resolve(import.meta.dirname, "../..");
const publicRoot = "https://d1p6rlowie26tp.cloudfront.net/runner-e2e/";
const routeRoot = "/runner-e2e/";
export async function createRunnerReportPreview(options: {
cacheRoot?: string;
offline?: boolean;
refresh?: boolean;
download?: (relative: string) => Promise<Buffer>;
} = {}) {
const cacheRoot = options.cacheRoot ?? path.join(repositoryRoot, "tests/runner-e2e/results/design-preview");
const offline = options.offline ?? false;
const downloadFile = options.download ?? (async (relative: string) => {
const response = await fetch(new URL(relative, publicRoot), {
signal: AbortSignal.timeout(60_000),
redirect: "error",
});
if (!response.ok) throw new Error(`Public artifact ${relative}: HTTP ${response.status}`);
return Buffer.from(await response.arrayBuffer());
});
const version = String(Date.now());
const inFlight = new Map<string, Promise<Buffer>>();
interface Manifest {
campaignId: string;
files: Array<{ path: string; sha256: string; bytes: number }>;
}
type PublishedResult = RunnerE2EResult & { evidenceValid?: boolean; evidenceErrors?: string[] };
type PublishedCampaign = Omit<RunnerE2ECampaign, "results"> & { results: PublishedResult[] };
function safePath(relative: string) {
if (!relative || relative.split("/").some((part) =>
!part || part === "." || part === ".." || !/^[A-Za-z0-9._-]+$/.test(part),
)) throw new Error("Invalid public artifact path");
return relative;
}
async function publicFile(relative: string, refresh = false): Promise<Buffer> {
safePath(relative);
const filename = path.join(cacheRoot, relative);
if (!refresh || offline) {
try { return await readFile(filename); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
}
if (offline) throw new Error(`Not cached: ${relative}. Run once without --offline.`);
const pending = inFlight.get(relative);
if (pending) return pending;
const download = (async () => {
const bytes = await downloadFile(relative);
await mkdir(path.dirname(filename), { recursive: true });
const temporary = `${filename}.${process.pid}.tmp`;
await writeFile(temporary, bytes);
await rename(temporary, filename);
return bytes;
})();
inFlight.set(relative, download);
try { return await download; }
finally { inFlight.delete(relative); }
}
async function publicJson<T>(relative: string, refresh = false): Promise<T> {
return JSON.parse((await publicFile(relative, refresh)).toString("utf8")) as T;
}
const retainedHistory = await publicJson<RunnerE2EHistoryIndex>("history.json", options.refresh ?? false);
if (retainedHistory.schema !== "paperclip.runner-e2e.history/v1") throw new Error("Unsupported public history");
const campaigns = new Set(retainedHistory.campaigns.map((campaign) => campaign.campaignId));
const localHistory: RunnerE2EHistoryIndex = {
...retainedHistory,
campaigns: retainedHistory.campaigns.map((campaign) => ({
...campaign,
publicUrl: `${routeRoot}campaigns/${encodeURIComponent(campaign.campaignId)}/`,
})),
};
const manifests = new Map<string, Promise<Manifest>>();
function manifestFor(campaignId: string) {
if (!campaigns.has(campaignId)) throw new Error("Unknown campaign");
let pending = manifests.get(campaignId);
if (!pending) {
pending = publicJson<Manifest>(`campaigns/${campaignId}/bundle-manifest.json`).then((manifest) => {
if (manifest.campaignId !== campaignId || !Array.isArray(manifest.files)) throw new Error("Invalid campaign manifest");
for (const file of manifest.files) safePath(file.path);
return manifest;
});
manifests.set(campaignId, pending);
pending.catch(() => manifests.delete(campaignId));
}
return pending;
}
async function campaignHtml(campaignId: string) {
const [campaign, manifest] = await Promise.all([
publicJson<PublishedCampaign>(`campaigns/${campaignId}/normalized-results.json`),
manifestFor(campaignId),
]);
if (campaign.campaignId !== campaignId || !Array.isArray(campaign.results)) throw new Error("Invalid retained campaign");
return renderRunnerE2EDashboard({
title: "Runner Full-Stack E2E",
generatedAt: campaign.generatedAt,
expected: campaign.expected,
catalog: runnerMatrix,
campaign,
history: localHistory,
entries: campaign.results.map((result) => {
const base = `evidence/${result.executionId}/attempt-${result.attempt}`;
return {
result,
valid: result.status === "passed" && result.cleanup === "passed" && result.evidenceValid !== false,
errors: result.evidenceErrors ?? [],
evidenceBaseHref: base,
evidenceFiles: manifest.files.filter((file) => file.path.startsWith(`${base}/`))
.map((file) => file.path.slice(base.length + 1)),
};
}),
});
}
function withReload(html: string) {
return html.replace("</body>", `<script>
const previewVersion = ${JSON.stringify(version)};
setInterval(async () => {
try {
const response = await fetch("/__preview/version", { cache: "no-store" });
if (response.ok && await response.text() !== previewVersion) location.reload();
} catch {}
}, 1000);
</script></body>`);
}
const contentTypes: Record<string, string> = {
".json": "application/json", ".png": "image/png", ".woff2": "font/woff2",
".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
".log": "text/plain; charset=utf-8", ".xml": "application/xml",
};
const server = createServer(async (request, response) => {
response.setHeader("Cache-Control", "no-store");
try {
if (request.method !== "GET" && request.method !== "HEAD") {
response.writeHead(405).end("Read-only preview");
return;
}
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
if (pathname === "/__preview/version") {
response.writeHead(200, { "Content-Type": "text/plain" }).end(version);
return;
}
if (pathname === "/" || pathname === "/runner-e2e") {
response.writeHead(302, { Location: routeRoot }).end();
return;
}
let html: string | undefined;
let bytes: Buffer | undefined;
let extension = ".html";
if (pathname === routeRoot || pathname === `${routeRoot}index.html`) {
html = renderRunnerHistoryIndex(localHistory);
} else if (pathname.startsWith(routeRoot)) {
const relative = safePath(decodeURIComponent(pathname.slice(routeRoot.length)).replace(/\/$/, ""));
const campaignMatch = /^campaigns\/([A-Za-z0-9._-]+)(?:\/(.*))?$/.exec(relative);
if (campaignMatch && campaigns.has(campaignMatch[1]!)) {
const campaignId = campaignMatch[1]!;
const artifact = campaignMatch[2];
if (!artifact || artifact === "index.html" || artifact === "dashboard.html") {
if (!artifact && !pathname.endsWith("/")) {
response.writeHead(302, { Location: `${pathname}/` }).end();
return;
}
html = await campaignHtml(campaignId);
} else if ((await manifestFor(campaignId)).files.some((file) => file.path === artifact)) {
extension = path.extname(artifact);
if (contentTypes[extension]) bytes = await publicFile(relative);
}
} else if (relative === "history.json") {
extension = ".json";
bytes = Buffer.from(JSON.stringify(retainedHistory));
} else if (relative === "assets/InterVariable.woff2" || relative === "assets/favicon-32x32.png") {
extension = path.extname(relative);
bytes = await publicFile(relative);
}
}
if (html !== undefined) bytes = Buffer.from(withReload(html));
if (!bytes) {
response.writeHead(404, { "Content-Type": "text/plain" }).end("Preview page or public artifact not found");
return;
}
response.writeHead(200, { "Content-Type": contentTypes[extension] ?? "text/html; charset=utf-8" });
response.end(request.method === "HEAD" ? undefined : bytes);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
response.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" }).end(message);
}
});
return { server, campaignCount: campaigns.size, cacheRoot };
}
async function main() {
const args = process.argv.slice(2).filter((argument) => argument !== "--");
const portIndex = args.indexOf("--port");
const port = portIndex < 0 ? 4317 : Number(args[portIndex + 1]);
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("Invalid --port");
const { server, campaignCount, cacheRoot } = await createRunnerReportPreview({
offline: args.includes("--offline"), refresh: args.includes("--refresh"),
});
server.listen(port, "127.0.0.1", () => {
const address = server.address();
const origin = `http://127.0.0.1:${typeof address === "object" && address ? address.port : port}`;
console.log(`History: ${origin}${routeRoot}`);
console.log(`${campaignCount} real campaigns; reports and public evidence cache on first access.`);
console.log(`Cached data: ${cacheRoot}`);
console.log("Edit history-index.ts or dashboard.ts; Node restarts the preview and the browser reloads.");
});
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();