diff --git a/skills/browser-trace/SKILL.md b/skills/browser-trace/SKILL.md index 2ecec6c..6047f81 100644 --- a/skills/browser-trace/SKILL.md +++ b/skills/browser-trace/SKILL.md @@ -125,6 +125,7 @@ The live `debugger_url` in the manifest opens an interactive Chrome DevTools vie ``` .o11y// manifest.json run metadata: target, domains, started_at, stopped_at + report.html standalone HTML report (generated by report.mjs) index.jsonl one line per sample: {ts, screenshot, dom, url} cdp/ raw.ndjson full CDP firehose (one JSON object per line) @@ -226,6 +227,108 @@ ls .o11y//screenshots/ | sort | awk -v t=20260427T1714123NZ ' See **REFERENCE.md** for the full jq recipe library and a method-by-method bisect map. See **EXAMPLES.md** for end-to-end debug scenarios. +## Generate HTML report + +After bisecting a run, generate a standalone HTML report that a reviewer can open in a browser. The report embeds screenshots inline (base64) so it works as a single file — no external dependencies. + +```bash +node scripts/report.mjs # write report.html into the run dir +node scripts/report.mjs --open # write + open in default browser +``` + +The report shows: +- **Summary bar**: page count, total CDP events, network requests, errors, duration +- **Network health bar**: percentage of successful requests (color-coded: green ≥95%, yellow ≥80%, red <80%) +- **Errors section**: all errors across pages (network failures, runtime exceptions, console errors, log errors) — only rendered when errors exist +- **Per-page cards**: each page as an expandable card with domain breakdown chips, network bar, and per-page errors. Pages with errors are open by default; clean pages are collapsed +- **Screenshot timeline**: horizontal scrollable row of all captured screenshots with timestamps, clickable to enlarge in a lightbox + +The report uses the same Browserbase-branded template as other skills (ui-test, etc.) — see [references/report-template.html](references/report-template.html). + +### How to generate + +1. Read the HTML template at [references/report-template.html](references/report-template.html) +2. Build the report by replacing the template placeholders with actual trace data: + +| Placeholder | Value | +|-------------|-------| +| `{{TITLE}}` | Report title for `` tag (e.g., "Browser Trace — a1b2c3d4") | +| `{{TITLE_HTML}}` | Report title for the visible `<h1>`. If a debugger URL is available, wrap the session ID in an `<a>` tag. | +| `{{META}}` | One-line context: timestamp, session ID, target | +| `{{PAGE_COUNT}}` | Number of pages navigated | +| `{{TOTAL_EVENTS}}` | Total CDP events captured | +| `{{TOTAL_REQUESTS}}` | Total network requests across all pages | +| `{{TOTAL_ERRORS}}` | Total errors across all pages (network + console + runtime + log) | +| `{{DURATION}}` | Human-readable duration (e.g., "12.3s" or "2.1m") | +| `{{HEALTH_RATE}}` | Integer percentage of successful network requests | +| `{{HEALTH_CLASS}}` | `good` (≥95%), `warn` (80–94%), `bad` (<80%) | +| `{{ERRORS_SECTION}}` | HTML for aggregated error cards | +| `{{PAGES_SECTION}}` | HTML for per-page detail cards | +| `{{SCREENSHOTS_SECTION}}` | HTML for screenshot timeline (omitted if no screenshots) | + +3. For each page, generate a `<details>` card. Pages with errors are **open by default**: + +```html +<details class="page-card has-errors" open> + <summary> + <span class="badge page-id">#0</span> + <span class="badge error">2 errors</span> + <span class="page-url">https://example.com/</span> + <span class="page-meta">60 events · 5.89s</span> + </summary> + <div class="body"> + <div class="domain-grid"><!-- domain chips --></div> + <!-- network bar + errors --> + </div> +</details> +``` + +4. **Embed screenshots as base64** so the HTML is fully self-contained: + +```bash +base64 -i .o11y/<run>/screenshots/<timestamp>.png | tr -d '\n' +``` + +5. The report is written to `.o11y/<run-id>/report.html`. + +**Rules:** +- Errors section comes before pages — reviewers care about what's broken first +- Pages with errors are `open` by default; clean pages are collapsed +- The report must work offline — no CDN links, no external assets +- Keep the HTML under 5MB — if screenshots push it over, reduce the number included or skip thumbnails + +### End-to-end example + +```bash +# Capture, bisect, and report in one flow +node scripts/start-capture.mjs 9222 my-run +# ...run automation... +node scripts/stop-capture.mjs my-run +node scripts/bisect-cdp.mjs my-run +node scripts/report.mjs my-run --open +``` + +## Teardown on interrupt + +When the user says "that's enough", "stop", "generate report", or otherwise signals they're done with the trace, run the full teardown sequence immediately — don't ask for confirmation: + +```bash +node scripts/stop-capture.mjs <run-id> +node scripts/bisect-cdp.mjs <run-id> +node scripts/report.mjs <run-id> --open +``` + +For Browserbase sessions, add `bb-finalize.mjs` before the report: + +```bash +node scripts/stop-capture.mjs <run-id> +node scripts/bisect-cdp.mjs <run-id> +node scripts/bb-finalize.mjs <run-id> --release # omit --release if you didn't create the session +node scripts/report.mjs <run-id> --open +``` + +The report is the final deliverable — once it opens, the run is done. + ## Best practices 1. **Use `bb-capture.mjs` on Browserbase**: it enforces `--keep-alive`, fetches the connectUrl, captures the debugger URL, and stamps the manifest. Doing it manually invites mistakes. diff --git a/skills/browser-trace/references/report-template.html b/skills/browser-trace/references/report-template.html new file mode 100644 index 0000000..1f97e2f --- /dev/null +++ b/skills/browser-trace/references/report-template.html @@ -0,0 +1,182 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>Browser Trace — {{TITLE}} + + + + + + +
+
+
+

