app-dev/chrome-cdp/SKILL.md

name: chrome-cdp description: Inspect, measure, and screenshot a running Chrome tab programmatically via the Chrome DevTools Protocol. Use when debugging frontend bugs that differ between environments (local dev vs staging/prod), comparing two pages side-by-side, reading computed CSS / DOM geometry / loaded stylesheet rules without asking the user to do DevTools ceremony, capturing visual proof during iteration, or inspecting content inside same-origin iframes (Storybook preview, embedded apps). Also use whenever the user has a debug-mode Chrome running on port 9222 or names CDP/DevTools directly. Not for driving user flows (clicks, forms, e2e runs) — use Playwright for that.

Chrome DevTools Protocol

Inspect and control a running Chrome instance from the command line — read computed styles, run arbitrary JS, capture screenshots, walk into iframes — without asking the user to open DevTools and click around.

When to use this

  • Debugging "works locally but breaks in prod" frontend issues — measure geometry / computed CSS in both tabs and diff them.
  • Verifying a fix landed (computed styles changed as expected, asset hashes updated).
  • Capturing a screenshot of the current page state for inclusion in your reasoning.
  • Inspecting Storybook stories (the preview lives in an iframe — iframe.contentDocument works because it's same-origin).
  • Comparing two pages programmatically — open both as tabs in a debug Chrome and run the same eval against each.

Skip this skill when the user just needs a quick eyeball check; ask for a screenshot instead.

For driving a browser through user flows — clicking, filling forms, multi-step e2e verification, test generation — use Playwright instead (playwright-cli is on PATH); it owns the browser lifecycle and provides trusted input events and auto-waiting. This skill is for attaching to and interrogating a tab the user already has open (their session, their cookies, their state).

playwright-cli writes .playwright-cli/ snapshots and console logs under its working directory. Run it from an explicit owned scratch/evidence directory that is already ignored, rather than a shared source checkout: untracked browser output can block clean-source build gates. If relocating an active session, detach it first and preserve only your own artifacts before resuming from the new directory.

Prerequisites — launch Chrome with the debug port

Chrome only exposes CDP when launched with --remote-debugging-port. The user must do this themselves — you cannot enable it on a Chrome they already have open. Tell them to run:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --remote-debugging-port=9222 \
  --user-data-dir="$HOME/.chrome-debug-cdp"

The separate --user-data-dir is important so they keep their main Chrome session intact. They then navigate to the page(s) they want inspected in this new Chrome instance.

If python3 -c "import websocket" fails, install it once:

pip3 install --user --break-system-packages websocket-client

Xcode's bundled Python uses an older pip that rejects --break-system-packages; with that interpreter, rerun the install without that flag:

python3 -m pip install --user websocket-client

Workflow

1. Find the tab you want

python3 ~/.claude/skills/chrome-cdp/scripts/cdp_tabs.py            # list all
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_tabs.py localhost  # filter by url substring
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_tabs.py --ws uat   # print only ws URLs

Each tab has a webSocketDebuggerUrl like ws://localhost:9222/devtools/page/<HEX>. That's the handle the eval/screenshot scripts take.

2. Run JS in the page

For one-line evals, pass the expression inline:

python3 ~/.claude/skills/chrome-cdp/scripts/cdp_eval.py "$WS_URL" "document.title"

For anything non-trivial, write the JS to a file and use -f. This avoids quoting nightmares with backticks and arrow functions:

cat > /tmp/probe.js <<'JS'
(() => {
  const el = document.querySelector('.my-component');
  if (!el) return { error: 'not found' };
  const cs = getComputedStyle(el);
  const r = el.getBoundingClientRect();
  return {
    width: Math.round(r.width),
    height: Math.round(r.height),
    overflow: cs.overflow,
    borderRadius: cs.borderRadius,
  };
})();
JS
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_eval.py "$WS_URL" -f /tmp/probe.js

Wrap your code in a (() => { ... })() IIFE that returns a plain object — the eval runs with returnByValue: true and awaitPromise: true, so async IIFEs work too.

3. Capture a screenshot

python3 ~/.claude/skills/chrome-cdp/scripts/cdp_screenshot.py "$WS_URL" /tmp/page.png
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_screenshot.py "$WS_URL" /tmp/full.png --full

Then Read the PNG to view it inline.

4. Capture network activity

# 5-second capture of whatever loads / requests during the window
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_net.py "$WS_URL"

# Reload the page and capture the full page-load lifecycle
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_net.py "$WS_URL" --reload --seconds 8

# Watch only API calls to /api, and pull response bodies for debugging
python3 ~/.claude/skills/chrome-cdp/scripts/cdp_net.py "$WS_URL" --filter /api --bodies --seconds 10

Output is a JSON list of {url, method, status, type, mimeType, encodedDataLength, errorText, fromCache, body?} — one entry per request that completed (or failed) inside the window. Useful for:

  • Verifying which CSS/JS chunks the page actually loads (type: "Stylesheet" / "Script").
  • Diffing the asset hash list across local vs prod.
  • Capturing the JSON body of a failing API call without DevTools.
  • Confirming a request was served from cache (fromCache: true).

--bodies skips responses larger than 1 MiB and tags binary bodies as <base64:N> so the output stays readable. Inline bodies are capped at 4000 chars.

5. Reach into an iframe (Storybook, embedded previews)

Storybook renders stories inside iframe#storybook-preview-iframe. From the outer page, you can walk into the iframe's contentDocument because it's same-origin:

const iframe = document.querySelector('#storybook-preview-iframe, iframe');
const doc = iframe?.contentDocument;
if (!doc) return { error: 'iframe doc not accessible' };
// then query doc.querySelector(...) instead of document.querySelector(...)

Cross-origin iframes will throw on contentDocument access — for those, target the iframe's own page tab if it has one, or use Page.navigate to load the iframe URL directly in a new tab.

Recipes

Compare element geometry across two tabs

LOCAL_WS=$(python3 ~/.claude/skills/chrome-cdp/scripts/cdp_tabs.py --ws localhost:5173)
PROD_WS=$(python3 ~/.claude/skills/chrome-cdp/scripts/cdp_tabs.py  --ws app.example.com)

cat > /tmp/measure.js <<'JS'
(() => {
  const el = document.querySelector('.target');
  const r = el.getBoundingClientRect();
  const cs = getComputedStyle(el);
  return { width: Math.round(r.width), overflow: cs.overflow };
})();
JS

echo "LOCAL:"; python3 ~/.claude/skills/chrome-cdp/scripts/cdp_eval.py "$LOCAL_WS" -f /tmp/measure.js
echo "PROD:";  python3 ~/.claude/skills/chrome-cdp/scripts/cdp_eval.py "$PROD_WS"  -f /tmp/measure.js

Check whether a CSS rule is loaded on the page

(() => {
  const matches = [];
  const walk = (rules) => {
    for (const rule of rules) {
      if ((rule.selectorText || '').includes('.target-class')) {
        matches.push({ selector: rule.selectorText, cssText: rule.cssText.slice(0, 200) });
      }
      if (rule.cssRules?.length) walk(rule.cssRules); // descend into grouping and nested rules
    }
  };
  for (const sheet of [...document.styleSheets]) {
    let rules; try { rules = sheet.cssRules || []; } catch (e) { continue; }
    walk(rules);
  }
  return { matchCount: matches.length, matches };
})();

Cross-origin stylesheets throw on cssRules access — the try/catch skips them silently. The recursive walk matters: rules inside @media/@supports/@layer blocks are nested and a flat iteration misses them. Check the current rule before descending: modern Chrome exposes an empty cssRules collection on ordinary style rules, so treating the property's presence as proof of a grouping rule silently skips normal selectors.

Find every rule that actually applies to an element

Selector-substring matching tells you what rules exist. To debug an unexpected computed style, you usually want what rules target this element — use element.matches(selector):

(() => {
  const el = document.querySelector('.target');
  if (!el) return { error: 'not found' };
  const out = [];
  const walk = (rules) => {
    for (const r of rules) {
      if (r.selectorText) {
        let m = false; try { m = el.matches(r.selectorText); } catch (e) {}
        if (m) out.push({ selector: r.selectorText, css: r.cssText.slice(0, 220) });
      }
      if (r.cssRules?.length) walk(r.cssRules);
    }
  };
  for (const sheet of [...document.styleSheets]) {
    let rules; try { rules = sheet.cssRules || []; } catch (e) { continue; }
    walk(rules);
  }
  return { count: out.length, rules: out };
})();

Order in the result roughly mirrors cascade order (later-added stylesheets last). Combine with getComputedStyle(el) to see which rule actually won.

Diff loaded rules between two environments

The single most useful pattern when "same code renders differently in env A vs env B": run the rule-finder above against the same selector in both tabs. If one tab is missing a rule the other has, you've found the bug — usually a bundler dropping a CSS side-effect import. (Vite pre-bundling linked CJS packages is a frequent offender; the JS gets bundled, the require("pkg/css") side effect doesn't.)

