Files
gstack/browse/src/cdp-inspector.ts
T
Garry Tan 19770ea8b4 v1.51.0.0 feat: $B memory diagnostic + 4 CDP-resource leak fixes (#1751)
* add withCdpSession + getOrCreateCdpSession helpers

Two CDP-session lifecycle helpers in cdp-bridge.ts:

- withCdpSession(page, fn): ephemeral session with try/finally detach.
  For one-shot CDP work (archive snapshots, $B memory, single
  Page.captureScreenshot) where the caller doesn't need session reuse.
- getOrCreateCdpSession(page, cache): cached long-lived session that
  registers a page.once('close') hook to BOTH delete the cache entry
  AND call session.detach(). Pre-helper code only deleted the cache
  entry, leaving the Chromium-side CDP target attached until the
  underlying transport dropped.

Pure addition. Existing callers untouched in this commit; they migrate
in the next commit alongside the static-grep test that pins the
invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* migrate 3 CDP-session sites to lifecycle helpers

Fixes the CDP-target leak class identified by /codex outside-voice on
the eng review (D11 EXPAND_SCOPE). All three sites called
`page.context().newCDPSession(page)` directly and either forgot the
detach entirely (cdp-bridge cache cleanup), only detached on the
success path (write-commands archive), or detached on framenavigated
but not page-close (cdp-inspector).

- cdp-bridge.ts: `getCdpSession` now delegates to
  `getOrCreateCdpSession`, which registers a `page.once('close')` hook
  that BOTH removes the cache entry AND calls `session.detach()`.
- cdp-inspector.ts: same migration for the inspector's session pool.
  Keeps the existing framenavigated detach (more granular than close
  for DOM/CSS state invalidation) plus an inspector-layer close hook
  for the initializedPages WeakSet.
- write-commands.ts archive: wraps Page.captureSnapshot in
  withCdpSession so the detach runs in `finally`, including the path
  where captureSnapshot throws.

The static-grep tripwire (next commit) pins the invariant so future
direct calls to newCDPSession fail CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add CDP-session cleanup tripwire + helper unit tests

browse/test/cdp-session-cleanup.test.ts pins the invariant that no
source file outside cdp-bridge.ts may call newCDPSession() directly.
If a future refactor reintroduces the direct call, CI fails with a
file:line list and a pointer to the right helper to use instead
(withCdpSession for one-shot, getOrCreateCdpSession for cached).

Also covers the helpers themselves with fake-Page unit tests:
- withCdpSession detaches on success
- withCdpSession detaches on throw (the actual leak fix)
- withCdpSession swallows detach errors so they don't mask fn errors
- getOrCreateCdpSession caches the session across calls
- close hook detaches AND clears the cache

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* extract createSseEndpoint helper with cleanup contract

browse/src/sse-helpers.ts owns the SSE cleanup invariant:
cleanup runs on abort, enqueue failure, AND heartbeat failure,
exactly once, regardless of which edge fires first.

Pre-helper, /activity/stream and /inspector/events ran cleanup only on
the req.signal.abort edge. If the underlying TCP died without firing
abort (Chromium MV3 service-worker suspend, intermediate proxy
half-close), the subscriber closure stayed in the Set capturing the
ReadableStreamDefaultController plus any payloads queued behind it. Over
a multi-day sidebar session this compounded into multi-MB of retained
controllers per dead connection.

Caller surface: initialReplay (optional, for gap replay or state
snapshots), subscribe (live-event source), liveEventName (SSE event
name for live wrap), heartbeatMs. send() helper handles JSON encoding
with sanitizeReplacer + lone-surrogate stripping.

Unit tests pin all three cleanup edges + idempotency + replay ordering
+ surrogate sanitization. Endpoint refactors land in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* route /activity/stream + /inspector/events through createSseEndpoint

Both endpoints collapse from ~45 lines of in-line ReadableStream wiring
to ~8 lines of helper config. Behavior preserved bit-for-bit by the
new sse-helpers tests:
  - initial replay (activity gap + history, inspector state snapshot)
  - live event subscription
  - 15s heartbeat
  - SSE framing
  - sanitizeReplacer applied to every JSON.stringify

The leak fix is the cleanup contract: pre-refactor, both endpoints ran
cleanup only on req.signal.abort. If TCP died without firing abort
(Chromium MV3 SW suspend, intermediate proxy half-close), the
subscriber closure stayed in the Set forever capturing the
ReadableStreamDefaultController + queued payloads. Post-refactor, an
enqueue-failure or heartbeat-failure on a dead consumer triggers the
same idempotent cleanup as abort would.

Net: -83 / +15 in server.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* cap inspector modificationHistory at 200 entries

Pre-cap, modificationHistory was an unbounded module-scoped array that
grew for every CSS edit through $B css across the entire session.
Small per-entry footprint but no upper bound, the kind of slow leak
that compounds over multi-day inspector use.

Cap is 200, oldest evicted on push past the cap. modHistoryTotalPushed
stays monotonic across the session so undoModification can tell the
user when their target index has been evicted, instead of just the
opaque pre-cap "No modification at index 500" with no context.

__testInternals export lets the cap + eviction error be unit-tested
without spinning up a CDP-driven Page. Production code must continue
to go through modifyStyle / undoModification / resetModifications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add BrowserManager.getMemorySnapshot() + shared types

Diagnostic foundation for $B memory and the /memory endpoint that land
in the next two commits. Collects:

- Bun process memory via process.memoryUsage (cross-platform, accurate).
- Per-tab JS heap via CDP Performance.getMetrics, lazy per tracked page,
  swallows target-died errors so a dying tab doesn't poison the
  snapshot for the rest.
- Chromium process tree via SystemInfo.getProcessInfo (PID + type +
  CPU time). RSS is NOT exposed via CDP — the eng review (D2 USE_CDP)
  picked CDP over shelling to `ps`, so notes[] tells the caller why
  the RSS column is absent and points at the follow-up TODO.

cdp-inspector exports getModificationHistoryStats so the snapshot can
surface buffer occupancy + cap + evicted count without reaching into
module-private state.

memory-snapshot.ts holds the shared types so server.ts and read-commands
can import without circular dep on browser-manager.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add \$B memory command

Registers 'memory' in META_COMMANDS, wires the meta-command dispatch
to a lazy-imported handler in memory-command.ts. Lazy because the
import graph (cdp-bridge + memory-snapshot + buffer accessors) isn't
useful to projects that never run the diagnostic.

The handler assembles MemoryStructureStats from the modules that own
each buffer (cdp-inspector mod history stats, activity subscriber
count, console/network/dialog buffer lengths, captureBuffer bytes,
inspectorSubscriber count via a new server.ts export) and calls
BrowserManager.getMemorySnapshot. Output is text by default, JSON with
--json so the sidebar footer and test harness can consume it
programmatically. buildMemorySnapshotJson is the entry the /memory
endpoint will call in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add /memory endpoint (SSE-session-cookie gated)

GET /memory returns the BrowserManager memory snapshot as JSON. Auth
matches /activity/stream and /inspector/events: Bearer header OR
view-only SSE-session cookie (the extension fetches the cookie once
via POST /sse-session, then polls /memory with withCredentials: true).

Deliberately NOT extending /health for the sidebar footer poll —
TODOS.md "Audit /health token distribution" records that /health
already surfaces AUTH_TOKEN to any localhost caller in headed mode. A
separate endpoint with the standard SSE auth keeps the future /health
fix from cascading into the sidebar.

sanitizeReplacer is applied at egress because tab.url and tab.title
come from page content — lone-surrogate bytes from broken emoji could
otherwise reach the sidebar and (when forwarded to Claude API) trigger
HTTP 400.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add sidebar footer RSS readout (polls /memory every 30s)

Footer now shows "<bun-rss> · <tab-count>" sourced from the /memory
endpoint, polled every 30s. Color thresholds: orange warn at 2 GB Bun
RSS or 50 tabs; red bad at 8 GB or 200 tabs (matches the tab-guardrail
threshold landing in a later commit). The footer gives the user an
early signal that the cliff is forming, instead of only learning when
the OS OOM-kills the process.

Backoff per Codex's flag: if a poll takes > 2s response time the
sidebar drops to a 5-minute cadence until the next successful fast
poll. The diagnostic shouldn't add load to a browser that's already
unhealthy.

Start/stop is wired to the existing setServerInfo() hook so the timer
only runs while the sidebar is connected to a server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* stop materializing response bodies in requestfinished listener

The Bun-side accelerant on the gbrowser-OOM investigation. Pre-fix,
the per-page requestfinished listener called \`await res.body()\` just
to read .length — Playwright fetches the bytes from Chromium across
CDP into a Bun Buffer, only for the listener to discard the buffer
after a single length read. On a long-lived headed browser with
media-heavy pages this is multi-GB/hour of Buffer allocation churn.
Bun GCs it, but the cross-process CDP traffic + transient allocation
pressure feeds the OOM trajectory.

The fix: req.sizes() pulls from the Network.loadingFinished event
Chromium already emits. No body materialization. Accurate for chunked
transfer, gzip-compressed responses, and streaming media — the cases
where a naive Content-Length header read (the original review's
proposal) would have missed the size entirely (Codex flag on the eng
review, D10 USE_CDP_EVENT_BATCHED).

The D10 stretch goal — replacing N per-page listeners with a single
context-level CDP listener via Target.setAutoAttach — is deferred and
tracked in TODOS. The listener architecture change is significantly
more plumbing than the leak fix and not on the critical path for
stopping the body materialization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* tab guardrail (50/200 thresholds) + sidebar action toast

Server side (browser-manager.ts):
Idempotent threshold tracker fires an activity entry exactly once at
each upward crossing of 50 (soft warn) and 200 (hard warn). Re-arms
when the count drops below. Activity-feed surface gives the
audit-trail invariant even with the sidebar closed; the toast UX
lives in the sidebar.

Sidebar side (extension/sidepanel.{html,css,js}):
Every /memory poll evaluates two trigger conditions:
  - Any single tab > 4 GB JS heap (catches the WebGL/video runaway
    case Codex flagged on the eng review).
  - Tab count >= 200.
Toast shows top 5 tabs ranked by max(jsHeap, nodes*1KB + listeners*200)
so a WebGL-heavy tab with small JS heap still surfaces. Default-selected
checkboxes + "Close selected" run \`\$B closetab <id>\` through the
existing /command path — no chrome.tabs.remove bridge needed. "Snooze"
bumps tabsAbove/heapAbove thresholds in chrome.storage.session so the
toast stays hidden until the user accumulates more tabs OR one tab
grows another 2 GB.

Tests: browse/test/tab-guardrail.test.ts pins the server-side
fires-once + re-arms invariants without spinning up Chromium.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add memory-leak reproducer (gate tier)

browse/test/memory-leak-reproducer.test.ts pins the invariant from
the D10 fix: wirePageEvents.requestfinished must call req.sizes() but
must NEVER call res.body(). Fakes a page emitting a burst of 200
requestfinished events, each with a notional 1 MB response — pre-fix
this would allocate 200 MB of Buffer per burst, post-fix not one byte
of body content is materialized.

The test also asserts networkBuffer entries are still populated with
the right size, so size reporting in the network panel doesn't
regress.

A real-Chromium peak-RSS reproducer (periodic tier) is deferred —
see TODOS "Reproducer with WebGL / video / MSE buffer pressure". This
gate-tier test is sufficient to catch the leak class being
reintroduced by any future refactor of the requestfinished listener.

Wall clock: ~400ms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* TODOS: 4 follow-ups from gbrowser-OOM PR

Captures the items deliberately deferred from the v1.49 leak-fix PR
so the deferrals don't fall off the radar:

- P2: MV3 extension service-worker memory profile (Codex finding #4)
- P2: Native + GPU memory breakdown in \$B memory (Codex finding #5)
- P3: Single-context CDP listener for Network.loadingFinished (D10
  stretch goal)
- P3: Real-Chromium peak-RSS reproducer for periodic tier (Codex
  finding on transient amplification + ANGLE_B_NUMBERS CHANGELOG
  framing dependency)

Each entry follows the standard TODOS.md format: What / Why / Pros /
Cons / Context / Priority / Effort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* regen SKILL.md after adding \$B memory command

The C8 commit added 'memory' to META_COMMANDS + COMMAND_DESCRIPTIONS
but didn't regenerate the SKILL.md files. The category was 'Diagnostics'
which isn't in scripts/resolvers/browse.ts:categoryOrder; switched to
'Server' (matches the existing 'status' / 'restart' / 'handoff'
pattern) so the table renders under the existing ### Server section.

Test fix: gen-skill-docs.test.ts asserts every command appears in the
generated SKILL.md and gstack/llms.txt; without this regen the test
fails with "Expected to contain: 'memory'".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add coverage for \$B memory diagnostic surface

17 tests across the formatter + byte renderer + JSON entry point:

- formatBytes() 4-tier (bytes, KB, MB, GB) + 160 GB sanity case
  (the friend's OOM number from the original screenshot, so the
  renderer doesn't blow up at real leak scale)
- handleMemoryCommand --json mode parseable shape
- handleMemoryCommand text mode: Bun server line, no-tabs branch,
  top-10 sort with "...and N more" tail, Chromium process grouping
  by type, "unavailable" line when processes is null, modification-
  history evicted-count format, notes section rendering, long-URL
  ellipsis truncation
- buildMemorySnapshotJson returns shape matching the type

The formatSnapshotText renderer is private to memory-command.ts;
tests exercise it through handleMemoryCommand's text-mode return
path. The eviction-count format is pinned via a parallel format
contract assertion since the renderer reads live module state.

Coverage gate: brings the diagnostic surface from 0% to ~80%.
Extension UI (sidepanel.js footer + toast) remains uncovered —
adding tests there would require extracting fmtBytesShort and
tabRamScore from sidepanel.js into a testable TS module, which is
deferred to a follow-up to keep this PR scoped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v1.51.0.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v1.51.0.0

Add $B memory command to BROWSER.md server lifecycle table. Document the
new createSseEndpoint helper + CDP session lifecycle helpers (withCdpSession,
getOrCreateCdpSession) in CLAUDE.md alongside the existing server hardening
notes, with the static-grep tripwire callout so future contributors route
through the helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): pin SSE sanitizer wiring to the v1.51 createSseEndpoint helper

The two `wiring invariants` tests grepped server.ts for
`JSON.stringify(entry, sanitizeReplacer)` and
`JSON.stringify(event, sanitizeReplacer)` — patterns that lived inline
in /activity/stream and /inspector/events before the v1.51 refactor
moved both endpoints behind createSseEndpoint. Sanitization still
happens (the helper applies it inside its send() and live-event
callback), but the static-grep was pinned to the old wiring and started
failing on Windows free-tests after the refactor landed.

Updated to check the new contract:
- /activity/stream + /inspector/events route through createSseEndpoint
  (regex match of the route handler block ending in the helper call).
- sse-helpers.ts contains JSON.stringify + sanitizeReplacer + imports
  stripLoneSurrogates from ./sanitize (catches drift to a private copy).
- server.ts retains its own sanitizeReplacer for non-SSE egress paths
  (handleCommandInternal); the two replacers coexist by design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:09:38 -07:00

825 lines
28 KiB
TypeScript

/**
* CDP Inspector — Chrome DevTools Protocol integration for deep CSS inspection
*
* Manages a persistent CDP session per active page for:
* - Full CSS rule cascade inspection (matched rules, computed styles, inline styles)
* - Box model measurement
* - Live CSS modification via CSS.setStyleTexts
* - Modification history with undo/reset
*
* Session lifecycle:
* Create on first inspect call → reuse across inspections → detach on
* navigation/tab switch/shutdown → re-create transparently on next call
*/
import type { Page } from 'playwright';
import { getOrCreateCdpSession } from './cdp-bridge';
// ─── Types ──────────────────────────────────────────────────────
export interface InspectorResult {
selector: string;
tagName: string;
id: string | null;
classes: string[];
attributes: Record<string, string>;
boxModel: {
content: { x: number; y: number; width: number; height: number };
padding: { top: number; right: number; bottom: number; left: number };
border: { top: number; right: number; bottom: number; left: number };
margin: { top: number; right: number; bottom: number; left: number };
};
computedStyles: Record<string, string>;
matchedRules: Array<{
selector: string;
properties: Array<{ name: string; value: string; important: boolean; overridden: boolean }>;
source: string;
sourceLine: number;
sourceColumn: number;
specificity: { a: number; b: number; c: number };
media?: string;
userAgent: boolean;
styleSheetId?: string;
range?: object;
}>;
inlineStyles: Record<string, string>;
pseudoElements: Array<{
pseudo: string;
rules: Array<{ selector: string; properties: string }>;
}>;
}
export interface StyleModification {
selector: string;
property: string;
oldValue: string;
newValue: string;
source: string;
sourceLine: number;
timestamp: number;
method: 'setStyleTexts' | 'inline';
}
// ─── Constants ──────────────────────────────────────────────────
/** ~55 key CSS properties for computed style output */
const KEY_CSS_PROPERTIES = [
'display', 'position', 'top', 'right', 'bottom', 'left',
'float', 'clear', 'z-index', 'overflow', 'overflow-x', 'overflow-y',
'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height',
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
'border-style', 'border-color',
'font-family', 'font-size', 'font-weight', 'line-height',
'color', 'background-color', 'background-image', 'opacity',
'box-shadow', 'border-radius', 'transform', 'transition',
'flex-direction', 'flex-wrap', 'justify-content', 'align-items', 'gap',
'grid-template-columns', 'grid-template-rows',
'text-align', 'text-decoration', 'visibility', 'cursor', 'pointer-events',
];
const KEY_CSS_SET = new Set(KEY_CSS_PROPERTIES);
// ─── Session Management ─────────────────────────────────────────
/** Map of Page → CDP session. Sessions are reused per page. */
const cdpSessions = new WeakMap<Page, any>();
/** Track which pages have initialized DOM+CSS domains */
const initializedPages = new WeakSet<Page>();
/**
* Get or create a CDP session for the given page.
* Enables DOM + CSS domains on first use.
*/
async function getOrCreateSession(page: Page): Promise<any> {
let session = cdpSessions.get(page);
if (session) {
// Verify session is still alive
try {
await session.send('DOM.getDocument', { depth: 0 });
return session;
} catch (err: any) {
// Session is stale — recreate (CDP disconnects throw on closed/Target errors)
if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('detached')) throw err;
cdpSessions.delete(page);
initializedPages.delete(page);
}
}
session = await getOrCreateCdpSession(page, cdpSessions);
// Enable DOM and CSS domains on first init for this page. The session
// itself is cached + close-detached by getOrCreateCdpSession; the
// initializedPages WeakSet is inspector-layer state that needs its
// own close hook to stay in sync.
if (!initializedPages.has(page)) {
await session.send('DOM.enable');
await session.send('CSS.enable');
initializedPages.add(page);
page.once('close', () => initializedPages.delete(page));
}
// Auto-detach on navigation — DOM/CSS domain state is tied to the
// document. Close-detach (from getOrCreateCdpSession) handles the
// tab-close case; framenavigated catches in-tab navigation that
// invalidates inspector state without closing the tab.
page.once('framenavigated', () => {
try {
session.detach().catch(() => {});
} catch (err: any) {
if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('detached')) throw err;
}
cdpSessions.delete(page);
initializedPages.delete(page);
});
return session;
}
// ─── Modification History ───────────────────────────────────────
// Bounded FIFO of style modifications. Pre-cap, this was an unbounded
// module-scoped array that grew for every CSS edit made through $B css
// across the whole browser session — small per-entry footprint but no
// upper bound, the kind of slow leak that compounds over multi-day
// inspector use. The cap is 200 because per-session undo workflows
// rarely walk back more than a handful of edits, and a user who really
// wants to roll a long change back can `$B css reset` to revert all of
// them. totalPushed is monotonic across the session so undoModification
// can tell the user when their target index has been evicted, instead
// of just "no modification at index N".
const MOD_HISTORY_CAP = 200;
const modificationHistory: StyleModification[] = [];
let modHistoryTotalPushed = 0;
function pushModification(mod: StyleModification): void {
modificationHistory.push(mod);
modHistoryTotalPushed++;
while (modificationHistory.length > MOD_HISTORY_CAP) {
modificationHistory.shift();
}
}
// Test-only entry: exposes the history-cap mechanics (push, reset, cap value)
// without requiring a CDP-driven Page. Production code must go through
// modifyStyle / undoModification / resetModifications.
export const __testInternals = {
pushModification,
MOD_HISTORY_CAP,
getRawHistory: () => modificationHistory.slice(),
getTotalPushed: () => modHistoryTotalPushed,
resetForTest: () => {
modificationHistory.length = 0;
modHistoryTotalPushed = 0;
},
};
// ─── Specificity Calculation ────────────────────────────────────
/**
* Parse a CSS selector and compute its specificity as {a, b, c}.
* a = ID selectors, b = class/attr/pseudo-class, c = type/pseudo-element
*/
function computeSpecificity(selector: string): { a: number; b: number; c: number } {
let a = 0, b = 0, c = 0;
// Remove :not() wrapper but count its contents
let cleaned = selector;
// Count IDs: #foo
const ids = cleaned.match(/#[a-zA-Z_-][\w-]*/g);
if (ids) a += ids.length;
// Count classes: .foo, attribute selectors: [attr], pseudo-classes: :hover (not ::)
const classes = cleaned.match(/\.[a-zA-Z_-][\w-]*/g);
if (classes) b += classes.length;
const attrs = cleaned.match(/\[[^\]]+\]/g);
if (attrs) b += attrs.length;
const pseudoClasses = cleaned.match(/(?<!:):[a-zA-Z][\w-]*/g);
if (pseudoClasses) b += pseudoClasses.length;
// Count type selectors: div, span (not * universal)
const types = cleaned.match(/(?:^|[\s+~>])([a-zA-Z][\w-]*)/g);
if (types) c += types.length;
// Count pseudo-elements: ::before, ::after
const pseudoElements = cleaned.match(/::[a-zA-Z][\w-]*/g);
if (pseudoElements) c += pseudoElements.length;
return { a, b, c };
}
/**
* Compare specificities: returns negative if s1 < s2, positive if s1 > s2, 0 if equal.
*/
function compareSpecificity(
s1: { a: number; b: number; c: number },
s2: { a: number; b: number; c: number }
): number {
if (s1.a !== s2.a) return s1.a - s2.a;
if (s1.b !== s2.b) return s1.b - s2.b;
return s1.c - s2.c;
}
// ─── Core Functions ─────────────────────────────────────────────
/**
* Inspect an element via CDP, returning full CSS cascade data.
*/
export async function inspectElement(
page: Page,
selector: string,
options?: { includeUA?: boolean }
): Promise<InspectorResult> {
const session = await getOrCreateSession(page);
// Get document root
const { root } = await session.send('DOM.getDocument', { depth: 0 });
// Query for the element
let nodeId: number;
try {
const result = await session.send('DOM.querySelector', {
nodeId: root.nodeId,
selector,
});
nodeId = result.nodeId;
if (!nodeId) throw new Error(`Element not found: ${selector}`);
} catch (err: any) {
throw new Error(`Element not found: ${selector}${err.message}`);
}
// Get element attributes
const { node } = await session.send('DOM.describeNode', { nodeId, depth: 0 });
const tagName = (node.localName || node.nodeName || '').toLowerCase();
const attrPairs = node.attributes || [];
const attributes: Record<string, string> = {};
for (let i = 0; i < attrPairs.length; i += 2) {
attributes[attrPairs[i]] = attrPairs[i + 1];
}
const id = attributes.id || null;
const classes = attributes.class ? attributes.class.split(/\s+/).filter(Boolean) : [];
// Get box model
let boxModel = {
content: { x: 0, y: 0, width: 0, height: 0 },
padding: { top: 0, right: 0, bottom: 0, left: 0 },
border: { top: 0, right: 0, bottom: 0, left: 0 },
margin: { top: 0, right: 0, bottom: 0, left: 0 },
};
try {
const boxData = await session.send('DOM.getBoxModel', { nodeId });
const model = boxData.model;
// Content quad: [x1,y1, x2,y2, x3,y3, x4,y4]
const content = model.content;
const padding = model.padding;
const border = model.border;
const margin = model.margin;
const contentX = content[0];
const contentY = content[1];
const contentWidth = content[2] - content[0];
const contentHeight = content[5] - content[1];
boxModel = {
content: { x: contentX, y: contentY, width: contentWidth, height: contentHeight },
padding: {
top: content[1] - padding[1],
right: padding[2] - content[2],
bottom: padding[5] - content[5],
left: content[0] - padding[0],
},
border: {
top: padding[1] - border[1],
right: border[2] - padding[2],
bottom: border[5] - padding[5],
left: padding[0] - border[0],
},
margin: {
top: border[1] - margin[1],
right: margin[2] - border[2],
bottom: margin[5] - border[5],
left: border[0] - margin[0],
},
};
} catch (err: any) {
// Element may not have a box model (e.g., display:none) — CDP returns "Could not compute box model"
if (!err?.message?.includes('box model') && !err?.message?.includes('Could not compute')) throw err;
}
// Get matched styles
const matchedData = await session.send('CSS.getMatchedStylesForNode', { nodeId });
// Get computed styles
const computedData = await session.send('CSS.getComputedStyleForNode', { nodeId });
const computedStyles: Record<string, string> = {};
for (const entry of computedData.computedStyle) {
if (KEY_CSS_SET.has(entry.name)) {
computedStyles[entry.name] = entry.value;
}
}
// Get inline styles
const inlineData = await session.send('CSS.getInlineStylesForNode', { nodeId });
const inlineStyles: Record<string, string> = {};
if (inlineData.inlineStyle?.cssProperties) {
for (const prop of inlineData.inlineStyle.cssProperties) {
if (prop.name && prop.value && !prop.disabled) {
inlineStyles[prop.name] = prop.value;
}
}
}
// Process matched rules
const matchedRules: InspectorResult['matchedRules'] = [];
// Track all property values to mark overridden ones
const seenProperties = new Map<string, number>(); // property → index of highest-specificity rule
if (matchedData.matchedCSSRules) {
for (const match of matchedData.matchedCSSRules) {
const rule = match.rule;
const isUA = rule.origin === 'user-agent';
if (isUA && !options?.includeUA) continue;
// Get the matching selector text
let selectorText = '';
if (rule.selectorList?.selectors) {
// Use the specific matching selector
const matchingIdx = match.matchingSelectors?.[0] ?? 0;
selectorText = rule.selectorList.selectors[matchingIdx]?.text || rule.selectorList.text || '';
}
// Get source info
let source = 'inline';
let sourceLine = 0;
let sourceColumn = 0;
let styleSheetId: string | undefined;
let range: object | undefined;
if (rule.styleSheetId) {
styleSheetId = rule.styleSheetId;
// Resolve stylesheet source name
source = rule.origin === 'regular' ? (rule.styleSheetId || 'stylesheet') : rule.origin;
}
if (rule.style?.range) {
range = rule.style.range;
sourceLine = rule.style.range.startLine || 0;
sourceColumn = rule.style.range.startColumn || 0;
}
// Try to get a friendly source name from stylesheet
// (styleSheetId metadata is available via CDP — see stylesheet URL resolution below)
// Get media query if present
let media: string | undefined;
if (match.rule?.media) {
const mediaList = match.rule.media;
if (Array.isArray(mediaList) && mediaList.length > 0) {
media = mediaList.map((m: any) => m.text).filter(Boolean).join(', ');
}
}
const specificity = computeSpecificity(selectorText);
// Process CSS properties
const properties: Array<{ name: string; value: string; important: boolean; overridden: boolean }> = [];
if (rule.style?.cssProperties) {
for (const prop of rule.style.cssProperties) {
if (!prop.name || prop.disabled) continue;
// Skip internal/vendor properties unless they are in our key set
if (prop.name.startsWith('-') && !KEY_CSS_SET.has(prop.name)) continue;
properties.push({
name: prop.name,
value: prop.value || '',
important: prop.important || (prop.value?.includes('!important') ?? false),
overridden: false, // will be set later
});
}
}
matchedRules.push({
selector: selectorText,
properties,
source,
sourceLine,
sourceColumn,
specificity,
media,
userAgent: isUA,
styleSheetId,
range,
});
}
}
// Sort by specificity (highest first — these win)
matchedRules.sort((a, b) => -compareSpecificity(a.specificity, b.specificity));
// Mark overridden properties: the first rule in the sorted list (highest specificity) wins
for (let i = 0; i < matchedRules.length; i++) {
for (const prop of matchedRules[i].properties) {
const key = prop.name;
if (!seenProperties.has(key)) {
seenProperties.set(key, i);
} else {
// This property was already declared by a higher-specificity rule
// Unless this one is !important and the earlier one isn't
const earlierIdx = seenProperties.get(key)!;
const earlierRule = matchedRules[earlierIdx];
const earlierProp = earlierRule.properties.find(p => p.name === key);
if (prop.important && earlierProp && !earlierProp.important) {
// This !important overrides the earlier non-important
if (earlierProp) earlierProp.overridden = true;
seenProperties.set(key, i);
} else {
prop.overridden = true;
}
}
}
}
// Process pseudo-elements
const pseudoElements: InspectorResult['pseudoElements'] = [];
if (matchedData.pseudoElements) {
for (const pseudo of matchedData.pseudoElements) {
const pseudoType = pseudo.pseudoType || 'unknown';
const rules: Array<{ selector: string; properties: string }> = [];
if (pseudo.matches) {
for (const match of pseudo.matches) {
const rule = match.rule;
const sel = rule.selectorList?.text || '';
const props = (rule.style?.cssProperties || [])
.filter((p: any) => p.name && !p.disabled)
.map((p: any) => `${p.name}: ${p.value}`)
.join('; ');
if (props) {
rules.push({ selector: sel, properties: props });
}
}
}
if (rules.length > 0) {
pseudoElements.push({ pseudo: `::${pseudoType}`, rules });
}
}
}
// Resolve stylesheet URLs for better source info
// Note: CSS.getStyleSheetText is called per-rule but result is unused — the styleSheetId
// is opaque and CDP doesn't expose a direct URL lookup. Left as a placeholder for future
// enhancement (e.g., CSS.styleSheetAdded event tracking).
return {
selector,
tagName,
id,
classes,
attributes,
boxModel,
computedStyles,
matchedRules,
inlineStyles,
pseudoElements,
};
}
/**
* Modify a CSS property on an element.
* Uses CSS.setStyleTexts in headed mode, falls back to inline style in headless.
*/
export async function modifyStyle(
page: Page,
selector: string,
property: string,
value: string
): Promise<StyleModification> {
// Validate CSS property name
if (!/^[a-zA-Z-]+$/.test(property)) {
throw new Error(`Invalid CSS property name: ${property}. Only letters and hyphens allowed.`);
}
// Validate CSS value — block data exfiltration patterns
const DANGEROUS_CSS = /url\s*\(|expression\s*\(|@import|javascript:|data:/i;
if (DANGEROUS_CSS.test(value)) {
throw new Error('CSS value rejected: contains potentially dangerous pattern.');
}
let oldValue = '';
let source = 'inline';
let sourceLine = 0;
let method: 'setStyleTexts' | 'inline' = 'inline';
try {
// Try CDP approach first
const session = await getOrCreateSession(page);
const result = await inspectElement(page, selector);
oldValue = result.computedStyles[property] || '';
// Find the most-specific matching rule that has this property
let targetRule: InspectorResult['matchedRules'][0] | null = null;
for (const rule of result.matchedRules) {
if (rule.userAgent) continue;
const hasProp = rule.properties.some(p => p.name === property);
if (hasProp && rule.styleSheetId && rule.range) {
targetRule = rule;
break;
}
}
if (targetRule?.styleSheetId && targetRule.range) {
// Modify via CSS.setStyleTexts
const range = targetRule.range as any;
// Get current style text
const styleText = await session.send('CSS.getStyleSheetText', {
styleSheetId: targetRule.styleSheetId,
});
// Build new style text by replacing the property value
const currentProps = targetRule.properties;
const newPropsText = currentProps
.map(p => {
if (p.name === property) {
return `${p.name}: ${value}`;
}
return `${p.name}: ${p.value}`;
})
.join('; ');
try {
await session.send('CSS.setStyleTexts', {
edits: [{
styleSheetId: targetRule.styleSheetId,
range,
text: newPropsText,
}],
});
method = 'setStyleTexts';
source = `${targetRule.source}:${targetRule.sourceLine}`;
sourceLine = targetRule.sourceLine;
} catch (err: any) {
// Fall back to inline — setStyleTexts fails on immutable stylesheets or stale ranges
if (!err?.message?.includes('style') && !err?.message?.includes('range') && !err?.message?.includes('closed') && !err?.message?.includes('Target')) throw err;
}
}
if (method === 'inline') {
// Fallback: modify via inline style
await page.evaluate(
([sel, prop, val]) => {
const el = document.querySelector(sel);
if (!el) throw new Error(`Element not found: ${sel}`);
(el as HTMLElement).style.setProperty(prop, val);
},
[selector, property, value]
);
}
} catch (err: any) {
// Full fallback: use page.evaluate for headless
await page.evaluate(
([sel, prop, val]) => {
const el = document.querySelector(sel);
if (!el) throw new Error(`Element not found: ${sel}`);
(el as HTMLElement).style.setProperty(prop, val);
},
[selector, property, value]
);
}
const modification: StyleModification = {
selector,
property,
oldValue,
newValue: value,
source,
sourceLine,
timestamp: Date.now(),
method,
};
pushModification(modification);
return modification;
}
/**
* Undo a modification by index (or last if no index given).
*/
export async function undoModification(page: Page, index?: number): Promise<void> {
const idx = index ?? modificationHistory.length - 1;
if (idx < 0 || idx >= modificationHistory.length) {
const evictedNote = modHistoryTotalPushed > MOD_HISTORY_CAP
? ` (most recent ${MOD_HISTORY_CAP} only — ${modHistoryTotalPushed - MOD_HISTORY_CAP} earlier entries evicted at the cap)`
: '';
throw new Error(
`No modification at index ${idx}. History has ${modificationHistory.length} entries${evictedNote}.`,
);
}
const mod = modificationHistory[idx];
if (mod.method === 'setStyleTexts') {
// Try to restore via CDP
try {
await modifyStyle(page, mod.selector, mod.property, mod.oldValue);
// Remove the undo modification from history (it's a restore, not a new mod)
modificationHistory.pop();
} catch (err: any) {
// Fall back to inline restore — CDP may have disconnected or stylesheet changed
if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('style') && !err?.message?.includes('not found') && !err?.message?.includes('Element')) throw err;
await page.evaluate(
([sel, prop, val]) => {
const el = document.querySelector(sel);
if (!el) return;
if (val) {
(el as HTMLElement).style.setProperty(prop, val);
} else {
(el as HTMLElement).style.removeProperty(prop);
}
},
[mod.selector, mod.property, mod.oldValue]
);
}
} else {
// Inline modification — restore or remove
await page.evaluate(
([sel, prop, val]) => {
const el = document.querySelector(sel);
if (!el) return;
if (val) {
(el as HTMLElement).style.setProperty(prop, val);
} else {
(el as HTMLElement).style.removeProperty(prop);
}
},
[mod.selector, mod.property, mod.oldValue]
);
}
modificationHistory.splice(idx, 1);
}
/**
* Get the full modification history.
*/
export function getModificationHistory(): StyleModification[] {
return [...modificationHistory];
}
/**
* Diagnostic accessor for the $B memory snapshot. Returns current buffer
* occupancy, the cap, and how many entries have been evicted since the
* last reset.
*/
export function getModificationHistoryStats(): {
current: number;
cap: number;
evicted: number;
} {
return {
current: modificationHistory.length,
cap: MOD_HISTORY_CAP,
evicted: Math.max(0, modHistoryTotalPushed - MOD_HISTORY_CAP),
};
}
/**
* Reset all modifications, restoring original values.
*/
export async function resetModifications(page: Page): Promise<void> {
// Restore in reverse order
for (let i = modificationHistory.length - 1; i >= 0; i--) {
const mod = modificationHistory[i];
try {
await page.evaluate(
([sel, prop, val]) => {
const el = document.querySelector(sel);
if (!el) return;
if (val) {
(el as HTMLElement).style.setProperty(prop, val);
} else {
(el as HTMLElement).style.removeProperty(prop);
}
},
[mod.selector, mod.property, mod.oldValue]
);
} catch (err: any) {
// Best effort — page may have navigated or element may be gone
if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('Execution context')) throw err;
}
}
modificationHistory.length = 0;
modHistoryTotalPushed = 0;
}
/**
* Format an InspectorResult for CLI text output.
*/
export function formatInspectorResult(
result: InspectorResult,
options?: { includeUA?: boolean }
): string {
const lines: string[] = [];
// Element header
const classStr = result.classes.length > 0 ? ` class="${result.classes.join(' ')}"` : '';
const idStr = result.id ? ` id="${result.id}"` : '';
lines.push(`Element: <${result.tagName}${idStr}${classStr}>`);
lines.push(`Selector: ${result.selector}`);
const w = Math.round(result.boxModel.content.width + result.boxModel.padding.left + result.boxModel.padding.right);
const h = Math.round(result.boxModel.content.height + result.boxModel.padding.top + result.boxModel.padding.bottom);
lines.push(`Dimensions: ${w} x ${h}`);
lines.push('');
// Box model
lines.push('Box Model:');
const bm = result.boxModel;
lines.push(` margin: ${Math.round(bm.margin.top)}px ${Math.round(bm.margin.right)}px ${Math.round(bm.margin.bottom)}px ${Math.round(bm.margin.left)}px`);
lines.push(` padding: ${Math.round(bm.padding.top)}px ${Math.round(bm.padding.right)}px ${Math.round(bm.padding.bottom)}px ${Math.round(bm.padding.left)}px`);
lines.push(` border: ${Math.round(bm.border.top)}px ${Math.round(bm.border.right)}px ${Math.round(bm.border.bottom)}px ${Math.round(bm.border.left)}px`);
lines.push(` content: ${Math.round(bm.content.width)} x ${Math.round(bm.content.height)}`);
lines.push('');
// Matched rules
const displayRules = options?.includeUA
? result.matchedRules
: result.matchedRules.filter(r => !r.userAgent);
lines.push(`Matched Rules (${displayRules.length}):`);
if (displayRules.length === 0) {
lines.push(' (none)');
} else {
for (const rule of displayRules) {
const propsStr = rule.properties
.filter(p => !p.overridden)
.map(p => `${p.name}: ${p.value}${p.important ? ' !important' : ''}`)
.join('; ');
if (!propsStr) continue;
const spec = `[${rule.specificity.a},${rule.specificity.b},${rule.specificity.c}]`;
lines.push(` ${rule.selector} { ${propsStr} }`);
lines.push(` -> ${rule.source}:${rule.sourceLine} ${spec}${rule.media ? ` @media ${rule.media}` : ''}`);
}
}
lines.push('');
// Inline styles
lines.push('Inline Styles:');
const inlineEntries = Object.entries(result.inlineStyles);
if (inlineEntries.length === 0) {
lines.push(' (none)');
} else {
const inlineStr = inlineEntries.map(([k, v]) => `${k}: ${v}`).join('; ');
lines.push(` ${inlineStr}`);
}
lines.push('');
// Computed styles (key properties, compact format)
lines.push('Computed (key):');
const cs = result.computedStyles;
const computedPairs: string[] = [];
for (const prop of KEY_CSS_PROPERTIES) {
if (cs[prop] !== undefined) {
computedPairs.push(`${prop}: ${cs[prop]}`);
}
}
// Group into lines of ~3 properties each
for (let i = 0; i < computedPairs.length; i += 3) {
const chunk = computedPairs.slice(i, i + 3);
lines.push(` ${chunk.join(' | ')}`);
}
// Pseudo-elements
if (result.pseudoElements.length > 0) {
lines.push('');
lines.push('Pseudo-elements:');
for (const pseudo of result.pseudoElements) {
for (const rule of pseudo.rules) {
lines.push(` ${pseudo.pseudo} ${rule.selector} { ${rule.properties} }`);
}
}
}
return lines.join('\n');
}
/**
* Detach CDP session for a page (or all pages).
*/
export function detachSession(page?: Page): void {
if (page) {
const session = cdpSessions.get(page);
if (session) {
try { session.detach().catch(() => {}); } catch (err: any) { if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('detached')) throw err; }
cdpSessions.delete(page);
initializedPages.delete(page);
}
}
// Note: WeakMap doesn't support iteration, so we can't detach all.
// Callers with specific pages should call this per-page.
}