1274 lines
73 KiB
TypeScript
1274 lines
73 KiB
TypeScript
import {
|
|
discoverReportCatalog,
|
|
type ReportExecution,
|
|
} from "./report-catalog.js";
|
|
import type {
|
|
RunnerE2ECampaign,
|
|
RunnerE2EHistoryIndex,
|
|
RunnerE2EResult,
|
|
RunnerE2ESuiteSummary,
|
|
} from "./types.js";
|
|
import {
|
|
aggregateCampaignBilling,
|
|
summarizeExecutionBilling,
|
|
} from "./billing.js";
|
|
|
|
export interface RunnerDashboardEntry {
|
|
result: RunnerE2EResult;
|
|
valid: boolean;
|
|
errors: readonly string[];
|
|
evidenceBaseHref?: string;
|
|
evidenceFiles?: readonly string[];
|
|
}
|
|
|
|
export interface RunnerDashboardInput {
|
|
title: string;
|
|
generatedAt: string;
|
|
expected: readonly string[];
|
|
catalog: readonly ReportExecution[];
|
|
entries: readonly RunnerDashboardEntry[];
|
|
campaign?: RunnerE2ECampaign;
|
|
history?: RunnerE2EHistoryIndex;
|
|
publicSummaryImageHref?: string;
|
|
}
|
|
|
|
interface ResolvedScreenshot {
|
|
id: string;
|
|
label: string;
|
|
file: string;
|
|
href: string;
|
|
}
|
|
|
|
function html(value: unknown) {
|
|
return String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function durationLabel(durationMs: number) {
|
|
if (durationMs < 1_000) return `${durationMs}ms`;
|
|
const seconds = Math.round(durationMs / 1_000);
|
|
if (seconds < 60) return `${seconds}s`;
|
|
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
}
|
|
|
|
function tokenLabel(value: number) {
|
|
return new Intl.NumberFormat("en-US").format(value);
|
|
}
|
|
|
|
function usdLabel(value: number) {
|
|
return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
|
|
}
|
|
|
|
function safeEvidenceHref(base: string | undefined, relative: string) {
|
|
if (!base || /^(?:[a-z]+:|\/\/)/i.test(base)) return null;
|
|
const cleanBase = base
|
|
.split("/")
|
|
.filter((segment) => segment && segment !== "." && segment !== "..")
|
|
.map(encodeURIComponent)
|
|
.join("/");
|
|
const cleanRelative = relative
|
|
.split("/")
|
|
.filter((segment) => segment && segment !== "." && segment !== "..")
|
|
.map(encodeURIComponent)
|
|
.join("/");
|
|
return `${cleanBase}/${cleanRelative}`;
|
|
}
|
|
|
|
function safePublicAssetHref(relative: string | undefined) {
|
|
if (!relative || /^(?:[a-z]+:|\/\/|\/)/i.test(relative)) return null;
|
|
const segments = relative.split("/");
|
|
if (
|
|
segments.some((segment) => !segment || segment === "." || segment === "..")
|
|
) {
|
|
return null;
|
|
}
|
|
return segments.map(encodeURIComponent).join("/");
|
|
}
|
|
|
|
function compactJson(value: unknown) {
|
|
const serialized = JSON.stringify(value);
|
|
return serialized && serialized.length > 1_200
|
|
? `${serialized.slice(0, 1_200)}…`
|
|
: serialized;
|
|
}
|
|
|
|
function resolveScreenshots(
|
|
entry: RunnerDashboardEntry | undefined,
|
|
): ResolvedScreenshot[] {
|
|
const declaredScreenshots = entry?.result.screenshots?.length
|
|
? entry.result.screenshots
|
|
: entry
|
|
? [
|
|
{
|
|
id: "final-state",
|
|
label: "Final visible task state",
|
|
file: "final-state.png",
|
|
},
|
|
]
|
|
: [];
|
|
const availableFiles = entry?.evidenceFiles
|
|
? new Set(entry.evidenceFiles)
|
|
: null;
|
|
const screenshots = declaredScreenshots
|
|
.filter((item) => !availableFiles || availableFiles.has(item.file))
|
|
.flatMap((item) => {
|
|
const href = safeEvidenceHref(entry?.evidenceBaseHref, item.file);
|
|
return href ? [{ ...item, href }] : [];
|
|
});
|
|
if (
|
|
entry?.result.status === "failed" &&
|
|
!screenshots.some((item) => item.file === "failure.png") &&
|
|
(!availableFiles || availableFiles.has("failure.png"))
|
|
) {
|
|
const href = safeEvidenceHref(entry.evidenceBaseHref, "failure.png");
|
|
if (href) {
|
|
screenshots.push({
|
|
id: "failure",
|
|
label: "Failure state",
|
|
file: "failure.png",
|
|
href,
|
|
});
|
|
}
|
|
}
|
|
return screenshots;
|
|
}
|
|
|
|
function renderCase(
|
|
execution: ReportExecution,
|
|
expected: ReadonlySet<string>,
|
|
entryById: ReadonlyMap<string, RunnerDashboardEntry>,
|
|
) {
|
|
const selected = expected.has(execution.id);
|
|
const entry = entryById.get(execution.id);
|
|
const state = !selected
|
|
? "not-selected"
|
|
: !entry
|
|
? "missing"
|
|
: entry.valid
|
|
? "passed"
|
|
: "failed";
|
|
const label = state.replace("-", " ");
|
|
const detail =
|
|
entry?.errors.join("; ") ||
|
|
(entry?.valid
|
|
? "All invariants passed"
|
|
: selected
|
|
? "No result artifact was uploaded"
|
|
: "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;
|
|
const playwright =
|
|
!availableFiles || availableFiles.has("html-report/index.html")
|
|
? safeEvidenceHref(entry?.evidenceBaseHref, "html-report/index.html")
|
|
: null;
|
|
const links =
|
|
screenshots.length > 0 || playwright
|
|
? `<nav class="evidence-links" aria-label="Evidence for ${html(execution.id)}">
|
|
${screenshots.map((item) => `<a href="${html(item.href)}" target="_blank" rel="noreferrer">Open ${html(item.label.toLowerCase())}</a>`).join("")}
|
|
${playwright ? `<a href="${html(playwright)}">Open Playwright report</a>` : ""}
|
|
</nav>`
|
|
: "";
|
|
const matcherRows = (entry?.result.matcherResults ?? [])
|
|
.map(
|
|
(result) => `<tr class="matcher-${result.passed ? "passed" : "failed"}">
|
|
<td><span class="matcher-state" aria-label="${result.passed ? "Passed" : "Failed"}">${result.passed ? "Pass" : "Fail"}</span></td>
|
|
<td><code>${html(result.matcher.kind)}</code></td>
|
|
<td>${html(compactJson(result.matcher))}</td>
|
|
<td>${html(result.detail)}</td>
|
|
</tr>`,
|
|
)
|
|
.join("");
|
|
const turnTimingRows = (entry?.result.turnTimings ?? [])
|
|
.map(
|
|
(timing) => `<tr>
|
|
<td>${timing.turn}</td>
|
|
<td><code>${html(timing.runId)}</code></td>
|
|
<td>${html(timing.leaseAcquisitionOutcome)}</td>
|
|
<td>${html(timing.schedulerLatencyMs === null ? "unavailable" : durationLabel(timing.schedulerLatencyMs))}</td>
|
|
<td>${html(timing.runDurationMs === null ? "unavailable" : durationLabel(timing.runDurationMs))}</td>
|
|
<td>${html(timing.responseLatencyMs === null ? "unavailable" : durationLabel(timing.responseLatencyMs))}</td>
|
|
</tr>`,
|
|
)
|
|
.join("");
|
|
const gallery = screenshots.length
|
|
? `<div class="gallery" aria-label="Screenshots for ${html(execution.id)}">${screenshots
|
|
.map(
|
|
(item) => `<button
|
|
class="screenshot-trigger"
|
|
type="button"
|
|
data-gallery-item
|
|
data-gallery-href="${html(item.href)}"
|
|
data-gallery-label="${html(item.label)}"
|
|
data-gallery-execution="${html(execution.id)}"
|
|
data-gallery-case="${html(execution.task.label)}"
|
|
data-gallery-profile="${html(execution.profile.label)}"
|
|
data-gallery-generation="${html(execution.profile.generation)}"
|
|
data-gallery-provider="${html(execution.profile.provider)}"
|
|
data-gallery-model="${html(execution.profile.model)}"
|
|
data-gallery-environment="${html(execution.environment.label)}"
|
|
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>
|
|
<span class="screenshot-caption"><span>${html(item.label)}</span><span aria-hidden="true">View</span></span>
|
|
</button>`,
|
|
)
|
|
.join("")}</div>`
|
|
: "";
|
|
const billingStrip = billing
|
|
? `<div class="billing-strip" aria-label="Billing for ${html(execution.id)}">
|
|
<div><span>Tokens</span><strong>${html(tokenLabel(billing.llm.inputTokens))} in · ${html(tokenLabel(billing.llm.outputTokens))} out</strong><small>${html(tokenLabel(billing.llm.cachedInputTokens))} cached · ${billing.llm.runsWithTokenUsage}/${billing.llm.runCount} runs covered</small></div>
|
|
<div><span>LLM spend</span><strong>${billing.llm.runsWithReportedCost > 0 ? html(usdLabel(billing.reportedCostUsd)) : html(billing.llm.costStatus)}</strong><small>${billing.llm.runsWithReportedCost}/${billing.llm.runCount} runs provider-priced</small></div>
|
|
<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
|
|
id="execution-${html(execution.id)}"
|
|
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>
|
|
</div>
|
|
${gallery}
|
|
${billingStrip}
|
|
<code class="execution-id">${html(execution.id)}</code>
|
|
<details class="case-context">
|
|
<summary>Matchers and test context</summary>
|
|
${
|
|
entry
|
|
? `<dl>
|
|
<div><dt>Attempt</dt><dd>${entry.result.attempt}</dd></div>
|
|
<div><dt>Duration</dt><dd>${html(durationLabel(entry.result.durationMs))}</dd></div>
|
|
<div><dt>Agent runtime</dt><dd>${html(durationLabel(billing!.runtime.agentRunDurationMs))}</dd></div>
|
|
${billing!.runtime.leaseDurationMs === null ? "" : `<div><dt>Environment lease</dt><dd>${html(durationLabel(billing!.runtime.leaseDurationMs))}</dd></div>`}
|
|
<div><dt>Runtime</dt><dd>${html(entry.result.runtimeMode)}</dd></div>
|
|
<div><dt>Provider</dt><dd>${html(entry.result.provider)}</dd></div>
|
|
<div><dt>Model</dt><dd>${html(entry.result.model)}</dd></div>
|
|
${entry.result.issueIdentifier ? `<div><dt>Issue</dt><dd>${html(entry.result.issueIdentifier)}</dd></div>` : ""}
|
|
</dl>`
|
|
: ""
|
|
}
|
|
<p class="detail">${html(detail)}</p>
|
|
${turnTimingRows ? `<div class="matcher-wrap"><table class="matchers"><thead><tr><th>Turn</th><th>Run</th><th>Lease</th><th>Scheduler</th><th>Run duration</th><th>Response</th></tr></thead><tbody>${turnTimingRows}</tbody></table></div>` : ""}
|
|
${matcherRows ? `<div class="matcher-wrap"><table class="matchers"><thead><tr><th>Result</th><th>Matcher</th><th>Expectation</th><th>Detail</th></tr></thead><tbody>${matcherRows}</tbody></table></div>` : `<p class="detail">No matcher result was recorded.</p>`}
|
|
${entry ? `<details class="usage"><summary>Usage and billing metadata</summary><pre>${html(JSON.stringify({ billing, rawUsage: entry.result.usage ?? null }, null, 2))}</pre></details>` : ""}
|
|
${links}
|
|
</details>
|
|
</article>`;
|
|
}
|
|
|
|
function renderTrendChart(input: {
|
|
history: RunnerE2EHistoryIndex;
|
|
label: string;
|
|
value(campaign: RunnerE2EHistoryIndex["campaigns"][number]): number;
|
|
format(value: number): string;
|
|
include?(campaign: RunnerE2EHistoryIndex["campaigns"][number]): boolean;
|
|
fingerprint?(campaign: RunnerE2EHistoryIndex["campaigns"][number]): string;
|
|
}) {
|
|
const campaigns = input.history.campaigns
|
|
.filter(input.include ?? ((campaign) => campaign.complete))
|
|
.slice(0, 20)
|
|
.reverse();
|
|
if (campaigns.length === 0) {
|
|
return `<article class="trend-card"><span>${html(input.label)}</span><strong>No complete campaigns</strong></article>`;
|
|
}
|
|
const values = campaigns.map(input.value);
|
|
const maximum = Math.max(...values, 1);
|
|
const pointRows = values.map((value, index) => {
|
|
const x =
|
|
campaigns.length === 1 ? 50 : (index / (campaigns.length - 1)) * 100;
|
|
const y = 96 - (value / maximum) * 88;
|
|
return {
|
|
point: `${x.toFixed(2)},${y.toFixed(2)}`,
|
|
x,
|
|
y,
|
|
fingerprint: input.fingerprint?.(campaigns[index]!) ?? "stable",
|
|
};
|
|
});
|
|
const segments = pointRows.reduce<Array<typeof pointRows>>(
|
|
(groups, point) => {
|
|
const current = groups.at(-1);
|
|
if (!current || current.at(-1)?.fingerprint !== point.fingerprint) {
|
|
groups.push([point]);
|
|
} else {
|
|
current.push(point);
|
|
}
|
|
return groups;
|
|
},
|
|
[],
|
|
);
|
|
const definitionCount = new Set(pointRows.map((point) => point.fingerprint))
|
|
.size;
|
|
const latest = values.at(-1) ?? 0;
|
|
return `<article class="trend-card">
|
|
<span>${html(input.label)}</span>
|
|
<strong>${html(input.format(latest))}</strong>
|
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" role="img" aria-label="${html(input.label)} across ${campaigns.length} complete campaign(s)">
|
|
${segments.map((segment) => `<polyline points="${segment.map((point) => point.point).join(" ")}" fill="none" vector-effect="non-scaling-stroke" />`).join("")}
|
|
${pointRows.map((point) => `<circle cx="${point.x.toFixed(2)}" cy="${point.y.toFixed(2)}" r="1.3" vector-effect="non-scaling-stroke" />`).join("")}
|
|
</svg>
|
|
<small>${campaigns.length} complete campaign${campaigns.length === 1 ? "" : "s"} · ${definitionCount} definition${definitionCount === 1 ? "" : "s"}</small>
|
|
</article>`;
|
|
}
|
|
|
|
function suiteSummaryFor(
|
|
campaign: RunnerE2EHistoryIndex["campaigns"][number],
|
|
suiteId: string,
|
|
) {
|
|
return campaign.suites.find((suite) => suite.suiteId === suiteId);
|
|
}
|
|
|
|
function renderHistory(history: RunnerE2EHistoryIndex | undefined) {
|
|
if (!history || history.campaigns.length === 0) {
|
|
return `<section class="history-section" id="history"><div class="section-heading"><div><p class="eyebrow">History</p><h2>Campaign trends</h2></div><p>No historical campaigns have been published yet.</p></div></section>`;
|
|
}
|
|
const suiteIds = [
|
|
...new Set(
|
|
history.campaigns.flatMap((campaign) =>
|
|
campaign.suites.map((suite) => suite.suiteId),
|
|
),
|
|
),
|
|
];
|
|
const charts = [
|
|
renderTrendChart({
|
|
history,
|
|
label: "Observed + estimated cost",
|
|
value: (campaign) => campaign.billing.observedAndEstimatedCostUsd,
|
|
format: usdLabel,
|
|
fingerprint: (campaign) =>
|
|
campaign.suites
|
|
.map((suite) => `${suite.suiteId}:${suite.suiteDefinitionHash}`)
|
|
.sort()
|
|
.join("|"),
|
|
}),
|
|
renderTrendChart({
|
|
history,
|
|
label: "Total tokens",
|
|
value: (campaign) => campaign.billing.llm.totalTokens,
|
|
format: tokenLabel,
|
|
fingerprint: (campaign) =>
|
|
campaign.suites
|
|
.map((suite) => `${suite.suiteId}:${suite.suiteDefinitionHash}`)
|
|
.sort()
|
|
.join("|"),
|
|
}),
|
|
renderTrendChart({
|
|
history,
|
|
label: "Agent execution time",
|
|
value: (campaign) => campaign.billing.agentRunDurationMs,
|
|
format: durationLabel,
|
|
fingerprint: (campaign) =>
|
|
campaign.suites
|
|
.map((suite) => `${suite.suiteId}:${suite.suiteDefinitionHash}`)
|
|
.sort()
|
|
.join("|"),
|
|
}),
|
|
renderTrendChart({
|
|
history,
|
|
label: "Daytona lease time",
|
|
value: (campaign) => campaign.billing.leaseDurationMs,
|
|
format: durationLabel,
|
|
fingerprint: (campaign) =>
|
|
campaign.suites
|
|
.map((suite) => `${suite.suiteId}:${suite.suiteDefinitionHash}`)
|
|
.sort()
|
|
.join("|"),
|
|
}),
|
|
renderTrendChart({
|
|
history,
|
|
label: "Pass rate",
|
|
value: (campaign) =>
|
|
campaign.selected > 0 ? (campaign.passed / campaign.selected) * 100 : 0,
|
|
format: (value) => `${value.toFixed(1)}%`,
|
|
fingerprint: (campaign) =>
|
|
campaign.suites
|
|
.map((suite) => `${suite.suiteId}:${suite.suiteDefinitionHash}`)
|
|
.sort()
|
|
.join("|"),
|
|
}),
|
|
].join("");
|
|
const suiteCharts = suiteIds
|
|
.map((suiteId) => {
|
|
const include = (campaign: RunnerE2EHistoryIndex["campaigns"][number]) =>
|
|
suiteSummaryFor(campaign, suiteId)?.complete === true;
|
|
const value = (
|
|
campaign: RunnerE2EHistoryIndex["campaigns"][number],
|
|
metric: "cost" | "tokens" | "agent" | "lease" | "passRate",
|
|
) => {
|
|
const suite = suiteSummaryFor(campaign, suiteId);
|
|
if (!suite) return 0;
|
|
if (metric === "cost") return suite.billing.observedAndEstimatedCostUsd;
|
|
if (metric === "tokens") return suite.billing.llm.totalTokens;
|
|
if (metric === "agent") return suite.billing.agentRunDurationMs;
|
|
if (metric === "lease") return suite.billing.leaseDurationMs;
|
|
return suite.selected > 0 ? (suite.passed / suite.selected) * 100 : 0;
|
|
};
|
|
const fingerprint = (
|
|
campaign: RunnerE2EHistoryIndex["campaigns"][number],
|
|
) => suiteSummaryFor(campaign, suiteId)?.suiteDefinitionHash ?? "unknown";
|
|
return `<section class="suite-trends" data-history-suite-trends="${html(suiteId)}">
|
|
<div class="suite-trends-heading"><h3>${html(suiteId)}</h3><span>Complete suite selections only; lines break at definition changes.</span></div>
|
|
<div class="trend-grid">
|
|
${renderTrendChart({ history, label: "Suite cost", value: (campaign) => value(campaign, "cost"), format: usdLabel, include, fingerprint })}
|
|
${renderTrendChart({ history, label: "Suite tokens", value: (campaign) => value(campaign, "tokens"), format: tokenLabel, include, fingerprint })}
|
|
${renderTrendChart({ history, label: "Suite agent time", value: (campaign) => value(campaign, "agent"), format: durationLabel, include, fingerprint })}
|
|
${renderTrendChart({ history, label: "Suite lease time", value: (campaign) => value(campaign, "lease"), format: durationLabel, include, fingerprint })}
|
|
${renderTrendChart({ history, label: "Suite pass rate", value: (campaign) => value(campaign, "passRate"), format: (metric) => `${metric.toFixed(1)}%`, include, fingerprint })}
|
|
</div>
|
|
</section>`;
|
|
})
|
|
.join("");
|
|
const rows = history.campaigns
|
|
.map((campaign) => {
|
|
const status = campaign.failed === 0 ? "passed" : "failed";
|
|
const sha = campaign.source.sha;
|
|
const searchable = [
|
|
campaign.campaignId,
|
|
sha,
|
|
campaign.source.ref,
|
|
...campaign.executions.flatMap((execution) => [
|
|
execution.suiteId,
|
|
execution.profileId,
|
|
execution.model,
|
|
execution.environmentId,
|
|
execution.caseId,
|
|
execution.status,
|
|
]),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
return `<tr data-history-campaign data-history-date="${html(campaign.generatedAt.slice(0, 10))}" data-history-status="${status}" data-history-complete="${campaign.complete}" data-history-suites="${html(campaign.suites.map((suite) => suite.suiteId).join(" "))}" data-history-search="${html(searchable)}">
|
|
<td><a href="${html(campaign.publicUrl)}">${html(campaign.campaignId)}</a><small>${html(new Date(campaign.generatedAt).toLocaleString("en-US", { timeZone: "UTC" }))} UTC</small></td>
|
|
<td>${sha ? `<code>${html(sha.slice(0, 10))}</code>` : "Unknown"}<small>${html(campaign.source.ref ?? "unknown ref")}</small></td>
|
|
<td><span class="status history-${status}">${status}</span><small>${campaign.passed}/${campaign.passed + campaign.failed} passed · ${campaign.complete ? "complete" : "partial"}</small></td>
|
|
<td>${html(tokenLabel(campaign.billing.llm.inputTokens))} / ${html(tokenLabel(campaign.billing.llm.outputTokens))}<small>input / output · ${html(tokenLabel(campaign.billing.llm.cachedInputTokens))} cached</small></td>
|
|
<td>${html(usdLabel(campaign.billing.reportedLlmCostUsd))}<small>${html(usdLabel(campaign.billing.estimatedRuntimeCostUsd))} runtime estimate</small></td>
|
|
<td>${html(durationLabel(campaign.billing.agentRunDurationMs))}<small>${html(durationLabel(campaign.billing.leaseDurationMs))} lease</small></td>
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
const latest = history.campaigns.find(
|
|
(campaign) => campaign.campaignId === history.latestCampaignId,
|
|
);
|
|
const latestGreen = history.campaigns.find(
|
|
(campaign) => campaign.campaignId === history.latestGreenCampaignId,
|
|
);
|
|
return `<section class="history-section" id="history">
|
|
<div class="section-heading"><div><p class="eyebrow">History</p><h2>Campaign trends</h2></div><p>Complete campaigns are compared by default. Partial smoke runs remain searchable and are labeled explicitly.</p></div>
|
|
<nav class="history-pointers" aria-label="Campaign pointers">
|
|
${latest ? `<a href="${html(latest.publicUrl)}"><span>Latest run</span><strong>${html(latest.campaignId)}</strong></a>` : ""}
|
|
${latestGreen ? `<a href="${html(latestGreen.publicUrl)}"><span>Latest green</span><strong>${html(latestGreen.campaignId)}</strong></a>` : ""}
|
|
</nav>
|
|
<div class="trend-grid">${charts}</div>
|
|
${suiteCharts}
|
|
<div class="history-filters">
|
|
<label>Search <input type="search" data-history-query placeholder="SHA, model, profile, case"></label>
|
|
<label>Suite <select data-history-suite><option value="">All suites</option>${suiteIds.map((suiteId) => `<option value="${html(suiteId)}">${html(suiteId)}</option>`).join("")}</select></label>
|
|
<label>Status <select data-history-status><option value="">All statuses</option><option value="passed">Passed</option><option value="failed">Failed</option></select></label>
|
|
<label>From <input type="date" data-history-from></label>
|
|
<label>Through <input type="date" data-history-through></label>
|
|
<label class="history-checkbox"><input type="checkbox" data-history-partial> Include partial campaigns</label>
|
|
</div>
|
|
<div class="history-table-wrap"><table class="history-table"><thead><tr><th>Campaign</th><th>Paperclip SHA</th><th>Result</th><th>Tokens</th><th>Cost</th><th>Execution</th></tr></thead><tbody>${rows}</tbody></table></div>
|
|
<p class="history-empty" data-history-empty hidden>No campaigns match these filters.</p>
|
|
</section>`;
|
|
}
|
|
|
|
function renderSuiteMatrix(input: {
|
|
suiteCatalog: readonly ReportExecution[];
|
|
expected: ReadonlySet<string>;
|
|
entryById: ReadonlyMap<string, RunnerDashboardEntry>;
|
|
summary?: RunnerE2ESuiteSummary;
|
|
}) {
|
|
const suite = input.suiteCatalog[0]?.suite;
|
|
if (!suite) return "";
|
|
const profiles = [
|
|
...new Map(
|
|
input.suiteCatalog.map((execution) => [
|
|
execution.profile.id,
|
|
execution.profile,
|
|
]),
|
|
).values(),
|
|
];
|
|
const environments = [
|
|
...new Map(
|
|
input.suiteCatalog.map((execution) => [
|
|
execution.environment.id,
|
|
execution.environment,
|
|
]),
|
|
).values(),
|
|
];
|
|
const rows = profiles
|
|
.map((profile, profileIndex) => {
|
|
const columns = environments
|
|
.map((environment) => {
|
|
const executions = input.suiteCatalog.filter(
|
|
(execution) =>
|
|
execution.profile.id === profile.id &&
|
|
execution.environment.id === environment.id,
|
|
);
|
|
return `<td>
|
|
<div class="mobile-environment-header"><strong>${html(environment.label)}</strong><span>${html(environment.provider)} · ${html(environment.expectedExecutionTarget.kind)}</span></div>
|
|
<div class="case-stack">${executions.map((execution) => renderCase(execution, input.expected, input.entryById)).join("")}</div>
|
|
</td>`;
|
|
})
|
|
.join("");
|
|
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>`;
|
|
})
|
|
.join("");
|
|
const environmentHeaders = environments
|
|
.map(
|
|
(environment) =>
|
|
`<th scope="col"><span class="environment-label">${html(environment.label)}</span><small>${html(environment.provider)} · ${html(environment.expectedExecutionTarget.kind)}</small></th>`,
|
|
)
|
|
.join("");
|
|
const selected = input.suiteCatalog.filter((execution) =>
|
|
input.expected.has(execution.id),
|
|
).length;
|
|
const summary = input.summary;
|
|
const summaryHtml = summary
|
|
? `<div class="suite-summary" aria-label="${html(suite.label)} current campaign summary">
|
|
<div><span>Pass rate</span><strong>${summary.selected > 0 ? ((summary.passed / summary.selected) * 100).toFixed(1) : "0.0"}%</strong><small>${summary.passed}/${summary.selected} passed</small></div>
|
|
<div><span>Tokens</span><strong>${html(tokenLabel(summary.billing.llm.totalTokens))}</strong><small>${html(tokenLabel(summary.billing.llm.inputTokens))} input · ${html(tokenLabel(summary.billing.llm.outputTokens))} output</small></div>
|
|
<div><span>Cost</span><strong>${html(usdLabel(summary.billing.observedAndEstimatedCostUsd))}</strong><small>reported LLM + runtime estimate</small></div>
|
|
<div><span>Agent time</span><strong>${html(durationLabel(summary.billing.agentRunDurationMs))}</strong><small>${html(durationLabel(summary.billing.leaseDurationMs))} lease</small></div>
|
|
<div><span>Execution</span><strong>${summary.executed}/${summary.selected}</strong><small>${summary.retries} retries · cleanup ${summary.cleanupPassed ? "passed" : "failed"}</small></div>
|
|
</div>`
|
|
: "";
|
|
return `<section class="suite-section" id="suite-${html(suite.id)}">
|
|
<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"><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>`;
|
|
}
|
|
|
|
export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
|
|
const catalog = discoverReportCatalog({
|
|
catalog: input.catalog,
|
|
expected: input.expected,
|
|
results: input.entries.map((entry) => entry.result),
|
|
});
|
|
const expected = new Set(input.expected);
|
|
const entryById = new Map(
|
|
input.entries.map((entry) => [entry.result.executionId, entry]),
|
|
);
|
|
const selectedEntries = input.entries.filter((entry) =>
|
|
expected.has(entry.result.executionId),
|
|
);
|
|
const passed = selectedEntries.filter((entry) => entry.valid).length;
|
|
const failed = input.expected.length - passed;
|
|
const totalDuration = selectedEntries.reduce(
|
|
(total, entry) => total + entry.result.durationMs,
|
|
0,
|
|
);
|
|
const screenshotCount = selectedEntries.reduce(
|
|
(total, entry) => total + resolveScreenshots(entry).length,
|
|
0,
|
|
);
|
|
const campaignBilling = aggregateCampaignBilling(
|
|
selectedEntries.map((entry) => entry.result),
|
|
);
|
|
const suites = [
|
|
...new Map(
|
|
catalog.map((execution) => [execution.suite.id, execution.suite]),
|
|
).values(),
|
|
];
|
|
const suiteSections = suites
|
|
.map((suite) =>
|
|
renderSuiteMatrix({
|
|
suiteCatalog: catalog.filter(
|
|
(execution) => execution.suite.id === suite.id,
|
|
),
|
|
expected,
|
|
entryById,
|
|
summary: input.campaign?.suites.find(
|
|
(summary) => summary.suiteId === suite.id,
|
|
),
|
|
}),
|
|
)
|
|
.join("");
|
|
const historySection = renderHistory(input.history);
|
|
const publicSummaryImageHref = safePublicAssetHref(
|
|
input.publicSummaryImageHref,
|
|
);
|
|
const filterProfiles = [
|
|
...new Map(
|
|
catalog.map((execution) => [execution.profile.id, execution.profile]),
|
|
).values(),
|
|
];
|
|
const filterEnvironments = [
|
|
...new Map(
|
|
catalog.map((execution) => [
|
|
execution.environment.id,
|
|
execution.environment,
|
|
]),
|
|
).values(),
|
|
];
|
|
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="color-scheme" content="light dark">
|
|
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
|
<meta name="theme-color" content="#141413" media="(prefers-color-scheme: dark)">
|
|
<link rel="icon" href="assets/favicon-32x32.png" type="image/png">
|
|
<title>${html(input.title)} · Paperclip</title>
|
|
<style>
|
|
@font-face { font-family: "Paperclip Inter"; src: url("assets/InterVariable.woff2") format("woff2"); font-style: normal; font-weight: 100 900; font-display: swap; }
|
|
:root {
|
|
color-scheme: light dark;
|
|
--background: #ffffff;
|
|
--foreground: #0a0a0a;
|
|
--card: #ffffff;
|
|
--raised: #fafafa;
|
|
--muted-foreground: #52585d;
|
|
--quiet: #70767a;
|
|
--border: #e5e5e5;
|
|
--border-strong: #a8aeb2;
|
|
--primary: #0a0a0a;
|
|
--primary-foreground: #fafafa;
|
|
--overlay: rgb(10 10 10 / 84%);
|
|
--pass-bg: #dcfce7;
|
|
--pass-text: #188a3c;
|
|
--pass-border: #22c55e;
|
|
--fail-bg: #fee2e2;
|
|
--fail-text: #991b1b;
|
|
--fail-border: #dc2626;
|
|
--missing-bg: #fef3c7;
|
|
--missing-text: #b45309;
|
|
--missing-border: #f59e0b;
|
|
--idle-bg: #f5f3f0;
|
|
--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;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--background: #141413;
|
|
--foreground: #fafafa;
|
|
--card: #1c1c1b;
|
|
--raised: #20201f;
|
|
--muted-foreground: #a3a3a3;
|
|
--quiet: #8c8c89;
|
|
--border: rgb(255 255 255 / 10%);
|
|
--border-strong: rgb(255 255 255 / 24%);
|
|
--primary: #fafafa;
|
|
--primary-foreground: #141413;
|
|
--overlay: rgb(0 0 0 / 88%);
|
|
--pass-bg: #22c55e1f;
|
|
--pass-text: #34d06f;
|
|
--pass-border: #22c55e73;
|
|
--fail-bg: #dc26262e;
|
|
--fail-text: #ef4444;
|
|
--fail-border: #dc262673;
|
|
--missing-bg: #f59e0b24;
|
|
--missing-text: #f59e0b;
|
|
--missing-border: #f59e0b73;
|
|
--idle-bg: #6e696024;
|
|
--idle-text: #9a958a;
|
|
--idle-border: #9e958a73;
|
|
}
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html { scroll-behavior: smooth; }
|
|
body { margin: 0; background: var(--background); color: var(--foreground); font: 14px/1.5 var(--font-sans); font-feature-settings: "ss01", "cv11"; }
|
|
button, summary, a { -webkit-tap-highlight-color: transparent; }
|
|
button, input, textarea, select { font: inherit; }
|
|
a { color: inherit; text-underline-offset: 3px; }
|
|
a:hover { text-decoration-thickness: 2px; }
|
|
.brand-bar { min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 0 max(24px, calc((100vw - 1560px) / 2)); border-bottom: 1px solid var(--border); }
|
|
.brand-lockup { display: inline-flex; align-items: center; gap: 10px; color: var(--foreground); font-size: 16px; font-weight: 650; letter-spacing: -.02em; text-decoration: none; }
|
|
.brand-lockup svg { width: 24px; height: 24px; flex: none; }
|
|
.brand-context { color: var(--muted-foreground); font-size: 12px; }
|
|
main { width: min(1560px, calc(100% - 48px)); margin: 48px auto 72px; }
|
|
.report-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 40px; padding-bottom: 32px; }
|
|
.eyebrow { margin: 0 0 12px; color: var(--muted-foreground); font: 500 11px/1.4 var(--font-mono); letter-spacing: .08em; text-transform: uppercase; }
|
|
h1 { max-width: 760px; margin: 0; font-size: clamp(32px, 4vw, 56px); font-weight: 650; line-height: 1.03; letter-spacing: -.04em; }
|
|
.lede { max-width: 720px; margin: 16px 0 0; color: var(--muted-foreground); font-size: 16px; }
|
|
.report-actions { display: flex; align-items: stretch; gap: 8px; }
|
|
.summary { display: flex; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); }
|
|
.metric { min-width: 108px; padding: 13px 16px; border-right: 1px solid var(--border); }
|
|
.metric:last-child { border-right: 0; }
|
|
.metric strong { display: block; font: 500 19px/1.2 var(--font-mono); font-variant-numeric: tabular-nums; }
|
|
.metric span { display: block; margin-top: 4px; color: var(--muted-foreground); font-size: 11px; letter-spacing: .07em; text-transform: uppercase; }
|
|
.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, .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; 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: 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: 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; }
|
|
.profile-copy small { overflow-wrap: anywhere; }
|
|
.agent-capsule { display: inline-block; width: 10px; height: 22px; border-radius: 999px; }
|
|
.agent-1 { background: linear-gradient(to bottom, #f7cfdc, #1f7a3a); }
|
|
.agent-2 { background: linear-gradient(to bottom, #c9a9e8, #ee79a1); }
|
|
.agent-3 { background: linear-gradient(to bottom, #28164b, #7a1530); }
|
|
.agent-4 { background: linear-gradient(to bottom, #f3e6c4, #e3a21a); }
|
|
.agent-5 { background: linear-gradient(to bottom, #1f4dd6, #3aa35c); }
|
|
.agent-6 { background: linear-gradient(to bottom, #e94b27, #5a1122); }
|
|
.agent-7 { background: linear-gradient(to bottom, #7eb6e3, #ee79a1); }
|
|
.agent-8 { background: linear-gradient(to bottom, #9ce8a7, #bd7ff0); }
|
|
.agent-9 { background: linear-gradient(to bottom, #f3b49e, #1f4ed4); }
|
|
.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; 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; }
|
|
.case-passed .status, .matcher-passed .matcher-state { border-color: var(--pass-border); background: var(--pass-bg); color: var(--pass-text); }
|
|
.case-failed .status, .matcher-failed .matcher-state { border-color: var(--fail-border); background: var(--fail-bg); color: var(--fail-text); }
|
|
.case-missing .status { border-color: var(--missing-border); background: var(--missing-bg); color: var(--missing-text); }
|
|
.case-not-selected { opacity: .68; }
|
|
.case-not-selected .status { border-color: var(--idle-border); background: var(--idle-bg); color: var(--idle-text); }
|
|
code, pre, .execution-id { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
|
.execution-id { display: block; margin: 10px 0; color: var(--muted-foreground); font-size: 11px; overflow-wrap: anywhere; }
|
|
dl { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 12px 0; }
|
|
dl div { display: flex; gap: 6px; }
|
|
dt { color: var(--muted-foreground); }
|
|
dd { margin: 0; font-family: var(--font-mono); font-size: 12px; }
|
|
.detail { margin: 12px 0; color: var(--muted-foreground); font-size: 12px; overflow-wrap: anywhere; }
|
|
.evidence-links { display: flex; flex-wrap: wrap; gap: 12px; padding-top: 4px; }
|
|
.evidence-links a { color: var(--foreground); font-size: 12px; }
|
|
.gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; margin: 14px 0 10px; }
|
|
.screenshot-trigger { min-width: 0; padding: 0; border: 1px solid var(--border); border-radius: calc(var(--radius) * .8); overflow: hidden; background: var(--background); color: var(--foreground); text-align: left; cursor: zoom-in; transition: border-color 150ms ease; }
|
|
.screenshot-trigger:hover { border-color: var(--foreground); }
|
|
.screenshot-frame { display: block; aspect-ratio: 16 / 10; overflow: hidden; border-bottom: 1px solid var(--border); background: #0a0a0a; }
|
|
.screenshot-frame img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: top; }
|
|
.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; }
|
|
.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); }
|
|
.billing-strip > div:last-child { border-right: 0; }
|
|
.billing-strip span, .billing-strip small { display: block; color: var(--muted-foreground); font-size: 9px; }
|
|
.billing-strip span { letter-spacing: .06em; text-transform: uppercase; }
|
|
.billing-strip strong { display: block; margin: 3px 0 1px; overflow-wrap: anywhere; font: 550 11px/1.35 var(--font-mono); font-variant-numeric: tabular-nums; }
|
|
.billing-overview { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0 0 32px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); }
|
|
.billing-metric { min-width: 0; padding: 14px 16px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
|
.billing-metric:nth-child(4n) { border-right: 0; }
|
|
.billing-metric:nth-child(n + 5) { border-bottom: 0; }
|
|
.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 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: 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); }
|
|
.suite-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 0 0 24px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
|
.suite-summary > div { min-width: 0; padding: 12px 14px; border-right: 1px solid var(--border); }
|
|
.suite-summary > div:last-child { border-right: 0; }
|
|
.suite-summary span, .suite-summary strong, .suite-summary small { display: block; }
|
|
.suite-summary span, .suite-summary small { color: var(--muted-foreground); font-size: 9px; }
|
|
.suite-summary span { letter-spacing: .06em; text-transform: uppercase; }
|
|
.suite-summary strong { margin: 3px 0 1px; font: 550 15px/1.3 var(--font-mono); }
|
|
.history-pointers { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
|
|
.history-pointers a { padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); text-decoration: none; }
|
|
.history-pointers span, .history-pointers strong { display: block; }
|
|
.history-pointers span { color: var(--muted-foreground); font-size: 10px; letter-spacing: .06em; text-transform: uppercase; }
|
|
.history-pointers strong { margin-top: 4px; font: 550 12px/1.4 var(--font-mono); overflow-wrap: anywhere; }
|
|
.trend-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
|
.trend-card { min-width: 0; padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card); }
|
|
.trend-card > span, .trend-card > strong, .trend-card > small { display: block; }
|
|
.trend-card > span, .trend-card > small { color: var(--muted-foreground); font-size: 10px; }
|
|
.trend-card > span { letter-spacing: .06em; text-transform: uppercase; }
|
|
.trend-card > strong { margin-top: 4px; font: 550 17px/1.3 var(--font-mono); }
|
|
.trend-card svg { width: 100%; height: 72px; margin: 10px 0 4px; overflow: visible; }
|
|
.trend-card polyline { stroke: var(--foreground); stroke-width: 1.6; }
|
|
.trend-card circle { fill: var(--background); stroke: var(--foreground); stroke-width: 1; }
|
|
.suite-trends { margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border); }
|
|
.suite-trends-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
|
.suite-trends-heading h3 { margin: 0; font: 600 14px/1.4 var(--font-mono); }
|
|
.suite-trends-heading span { color: var(--muted-foreground); font-size: 10px; }
|
|
.history-filters { display: grid; grid-template-columns: minmax(220px, 1fr) repeat(4, minmax(130px, 180px)) auto; align-items: end; gap: 12px; margin: 24px 0 12px; }
|
|
.history-filters label { display: grid; gap: 5px; color: var(--muted-foreground); font-size: 10px; letter-spacing: .05em; text-transform: uppercase; }
|
|
.history-filters input, .history-filters select { 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); }
|
|
.history-filters .history-checkbox { display: flex; align-items: center; min-height: 38px; padding-bottom: 7px; white-space: nowrap; }
|
|
.history-checkbox input { width: 16px; min-height: 16px; }
|
|
.history-table-wrap { overflow-x: auto; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
|
.history-table { width: 100%; min-width: 1020px; border-collapse: collapse; }
|
|
.history-table th, .history-table td { padding: 12px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
|
|
.history-table tr:last-child td { border-bottom: 0; }
|
|
.history-table th { background: var(--raised); color: var(--muted-foreground); font-size: 10px; letter-spacing: .05em; text-transform: uppercase; }
|
|
.history-table td small { display: block; margin-top: 3px; color: var(--muted-foreground); font-size: 10px; }
|
|
.history-table .status { margin-bottom: 2px; }
|
|
.history-passed { border-color: var(--pass-border); background: var(--pass-bg); color: var(--pass-text); }
|
|
.history-failed { border-color: var(--fail-border); background: var(--fail-bg); color: var(--fail-text); }
|
|
.history-empty { padding: 24px 0; color: var(--muted-foreground); text-align: center; }
|
|
.case-context > summary, .usage > summary { width: fit-content; cursor: pointer; color: var(--foreground); font-size: 12px; font-weight: 600; }
|
|
pre { max-height: 240px; overflow: auto; padding: 12px; border: 1px solid var(--border); border-radius: calc(var(--radius) * .8); background: var(--raised); color: var(--foreground); font-size: 10px; white-space: pre-wrap; }
|
|
footer { display: flex; justify-content: space-between; gap: 16px; padding-top: 16px; color: var(--muted-foreground); font: 11px/1.4 var(--font-mono); }
|
|
dialog.gallery-dialog { width: 100vw; max-width: none; height: 100dvh; max-height: none; margin: 0; padding: 0; border: 0; background: transparent; color: #fafafa; }
|
|
dialog.gallery-dialog::backdrop { background: var(--overlay); }
|
|
.gallery-shell { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: 100%; height: 100%; background: var(--overlay); }
|
|
.gallery-toolbar, .gallery-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 24px; border-color: rgb(255 255 255 / 18%); }
|
|
.gallery-toolbar { border-bottom: 1px solid rgb(255 255 255 / 18%); }
|
|
.gallery-footer { border-top: 1px solid rgb(255 255 255 / 18%); }
|
|
.gallery-meta { min-width: 0; }
|
|
.gallery-meta > strong, .gallery-execution { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.gallery-meta > strong { font-size: 14px; }
|
|
.gallery-context { display: flex; flex-wrap: wrap; gap: 4px 18px; margin-top: 5px; }
|
|
.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; }
|
|
.gallery-stage img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; border: 1px solid rgb(255 255 255 / 18%); background: #0a0a0a; }
|
|
.gallery-control { position: absolute; top: 50%; z-index: 2; width: 48px; height: 64px; padding: 0; transform: translateY(-50%); font-size: 22px; }
|
|
.gallery-previous { left: 24px; }
|
|
.gallery-next { right: 24px; }
|
|
.gallery-position { color: #b8b8b5; font: 12px/1.4 var(--font-mono); font-variant-numeric: tabular-nums; }
|
|
@media (max-width: 1180px) {
|
|
.matrix { display: block; min-width: 0; }
|
|
.matrix thead { display: none; }
|
|
.matrix tbody { display: grid; gap: 40px; }
|
|
.matrix tbody, .matrix tr, .matrix th, .matrix td { width: 100%; }
|
|
.matrix tr, .matrix th, .matrix td { display: block; }
|
|
.matrix th, .matrix td { padding: 0; border: 0; }
|
|
.profile-cell { position: static; width: auto; padding: 12px 14px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); background: var(--raised); }
|
|
.profile-sticky { position: static; }
|
|
.matrix td { padding-top: 18px; }
|
|
.mobile-environment-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; padding: 9px 12px; background: var(--raised); }
|
|
.mobile-environment-header strong { font-size: 14px; }
|
|
.mobile-environment-header span { color: var(--muted-foreground); font: 10px/1.4 var(--font-mono); }
|
|
}
|
|
@media (max-width: 980px) {
|
|
main { width: min(100% - 32px, 1560px); margin-top: 32px; }
|
|
.report-header { grid-template-columns: 1fr; gap: 24px; }
|
|
.report-actions { flex-wrap: wrap; }
|
|
.billing-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
.suite-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
.suite-summary > div { border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
|
.suite-summary > div:nth-child(even) { border-right: 0; }
|
|
.suite-summary > div:last-child { border-bottom: 0; }
|
|
.billing-metric, .billing-metric:nth-child(4n) { border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
|
.billing-metric:nth-child(even) { border-right: 0; }
|
|
.billing-metric:nth-child(n + 7) { border-bottom: 0; }
|
|
.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; }
|
|
.brand-context { display: none; }
|
|
main { width: min(100% - 24px, 1560px); margin-top: 28px; }
|
|
.report-header { padding-bottom: 24px; }
|
|
.report-actions { display: grid; }
|
|
.summary { width: 100%; }
|
|
.metric { min-width: 0; flex: 1; padding: 11px; }
|
|
.metric strong { font-size: 16px; }
|
|
.gallery-launch { width: 100%; }
|
|
.billing-strip { grid-template-columns: 1fr; }
|
|
.billing-strip > div { border-right: 0; border-bottom: 1px solid var(--border); }
|
|
.billing-strip > div:last-child { border-bottom: 0; }
|
|
.gallery-toolbar, .gallery-footer { padding: 12px; }
|
|
.gallery-stage { padding: 12px 56px; }
|
|
.gallery-control { width: 40px; height: 56px; }
|
|
.gallery-previous { left: 8px; }
|
|
.gallery-next { right: 8px; }
|
|
.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; }
|
|
.suite-trends-heading { display: block; }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 100ms !important; }
|
|
}
|
|
.public-summary { margin: 0 0 32px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--raised); }
|
|
.public-summary img { display: block; width: 100%; height: auto; border-radius: 6px; }
|
|
@media print {
|
|
.brand-bar, .gallery-launch, dialog { display: none; }
|
|
main { width: 100%; margin: 0; }
|
|
.table-wrap { max-height: none; overflow: visible; }
|
|
.matrix thead th, .profile-cell, .profile-sticky { position: static; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="brand-bar">
|
|
<a class="brand-lockup" href="https://paperclip.ing" aria-label="Paperclip home">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 6-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551"/></svg>
|
|
<span>Paperclip</span>
|
|
</a>
|
|
<span class="brand-context">Quality engineering · Runner acceptance</span>
|
|
</div>
|
|
<main id="overview">
|
|
<header class="report-header">
|
|
<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. Declared PNG screenshots and sanitized structured evidence are retained with every published campaign; additional diagnostic evidence remains in the access-controlled workflow artifact.</p>
|
|
</div>
|
|
<div class="report-actions">
|
|
<div class="summary" aria-label="Campaign summary">
|
|
<div class="metric"><strong>${passed}/${input.expected.length}</strong><span>Passed</span></div>
|
|
<div class="metric"><strong>${failed}</strong><span>Failed</span></div>
|
|
<div class="metric"><strong>${html(durationLabel(totalDuration))}</strong><span>Test time</span></div>
|
|
</div>
|
|
<button class="gallery-launch" type="button" data-gallery-open ${screenshotCount === 0 ? "disabled" : ""}>${screenshotCount === 0 ? "Visual evidence · workflow artifact only" : `View gallery · ${screenshotCount}`}</button>
|
|
</div>
|
|
</header>
|
|
${publicSummaryImageHref ? `<figure class="public-summary"><img src="${html(publicSummaryImageHref)}" alt="Runner E2E campaign status summary"></figure>` : ""}
|
|
<section class="billing-overview" aria-label="Campaign billing summary">
|
|
<div class="billing-metric"><strong>${html(tokenLabel(campaignBilling.llm.inputTokens))}</strong><span>Input tokens</span></div>
|
|
<div class="billing-metric"><strong>${html(tokenLabel(campaignBilling.llm.outputTokens))}</strong><span>Output tokens</span></div>
|
|
<div class="billing-metric"><strong>${html(tokenLabel(campaignBilling.llm.cachedInputTokens))}</strong><span>Cached tokens</span></div>
|
|
<div class="billing-metric"><strong>${html(usdLabel(campaignBilling.reportedLlmCostUsd))}</strong><span>LLM reported subtotal</span></div>
|
|
<div class="billing-metric"><strong>${html(usdLabel(campaignBilling.estimatedRuntimeCostUsd))}</strong><span>Daytona list estimate</span></div>
|
|
<div class="billing-metric"><strong>${html(durationLabel(campaignBilling.agentRunDurationMs))}</strong><span>Agent execution time</span></div>
|
|
<div class="billing-metric"><strong>${html(durationLabel(campaignBilling.leaseDurationMs))}</strong><span>Daytona lease time</span></div>
|
|
<div class="billing-metric"><strong>${campaignBilling.llm.runsWithReportedCost}/${campaignBilling.llm.runCount}</strong><span>Runs provider-priced</span></div>
|
|
<p class="billing-note">Model spend is the provider-reported subtotal; unpriced or unavailable runs are excluded, never counted as free. Daytona runtime is a public-list-price estimate from captured lease time and pinned resources, before credits, discounts, storage allowance, or invoice adjustments. Local execution has no external runtime meter.</p>
|
|
</section>
|
|
<nav class="suite-nav" aria-label="Report sections">
|
|
<a href="#overview">Overview</a>
|
|
${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>${catalog.length} catalog executions · Declared screenshots and sanitized structured evidence published</span></footer>
|
|
</main>
|
|
<dialog class="gallery-dialog" data-gallery-dialog aria-labelledby="gallery-title">
|
|
<div class="gallery-shell">
|
|
<div class="gallery-toolbar">
|
|
<div class="gallery-meta">
|
|
<strong id="gallery-title" data-gallery-title>Screenshot evidence</strong>
|
|
<div class="gallery-context">
|
|
<span><strong data-gallery-profile></strong><em data-gallery-profile-detail></em></span>
|
|
<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>
|
|
</div>
|
|
<div class="gallery-stage" data-gallery-stage>
|
|
<button class="gallery-control gallery-previous" type="button" data-gallery-previous aria-label="Previous">←</button>
|
|
<img data-gallery-image alt="">
|
|
<button class="gallery-control gallery-next" type="button" data-gallery-next aria-label="Next">→</button>
|
|
</div>
|
|
<div class="gallery-footer">
|
|
<span class="gallery-position" data-gallery-position aria-live="polite"></span>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
<script>
|
|
(() => {
|
|
const dialog = document.querySelector("[data-gallery-dialog]");
|
|
const items = Array.from(document.querySelectorAll("[data-gallery-item]"));
|
|
if (!dialog || items.length === 0) return;
|
|
const image = dialog.querySelector("[data-gallery-image]");
|
|
const title = dialog.querySelector("[data-gallery-title]");
|
|
const execution = dialog.querySelector("[data-gallery-execution]");
|
|
const profile = dialog.querySelector("[data-gallery-profile]");
|
|
const profileDetail = dialog.querySelector("[data-gallery-profile-detail]");
|
|
const environment = dialog.querySelector("[data-gallery-environment]");
|
|
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 activeItem = items[0];
|
|
let pointerStartX = null;
|
|
|
|
const render = () => {
|
|
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;
|
|
execution.textContent = item.dataset.galleryExecution;
|
|
profile.textContent = item.dataset.galleryProfile;
|
|
profileDetail.textContent = item.dataset.galleryGeneration + " · " + item.dataset.galleryProvider + " · " + item.dataset.galleryModel;
|
|
environment.textContent = item.dataset.galleryEnvironment;
|
|
environmentDetail.textContent = item.dataset.galleryEnvironmentProvider + " · " + item.dataset.galleryExecutionTarget;
|
|
caseLabel.textContent = item.dataset.galleryCase;
|
|
runtime.textContent = item.dataset.galleryRuntime + " runtime";
|
|
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) => {
|
|
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 = (item) => {
|
|
if (!item) return;
|
|
activeItem = item;
|
|
render();
|
|
if (typeof dialog.showModal === "function") dialog.showModal();
|
|
else dialog.setAttribute("open", "");
|
|
};
|
|
const close = () => {
|
|
if (typeof dialog.close === "function") dialog.close();
|
|
else dialog.removeAttribute("open");
|
|
};
|
|
|
|
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);
|
|
dialog.addEventListener("click", (event) => { if (event.target === dialog) close(); });
|
|
dialog.addEventListener("keydown", (event) => {
|
|
if (event.key === "ArrowLeft") { event.preventDefault(); move(-1); }
|
|
if (event.key === "ArrowRight") { event.preventDefault(); move(1); }
|
|
});
|
|
stage.addEventListener("pointerdown", (event) => { pointerStartX = event.clientX; });
|
|
stage.addEventListener("pointerup", (event) => {
|
|
if (pointerStartX === null) return;
|
|
const distance = event.clientX - pointerStartX;
|
|
pointerStartX = null;
|
|
if (Math.abs(distance) < 60) return;
|
|
move(distance > 0 ? -1 : 1);
|
|
});
|
|
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]"));
|
|
if (rows.length === 0) return;
|
|
const query = document.querySelector("[data-history-query]");
|
|
const suite = document.querySelector("[data-history-suite]");
|
|
const status = document.querySelector("[data-history-status]");
|
|
const from = document.querySelector("[data-history-from]");
|
|
const through = document.querySelector("[data-history-through]");
|
|
const partial = document.querySelector("[data-history-partial]");
|
|
const empty = document.querySelector("[data-history-empty]");
|
|
const suiteTrends = [...document.querySelectorAll("[data-history-suite-trends]")];
|
|
const apply = () => {
|
|
const search = query.value.trim().toLowerCase();
|
|
let visible = 0;
|
|
rows.forEach((row) => {
|
|
const matches =
|
|
(!search || row.dataset.historySearch.includes(search)) &&
|
|
(!suite.value || row.dataset.historySuites.split(" ").includes(suite.value)) &&
|
|
(!status.value || row.dataset.historyStatus === status.value) &&
|
|
(!from.value || row.dataset.historyDate >= from.value) &&
|
|
(!through.value || row.dataset.historyDate <= through.value) &&
|
|
(partial.checked || row.dataset.historyComplete === "true");
|
|
row.hidden = !matches;
|
|
if (matches) visible += 1;
|
|
});
|
|
suiteTrends.forEach((section) => {
|
|
section.hidden = Boolean(suite.value) && section.dataset.historySuiteTrends !== suite.value;
|
|
});
|
|
empty.hidden = visible !== 0;
|
|
};
|
|
query.addEventListener("input", apply);
|
|
suite.addEventListener("change", apply);
|
|
status.addEventListener("change", apply);
|
|
from.addEventListener("change", apply);
|
|
through.addEventListener("change", apply);
|
|
partial.addEventListener("change", apply);
|
|
apply();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`;
|
|
}
|