LOCAL_WS=$(python3 ~/.claude/skills/chrome-cdp/scripts/cdp_tabs.py --ws localhost:5173)
PROD_WS=$(python3 ~/.claude/skills/chrome-cdp/scripts/cdp_tabs.py  --ws localhost:6006)
# same probe.js for both — diff the rule lists
echo "LOCAL:"; python3 ~/.claude/skills/chrome-cdp/scripts/cdp_eval.py "$LOCAL_WS" -f /tmp/probe.js
echo "PROD:";  python3 ~/.claude/skills/chrome-cdp/scripts/cdp_eval.py "$PROD_WS"  -f /tmp/probe.js

List asset URLs the page loaded

({
  scripts: [...document.scripts].map(s => s.src).filter(Boolean),
  styles: [...document.styleSheets].map(s => s.href).filter(Boolean),
})

Get the active element / scroll position / viewport state

({
  active: document.activeElement?.tagName + '#' + document.activeElement?.id,
  scroll: { x: window.scrollX, y: window.scrollY },
  viewport: { w: window.innerWidth, h: window.innerHeight },
})

Anti-patterns

  • Don't try to attach to the user's main Chrome. Recent Chrome rejects CDP on profiles that weren't launched with --remote-debugging-port. They need to launch a separate debug Chrome (with --user-data-dir).
  • Don't skip suppress_origin=True or localhost proxy bypass. The Python websocket-client library sends Origin: http://localhost:9222 by default, which Chrome's CDP server rejects with 403 Forbidden. It can also honor proxy env vars for 127.0.0.1 and fail or hit the wrong socket. The bundled scripts pass suppress_origin=True, http_proxy_host=None, and http_proxy_port=None; use the same settings in custom clients.
  • Don't shell-quote complex JS. Use the -f <file> form for anything beyond a one-liner. Quoting a multi-line arrow function with backticks through bash will eat your day.
  • Don't poll on a sleep loop after an action. If you triggered a navigation or HMR, use Page.frameStoppedLoading or just check the result of a fresh eval. Sleeping rarely catches the right moment.
  • Don't expect cssRules on cross-origin stylesheets. The browser throws SecurityError — wrap in try/catch and continue.
  • Don't forget Vite's dev cache. When linking a local package and HMR doesn't pick it up, rm node_modules/.vite/deps/<pkg>.js (or restart the dev server with --force). CDP can't fix a stale dep pre-bundle.
  • getBoundingClientRect() is post-transform; getComputedStyle().height is pre-transform. When the page (or any ancestor) applies transform: scale(...) — React Flow's zoom, embla, canvas viewports, CSS animations — bounding rects return the painted size, not the layout size. A 389px element under scale(0.62) measures as 242px. For "what did the layout engine decide?" use computed style; for "what's on screen right now?" use bounding rect. Probing both side-by-side surfaces the discrepancy fast.
  • Don't click a canvas by dispatchEvent from Runtime.evaluate. Page-constructed PointerEvent/MouseEvents look fine in the console and still miss Three.js/WebGL picking (raycasters, OrbitControls). Send CDP Input.dispatchMouseEvent with viewport CSS pixels (mousePressed then mouseReleased, same x/y) when you must click a canvas from this skill; prefer playwright for a real flow. DOM .click() on buttons still works.