Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions skills/browser-trace/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ The live `debugger_url` in the manifest opens an interactive Chrome DevTools vie
```
.o11y/<run-id>/
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)
Expand Down Expand Up @@ -226,6 +227,108 @@ ls .o11y/<run>/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 <run-id> # write report.html into the run dir
node scripts/report.mjs <run-id> --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 `<title>` 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.
Expand Down
182 changes: 182 additions & 0 deletions skills/browser-trace/references/report-template.html
Original file line number Diff line number Diff line change
@@ -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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Google Fonts CDN link violates offline requirement

Medium Severity

The template loads the Inter font from fonts.googleapis.com, but SKILL.md explicitly requires "no CDN links, no external assets" so the report works offline. Opening the report without internet triggers failed network requests for the font stylesheet. The CSS fallback chain (-apple-system, system-ui, etc.) prevents total breakage, but the CDN links contradict the documented design constraint and the goal of a fully self-contained file.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 64550b6. Configure here.

<style>
:root {
--brand: #F03603;
--pass: #90C94D;
--fail: #F03603;
--blue: #4DA9E4;
--yellow: #F4BA41;
--black: #100D0D;
--gray: #514F4F;
--border: #edebeb;
--bg: #F9F6F4;
--card: #ffffff;
--text: #100D0D;
--muted: #514F4F;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; font-size: 16px; }
.container { max-width: 960px; margin: 0 auto; padding: 2rem 1.5rem; }
code { font-family: 'Geist Mono', 'SF Mono', 'Fira Code', monospace; font-size: 0.8125em; background: #f6f5f5; padding: 0.125em 0.375em; border-radius: 2px; border: 1px solid var(--border); }

/* Header */
header { margin-bottom: 2rem; display: flex; align-items: center; justify-content: space-between; }
.header-left h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.25rem; color: var(--black); }
.header-left h1 a { color: var(--brand); text-decoration: none; }
.header-left h1 a:hover { text-decoration: underline; }
.header-left .meta { color: var(--muted); font-size: 0.875rem; }
.header-logo { flex-shrink: 0; }

/* Summary bar */
.summary { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.stat { background: var(--card); border: 1px solid var(--border); border-radius: 4px; padding: 1rem 1.25rem; flex: 1; min-width: 120px; }
.stat .label { font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 600; margin-bottom: 0.25rem; }
.stat .value { font-size: 1.5rem; font-weight: 700; color: var(--black); }
.stat .value.pass { color: var(--pass); }
.stat .value.fail { color: var(--fail); }
.stat .value.blue { color: var(--blue); }

/* Health bar */
.health-bar { background: var(--card); border: 1px solid var(--border); border-radius: 4px; padding: 1rem 1.25rem; margin-bottom: 2rem; }
.health-bar .bar-track { height: 6px; background: #f6f5f5; border-radius: 3px; overflow: hidden; margin-top: 0.5rem; }
.health-bar .bar-fill { height: 100%; border-radius: 3px; }
.health-bar .bar-fill.good { background: var(--pass); }
.health-bar .bar-fill.warn { background: var(--yellow); }
.health-bar .bar-fill.bad { background: var(--fail); }

/* Section */
.section { margin-bottom: 2rem; }
.section h2 { font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem; color: var(--black); }
.section h2 .count { font-size: 0.6875rem; font-weight: 600; border-radius: 2px; padding: 0.125rem 0.5rem; border: 1px solid var(--border); background: #f6f5f5; color: var(--muted); }

/* Page card */
.page-card { background: var(--card); border: 1px solid var(--border); border-radius: 4px; margin-bottom: 0.5rem; overflow: hidden; }
.page-card.has-errors { border-left: 3px solid var(--fail); }
.page-card.clean { border-left: 3px solid var(--pass); }
.page-card summary { padding: 0.75rem 1rem; cursor: pointer; display: flex; align-items: center; gap: 0.75rem; list-style: none; }
.page-card summary::-webkit-details-marker { display: none; }
.page-card summary::before { content: '\25B6'; font-size: 0.5rem; color: var(--muted); transition: transform 0.15s; flex-shrink: 0; }
.page-card[open] summary::before { transform: rotate(90deg); }
.page-card .badge { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 10px; border-radius: 2px; flex-shrink: 0; }
.page-card .badge.error { background: rgba(240,54,3,0.08); color: var(--fail); border: 1px solid rgba(240,54,3,0.2); }
.page-card .badge.clean { background: rgba(144,201,77,0.12); color: #5a8a1a; border: 1px solid rgba(144,201,77,0.3); }
.page-card .badge.page-id { background: #f6f5f5; color: var(--muted); border: 1px solid var(--border); }
.page-card .page-url { font-family: 'Geist Mono', 'SF Mono', 'Fira Code', monospace; font-size: 0.8125rem; color: var(--text); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.page-card .page-meta { color: var(--muted); font-size: 0.8125rem; margin-left: auto; white-space: nowrap; }
.page-card .body { padding: 0 1rem 1rem 1rem; }

/* Domain breakdown */
.domain-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.5rem; margin-bottom: 0.75rem; }
.domain-chip { background: #f6f5f5; border: 1px solid var(--border); border-radius: 4px; padding: 0.5rem 0.75rem; }
.domain-chip .domain-name { font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 600; }
.domain-chip .domain-count { font-size: 1.125rem; font-weight: 700; color: var(--black); }
.domain-chip .domain-errors { font-size: 0.75rem; color: var(--fail); font-weight: 600; }
.domain-chip .domain-warnings { font-size: 0.75rem; color: var(--yellow); font-weight: 600; }

/* Network breakdown */
.network-bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; margin: 0.5rem 0; background: #f6f5f5; }
.network-bar .seg { height: 100%; }
.network-bar .seg-ok { background: var(--pass); }
.network-bar .seg-fail { background: var(--fail); }
.network-types { display: flex; gap: 0.75rem; flex-wrap: wrap; font-size: 0.75rem; color: var(--muted); }
.network-types span { display: inline-flex; align-items: center; gap: 0.25rem; }

/* Error list */
.error-list { margin-top: 0.75rem; }
.error-item { background: rgba(240,54,3,0.04); border: 1px solid rgba(240,54,3,0.12); border-radius: 4px; padding: 0.5rem 0.75rem; margin-bottom: 0.375rem; font-size: 0.8125rem; }
.error-item .error-kind { font-weight: 600; color: var(--fail); font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.03em; }
.error-item .error-msg { color: var(--text); margin-top: 0.125rem; word-break: break-all; }

/* Screenshot */
.screenshot { margin-top: 0.75rem; }
.screenshot img { max-width: 100%; border-radius: 4px; border: 1px solid var(--border); cursor: pointer; }
.screenshot .caption { font-size: 0.75rem; color: var(--muted); margin-top: 0.375rem; }

/* Screenshot timeline */
.screenshot-timeline { display: flex; gap: 0.5rem; overflow-x: auto; padding: 0.5rem 0; }
.screenshot-timeline .thumb { flex-shrink: 0; width: 160px; cursor: pointer; }
.screenshot-timeline .thumb img { width: 100%; border-radius: 4px; border: 1px solid var(--border); }
.screenshot-timeline .thumb .caption { font-size: 0.6875rem; color: var(--muted); margin-top: 0.25rem; text-align: center; }

/* Lightbox */
.lightbox { display: none; position: fixed; inset: 0; background: rgba(16,13,13,0.85); z-index: 1000; align-items: center; justify-content: center; padding: 2rem; cursor: zoom-out; }
.lightbox.active { display: flex; }
.lightbox img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; }

/* Footer */
footer { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: center; gap: 0.5rem; font-size: 0.75rem; color: var(--muted); }
footer a { color: var(--brand); text-decoration: none; font-weight: 500; }
footer a:hover { text-decoration: underline; }
footer svg { flex-shrink: 0; }
</style>
</head>
<body>

<div class="container">
<header>
<div class="header-left">
<h1>{{TITLE_HTML}}</h1>
<div class="meta">{{META}}</div>
</div>
<a href="https://browserbase.com" target="_blank" rel="noopener" class="header-logo" title="Powered by Browserbase" style="display:flex;align-items:center;gap:0.5rem;text-decoration:none;color:var(--muted);font-size:0.8125rem;font-weight:500;">
<span>Powered by Browserbase</span>
<svg width="32" height="32" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" rx="8" fill="#F03603"/><path d="M36 72.2222V27.7778H51.2381C57.5873 27.7778 62.6667 32.8571 62.6667 39.2063V41.746C62.6667 44.6667 61.5873 47.3968 59.7461 49.3651C62.2858 51.4603 63.9366 54.6349 63.9366 58.254V60.7936C63.9366 67.1428 58.8572 72.2222 52.508 72.2222H36ZM42.3493 65.873H52.508C55.3651 65.873 57.5873 63.6508 57.5873 60.7936V58.254C57.5873 55.3968 55.3651 53.1746 52.508 53.1746H42.3493V65.873ZM42.3493 46.8254H51.2381C54.0953 46.8254 56.3175 44.6032 56.3175 41.746V39.2063C56.3175 36.3492 54.0953 34.127 51.2381 34.127H42.3493V46.8254Z" fill="white"/></svg>
</a>
</header>

<div class="summary">
<div class="stat"><div class="label">Pages</div><div class="value">{{PAGE_COUNT}}</div></div>
<div class="stat"><div class="label">Events</div><div class="value blue">{{TOTAL_EVENTS}}</div></div>
<div class="stat"><div class="label">Requests</div><div class="value">{{TOTAL_REQUESTS}}</div></div>
<div class="stat"><div class="label">Errors</div><div class="value fail">{{TOTAL_ERRORS}}</div></div>
<div class="stat"><div class="label">Duration</div><div class="value">{{DURATION}}</div></div>
</div>

<div class="health-bar">
<div style="display:flex;justify-content:space-between;align-items:baseline;">
<span style="font-size:0.875rem;font-weight:500;">Network Health</span>
<span style="font-size:1.25rem;font-weight:700;">{{HEALTH_RATE}}%</span>
</div>
<div class="bar-track"><div class="bar-fill {{HEALTH_CLASS}}" style="width:{{HEALTH_RATE}}%"></div></div>
</div>

<!-- ERRORS SECTION — only rendered if there are errors -->
{{ERRORS_SECTION}}

<!-- PAGES SECTION -->
{{PAGES_SECTION}}

<!-- SCREENSHOTS SECTION — only rendered if screenshots exist -->
{{SCREENSHOTS_SECTION}}
</div>

<div class="lightbox" id="lightbox" onclick="this.classList.remove('active')">
<img id="lightbox-img" src="" alt="Screenshot enlarged">
</div>

<footer>
<svg width="16" height="16" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" rx="8" fill="#F03603"/><path d="M36 72.2222V27.7778H51.2381C57.5873 27.7778 62.6667 32.8571 62.6667 39.2063V41.746C62.6667 44.6667 61.5873 47.3968 59.7461 49.3651C62.2858 51.4603 63.9366 54.6349 63.9366 58.254V60.7936C63.9366 67.1428 58.8572 72.2222 52.508 72.2222H36ZM42.3493 65.873H52.508C55.3651 65.873 57.5873 63.6508 57.5873 60.7936V58.254C57.5873 55.3968 55.3651 53.1746 52.508 53.1746H42.3493V65.873ZM42.3493 46.8254H51.2381C54.0953 46.8254 56.3175 44.6032 56.3175 41.746V39.2063C56.3175 36.3492 54.0953 34.127 51.2381 34.127H42.3493V46.8254Z" fill="white"/></svg>
Generated by <a href="https://github.com/browserbase/skills">browser-trace</a> · Powered by <a href="https://browserbase.com">Browserbase</a>
</footer>

<script>
document.querySelectorAll('.screenshot img, .screenshot-timeline .thumb img').forEach(img => {
img.addEventListener('click', e => {
e.stopPropagation();
document.getElementById('lightbox-img').src = img.src;
document.getElementById('lightbox').classList.add('active');
});
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') document.getElementById('lightbox').classList.remove('active');
});
</script>
</body>
</html>
Loading