UNPKG

donobu

Version:

Create browser automations with an LLM agent and replay them as Playwright scripts.

1,160 lines 148 kB
"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 ? '&gt;' : '&nbsp;'; 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, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#039;'); } 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 ? '&#10003;' : '&#10007;'} ${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]} &rarr; ${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: '&#10003;', }, failed: { label: 'Failed', color: '#ef4444', bg: 'rgba(239,68,68,0.08)', icon: '&#10007;', }, healed: { label: 'Healed', color: '#8b5cf6', bg: 'rgba(139,92,246,0.08)', icon: '&#9829;', }, timedOut: { label: 'Timed Out', color: '#f59e0b', bg: 'rgba(245,158,11,0.08)', icon: '&#9202;', }, skipped: { label: 'Skipped', color: '#6b7280', bg: 'rgba(107,114,128,0.08)', icon: '&#9654;', }, interrupted: { label: 'Interrupted', color: '#f97316', bg: 'rgba(249,115,22,0.08)', icon: '&#9889;', }, 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)}">&#9654; ${esc(att.name)}</a>`); } else if (isTrace) { rendered.push(`<a href="${esc(assetHref)}" download class="attachment-link trace-link" title="Download Playwright trace">&#8675; 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">&#10003;</span>' : '<span class="step-status-diverged" title="Cache-verify locator did not match the AI&#39;s visual verdict">&#10073;</span>' : ns.passed ? '<span class="step-status-ok">&#10003;</span>' : '<span class="step-status-fail">&#10007;</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">&#9656;</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">&#10003;</span>' : '<span class="step-status-fail">&#10007;</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">&#9656;</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&rsquo;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 &#8599;</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 ? '&#10003;' : '&#9888;'; 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">&#8212;</span><span class="audit-check-label">${esc(check.label)}</span><span class="audit-check-inline audit-text-skip">&mdash; Skipped</span><span class="audit-chevron">&#9656;</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">&#10003;</span>' : '<span class="audit-icon-fail">&#10007;</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}">&mdash; ${esc(inline)}</span><span class="audit-chevron">&#9656;</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">&#10003;</span>' : '<span class="step-status-fail">&#10007;</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">&#9656;</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