Files
PaperClipAI/tests/runner-e2e/dashboard-regenerate.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

215 lines
6.6 KiB
TypeScript

import path from "node:path";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { runnerMatrix } from "./catalog.js";
import { renderRunnerE2EDashboard } from "./dashboard.js";
import {
buildRunnerCampaign,
canonicalExecutionId,
upgradeRunnerResult,
} from "./history.js";
import type {
RunnerE2ECampaign,
RunnerE2EHistoryIndex,
RunnerE2EResult,
} from "./types.js";
interface PublishedResult extends RunnerE2EResult {
evidenceValid?: boolean;
evidenceErrors?: string[];
}
interface PublishedCampaign {
schema?: string;
campaignId?: string;
source?: RunnerE2ECampaign["source"];
generatedAt: string;
expected: string[];
results: PublishedResult[];
}
async function relativeFiles(root: string, current = root): Promise<string[]> {
const entries = await readdir(current, { withFileTypes: true }).catch(
() => [],
);
const files: string[] = [];
for (const entry of entries) {
const absolute = path.join(current, entry.name);
if (entry.isDirectory())
files.push(...(await relativeFiles(root, absolute)));
if (entry.isFile()) {
files.push(path.relative(root, absolute).split(path.sep).join("/"));
}
}
return files;
}
async function readOptionalHistory(file: string) {
return readFile(file, "utf8")
.then((value) => JSON.parse(value) as RunnerE2EHistoryIndex)
.then((value) =>
value.schema === "paperclip.runner-e2e.history/v1" ? value : undefined,
)
.catch(() => undefined);
}
export async function regenerateRunnerDashboard(input: {
bundle: string;
historyFile?: string | null;
outputDirectory?: string;
evidenceHrefPrefix?: string;
}) {
const bundle = path.resolve(input.bundle);
const outputDirectory = path.resolve(input.outputDirectory ?? bundle);
const evidenceHrefPrefix = input.evidenceHrefPrefix?.replace(
/^\/+|\/+$/g,
"",
);
if (
evidenceHrefPrefix &&
(evidenceHrefPrefix.includes("\\") ||
evidenceHrefPrefix
.split("/")
.some((segment) => !segment || segment === "." || segment === ".."))
) {
throw new Error("evidenceHrefPrefix must be a safe relative URL path");
}
const normalized = JSON.parse(
await readFile(path.join(bundle, "normalized-results.json"), "utf8"),
) as PublishedCampaign;
if (
!Array.isArray(normalized.expected) ||
!Array.isArray(normalized.results) ||
typeof normalized.generatedAt !== "string"
) {
throw new Error("Published bundle has an invalid normalized-results.json");
}
const results = normalized.results.map(upgradeRunnerResult);
const expected = normalized.expected.map(canonicalExecutionId);
const campaign: RunnerE2ECampaign = buildRunnerCampaign({
campaignId:
normalized.campaignId ??
`legacy-${normalized.generatedAt.replace(/[:.]/g, "-")}`,
generatedAt: normalized.generatedAt,
expected,
results,
});
// A rendering refresh must not inherit the renderer's checkout or CI event.
const retainedSource = results.find((result) =>
result.source?.sha || result.source?.ref || result.source?.workflowRunUrl,
)?.source;
campaign.source = normalized.source ?? {
sha: retainedSource?.sha ?? null,
ref: retainedSource?.ref ?? null,
workflowRunUrl: retainedSource?.workflowRunUrl ?? null,
eventName: null,
};
const history =
input.historyFile === null
? undefined
: await readOptionalHistory(
input.historyFile ?? path.join(bundle, "history.json"),
);
const entries = await Promise.all(
normalized.results.map(async (publishedResult, index) => {
const result = results[index]!;
const originalExecutionId = publishedResult.executionId;
const evidenceIds = [
originalExecutionId,
...(originalExecutionId.startsWith("core-compatibility.")
? [originalExecutionId.slice("core-compatibility.".length)]
: []),
];
let evidenceBaseHref = "";
let evidenceFiles: string[] = [];
for (const evidenceId of evidenceIds) {
const candidate = [
"evidence",
evidenceId,
`attempt-${result.attempt}`,
].join("/");
const files = await relativeFiles(
path.join(bundle, ...candidate.split("/")),
);
if (files.length === 0) continue;
evidenceBaseHref = [evidenceHrefPrefix, candidate]
.filter(Boolean)
.join("/");
evidenceFiles = files;
break;
}
return {
result,
valid:
result.status === "passed" && result.cleanup === "passed" &&
publishedResult.evidenceValid !== false,
errors: publishedResult.evidenceErrors ?? [],
evidenceBaseHref,
evidenceFiles,
};
}),
);
const dashboard = renderRunnerE2EDashboard({
title: "Runner Full-Stack E2E",
generatedAt: normalized.generatedAt,
expected,
catalog: runnerMatrix,
entries,
campaign,
history,
});
const upgraded = {
...campaign,
results: campaign.results.map((result, index) => ({
...result,
evidenceValid:
normalized.results[index]?.evidenceValid ??
(result.status === "passed" && result.cleanup === "passed"),
evidenceErrors: normalized.results[index]?.evidenceErrors ?? [],
})),
};
await mkdir(outputDirectory, { recursive: true });
await Promise.all([
writeFile(path.join(outputDirectory, "index.html"), dashboard, "utf8"),
writeFile(path.join(outputDirectory, "dashboard.html"), dashboard, "utf8"),
writeFile(
path.join(outputDirectory, "normalized-results.json"),
`${JSON.stringify(upgraded, null, 2)}\n`,
"utf8",
),
]);
console.log(
`Regenerated dashboard from ${normalized.results.length} retained result(s) in ${outputDirectory}`,
);
}
async function main() {
const arguments_ = process.argv
.slice(2)
.filter((argument) => argument !== "--");
const bundleArgument = arguments_.find(
(argument) => !argument.startsWith("--"),
);
if (!bundleArgument) {
throw new Error(
"Usage: pnpm test:e2e:runner:dashboard -- <published-bundle-directory> [--history <history.json>]",
);
}
const historyIndex = arguments_.indexOf("--history");
const historyFile =
historyIndex >= 0 ? arguments_[historyIndex + 1] : undefined;
if (historyIndex >= 0 && !historyFile)
throw new Error("--history requires a path");
await regenerateRunnerDashboard({ bundle: bundleArgument, historyFile });
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(import.meta.filename)
) {
await main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}