donobu
Version:
Create browser automations with an LLM agent and replay them as Playwright scripts.
1,160 lines • 148 kB
JavaScript
"use strict";
/**
* @fileoverview Donobu HTML report renderer.
*
* Pure library that turns a `DonobuReport` (Playwright-JSON superset with
* optional triage data) into a self-contained HTML document. No filesystem
* writes, no CLI arg parsing, no environment variable reads — callers (the
* reporter and the auto-heal orchestrator) own I/O.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadTriageData = loadTriageData;
exports.renderHtml = renderHtml;
exports.renderPerTestStub = renderPerTestStub;
const fs_1 = require("fs");
const path_1 = require("path");
const ansi_1 = require("../utils/ansi");
const MiscUtils_1 = require("../utils/MiscUtils");
const reportWalk_1 = require("./reportWalk");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Convert ANSI SGR color codes to HTML spans. Handles the subset Playwright's
* expect formatter emits: red (31), green (32), dim (2), and resets (0/22/39).
* Text segments are HTML-escaped before wrapping.
*/
function ansiToHtml(str) {
const colorMap = {
'31': 'var(--red)',
'32': 'var(--green)',
'2': 'var(--text-dim)',
};
const resetCodes = new Set(['0', '22', '39', '49', '']);
let html = '';
let openSpans = 0;
// Split on ESC [ ... m sequences; odd indices are the captured code group
const parts = str.split(/\x1b\[([0-9;]*)m/);
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) {
html += esc(parts[i]);
}
else {
const code = parts[i];
if (resetCodes.has(code)) {
if (openSpans > 0) {
html += '</span>'.repeat(openSpans);
openSpans = 0;
}
}
else {
const color = colorMap[code];
if (color) {
html += `<span style="color:${color}">`;
openSpans++;
}
}
}
}
if (openSpans > 0) {
html += '</span>'.repeat(openSpans);
}
return html;
}
/**
* Read a few lines of source around `targetLine` (1-based) and return an HTML
* snippet block, or null if the file is unreadable.
*/
function readSourceSnippet(file, targetLine, wrapperClass = 'native-step-snippet') {
try {
const source = (0, fs_1.readFileSync)(file, 'utf8');
const lines = source.split('\n');
const context = 2;
const start = Math.max(0, targetLine - 1 - context);
const end = Math.min(lines.length - 1, targetLine - 1 + context);
let html = `<div class="${wrapperClass}">`;
for (let i = start; i <= end; i++) {
const lineNum = i + 1;
const isTarget = lineNum === targetLine;
const marker = isTarget ? '>' : ' ';
html += `<div class="snippet-line${isTarget ? ' snippet-line--target' : ''}">`;
html += `<span class="snippet-linenum">${marker} ${String(lineNum).padStart(3)}</span>`;
html += `<span class="snippet-code"> ${esc(lines[i] ?? '')}</span>`;
html += '</div>';
}
html += '</div>';
return html;
}
catch {
return null;
}
}
function esc(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function fmtDuration(ms) {
if (ms < 1000) {
return `${ms}ms`;
}
const s = Math.floor(ms / 1000);
if (s < 60) {
return `${s}s`;
}
return `${Math.floor(s / 60)}m ${s % 60}s`;
}
function fmtPercent(n) {
return `${Math.round(n * 100)}%`;
}
function uid() {
return Math.random().toString(36).slice(2, 10);
}
/** Normalize a file path to just the basename for matching purposes. */
function normalizeTestFile(filePath) {
if (!filePath) {
return '';
}
// Extract just the filename (without directory) so that matching works
// regardless of whether the path is absolute (CI triage files) or relative
// to testDir (Playwright report). E.g. both
// "/home/runner/.../tests/advanced/foo.test.ts"
// "advanced/foo.test.ts"
// normalize to "foo.test.ts".
return (0, path_1.basename)(filePath);
}
function loadTriageData(triageDir) {
const plans = [];
const evidence = [];
if (!(0, fs_1.existsSync)(triageDir)) {
return { plans, evidence };
}
try {
const files = (0, fs_1.readdirSync)(triageDir);
for (const f of files) {
const full = (0, path_1.resolve)(triageDir, f);
try {
const raw = JSON.parse((0, fs_1.readFileSync)(full, 'utf8'));
if (f.startsWith('treatment-plan-') && f.endsWith('.json')) {
plans.push(raw);
}
else if (f.startsWith('failure-evidence-') && f.endsWith('.json')) {
evidence.push(raw);
}
}
catch {
// Skip unparseable files
}
}
}
catch {
// Directory unreadable
}
return { plans, evidence };
}
/**
* Build a lookup key from test metadata for matching triage files to tests.
* Normalizes file paths to handle absolute vs relative mismatches.
*/
function triageKey(file, projectName, title) {
return [normalizeTestFile(file), projectName ?? '', title]
.join('::')
.toLowerCase();
}
function buildTriageLookups(triage) {
const plansByKey = new Map();
const evidenceByKey = new Map();
for (const plan of triage.plans) {
const tc = plan.failure?.testCase;
if (tc) {
plansByKey.set(triageKey(tc.file, tc.projectName, tc.title), plan);
}
}
for (const ev of triage.evidence) {
const tc = ev.failureContext?.testCase;
if (tc) {
evidenceByKey.set(triageKey(tc.file, tc.projectName, tc.title), ev);
}
}
return { plansByKey, evidenceByKey };
}
function parseStderrSteps(stderrEntries) {
const steps = [];
for (const entry of stderrEntries) {
const raw = entry.text?.trim();
if (!raw) {
continue;
}
// Parse timestamp: "HH:MM:SS.mmm [uuid] LEVEL message"
const m = raw.match(/^(\d{2}:\d{2}:\d{2}\.\d{3})\s+\[[^\]]+\]\s+\w+\s+(.+)$/);
if (!m) {
continue;
}
const time = m[1];
const msg = m[2];
// Categorize the log line
if (msg.startsWith('Taking action:')) {
steps.push({
time,
text: msg.replace('Taking action: ', ''),
type: 'action',
});
}
else if (msg.startsWith('The ') && msg.includes(' tool completed in ')) {
// "The assertPage tool completed in 5054ms with outcome {...}"
const toolMatch = msg.match(/^The (\S+) tool completed in (\d+)ms with outcome (.+)$/);
if (toolMatch) {
const outcome = toolMatch[3];
let success = false;
let summary = '';
try {
const parsed = JSON.parse(outcome);
success = parsed.isSuccessful === true;
summary = parsed.forLlm ?? '';
}
catch {
summary = outcome;
}
steps.push({
time,
text: `${toolMatch[1]} (${toolMatch[2]}ms) ${success ? '✓' : '✗'} ${esc(summary)}`,
type: 'result',
});
}
}
else if (msg.startsWith('Transitioned flow state from ')) {
const stateMatch = msg.match(/Transitioned flow state from (\S+) to (\S+)/);
if (stateMatch) {
steps.push({
time,
text: `${stateMatch[1]} → ${stateMatch[2]}`,
type: 'state',
});
}
}
else if (msg.startsWith('Completed flow with state:')) {
steps.push({ time, text: msg, type: 'state' });
}
else if (msg.includes('Persisted Donobu triage') ||
msg.includes('Set up DONOBU client')) {
steps.push({ time, text: msg, type: 'info' });
}
// Skip noisy plugin loading lines, etc.
}
return steps;
}
function extractTests(jsonData) {
const tests = [];
for (const suite of jsonData.suites ?? []) {
for (const spec of (0, reportWalk_1.collectSpecs)(suite)) {
for (const test of spec.tests ?? []) {
const annotations = test.annotations ?? [];
const isSelfHealed = (0, reportWalk_1.isSelfHealed)(test);
let status;
const lastResult = test.results?.at(-1);
if (test.status === 'skipped' ||
(!lastResult && test.status === undefined)) {
status = 'skipped';
}
else if (isSelfHealed) {
status = 'healed';
}
else {
status = lastResult?.status ?? 'unknown';
}
const objectiveAnnotation = annotations.find((a) => a.type === 'objective');
const results = (test.results ?? []).map((r, i) => {
const attachments = r.attachments ?? [];
// Parse step screenshot data from donobu-step-summary attachment
let stepScreenshots = [];
const summaryAtt = attachments.find((a) => a.name === 'donobu-step-summary');
if (summaryAtt?.body) {
try {
const raw = summaryAtt.body;
const decoded = Buffer.from(raw, 'base64').toString('utf8');
const parsed = JSON.parse(decoded);
stepScreenshots = parsed.map((s) => {
// Find the corresponding screenshot attachment
const imgAtt = attachments.find((a) => a.name === `donobu-step-${s.index}-${s.toolName}`);
return {
index: s.index,
toolName: s.toolName,
page: s.page,
startedAt: s.startedAt,
completedAt: s.completedAt,
success: s.success,
summary: s.summary,
imagePath: imgAtt?.path ?? null,
imageBody: imgAtt?.body ?? null,
imageContentType: imgAtt?.contentType ?? null,
parameters: s.parameters ?? undefined,
outcome: s.outcome ?? undefined,
metadata: s.metadata ?? undefined,
};
});
}
catch {
// Ignore parse failures
}
}
// Parse native Playwright steps from donobu-native-steps attachment
let nativeSteps = [];
const nativeAtt = attachments.find((a) => a.name === 'donobu-native-steps');
if (nativeAtt?.body) {
try {
const decoded = Buffer.from(nativeAtt.body, 'base64').toString('utf8');
nativeSteps = JSON.parse(decoded);
}
catch {
// Ignore parse failures
}
}
// Parse AI invocation wrappers from donobu-ai-invocations attachment
let aiInvocations = [];
const aiInvAtt = attachments.find((a) => a.name === 'donobu-ai-invocations');
if (aiInvAtt?.body) {
try {
const decoded = Buffer.from(aiInvAtt.body, 'base64').toString('utf8');
aiInvocations = JSON.parse(decoded);
}
catch {
// Ignore parse failures
}
}
return {
index: i,
status: r.status,
duration: r.duration ?? 0,
retry: r.retry ?? 0,
startTime: r.startTime ?? null,
errors: (r.errors ?? (r.error ? [r.error] : [])).map((e) => ({
message: e.message,
stack: e.stack,
snippet: e.snippet ?? undefined,
actual: e.actual,
expected: e.expected,
location: e.location
? {
file: e.location.file ?? '',
line: e.location.line ?? 0,
column: e.location.column ?? 0,
}
: undefined,
})),
attachments,
steps: parseStderrSteps(r.stderr ?? []),
stepScreenshots,
nativeSteps,
aiInvocations,
};
});
// Extract flow ID from the test-flow-metadata.json attachment
let flowId = null;
const lastResultAtts = test.results?.at(-1)?.attachments;
const flowMetaAtt = lastResultAtts?.find((a) => a.name === 'test-flow-metadata.json');
if (flowMetaAtt?.body) {
try {
const decoded = Buffer.from(flowMetaAtt.body, 'base64').toString('utf8');
flowId = JSON.parse(decoded).id ?? null;
}
catch {
// Ignore parse failures
}
}
tests.push({
file: suite.file,
specTitle: spec.title,
testId: typeof test.testId === 'string' ? test.testId : '',
status,
isSelfHealed,
objective: objectiveAnnotation?.description ?? null,
annotations,
tags: Array.isArray(test.tags) ? test.tags : [],
results,
projectName: test.projectName ?? '',
plan: null,
evidence: null,
flowId,
});
}
}
}
return tests;
}
// ---------------------------------------------------------------------------
// HTML generation
// ---------------------------------------------------------------------------
const STATUS_CFG = {
passed: {
label: 'Passed',
color: '#10b981',
bg: 'rgba(16,185,129,0.08)',
icon: '✓',
},
failed: {
label: 'Failed',
color: '#ef4444',
bg: 'rgba(239,68,68,0.08)',
icon: '✗',
},
healed: {
label: 'Healed',
color: '#8b5cf6',
bg: 'rgba(139,92,246,0.08)',
icon: '♥',
},
timedOut: {
label: 'Timed Out',
color: '#f59e0b',
bg: 'rgba(245,158,11,0.08)',
icon: '⏲',
},
skipped: {
label: 'Skipped',
color: '#6b7280',
bg: 'rgba(107,114,128,0.08)',
icon: '▶',
},
interrupted: {
label: 'Interrupted',
color: '#f97316',
bg: 'rgba(249,115,22,0.08)',
icon: '⚡',
},
unknown: {
label: 'Unknown',
color: '#6b7280',
bg: 'rgba(107,114,128,0.08)',
icon: '?',
},
};
function cfg(status) {
return STATUS_CFG[status] ?? STATUS_CFG['unknown'];
}
const REASON_LABELS = {
SELECTOR_REGRESSION: { label: 'Selector Regression', color: '#f97316' },
TIMING_OR_SYNCHRONISATION: { label: 'Timing Issue', color: '#f59e0b' },
ASSERTION_DRIFT: { label: 'Assertion Drift', color: '#eab308' },
APPLICATION_DEFECT: { label: 'App Defect', color: '#ef4444' },
AUTOMATION_SCRIPT_ISSUE: { label: 'Script Issue', color: '#f97316' },
AUTHENTICATION_FAILURE: { label: 'Auth Failure', color: '#ec4899' },
ENVIRONMENT_CONFIGURATION: { label: 'Env Config', color: '#6366f1' },
TEST_DATA_UNAVAILABLE: { label: 'Test Data', color: '#8b5cf6' },
NETWORK_OR_DEPENDENCY: { label: 'Network/Deps', color: '#06b6d4' },
UNKNOWN: { label: 'Unknown', color: '#6b7280' },
};
function reasonCfg(reason) {
return REASON_LABELS[reason] ?? REASON_LABELS['UNKNOWN'];
}
function renderAttachments(attachments, outputDir, stepScreenshots = []) {
const rendered = [];
for (const att of attachments) {
if (!att.path && !att.body) {
continue;
}
if (att.contentType === 'application/json') {
continue;
}
// Step screenshots are rendered in the filmstrip, not here
if (att.name.startsWith('donobu-step-')) {
continue;
}
const isImage = att.contentType?.startsWith('image/');
const isVideo = att.contentType?.startsWith('video/');
const isTrace = att.name === 'trace' ||
(att.path?.endsWith('.zip') && att.contentType === 'application/zip');
if (att.path) {
// Try to resolve the file — it may be at the original absolute path,
// or it may have been relocated (e.g., CI artifact download) and exist
// as a sibling of the output directory with the same basename structure.
let resolvedPath = (0, path_1.resolve)(att.path);
if (!(0, fs_1.existsSync)(resolvedPath) && outputDir) {
// Try stripping an absolute CI prefix by matching on test-results/
const trMatch = att.path.match(/test-results[/\\].+$/);
if (trMatch) {
const candidate = (0, path_1.resolve)((0, path_1.dirname)(outputDir), trMatch[0]);
if ((0, fs_1.existsSync)(candidate)) {
resolvedPath = candidate;
}
}
}
if (!(0, fs_1.existsSync)(resolvedPath)) {
// Suppress "file not available" for images/video when step screenshots
// can serve as a fallback — avoids confusing noise next to a visible screenshot.
if ((isImage || isVideo) && stepScreenshots.length > 0) {
continue;
}
rendered.push(`<span class="attachment-missing">${esc(att.name)} (file not available)</span>`);
continue;
}
const assetHref = outputDir
? (0, path_1.relative)(outputDir, resolvedPath)
: resolvedPath;
if (isImage) {
const imgLabel = att.name === 'screenshot'
? 'Screenshot at test completion'
: att.name;
rendered.push(`<div class="img-wrapper"><a href="${esc(assetHref)}" target="_blank" class="attachment-link img-link" title="${esc(imgLabel)}"><img src="${esc(assetHref)}" alt="${esc(imgLabel)}" loading="lazy" class="screenshot" /></a><span class="img-label">${esc(imgLabel)}</span></div>`);
}
else if (isVideo) {
rendered.push(`<a href="${esc(assetHref)}" target="_blank" class="attachment-link video-link" title="${esc(att.name)}">▶ ${esc(att.name)}</a>`);
}
else if (isTrace) {
rendered.push(`<a href="${esc(assetHref)}" download class="attachment-link trace-link" title="Download Playwright trace">⇣ Trace</a>`);
}
else if (att.contentType === 'text/markdown') {
// Read and render markdown content inline (e.g., error-context.md page snapshots)
try {
const mdContent = (0, fs_1.readFileSync)(resolvedPath, 'utf8');
rendered.push(`<details class="page-snapshot"><summary>${esc(att.name)}</summary><pre class="snapshot-block">${esc(mdContent)}</pre></details>`);
}
catch {
rendered.push(`<a href="${esc(assetHref)}" target="_blank" class="attachment-link" title="${esc(att.name)}">${esc(att.name)}</a>`);
}
}
else {
rendered.push(`<a href="${esc(assetHref)}" target="_blank" class="attachment-link" title="${esc(att.name)}">${esc(att.name)}</a>`);
}
}
else if (att.body && isImage) {
rendered.push(`<img src="data:${att.contentType};base64,${att.body}" alt="${esc(att.name)}" loading="lazy" class="screenshot" />`);
}
}
// If no top-level screenshot was rendered, fall back to the last step screenshot
const hasScreenshot = rendered.some((r) => r.includes('class="screenshot"'));
if (!hasScreenshot && stepScreenshots.length > 0) {
const lastStep = stepScreenshots[stepScreenshots.length - 1];
const imgSrc = resolveStepImageSrc(lastStep, outputDir);
if (imgSrc) {
const imgLabel = 'Screenshot at test completion';
rendered.push(`<div class="img-wrapper"><img src="${esc(imgSrc)}" alt="${esc(imgLabel)}" loading="lazy" class="screenshot" /><span class="img-label">${esc(imgLabel)} (from last step)</span></div>`);
}
}
if (!rendered.length) {
return '';
}
// Separate images from other attachments for better layout
const images = [];
const others = [];
for (const r of rendered) {
if (r.includes('class="screenshot"')) {
images.push(r);
}
else {
others.push(r);
}
}
let html = '<div class="attachments-group">';
if (images.length) {
html += `<div class="attachments-images">${images.join('')}</div>`;
}
if (others.length) {
html += `<div class="attachments-meta">${others.join('')}</div>`;
}
html += '</div>';
return html;
}
function renderErrors(errors) {
if (!errors.length) {
return '';
}
let html = '';
for (const err of errors) {
if (err.message) {
// Use a neutral container so ANSI-derived colors (green Expected, red
// Received, dim call log) render correctly without a red tint fighting them.
html += `<pre class="error-block-ansi">${ansiToHtml(err.message)}</pre>`;
}
if (err.snippet) {
// Playwright's pre-formatted snippet (pipe/arrow markers) — convert ANSI
// highlight codes to HTML spans so the failing line shows in color.
html += `<pre class="snippet-block">${ansiToHtml(err.snippet)}</pre>`;
}
if (err.stack && err.stack !== err.message) {
html += `<details class="stack-details"><summary>Stack trace</summary><pre class="stack-block">${esc((0, ansi_1.stripAnsi)(err.stack))}</pre></details>`;
}
}
return html;
}
function renderNativeStep(ns, childrenHtml, verifyContext = false) {
// Expects inside an assert tool's cache-worthiness verification window are
// not real assertion checks — they're AssertTool re-running its own
// AI-emitted structured `expect()` calls to decide whether to cache them.
// When one fails, the AI's screenshot-based verdict still stands; only the
// structured locator faithfulness is in question. Render those with a
// distinct status (passed → "verified", failed → "diverged") so they
// don't look like assertion failures sitting under a passing assertion.
const statusIcon = verifyContext
? ns.passed
? '<span class="step-status-verified" title="Cache-verify check passed">✓</span>'
: '<span class="step-status-diverged" title="Cache-verify locator did not match the AI's visual verdict">❙</span>'
: ns.passed
? '<span class="step-status-ok">✓</span>'
: '<span class="step-status-fail">✗</span>';
const categoryLabel = verifyContext
? ns.passed
? 'verify-cache'
: 'verify-cache diverged'
: ns.category;
const categoryClass = verifyContext
? ns.passed
? 'native-step-badge--verify'
: 'native-step-badge--verify-diverged'
: `native-step-badge--${ns.category}`;
const categoryBadge = `<span class="native-step-badge ${categoryClass}">${esc(categoryLabel)}</span>`;
const locationStr = ns.location?.file
? esc(`${ns.location.file.replace(/.*[/\\]/, '')}:${ns.location.line}`)
: '';
const snippet = ns.location?.file
? readSourceSnippet(ns.location.file, ns.location.line)
: null;
// Cache-verify failures aren't surfaced as red errors; the message lives
// alongside the parent invocation's `cache · miss` pill instead. We still
// want the body open so the locator's call log is visible at a glance.
const hasError = !ns.passed && !!ns.error?.message && !verifyContext;
const hasBody = !!snippet || hasError || !!childrenHtml;
const renderHeader = (tag) => {
let header = `<${tag} class="filmstrip-header">`;
header +=
'<span class="native-step-chevron" aria-hidden="true">▸</span>';
header += statusIcon;
header += `<span class="native-step-title">${esc(ns.title)}</span>`;
header += categoryBadge;
if (locationStr) {
header += `<span class="native-step-location">${locationStr}</span>`;
}
header += `</${tag}>`;
return header;
};
if (!hasBody) {
return `<div class="filmstrip-step native-step">${renderHeader('div')}</div>`;
}
// Failures always render expanded so the error is immediately visible.
// test.step blocks with nested content also default open so users see
// what's inside; bare passing expects with just a snippet collapse to
// keep tests with many assertions scannable. Cache-verify divergences
// are routine signal — start collapsed so they don't dominate the view.
const defaultOpen = !verifyContext &&
(!ns.passed || (ns.category === 'test.step' && !!childrenHtml));
const passClass = verifyContext
? ns.passed
? 'native-step--verify'
: 'native-step--verify-diverged'
: ns.passed
? 'native-step--passed'
: 'native-step--failed';
let html = `<details class="filmstrip-step native-step expandable ${passClass}"${defaultOpen ? ' open' : ''}>`;
html += renderHeader('summary');
if (hasError) {
html += `<pre class="native-step-error">${ansiToHtml(ns.error.message)}</pre>`;
}
if (snippet) {
html += snippet;
}
if (childrenHtml) {
html += childrenHtml;
}
html += `</details>`;
return html;
}
const AI_KIND_LABELS = {
act: 'page.ai',
assert: 'page.ai.assert',
locate: 'page.ai.locate',
};
/**
* Render a single structured assertion step back as the Playwright source
* line that effectively executes — e.g. `expect(page.getByRole('heading',
* { name: 'Create an account' })).toBeVisible()`. Used to surface in the
* report what a cached `page.ai.assert` actually checked.
*/
function formatAssertionStep(step) {
const quote = (s) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
const matcher = step.valueIsRegex ? `/${step.value}/` : quote(step.value);
// Page-level assertions (no element locator)
if (step.locator === null) {
return `expect(page).${step.assertion}(${matcher})`;
}
let locatorExpr;
if (step.locator === 'role' && step.role) {
locatorExpr = `page.getByRole(${quote(step.role)}, { name: ${matcher} })`;
}
else if (step.locator === 'label') {
locatorExpr = `page.getByLabel(${matcher})`;
}
else {
locatorExpr = `page.getByText(${matcher})`;
}
locatorExpr += '.first()';
const attrValue = step.attributeValue ?? '';
switch (step.assertion) {
case 'toBeVisible':
case 'toBeEnabled':
case 'toBeDisabled':
case 'toBeChecked':
return `expect(${locatorExpr}).${step.assertion}()`;
case 'toBeHidden':
// Executor uses `not.toBeVisible()` for `toBeHidden`; mirror that here.
return `expect(${locatorExpr}).not.toBeVisible()`;
case 'toHaveValue':
case 'toContainText':
return `expect(${locatorExpr}).${step.assertion}(${quote(attrValue)})`;
case 'toHaveAttribute':
return `expect(${locatorExpr}).toHaveAttribute(${quote(step.value)}, ${quote(attrValue)})`;
default:
return `expect(${locatorExpr}).${step.assertion}(${matcher})`;
}
}
function renderAiInvocation(inv, childrenHtml) {
const statusIcon = inv.passed
? '<span class="step-status-ok">✓</span>'
: '<span class="step-status-fail">✗</span>';
const kindBadge = `<span class="ai-invocation-badge ai-invocation-badge--${inv.kind}">${esc(AI_KIND_LABELS[inv.kind])}</span>`;
const cacheState = inv.cacheHit
? 'hit'
: inv.cacheStored
? 'stored'
: 'miss';
const cacheLabel = {
hit: 'cache · hit',
stored: 'cache · stored',
miss: 'cache · miss',
};
const cacheTitle = {
hit: 'Replayed from the page-AI cache; no AI used for this step.',
stored: 'Live AI run; the resulting locators/steps were recorded to the page-AI cache. The next run can replay them without calling the AI.',
miss: "Live AI run; nothing was recorded to the page-AI cache. The next run will hit the AI again. For asserts, this typically means the AI's structured Playwright locators didn't reproduce its screenshot verdict.",
};
const cacheBadge = `<span class="ai-cache-badge ai-cache-badge--${cacheState}" title="${esc(cacheTitle[cacheState])}">${cacheLabel[cacheState]}</span>`;
// For a passing assert whose structured-step verifier failed, surface
// *why* the cache outcome was `miss`. The header pill carries the
// at-a-glance signal; this body content is the technical detail.
// (When the assert itself failed, the regular failure path already
// covers it.)
const showVerifierDetail = inv.passed && inv.verification?.failed === true;
const hasError = !inv.passed && !!inv.error?.message;
const hasAssertSteps = !!inv.assertSteps && inv.assertSteps.length > 0;
const hasBody = hasError || !!childrenHtml || hasAssertSteps || showVerifierDetail;
const renderHeader = (tag) => {
let header = `<${tag} class="filmstrip-header">`;
header +=
'<span class="native-step-chevron" aria-hidden="true">▸</span>';
header += statusIcon;
header += `<span class="ai-invocation-title">${esc(inv.description)}</span>`;
header += kindBadge;
header += cacheBadge;
header += `</${tag}>`;
return header;
};
if (!hasBody) {
// Leaf row — no children, no error. Common for `page.ai.locate` cache
// hits and for any other invocation whose internal work didn't surface
// any captured tool calls or native steps.
return `<div class="filmstrip-step ai-invocation">${renderHeader('div')}</div>`;
}
// Failures always render expanded; passing wrappers with children open
// by default so the contents are visible without an extra click.
const defaultOpen = !inv.passed || !!childrenHtml || hasAssertSteps;
const passClass = inv.passed
? showVerifierDetail
? 'ai-invocation--passed ai-invocation--cache-miss'
: 'ai-invocation--passed'
: 'ai-invocation--failed';
let html = `<details class="filmstrip-step ai-invocation expandable ${passClass}"${defaultOpen ? ' open' : ''}>`;
html += renderHeader('summary');
if (hasError) {
html += `<pre class="native-step-error">${ansiToHtml(inv.error.message)}</pre>`;
}
if (showVerifierDetail && inv.verification?.errorMessage) {
html +=
`<div class="ai-cache-miss-explainer">The AI’s screenshot verdict (passed) is what counts. Its structured Playwright steps did not reproduce that verdict against the live page — most often an over-broad locator — so they were not cached. The diverging check is highlighted below.</div>` +
`<pre class="ai-cache-miss-detail">${ansiToHtml(inv.verification.errorMessage)}</pre>`;
}
if (hasAssertSteps) {
const lines = inv
.assertSteps.map((s) => esc(formatAssertionStep(s)))
.join('\n');
html += `<pre class="ai-assert-steps">${lines}</pre>`;
}
if (childrenHtml) {
html += childrenHtml;
}
html += `</details>`;
return html;
}
const AUDIT_CHECK_DEFS = [
{
key: 'pageLoad',
optionsKey: 'pageLoad',
label: 'Page Load',
description: 'Uses AI to verify the page has fully loaded by checking for the absence of loading spinners, skeleton screens, and placeholder content.',
passedSummary: 'Page fully loaded',
getFailSummary: (s) => s.error ?? 'Page did not fully load',
},
{
key: 'accessibility',
optionsKey: 'accessibility',
label: 'Accessibility',
description: 'Runs an axe-core accessibility scan and reports critical violations that affect usability for people using assistive technologies.',
passedSummary: 'No critical violations',
getFailSummary: (s) => `${s.violations?.length ?? 0} critical violation(s)`,
renderDetail: (s) => {
if (!s.violations?.length) {
return '';
}
let html = '';
for (const v of s.violations) {
const nodeCount = v.nodes?.length ?? 0;
html += '<div class="audit-a11y-violation">';
html += '<div class="audit-a11y-header">';
html += `<code>${esc(v.id ?? '')}</code> `;
html += `<span>${esc(v.help ?? v.description ?? '')}</span>`;
if (v.helpUrl) {
html += ` <a href="${esc(v.helpUrl)}" target="_blank" rel="noopener" class="audit-a11y-link">Learn more ↗</a>`;
}
html += '</div>';
if (nodeCount > 0) {
const maxNodes = 5;
const shown = v.nodes.slice(0, maxNodes);
for (const node of shown) {
if (node.html) {
html += `<pre class="audit-a11y-snippet">${esc(node.html)}</pre>`;
}
}
if (nodeCount > maxNodes) {
html += `<div class="audit-a11y-more">+ ${nodeCount - maxNodes} more element(s)</div>`;
}
}
html += '</div>';
}
return html;
},
},
{
key: 'uniqueIds',
optionsKey: 'uniqueIds',
label: 'Unique IDs',
description: 'Verifies that every DOM element with an id attribute has a value that is unique across the page.',
passedSummary: 'All IDs are unique',
getFailSummary: (s) => `${s.duplicates?.length ?? 0} duplicate(s)`,
renderDetail: (s) => {
if (!s.duplicates?.length) {
return '';
}
return s.duplicates
.map((d) => `<div class="audit-detail-row"><code>#${esc(d.id)}</code><span>${d.count} occurrences</span></div>`)
.join('');
},
},
{
key: 'uniqueTestIds',
optionsKey: 'uniqueTestIds',
label: 'Unique Test IDs',
description: 'Checks that test selector attributes (data-testid and common variants) are unique.',
passedSummary: 'All test IDs are unique',
getFailSummary: (s) => `${s.duplicates?.length ?? 0} duplicate(s)`,
renderDetail: (s) => {
if (!s.duplicates?.length) {
return '';
}
return s.duplicates
.map((d) => `<div class="audit-detail-row"><code>${esc(d.attribute)}="${esc(d.value)}"</code><span>${d.count} occurrences</span></div>`)
.join('');
},
},
{
key: 'consoleErrors',
optionsKey: 'consoleErrors',
label: 'Console Errors',
description: 'Captures JavaScript console.error calls and uncaught exceptions during page load.',
passedSummary: 'No errors',
getFailSummary: (s) => `${s.errors?.length ?? 0} error(s)`,
renderDetail: (s) => {
if (!s.errors?.length) {
return '';
}
return s.errors
.map((e) => `<div class="audit-detail-row"><span class="audit-error-msg">${esc(e.message)}</span>${e.source ? `<span class="audit-error-src">${esc(e.source)}</span>` : ''}</div>`)
.join('');
},
},
{
key: 'networkErrors',
optionsKey: 'networkErrors',
label: 'Network Errors',
description: 'Detects failed network requests during page load, including HTTP 4xx/5xx responses and request failures.',
passedSummary: 'No failures',
getFailSummary: (s) => `${s.errors?.length ?? 0} failure(s)`,
renderDetail: (s) => {
if (!s.errors?.length) {
return '';
}
return s.errors
.map((e) => `<div class="audit-detail-row">${e.statusCode ? `<code>${e.statusCode}</code>` : ''}${e.method ? `<span class="audit-method">${esc(e.method)}</span>` : ''}<span class="audit-url">${esc(e.url)}</span>${e.failureReason ? `<span class="audit-error-src">(${esc(e.failureReason)})</span>` : ''}</div>`)
.join('');
},
},
];
function isAuditCheckEnabled(options, optionsKey) {
if (!options) {
return true;
}
const opt = options[optionsKey];
if (opt === undefined) {
return true;
}
if (typeof opt === 'object' && opt !== null) {
return opt.enabled !== false;
}
return true;
}
function renderAuditReport(metadata) {
const report = metadata;
const options = report.options ?? {};
const checks = AUDIT_CHECK_DEFS.map((def) => ({
...def,
enabled: isAuditCheckEnabled(options, def.optionsKey),
section: report[def.key] ?? {
passed: false,
violations: [],
duplicates: [],
errors: [],
},
}));
const enabled = checks.filter((c) => c.enabled);
const passedCount = enabled.filter((c) => c.section.passed).length;
const allPassed = passedCount === enabled.length;
let html = '<div class="audit-report">';
// Summary
const summaryIcon = allPassed ? '✓' : '⚠';
const summaryClass = allPassed ? 'audit-summary-pass' : 'audit-summary-fail';
html += `<div class="${summaryClass}">${summaryIcon} ${passedCount} of ${enabled.length} checks passed</div>`;
// Check rows
html += '<div class="audit-checks">';
for (const check of checks) {
if (!check.enabled) {
html += `<div class="audit-check audit-check-skip expandable">`;
html += `<div class="audit-check-header"><span class="audit-icon-skip">—</span><span class="audit-check-label">${esc(check.label)}</span><span class="audit-check-inline audit-text-skip">— Skipped</span><span class="audit-chevron">▸</span></div>`;
html += `<div class="audit-check-desc">${esc(check.description)}</div>`;
html += `</div>`;
continue;
}
const passed = check.section.passed;
const cls = passed ? 'audit-check-pass' : 'audit-check-fail';
const icon = passed
? '<span class="audit-icon-pass">✓</span>'
: '<span class="audit-icon-fail">✗</span>';
const inline = passed
? check.passedSummary
: check.getFailSummary(check.section);
const inlineClass = passed ? 'audit-text-pass' : 'audit-text-fail';
const detail = !passed && check.renderDetail ? check.renderDetail(check.section) : '';
html += `<div class="audit-check ${cls} expandable">`;
html += `<div class="audit-check-header">${icon}<span class="audit-check-label">${esc(check.label)}</span><span class="audit-check-inline ${inlineClass}">— ${esc(inline)}</span><span class="audit-chevron">▸</span></div>`;
html += `<div class="audit-check-desc">${esc(check.description)}</div>`;
if (detail) {
html += `<div class="audit-check-detail">${detail}</div>`;
}
html += `</div>`;
}
html += '</div></div>';
return html;
}
function resolveStepImageSrc(ss, outputDir) {
if (ss.imagePath && (0, fs_1.existsSync)(ss.imagePath)) {
return outputDir ? (0, path_1.relative)(outputDir, ss.imagePath) : ss.imagePath;
}
if (ss.imageBody && ss.imageContentType) {
return `data:${ss.imageContentType};base64,${ss.imageBody}`;
}
return null;
}
function renderFilmstripStep(ss, outputDir) {
const duration = ss.completedAt - ss.startedAt;
const durationStr = duration >= 1000 ? `${(duration / 1000).toFixed(1)}s` : `${duration}ms`;
const statusIcon = ss.success
? '<span class="step-status-ok">✓</span>'
: '<span class="step-status-fail">✗</span>';
const imgSrc = resolveStepImageSrc(ss, outputDir);
// For the audit tool, render a structured report instead of raw JSON.
const isAudit = ss.toolName === 'audit' && ss.metadata && 'pageLoad' in ss.metadata;
let detailBlock = '';
if (isAudit) {
detailBlock = renderAuditReport(ss.metadata);
}
else {
const hasDetail = !!(ss.parameters || ss.outcome);
if (hasDetail) {
const jsonObj = {
toolName: ss.toolName,
page: ss.page,
};
if (ss.parameters) {
jsonObj.parameters = ss.parameters;
}
if (ss.outcome) {
jsonObj.outcome = ss.outcome;
}
const jsonStr = JSON.stringify(jsonObj, null, 2);
detailBlock = `<div class="step-json-wrap"><button class="copy-json" title="Copy JSON"><svg viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg></button><pre class="step-json">${esc(jsonStr)}</pre></div>`;
}
}
const hasExpandable = !!(imgSrc || detailBlock);
const expandId = `step-expand-${uid()}`;
let html = `<div class="filmstrip-step${hasExpandable ? ' expandable' : ''}">`;
html += `<div class="filmstrip-header">`;
html += `<span class="filmstrip-chevron" aria-hidden="true">▸</span>`;
html += statusIcon;
html += `<span class="filmstrip-tool">${esc(ss.toolName)}</span>`;
html += `<span class="filmstrip-duration">${durationStr}</span>`;
html += `</div>`;
if (ss.summary) {
html += `<div class="filmstrip-summary">${esc(ss.summary)}</div>`;
}
if (hasExpandable) {
html += `<div class="filmstrip-detail" id="${expandId}">`;
if (imgSrc) {
html += `<a href="${esc(imgSrc)}" target="_blank"><img src="${esc(imgSrc)}" alt="Step ${ss.index}: ${esc(ss.toolName)}" loading="lazy" class="step-screenshot" /></a>`;
}
html += detailBlock;
html += `</div>`;
}
html += `</div>`;
return html;
}
function renderSteps(steps, stepScreenshots, nativeSteps, aiInvocations, outputDir) {
const meaningful = steps.filter((s) => s.type === 'action' || s.type === 'result');
const hasScreenshots = stepScreenshots.length > 0;
const hasNative = nativeSteps.length > 0;
const hasAi = aiInvocations.length > 0;
if (!meaningful.length && !hasScreenshots && !hasNative && !hasAi) {
return '';
}
if (hasScreenshots || hasNative || hasAi) {
const buildNativeTree = (nss) => nss.map((ns) => ({
kind: 'native',
ns,
t: ns.startWallTime,
tEnd: ns.endWallTime,
children: buildNativeTree(ns.children),
}));
let roots = buildNativeTree(nativeSteps);
// Place a leaf into the deepest container whose [t, tEnd] window
// contains its [tStart, tEnd]. Used for Donobu screenshots, which can
// never gain new children, so no absorption is needed.
const placeLeaf = (nodes, leaf, tStart, tEnd) => {
for (const n of nodes) {
if (n.kind !== 'native' && n.kind !== 'ai') {
continue;
}
if (tStart >= n.t && tEnd <= n.tEnd) {
if (!placeLeaf(n.children, leaf, tStart, tEnd)) {
n.children.push(leaf);
}
return true;
}
}
return false;
};
// Place a container into the tree at the deepest level whose window
// contains it. Once placed, absorb any pre-existing siblings whose
// windows fall fully inside the container's window — this matters when
// a cached `page.ai` runs raw `expect(...)` calls that Playwright's
// `_steps` recorded as bare native steps at the parent level. Without
// absorption they'd render as siblings of the wrapper instead of
// children. Returns the (possibly rebuilt) `level` array.
const placeContainer = (level, container) => {
for (const n of level) {
if ((n.kind === 'native' || n.kind === 'ai') &&
container.t >= n.t &&
container.tEnd <= n.tEnd) {
n.children = placeContainer(n.children, container);
return level;
}
}
const remaining = [];
for (const child of level) {
if (child.t >= container.t && child.tEnd <= container.tEnd) {
container.children.push(child);
}
else {
remaining.push(child);
}
}
remaining.push(container);
return remaining;
};
// AI invocations placed first, longer-window first so an outer cached
// `page.ai` is in place before its inner `page.ai.assert` lands.
const sortedInvocations = [...aiInvocations].sort((a, b) => b.endedAt - b.startedAt - (a.endedAt - a.startedAt));
for (const inv of sortedInvocations) {
const node = {
kind: 'ai',
inv,
t: inv.startedAt,
tEnd: inv.endedAt,
children: [],
};
roots = placeContainer(roots, node);
}
for (const ss of stepScreenshots) {
const d = {
kind: 'donobu',
ss,
t: ss.startedAt,
tEnd: ss.completedAt,
};
if (!placeLeaf(roots, d, ss.startedAt, ss.completedAt)) {
roots.push(d);
}
}
const sortTree = (nodes) => {
nodes.sort((a, b) => a.t - b.t);
for (const n of nodes) {
if (n.kind === 'native' || n.kind === 'ai') {
sortTree(n.children);
}
}
};
sortTree(roots);
const countNodes = (nodes) => {
let c = 0;
for (const n of nodes) {
c += 1;
if (n.kind === 'native' || n.kind === 'ai') {
c += countNodes(n.children);
}
}
return c;
};
// A native step is part of an AssertTool cache-worthiness verification
// (rather than a user-authored assertion) iff its time window falls
// inside the `verification` window of some enclosing AI invocation.
// `verifyWindows` is the ordered list of those windows; `inVerify`
// checks membership without scanning the tree.
const verifyWindows = [];
for (const inv of aiInvocations) {
if (inv.verification) {
verifyWindows.push({
start: inv.verification.startedAt,
end: inv.verification.endedAt,
});
}
}
const inVerify = (t, tEnd) => {
for (const w of verifyWindows) {
if (t >= w.start && tEnd <= w.end) {
return true;
}
}
return false;
};
const renderNode = (node) => {
if (node.kind === 'donobu') {
return renderFilmstripStep(node.ss, outputDir);
}
if (node.kind === 'ai') {
con