{{TITLE_HTML}}

+
{{META}}
+
+ +
+ +
+
Pages
{{PAGE_COUNT}}
+
Events
{{TOTAL_EVENTS}}
+
Requests
{{TOTAL_REQUESTS}}
+
Errors
{{TOTAL_ERRORS}}
+
Duration
{{DURATION}}
+
+ +
+
+ Network Health + {{HEALTH_RATE}}% +
+
+
+ + + {{ERRORS_SECTION}} + + + {{PAGES_SECTION}} + + + {{SCREENSHOTS_SECTION}} +
+ + + + + + + + diff --git a/skills/browser-trace/scripts/report.mjs b/skills/browser-trace/scripts/report.mjs new file mode 100644 index 0000000..9effa8f --- /dev/null +++ b/skills/browser-trace/scripts/report.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node +// Generate a standalone HTML report from a bisected browser-trace run. +// +// Usage: +// node scripts/report.mjs [--open] +// +// Reads cdp/summary.json, per-page error data, screenshots, and the HTML +// template at references/report-template.html. Writes /report.html. + +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { runDir, readJson, readJsonl } from './lib.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const TEMPLATE_PATH = path.join(__dirname, '..', 'references', 'report-template.html'); + +const args = process.argv.slice(2); +const shouldOpen = args.includes('--open'); +const runId = args.find(a => !a.startsWith('--')); + +if (!runId) { + console.error('usage: report.mjs [--open]'); + process.exit(2); +} + +const RD = runDir(runId); +const cdpDir = path.join(RD, 'cdp'); +const summaryPath = path.join(cdpDir, 'summary.json'); + +if (!fs.existsSync(summaryPath)) { + console.error(`no summary.json at ${summaryPath} — run bisect-cdp.mjs first`); + process.exit(1); +} + +const summary = readJson(summaryPath); +const manifest = readJson(path.join(RD, 'manifest.json'), {}); +let template = fs.readFileSync(TEMPLATE_PATH, 'utf8'); + +// --- Collect errors across all pages --- +function collectErrors(pid) { + const pdir = path.join(cdpDir, 'pages', String(pid).padStart(3, '0')); + const errors = []; + + for (const ev of readJsonl(path.join(pdir, 'network/failed.jsonl'))) { + errors.push({ + kind: 'network.failed', + msg: `${ev?.params?.errorText ?? 'unknown'} — ${ev?.params?.type ?? ''} (reqId: ${ev?.params?.requestId ?? '?'})`, + }); + } + for (const ev of readJsonl(path.join(pdir, 'console/exceptions.jsonl'))) { + const detail = ev?.params?.exceptionDetails; + errors.push({ + kind: 'runtime.exception', + msg: detail?.exception?.description ?? detail?.text ?? 'unknown exception', + }); + } + for (const ev of readJsonl(path.join(pdir, 'console/logs.jsonl'))) { + if (ev?.params?.type !== 'error') continue; + const arg0 = ev?.params?.args?.[0]; + errors.push({ + kind: 'console.error', + msg: arg0?.value ?? arg0?.description ?? '', + }); + } + for (const ev of readJsonl(path.join(pdir, 'log/entries.jsonl'))) { + if (ev?.params?.entry?.level !== 'error') continue; + errors.push({ + kind: 'log.error', + msg: `[${ev?.params?.entry?.source ?? '?'}] ${ev?.params?.entry?.text ?? ''}`, + }); + } + return errors; +} + +// --- Compute totals --- +let totalRequests = 0; +let totalFailed = 0; +let totalErrors = 0; + +const pageData = summary.pages.map(p => { + const errors = collectErrors(p.pageId); + const reqs = p.network?.requests ?? 0; + const failed = p.network?.failed ?? 0; + totalRequests += reqs; + totalFailed += failed; + totalErrors += errors.length; + return { ...p, errors, netRequests: reqs, netFailed: failed }; +}); + +const durationMs = summary.duration?.totalMs; +const durationStr = durationMs != null + ? durationMs >= 60000 + ? `${(durationMs / 60000).toFixed(1)}m` + : `${(durationMs / 1000).toFixed(1)}s` + : '—'; + +const healthRate = totalRequests > 0 + ? Math.round(((totalRequests - totalFailed) / totalRequests) * 100) + : 100; +const healthClass = healthRate >= 95 ? 'good' : healthRate >= 80 ? 'warn' : 'bad'; + +// --- Build meta line --- +const metaParts = []; +if (manifest.started_at) metaParts.push(new Date(manifest.started_at).toISOString().replace('T', ' ').replace(/\.\d+Z/, ' UTC')); +if (manifest.browserbase?.session_id) metaParts.push(`Session: ${manifest.browserbase.session_id.slice(0, 8)}…`); +if (manifest.target) metaParts.push(`Target: ${manifest.target}`); +const meta = metaParts.join(' · ') || `Run: ${runId}`; + +// --- Build title --- +const sessionLabel = manifest.browserbase?.session_id + ? `Session ${manifest.browserbase.session_id.slice(0, 8)}` + : runId; +const title = sessionLabel; +const titleHtml = manifest.browserbase?.debugger_url + ? `${esc(sessionLabel)}` + : esc(sessionLabel); + +// --- Collect screenshots --- +const screenshotsDir = path.join(RD, 'screenshots'); +let screenshotsHtml = ''; +if (fs.existsSync(screenshotsDir)) { + const pngs = fs.readdirSync(screenshotsDir).filter(f => f.endsWith('.png')).sort(); + if (pngs.length > 0) { + const thumbs = pngs.map(f => { + const b64 = fs.readFileSync(path.join(screenshotsDir, f)).toString('base64'); + const ts = f.replace('.png', ''); + return `
${esc(ts)}
${esc(ts)}
`; + }).join('\n '); + + screenshotsHtml = ` +
+

