Files
PaperClipAI/tests/runner-e2e/preview.test.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

106 lines
4.8 KiB
TypeScript

import { once } from "node:events";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import type { Server } from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRunnerReportPreview } from "./preview.js";
const directories: string[] = [];
const servers: Server[] = [];
const campaignId = "preview-test";
const campaignRoot = `campaigns/${campaignId}`;
const screenshotPath = `${campaignRoot}/evidence/screenshot.png`;
const missingPath = `${campaignRoot}/evidence/missing.png`;
const screenshot = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6nAAAAABJRU5ErkJggg==", "base64");
const files = new Map([
["history.json", Buffer.from(JSON.stringify({
schema: "paperclip.runner-e2e.history/v1", campaigns: [{ campaignId }],
}))],
[`${campaignRoot}/bundle-manifest.json`, Buffer.from(JSON.stringify({
campaignId,
files: ["evidence/screenshot.png", "evidence/missing.png"].map((file) => ({ path: file, bytes: screenshot.length, sha256: "fixture" })),
}))],
[screenshotPath, screenshot],
]);
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
server.closeAllConnections();
})));
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
vi.restoreAllMocks();
});
async function cacheDirectory() {
const directory = await mkdtemp(path.join(os.tmpdir(), "runner-report-preview-"));
directories.push(directory);
return path.join(directory, "cache");
}
async function listen(options: Parameters<typeof createRunnerReportPreview>[0]) {
const { server } = await createRunnerReportPreview(options);
servers.push(server);
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("No preview port");
return `http://127.0.0.1:${address.port}/runner-e2e/`;
}
describe("local report preview boundaries", () => {
it("serves and caches listed evidence while rejecting unlisted and encoded traversal paths", async () => {
const cacheRoot = await cacheDirectory();
const privateBytes = Buffer.from("outside the public cache");
await writeFile(path.join(cacheRoot, "../private.png"), privateBytes);
const download = vi.fn(async (relative: string) => {
const bytes = files.get(relative);
if (!bytes) throw new Error(`Unexpected download: ${relative}`);
return bytes;
});
const origin = await listen({ cacheRoot, download });
const response = await fetch(`${origin}${screenshotPath}`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("image/png");
expect(Buffer.from(await response.arrayBuffer())).toEqual(screenshot);
expect(await readFile(path.join(cacheRoot, screenshotPath))).toEqual(screenshot);
await fetch(`${origin}${screenshotPath}`).then((response) => response.arrayBuffer());
expect(download.mock.calls.filter(([relative]) => relative === screenshotPath)).toHaveLength(1);
await writeFile(path.join(cacheRoot, campaignRoot, "private.png"), privateBytes);
vi.spyOn(console, "error").mockImplementation(() => {});
for (const relative of [
`${campaignRoot}/private.png`,
`${campaignRoot}/%2e%2e%2f%2e%2e%2f%2e%2e%2fprivate.png`,
`${campaignRoot}/evidence/%2fprivate.png`,
]) {
const blocked = await fetch(`${origin}${relative}`);
expect(blocked.status).not.toBe(200);
expect(await blocked.text()).not.toContain(privateBytes.toString());
}
expect(download.mock.calls.map(([relative]) => relative)).toEqual([
"history.json", `${campaignRoot}/bundle-manifest.json`, screenshotPath,
]);
});
it("serves cached evidence offline and never downloads a missing listed file", async () => {
const cacheRoot = await cacheDirectory();
for (const [relative, bytes] of files) {
const filename = path.join(cacheRoot, relative);
await mkdir(path.dirname(filename), { recursive: true });
await writeFile(filename, bytes);
}
const download = vi.fn(async () => { throw new Error("Offline previews must not download"); });
const origin = await listen({ cacheRoot, offline: true, refresh: true, download });
const cached = await fetch(`${origin}${screenshotPath}`);
expect(cached.status).toBe(200);
expect(Buffer.from(await cached.arrayBuffer())).toEqual(screenshot);
vi.spyOn(console, "error").mockImplementation(() => {});
const missing = await fetch(`${origin}${missingPath}`);
expect(missing.status).toBe(502);
expect(await missing.text()).toContain(`Not cached: ${missingPath}`);
expect(download).not.toHaveBeenCalled();
});
});