feat(runner-e2e): improve matrix report browsing (#12889)

## Thinking Path

> - Paperclip is the open source app that people use to manage AI agents
for work.
> - The runner E2E system verifies agent profiles across supported
execution environments.
> - Its generated report is the main surface for inspecting those
results and their visual evidence.
> - Expanded matcher content could change the matrix column widths and
make comparisons difficult.
> - Screenshots also required extra navigation, and the report did not
have current-result search and filters.
> - This pull request stabilizes the matrix layout and makes visual
evidence directly browsable.
> - The benefit is faster inspection of retained test evidence without
another paid matrix run.

## Linked Issues or Issue Description

**What happened?**

The runner E2E report changed matrix column widths when a matcher table
expanded. The report also made screenshot comparison and current-result
discovery slower than necessary.

**Expected behavior**

The matrix columns must remain stable. Each retained screenshot must
appear as a thumbnail. The gallery must support keyboard navigation and
show the relevant execution metadata. The report must support
client-side search and filters.

**Steps to reproduce**

1. Open a runner E2E matrix report that contains retained screenshots.
2. Expand the matcher details in a matrix cell.
3. Observe the matrix column movement in the old report.

**Paperclip version or commit**

`8430bd897`

**Deployment mode**

Generated static runner E2E report.

## What Changed

- Keep matrix and matcher table widths stable when details expand.
- Show retained screenshot thumbnails in each test card.
- Add a full-screen evidence gallery with mouse, keyboard, and swipe
navigation.
- Show agent, environment, runtime, status, duration, token, and matcher
data in the gallery header.
- Add client-side search and profile, environment, suite, and status
filters below the report section tabs.
- Keep the filters in normal document flow while the report tabs remain
sticky.
- Add report generator assertions for the new layout and controls.
- Add Playwright coverage for filtering, stable matcher expansion, and
filtered gallery navigation.

## Verification

- `pnpm test:e2e:runner:typecheck`
- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/report.test.ts`
- `PAPERCLIP_PLAYWRIGHT_CHANNEL=chrome pnpm exec playwright test
--config tests/e2e/playwright.config.ts
tests/e2e/runner-e2e-dashboard.spec.ts`
- `node --test ./scripts/__tests__/e2e-shard.test.mjs`
- `pnpm check:token-gates`
- `pnpm -r typecheck`
- `pnpm test:run`
- `pnpm build`
- Regenerated the report from GitHub Actions run `33963318820` without
rerunning the matrix.
- Verified 121 retained thumbnails across 66 results in a local browser.
- Verified that expanded matchers keep matrix widths at 260, 486, and
486 pixels in a 1280-pixel viewport.
- Verified search, filters, modal metadata, and arrow-key gallery
navigation.

## Risks

Low risk. This change only modifies the static runner E2E report
generator and its tests. It does not change runner execution or retained
evidence data.

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

## Model Used

OpenAI Codex with `gpt-5.6-sol`. Codex desktop managed the context
window. The model used reasoning, filesystem tools, code execution,
GitHub CLI access, and in-app browser verification.

## 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>
This commit is contained in:
Dotta 2026-09-05 07:46:05 -05:00 committed by GitHub
parent 8430bd897f
commit 4a7172f5ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 371 additions and 21 deletions

View File

@ -0,0 +1,164 @@
import { expect, test } from "@playwright/test";
import { runnerExecutionById } from "../runner-e2e/catalog.js";
import {
renderRunnerE2EDashboard,
type RunnerDashboardEntry,
} from "../runner-e2e/dashboard.js";
import type { MatrixExecution, RunnerE2EResult } from "../runner-e2e/types.js";
const executionIds = [
"core-compatibility.legacy-codex.local.message-marker",
"core-compatibility.legacy-codex.daytona.message-marker",
"core-compatibility.legacy-claude.local.message-marker",
"local-session-integrity.runner-acpx-codex.local.structured-question-restart-resume",
] as const;
function resultFor(
execution: MatrixExecution,
status: RunnerE2EResult["status"],
): RunnerDashboardEntry {
const result: RunnerE2EResult = {
schema: "paperclip.runner-e2e.result/v1",
executionId: execution.id,
suiteId: execution.suite.id,
attempt: 1,
status,
profileId: execution.profile.id,
environmentId: execution.environment.id,
caseId: execution.task.id,
provider: execution.profile.provider,
model: execution.profile.model,
runtimeMode: execution.profile.expectedRuntimeMode,
startedAt: "2026-09-05T12:00:00.000Z",
finishedAt: "2026-09-05T12:00:12.000Z",
durationMs: 12_000,
runIds: [`run-${execution.id}`],
usage: {
inputTokens: 1_250,
outputTokens: 75,
cachedInputTokens: 500,
},
matcherResults: [
{
matcher: { kind: "message_contains", expected: "PAPERCLIP_E2E_OK" },
passed: status === "passed",
detail: status === "passed" ? "matched" : "marker missing",
},
],
screenshots: [
{
id: "final-state",
label: "Final visible task state",
file: "final-state.png",
},
],
cleanup: "passed",
};
return {
result,
valid: status === "passed",
errors: status === "passed" ? [] : ["fixture failure"],
evidenceBaseHref: `evidence/${execution.id}/attempt-1`,
evidenceFiles: ["final-state.png"],
};
}
test("filters matrix results and pages through the filtered screenshot gallery", async ({
page,
}) => {
const catalog = executionIds.map(runnerExecutionById);
const entries = catalog.map((execution, index) =>
resultFor(execution, index === 1 ? "failed" : "passed"),
);
await page.setContent(
renderRunnerE2EDashboard({
title: "Runner E2E interaction fixture",
generatedAt: "2026-09-05T12:01:00.000Z",
expected: executionIds,
catalog,
entries,
}),
);
const filters = page
.locator("[data-report-query]")
.locator("..")
.locator("..");
const tabs = page.locator(".suite-nav");
await expect(page.locator("[data-report-result]")).toHaveText(
"4 of 4 tests shown",
);
await expect(page.locator("[data-gallery-item]")).toHaveCount(4);
expect(
await filters.evaluate((element) => getComputedStyle(element).position),
).toBe("static");
expect(
await tabs.evaluate((element) =>
element.nextElementSibling?.classList.contains("report-filters"),
),
).toBe(true);
const firstTable = page.locator(".matrix").first();
const widthsBefore = await firstTable
.locator("thead th")
.evaluateAll((cells) =>
cells.map((cell) => cell.getBoundingClientRect().width),
);
await firstTable.locator("details summary").first().click();
const widthsAfter = await firstTable
.locator("thead th")
.evaluateAll((cells) =>
cells.map((cell) => cell.getBoundingClientRect().width),
);
expect(widthsAfter).toEqual(widthsBefore);
await page.locator("[data-report-query]").fill("Legacy Claude");
await expect(page.locator("[data-report-result]")).toHaveText(
"1 of 4 tests shown",
);
await expect(page.locator("[data-gallery-open]")).toHaveText(
"View gallery · 1",
);
await page.locator("[data-report-reset]").click();
await expect(page.locator("[data-report-query]")).toBeFocused();
await page
.locator("select[data-report-profile]")
.selectOption("legacy-codex");
await expect(page.locator("[data-report-result]")).toHaveText(
"2 of 4 tests shown",
);
await expect(page.locator("[data-gallery-open]")).toHaveText(
"View gallery · 2",
);
await page.locator("[data-gallery-open]").click();
const dialog = page.locator("[data-gallery-dialog]");
await expect(dialog).toBeVisible();
await expect(dialog.locator("[data-gallery-profile]")).toHaveText(
"Legacy Codex",
);
await expect(dialog.locator("[data-gallery-environment]")).toHaveText(
"Isolated local",
);
await expect(dialog.locator("[data-gallery-position]")).toHaveText(
"Image 1 of 2",
);
await page.keyboard.press("ArrowRight");
await expect(dialog.locator("[data-gallery-environment]")).toHaveText(
"Daytona sandbox",
);
await expect(dialog.locator("[data-gallery-status]")).toHaveText("failed");
await expect(dialog.locator("[data-gallery-position]")).toHaveText(
"Image 2 of 2",
);
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
await page.locator("[data-report-reset]").click();
await page.keyboard.press("/");
await expect(page.locator("[data-report-query]")).toBeFocused();
});

View File

@ -154,6 +154,24 @@ function renderCase(
(entry?.valid ? "All invariants passed" : "Not selected");
const screenshots = resolveScreenshots(entry);
const billing = entry ? summarizeExecutionBilling(entry.result) : null;
const matcherResults = entry?.result.matcherResults ?? [];
const passedMatchers = matcherResults.filter(
(result) => result.passed,
).length;
const searchText = [
execution.id,
execution.task.label,
execution.profile.label,
execution.profile.generation,
execution.profile.provider,
execution.profile.model,
execution.environment.label,
execution.environment.provider,
execution.suite.label,
label,
]
.join(" ")
.toLowerCase();
const availableFiles = entry?.evidenceFiles
? new Set(entry.evidenceFiles)
: null;
@ -197,6 +215,10 @@ function renderCase(
data-gallery-environment-provider="${html(execution.environment.provider)}"
data-gallery-execution-target="${html(execution.environment.expectedExecutionTarget.kind)}"
data-gallery-runtime="${html(entry?.result.runtimeMode ?? execution.profile.expectedRuntimeMode)}"
data-gallery-status="${html(label)}"
data-gallery-duration="${html(entry ? durationLabel(entry.result.durationMs) : "Not run")}"
data-gallery-tokens="${html(billing ? `${tokenLabel(billing.llm.inputTokens)} in · ${tokenLabel(billing.llm.outputTokens)} out` : "Unavailable")}"
data-gallery-matchers="${html(matcherResults.length > 0 ? `${passedMatchers}/${matcherResults.length} matchers passed` : "No matchers recorded")}"
aria-label="Open ${html(item.label)} for ${html(execution.id)} in gallery"
>
<span class="screenshot-frame"><img src="${html(item.href)}" loading="lazy" alt="${html(item.label)} for ${html(execution.id)}"></span>
@ -212,7 +234,16 @@ function renderCase(
<div><span>Execution</span><strong>${billing.runtime.estimatedListCostUsd === undefined ? html(billing.runtime.costStatus === "not_metered" ? "Local · not metered" : "Cost unavailable") : `${html(usdLabel(billing.runtime.estimatedListCostUsd))} est.`}</strong><small>${html(durationLabel(billing.runtime.agentRunDurationMs))} agent${billing.runtime.leaseDurationMs === null ? "" : ` · ${html(durationLabel(billing.runtime.leaseDurationMs))} lease`}</small></div>
</div>`
: "";
return `<article class="case case-${state}" data-execution-id="${html(execution.id)}">
return `<article
class="case case-${state}"
data-execution-id="${html(execution.id)}"
data-report-case
data-report-suite="${html(execution.suite.id)}"
data-report-profile="${html(execution.profile.id)}"
data-report-environment="${html(execution.environment.id)}"
data-report-status="${html(state)}"
data-report-search="${html(searchText)}"
>
<div class="case-heading">
<strong>${html(execution.task.label)}</strong>
<span class="status">${html(label)}</span>
@ -501,7 +532,7 @@ function renderSuiteMatrix(input: {
</td>`;
})
.join("");
return `<tr><th scope="row" class="profile-cell"><div class="profile-sticky">
return `<tr data-report-profile-row><th scope="row" class="profile-cell"><div class="profile-sticky">
<span class="agent-capsule agent-${(profileIndex % 10) + 1}" aria-hidden="true"></span>
<span class="profile-copy"><span><strong>${html(profile.label)}</strong><span class="generation">${html(profile.generation)}</span></span><small>${html(profile.provider)} · ${html(profile.model)}</small></span>
</div></th>${columns}</tr>`;
@ -530,7 +561,7 @@ function renderSuiteMatrix(input: {
<div class="section-heading"><div><p class="eyebrow">Test suite</p><h2>${html(suite.label)}</h2></div><p>${html(suite.description)}</p></div>
${summaryHtml}
<div class="table-kicker"><strong>Configuration matrix</strong><span>${profiles.length} profiles · ${environments.length} environments · ${selected} selected</span></div>
<div class="table-wrap"><table class="matrix"><thead><tr><th scope="col">Agent profile</th>${environmentHeaders}</tr></thead><tbody>${rows}</tbody></table></div>
<div class="table-wrap"><table class="matrix"><colgroup><col class="profile-column">${environments.map(() => '<col class="environment-column">').join("")}</colgroup><thead><tr><th scope="col">Agent profile</th>${environmentHeaders}</tr></thead><tbody>${rows}</tbody></table></div>
</section>`;
}
@ -578,6 +609,22 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
const publicSummaryImageHref = safePublicAssetHref(
input.publicSummaryImageHref,
);
const filterProfiles = [
...new Map(
input.catalog.map((execution) => [
execution.profile.id,
execution.profile,
]),
).values(),
];
const filterEnvironments = [
...new Map(
input.catalog.map((execution) => [
execution.environment.id,
execution.environment,
]),
).values(),
];
return `<!doctype html>
<html lang="en">
@ -617,6 +664,7 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
--idle-text: #52585d;
--idle-border: #a8aeb2;
--radius: 8px;
--navigation-sticky-offset: 58px;
--font-sans: "Paperclip Inter", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
@ -672,23 +720,32 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
.gallery-launch, .gallery-control, .gallery-close { border: 1px solid var(--border-strong); border-radius: calc(var(--radius) * .8); background: var(--background); color: var(--foreground); cursor: pointer; font-weight: 600; transition: background 150ms ease, color 150ms ease, border-color 150ms ease; }
.gallery-launch { min-width: 138px; padding: 10px 15px; }
.gallery-launch:hover, .gallery-control:hover, .gallery-close:hover { border-color: var(--foreground); background: var(--primary); color: var(--primary-foreground); }
.gallery-launch:focus-visible, .gallery-control:focus-visible, .gallery-close:focus-visible, .screenshot-trigger:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px solid #2563eb; outline-offset: 3px; }
.gallery-launch:focus-visible, .gallery-control:focus-visible, .gallery-close:focus-visible, .screenshot-trigger:focus-visible, .report-filters input:focus-visible, .report-filters select:focus-visible, .filter-reset:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px solid #2563eb; outline-offset: 3px; }
.report-filters { display: grid; grid-template-columns: minmax(240px, 1.6fr) repeat(4, minmax(130px, .7fr)) auto; align-items: end; gap: 10px; margin: 0 0 40px; padding: 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); }
.report-filters label { display: grid; gap: 5px; min-width: 0; }
.report-filters label > span { color: var(--muted-foreground); font-size: 10px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; }
.report-filters input, .report-filters select, .filter-reset { width: 100%; min-height: 38px; padding: 7px 9px; border: 1px solid var(--border-strong); border-radius: calc(var(--radius) * .8); background: var(--background); color: var(--foreground); }
.filter-reset { width: auto; cursor: pointer; font-weight: 600; }
.filter-reset:hover { border-color: var(--foreground); }
.filter-result { grid-column: 1 / -1; margin: 0; color: var(--muted-foreground); font: 10px/1.4 var(--font-mono); }
.table-kicker { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 0; border-top: 1px solid var(--border); color: var(--muted-foreground); }
.table-kicker strong { color: var(--foreground); font-weight: 600; }
.table-kicker span { font: 11px/1.4 var(--font-mono); font-variant-numeric: tabular-nums; }
.table-wrap { overflow: visible; }
.matrix { width: 100%; min-width: 1120px; border-collapse: separate; border-spacing: 0; }
.matrix { width: 100%; min-width: 1120px; table-layout: fixed; border-collapse: separate; border-spacing: 0; }
.matrix .profile-column { width: 260px; }
.matrix .environment-column { width: auto; }
.matrix th, .matrix td { padding: 16px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); vertical-align: top; text-align: left; }
.matrix tr:last-child th, .matrix tr:last-child td { border-bottom: 0; }
.matrix th:last-child, .matrix td:last-child { border-right: 0; }
.matrix thead th { position: sticky; top: 0; z-index: 4; background: var(--raised); }
.matrix thead th { position: sticky; top: var(--navigation-sticky-offset); z-index: 4; background: var(--raised); }
.matrix thead th:first-child { left: 0; z-index: 6; width: 260px; }
.matrix thead small, .matrix tbody th small { display: block; margin-top: 4px; color: var(--muted-foreground); font-weight: 400; }
.environment-label { font-size: 15px; font-weight: 650; }
.mobile-environment-header { display: none; }
.profile-cell { position: sticky; left: 0; z-index: 3; width: 260px; background: var(--card); }
.profile-cell { display: table-cell; }
.profile-sticky { position: sticky; top: 76px; display: flex; align-items: flex-start; min-width: 0; }
.profile-sticky { position: sticky; top: calc(var(--navigation-sticky-offset) + 76px); display: flex; align-items: flex-start; min-width: 0; }
.profile-sticky > .agent-capsule { flex: none; margin: 2px 12px 18px 0; }
.profile-copy { display: block; min-width: 0; }
.profile-copy strong { font-size: 14px; }
@ -706,7 +763,7 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
.agent-10 { background: linear-gradient(to bottom, #f2d95f, #4fbcba); }
.generation { display: inline-block; margin-left: 7px; padding: 2px 7px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted-foreground); font: 500 10px/1.4 var(--font-mono); letter-spacing: .06em; text-transform: uppercase; }
.case-stack { display: grid; gap: 12px; }
.case { min-width: 0; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); }
.case { min-width: 0; max-width: 100%; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); overflow: hidden; }
.case-heading { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
.case-heading strong { font-size: 14px; font-weight: 650; }
.status, .matcher-state { display: inline-block; flex: none; padding: 3px 8px; border: 1px solid; border-radius: 999px; font: 600 10px/1.4 var(--font-sans); letter-spacing: .05em; text-transform: uppercase; }
@ -732,13 +789,18 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
.screenshot-caption { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 9px; }
.screenshot-caption span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted-foreground); font-size: 10px; }
.screenshot-caption span:last-child { font-size: 10px; font-weight: 650; }
.matcher-wrap { overflow-x: auto; margin: 12px 0; border: 1px solid var(--border); border-radius: calc(var(--radius) * .8); }
.matchers { width: 100%; min-width: 680px; border-collapse: collapse; font-size: 11px; }
.case-context { display: block; max-width: 100%; }
.matcher-wrap { width: 100%; max-width: 100%; overflow-x: auto; margin: 12px 0; border: 1px solid var(--border); border-radius: calc(var(--radius) * .8); }
.matchers { width: 100%; min-width: 680px; table-layout: fixed; border-collapse: collapse; font-size: 11px; }
.matchers th:nth-child(1) { width: 72px; }
.matchers th:nth-child(2) { width: 144px; }
.matchers th:nth-child(4) { width: 110px; }
.matchers th, .matchers td { padding: 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); vertical-align: top; }
.matchers tr:last-child td { border-bottom: 0; }
.matchers th:last-child, .matchers td:last-child { border-right: 0; }
.matchers th { background: var(--raised); color: var(--muted-foreground); font-weight: 600; }
.matchers code { display: inline; color: var(--foreground); }
.matchers td { overflow-wrap: anywhere; }
.usage { margin: 12px 0; color: var(--muted-foreground); }
.billing-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 12px 0 10px; border: 1px solid var(--border); border-radius: calc(var(--radius) * .8); background: var(--raised); }
.billing-strip > div { min-width: 0; padding: 9px 10px; border-right: 1px solid var(--border); }
@ -753,10 +815,10 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
.billing-metric strong { display: block; overflow-wrap: anywhere; font: 550 17px/1.25 var(--font-mono); font-variant-numeric: tabular-nums; }
.billing-metric span { display: block; margin-top: 4px; color: var(--muted-foreground); font-size: 10px; letter-spacing: .06em; text-transform: uppercase; }
.billing-note { grid-column: 1 / -1; margin: 0; padding: 11px 16px; border-top: 1px solid var(--border); color: var(--muted-foreground); font-size: 11px; }
.suite-nav { position: sticky; top: 0; z-index: 12; display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 40px; padding: 10px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: var(--background); }
.suite-nav { position: sticky; top: 0; z-index: 12; display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 24px; padding: 10px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: var(--background); }
.suite-nav a { padding: 7px 10px; border-radius: calc(var(--radius) * .8); color: var(--muted-foreground); font-size: 12px; font-weight: 600; text-decoration: none; }
.suite-nav a:hover { background: var(--raised); color: var(--foreground); }
.suite-section, .history-section { scroll-margin-top: 72px; margin-top: 64px; }
.suite-section, .history-section { scroll-margin-top: calc(var(--navigation-sticky-offset) + 16px); margin-top: 64px; }
.section-heading { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 520px); align-items: end; gap: 32px; margin-bottom: 24px; }
.section-heading h2 { margin: 0; font-size: clamp(25px, 3vw, 38px); line-height: 1.05; letter-spacing: -.035em; }
.section-heading > p { margin: 0; color: var(--muted-foreground); }
@ -816,6 +878,13 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
.gallery-context span { display: inline-flex; align-items: baseline; gap: 6px; min-width: 0; }
.gallery-context strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.gallery-context em { color: #b8b8b5; font: 10px/1.4 var(--font-mono); font-style: normal; white-space: nowrap; }
.gallery-facts { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 18px; margin-top: 8px; }
.gallery-facts > span { display: inline-flex; align-items: baseline; gap: 6px; min-width: 0; }
.gallery-facts em { color: #b8b8b5; font: 10px/1.4 var(--font-mono); font-style: normal; text-transform: uppercase; }
.gallery-facts strong { font: 600 11px/1.4 var(--font-mono); }
.gallery-status { padding: 3px 8px; border: 1px solid rgb(255 255 255 / 36%); border-radius: 999px; font: 650 10px/1.4 var(--font-sans); letter-spacing: .05em; text-transform: uppercase; }
.gallery-status[data-status="passed"] { border-color: var(--pass-border); background: var(--pass-bg); color: #6ee79a; }
.gallery-status[data-status="failed"] { border-color: var(--fail-border); background: var(--fail-bg); color: #ff8181; }
.gallery-execution { margin-top: 3px; color: #b8b8b5; font: 10px/1.4 var(--font-mono); }
.gallery-close, .gallery-control { min-height: 38px; padding: 8px 12px; border-color: rgb(255 255 255 / 32%); background: transparent; color: #fafafa; }
.gallery-stage { position: relative; display: grid; place-items: center; min-height: 0; padding: 24px 88px; overflow: hidden; touch-action: pan-y; }
@ -853,6 +922,10 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
.section-heading { grid-template-columns: 1fr; gap: 12px; }
.trend-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.history-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.report-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.report-search, .filter-result { grid-column: 1 / -1; }
.suite-nav { top: 0; }
.suite-section, .history-section { scroll-margin-top: 72px; }
}
@media (max-width: 640px) {
.brand-bar { min-height: 56px; padding: 0 16px; }
@ -875,6 +948,8 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
.suite-nav { top: 0; overflow-x: auto; flex-wrap: nowrap; }
.suite-nav a { white-space: nowrap; }
.history-pointers, .trend-grid, .history-filters { grid-template-columns: 1fr; }
.report-filters { grid-template-columns: 1fr; }
.report-search, .filter-result { grid-column: auto; }
.suite-summary { grid-template-columns: 1fr; }
.suite-summary > div, .suite-summary > div:nth-child(even) { border-right: 0; border-bottom: 1px solid var(--border); }
.suite-summary > div:last-child { border-bottom: 0; }
@ -906,7 +981,7 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
<div>
<p class="eyebrow">Full-stack acceptance campaign</p>
<h1>${html(input.title)}</h1>
<p class="lede">A browser-verified matrix of runner profiles, execution environments, and deterministic task contracts. Provider-produced visual evidence remains in the access-controlled workflow artifact; public history contains inert structured evidence and a trusted synthetic campaign summary.</p>
<p class="lede">A browser-verified matrix of runner profiles, execution environments, and deterministic task contracts. Visual evidence is retained in the access-controlled workflow artifact; public history contains inert structured evidence and a trusted synthetic campaign summary.</p>
</div>
<div class="report-actions">
<div class="summary" aria-label="Campaign summary">
@ -934,6 +1009,15 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
${suites.map((suite) => `<a href="#suite-${html(suite.id)}">${html(suite.label)}</a>`).join("")}
<a href="#history">History</a>
</nav>
<section class="report-filters" aria-label="Filter test results">
<label class="report-search"><span>Search tests</span><input type="search" placeholder="Test, model, provider, execution ID…" autocomplete="off" data-report-query></label>
<label><span>Agent profile</span><select data-report-profile><option value="">All profiles</option>${filterProfiles.map((profile) => `<option value="${html(profile.id)}">${html(profile.label)}</option>`).join("")}</select></label>
<label><span>Environment</span><select data-report-environment><option value="">All environments</option>${filterEnvironments.map((environment) => `<option value="${html(environment.id)}">${html(environment.label)}</option>`).join("")}</select></label>
<label><span>Suite</span><select data-report-suite><option value="">All suites</option>${suites.map((suite) => `<option value="${html(suite.id)}">${html(suite.label)}</option>`).join("")}</select></label>
<label><span>Status</span><select data-report-status><option value="">All statuses</option><option value="passed">Passed</option><option value="failed">Failed</option><option value="missing">Missing</option><option value="not-selected">Not selected</option></select></label>
<button class="filter-reset" type="button" data-report-reset>Reset</button>
<p class="filter-result" data-report-result aria-live="polite"></p>
</section>
${suiteSections}
${historySection}
<footer><span>Generated ${html(input.generatedAt)}</span><span>${input.catalog.length} catalog executions · Public history excludes provider-produced visual evidence</span></footer>
@ -948,6 +1032,12 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
<span><strong data-gallery-environment></strong><em data-gallery-environment-detail></em></span>
<span><strong data-gallery-case></strong><em data-gallery-runtime></em></span>
</div>
<div class="gallery-facts">
<span class="gallery-status" data-gallery-status></span>
<span><em>Duration</em><strong data-gallery-duration></strong></span>
<span><em>Tokens</em><strong data-gallery-tokens></strong></span>
<span><em>Tests</em><strong data-gallery-matchers></strong></span>
</div>
<code class="gallery-execution" data-gallery-execution></code>
</div>
<button class="gallery-close" type="button" data-gallery-close>Close</button>
@ -976,13 +1066,24 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
const environmentDetail = dialog.querySelector("[data-gallery-environment-detail]");
const caseLabel = dialog.querySelector("[data-gallery-case]");
const runtime = dialog.querySelector("[data-gallery-runtime]");
const status = dialog.querySelector("[data-gallery-status]");
const duration = dialog.querySelector("[data-gallery-duration]");
const tokens = dialog.querySelector("[data-gallery-tokens]");
const matchers = dialog.querySelector("[data-gallery-matchers]");
const position = dialog.querySelector("[data-gallery-position]");
const stage = dialog.querySelector("[data-gallery-stage]");
let activeIndex = 0;
let activeItem = items[0];
let pointerStartX = null;
const render = () => {
const item = items[activeIndex];
const visibleItems = items.filter((item) => !item.closest("[data-report-case]").hidden);
if (visibleItems.length === 0) return;
let activeIndex = visibleItems.indexOf(activeItem);
if (activeIndex < 0) {
activeIndex = 0;
activeItem = visibleItems[0];
}
const item = activeItem;
image.src = item.dataset.galleryHref;
image.alt = item.dataset.galleryLabel + " for " + item.dataset.galleryExecution;
title.textContent = item.dataset.galleryLabel;
@ -993,14 +1094,23 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
environmentDetail.textContent = item.dataset.galleryEnvironmentProvider + " · " + item.dataset.galleryExecutionTarget;
caseLabel.textContent = item.dataset.galleryCase;
runtime.textContent = item.dataset.galleryRuntime + " runtime";
position.textContent = "Image " + (activeIndex + 1) + " of " + items.length;
status.textContent = item.dataset.galleryStatus;
status.dataset.status = item.dataset.galleryStatus;
duration.textContent = item.dataset.galleryDuration;
tokens.textContent = item.dataset.galleryTokens;
matchers.textContent = item.dataset.galleryMatchers;
position.textContent = "Image " + (activeIndex + 1) + " of " + visibleItems.length;
};
const move = (amount) => {
activeIndex = (activeIndex + amount + items.length) % items.length;
const visibleItems = items.filter((item) => !item.closest("[data-report-case]").hidden);
if (visibleItems.length === 0) return;
const activeIndex = Math.max(0, visibleItems.indexOf(activeItem));
activeItem = visibleItems[(activeIndex + amount + visibleItems.length) % visibleItems.length];
render();
};
const open = (index) => {
activeIndex = index;
const open = (item) => {
if (!item) return;
activeItem = item;
render();
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
@ -1010,8 +1120,8 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
else dialog.removeAttribute("open");
};
items.forEach((item, index) => item.addEventListener("click", () => open(index)));
document.querySelectorAll("[data-gallery-open]").forEach((button) => button.addEventListener("click", () => open(0)));
items.forEach((item) => item.addEventListener("click", () => open(item)));
document.querySelectorAll("[data-gallery-open]").forEach((button) => button.addEventListener("click", () => open(items.find((item) => !item.closest("[data-report-case]").hidden))));
dialog.querySelector("[data-gallery-previous]").addEventListener("click", () => move(-1));
dialog.querySelector("[data-gallery-next]").addEventListener("click", () => move(1));
dialog.querySelector("[data-gallery-close]").addEventListener("click", close);
@ -1031,6 +1141,69 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
stage.addEventListener("pointercancel", () => { pointerStartX = null; });
})();
</script>
<script>
(() => {
const cases = Array.from(document.querySelectorAll("[data-report-case]"));
if (cases.length === 0) return;
const query = document.querySelector("[data-report-query]");
const profile = document.querySelector("[data-report-profile]");
const environment = document.querySelector("[data-report-environment]");
const suite = document.querySelector("[data-report-suite]");
const status = document.querySelector("[data-report-status]");
const reset = document.querySelector("[data-report-reset]");
const result = document.querySelector("[data-report-result]");
const galleryLaunch = document.querySelector("[data-gallery-open]");
const apply = () => {
const search = query.value.trim().toLowerCase();
let visible = 0;
cases.forEach((item) => {
const matches =
(!search || item.dataset.reportSearch.includes(search)) &&
(!profile.value || item.dataset.reportProfile === profile.value) &&
(!environment.value || item.dataset.reportEnvironment === environment.value) &&
(!suite.value || item.dataset.reportSuite === suite.value) &&
(!status.value || item.dataset.reportStatus === status.value);
item.hidden = !matches;
if (matches) visible += 1;
});
document.querySelectorAll("[data-report-profile-row]").forEach((row) => {
row.hidden = !row.querySelector("[data-report-case]:not([hidden])");
});
document.querySelectorAll(".suite-section").forEach((section) => {
section.hidden = !section.querySelector("[data-report-case]:not([hidden])");
});
const visibleImages = cases.reduce(
(total, item) => total + (item.hidden ? 0 : item.querySelectorAll("[data-gallery-item]").length),
0,
);
result.textContent = visible + " of " + cases.length + " tests shown";
if (galleryLaunch) {
galleryLaunch.disabled = visibleImages === 0;
galleryLaunch.textContent = visibleImages === 0 ? "No visual evidence in selection" : "View gallery · " + visibleImages;
}
};
[query, profile, environment, suite, status].forEach((control) => {
control.addEventListener(control === query ? "input" : "change", apply);
});
reset.addEventListener("click", () => {
query.value = "";
profile.value = "";
environment.value = "";
suite.value = "";
status.value = "";
apply();
query.focus();
});
document.addEventListener("keydown", (event) => {
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target;
if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement) return;
event.preventDefault();
query.focus();
});
apply();
})();
</script>
<script>
(() => {
const rows = Array.from(document.querySelectorAll("[data-history-campaign]"));

View File

@ -212,6 +212,19 @@ describe("runner E2E report aggregation", () => {
expect(dashboard).toContain('class="mobile-environment-header"');
expect(dashboard).toContain("data-gallery-profile=");
expect(dashboard).toContain("data-gallery-environment=");
expect(dashboard).toContain("data-gallery-duration=");
expect(dashboard).toContain("data-gallery-tokens=");
expect(dashboard).toContain("data-gallery-matchers=");
expect(dashboard).toContain("data-report-query");
expect(dashboard).toContain("data-report-profile");
expect(dashboard).toContain("data-report-environment");
expect(dashboard).toContain("data-report-status");
expect(dashboard.indexOf('class="report-filters"')).toBeGreaterThan(
dashboard.indexOf('class="suite-nav"'),
);
expect(dashboard).not.toContain(".report-filters { position: sticky");
expect(dashboard).toContain("table-layout: fixed");
expect(dashboard).toContain('class="profile-column"');
expect(dashboard).toContain('aria-label="Previous"');
expect(dashboard).toContain('aria-label="Next"');
expect(dashboard).not.toContain("overflow: auto; max-height: calc(100vh");