Screenshots ${pngs.length}

+
+ ${thumbs} +
+
`; + } +} + +// --- Build errors section --- +let errorsHtml = ''; +if (totalErrors > 0) { + const allErrors = pageData.flatMap(p => + p.errors.map(e => ({ ...e, pageId: p.pageId, url: p.url })) + ); + const items = allErrors.map(e => ` +
+ ${esc(e.kind)} · Page ${e.pageId} · ${esc(truncate(e.url, 60))} +
${esc(e.msg)}
+
`).join(''); + + errorsHtml = ` +
+

Errors ${totalErrors}

+ ${items} +
`; +} + +// --- Build pages section --- +const pageCards = pageData.map(p => { + const hasErrors = p.errors.length > 0; + const cardClass = hasErrors ? 'has-errors' : 'clean'; + const badgeClass = hasErrors ? 'error' : 'clean'; + const badgeText = hasErrors ? `${p.errors.length} error${p.errors.length > 1 ? 's' : ''}` : 'clean'; + const dur = p.durationMs != null ? `${(p.durationMs / 1000).toFixed(2)}s` : '—'; + + // Domain chips + const domainChips = Object.entries(p.domains || {}).map(([name, d]) => { + let extra = ''; + if (d.errors) extra += `
${d.errors} error${d.errors > 1 ? 's' : ''}
`; + if (d.warnings) extra += `
${d.warnings} warning${d.warnings > 1 ? 's' : ''}
`; + return `
${esc(name)}
${d.count}
${extra}
`; + }).join(''); + + // Network bar + let networkHtml = ''; + if (p.netRequests > 0) { + const okPct = Math.round(((p.netRequests - p.netFailed) / p.netRequests) * 100); + const failPct = 100 - okPct; + const typeLabels = Object.entries(p.network?.byType || {}).map(([t, c]) => `${esc(t)}: ${c}`).join(''); + networkHtml = ` +
+ Network: ${p.netRequests} requests, ${p.netFailed} failed +
+
${typeLabels}
+
`; + } + + // Per-page errors + let pageErrorsHtml = ''; + if (hasErrors) { + const items = p.errors.map(e => + `
${esc(e.kind)}
${esc(e.msg)}
` + ).join(''); + pageErrorsHtml = `
${items}
`; + } + + return ` +
+ + #${p.pageId} + ${badgeText} + ${esc(truncate(p.url, 60))} + ${p.eventCount} events · ${dur} + +
+
${domainChips}
+ ${networkHtml} + ${pageErrorsHtml} +
+
`; +}).join('\n'); + +const pagesHtml = ` +
+

