mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## 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>
106 lines
4.8 KiB
TypeScript
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();
|
|
});
|
|
});
|