Pages ${pageData.length}

+ ${pageCards} +
`; + +// --- Replace template placeholders --- +template = template + .replace('{{TITLE}}', esc(title)) + .replace('{{TITLE_HTML}}', titleHtml) + .replace('{{META}}', esc(meta)) + .replace('{{PAGE_COUNT}}', String(pageData.length)) + .replace('{{TOTAL_EVENTS}}', String(summary.totalEvents)) + .replace('{{TOTAL_REQUESTS}}', String(totalRequests)) + .replace('{{TOTAL_ERRORS}}', String(totalErrors)) + .replace('{{DURATION}}', durationStr) + .replace('{{HEALTH_RATE}}', String(healthRate)) + .replace(/\{\{HEALTH_RATE\}\}/g, String(healthRate)) + .replace('{{HEALTH_CLASS}}', healthClass) + .replace('{{ERRORS_SECTION}}', errorsHtml) + .replace('{{PAGES_SECTION}}', pagesHtml) + .replace('{{SCREENSHOTS_SECTION}}', screenshotsHtml); + +// --- Write report --- +const outPath = path.join(RD, 'report.html'); +fs.writeFileSync(outPath, template); +console.log(`report written to ${outPath}`); + +if (shouldOpen) { + try { + execSync(`open "${outPath}"`, { stdio: 'ignore' }); + } catch { + console.log('(could not auto-open — open the file manually)'); + } +} + +// --- Helpers --- + +function esc(s) { + if (!s) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function truncate(s, max) { + if (!s || s.length <= max) return s || ''; + return s.slice(0, max - 1) + '…'; +}