Skill Tracer
Skills › gstack › browse › cdp-inspector.ts

cdp-inspector.ts

824 lines · browse/src/cdp-inspector.ts at 3f79d81

1/**
2 * CDP Inspector — Chrome DevTools Protocol integration for deep CSS inspection
3 *
4 * Manages a persistent CDP session per active page for:
5 * - Full CSS rule cascade inspection (matched rules, computed styles, inline styles)
6 * - Box model measurement
7 * - Live CSS modification via CSS.setStyleTexts
8 * - Modification history with undo/reset
9 *
10 * Session lifecycle:
11 * Create on first inspect call → reuse across inspections → detach on
12 * navigation/tab switch/shutdown → re-create transparently on next call
13 */
15import type { Page } from 'playwright';
16import { getOrCreateCdpSession } from './cdp-bridge';
18// ─── Types ──────────────────────────────────────────────────────
20export interface InspectorResult {
21 selector: string;
22 tagName: string;
23 id: string | null;
24 classes: string[];
25 attributes: Record<string, string>;
26 boxModel: {
27 content: { x: number; y: number; width: number; height: number };
28 padding: { top: number; right: number; bottom: number; left: number };
29 border: { top: number; right: number; bottom: number; left: number };
30 margin: { top: number; right: number; bottom: number; left: number };
31 };
32 computedStyles: Record<string, string>;
33 matchedRules: Array<{
34 selector: string;
35 properties: Array<{ name: string; value: string; important: boolean; overridden: boolean }>;
36 source: string;
37 sourceLine: number;
38 sourceColumn: number;
39 specificity: { a: number; b: number; c: number };
40 media?: string;
41 userAgent: boolean;
42 styleSheetId?: string;
43 range?: object;
44 }>;
45 inlineStyles: Record<string, string>;
46 pseudoElements: Array<{
47 pseudo: string;
48 rules: Array<{ selector: string; properties: string }>;
49 }>;
52export interface StyleModification {
53 selector: string;
54 property: string;
55 oldValue: string;
56 newValue: string;
57 source: string;
58 sourceLine: number;
59 timestamp: number;
60 method: 'setStyleTexts' | 'inline';
63// ─── Constants ──────────────────────────────────────────────────
65/** ~55 key CSS properties for computed style output */
66const KEY_CSS_PROPERTIES = [
67 'display', 'position', 'top', 'right', 'bottom', 'left',
68 'float', 'clear', 'z-index', 'overflow', 'overflow-x', 'overflow-y',
69 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height',
70 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
71 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
72 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
73 'border-style', 'border-color',
74 'font-family', 'font-size', 'font-weight', 'line-height',
75 'color', 'background-color', 'background-image', 'opacity',
76 'box-shadow', 'border-radius', 'transform', 'transition',
77 'flex-direction', 'flex-wrap', 'justify-content', 'align-items', 'gap',
78 'grid-template-columns', 'grid-template-rows',
79 'text-align', 'text-decoration', 'visibility', 'cursor', 'pointer-events',
80];
82const KEY_CSS_SET = new Set(KEY_CSS_PROPERTIES);
84// ─── Session Management ─────────────────────────────────────────
86/** Map of Page → CDP session. Sessions are reused per page. */
87const cdpSessions = new WeakMap<Page, any>();
88/** Track which pages have initialized DOM+CSS domains */
89const initializedPages = new WeakSet<Page>();
91/**
92 * Get or create a CDP session for the given page.
93 * Enables DOM + CSS domains on first use.
94 */
95async function getOrCreateSession(page: Page): Promise<any> {
96 let session = cdpSessions.get(page);
97 if (session) {
98 // Verify session is still alive
99 try {
100 await session.send('DOM.getDocument', { depth: 0 });
101 return session;
102 } catch (err: any) {
103 // Session is stale — recreate (CDP disconnects throw on closed/Target errors)
104 if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('detached')) throw err;
105 cdpSessions.delete(page);
106 initializedPages.delete(page);
107 }
108 }
110 session = await getOrCreateCdpSession(page, cdpSessions);
112 // Enable DOM and CSS domains on first init for this page. The session
113 // itself is cached + close-detached by getOrCreateCdpSession; the
114 // initializedPages WeakSet is inspector-layer state that needs its
115 // own close hook to stay in sync.
116 if (!initializedPages.has(page)) {
117 await session.send('DOM.enable');
118 await session.send('CSS.enable');
119 initializedPages.add(page);
120 page.once('close', () => initializedPages.delete(page));
121 }
123 // Auto-detach on navigation — DOM/CSS domain state is tied to the
124 // document. Close-detach (from getOrCreateCdpSession) handles the
125 // tab-close case; framenavigated catches in-tab navigation that
126 // invalidates inspector state without closing the tab.
127 page.once('framenavigated', () => {
128 try {
129 session.detach().catch(() => {});
130 } catch (err: any) {
131 if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('detached')) throw err;
132 }
133 cdpSessions.delete(page);
134 initializedPages.delete(page);
135 });
137 return session;
140// ─── Modification History ───────────────────────────────────────
142// Bounded FIFO of style modifications. Pre-cap, this was an unbounded
143// module-scoped array that grew for every CSS edit made through $B css
144// across the whole browser session — small per-entry footprint but no
145// upper bound, the kind of slow leak that compounds over multi-day
146// inspector use. The cap is 200 because per-session undo workflows
147// rarely walk back more than a handful of edits, and a user who really
148// wants to roll a long change back can `$B css reset` to revert all of
149// them. totalPushed is monotonic across the session so undoModification
150// can tell the user when their target index has been evicted, instead
151// of just "no modification at index N".
152const MOD_HISTORY_CAP = 200;
153const modificationHistory: StyleModification[] = [];
154let modHistoryTotalPushed = 0;
156function pushModification(mod: StyleModification): void {
157 modificationHistory.push(mod);
158 modHistoryTotalPushed++;
159 while (modificationHistory.length > MOD_HISTORY_CAP) {
160 modificationHistory.shift();
161 }
164// Test-only entry: exposes the history-cap mechanics (push, reset, cap value)
165// without requiring a CDP-driven Page. Production code must go through
166// modifyStyle / undoModification / resetModifications.
167export const __testInternals = {
168 pushModification,
169 MOD_HISTORY_CAP,
170 getRawHistory: () => modificationHistory.slice(),
171 getTotalPushed: () => modHistoryTotalPushed,
172 resetForTest: () => {
173 modificationHistory.length = 0;
174 modHistoryTotalPushed = 0;
175 },
176};
178// ─── Specificity Calculation ────────────────────────────────────
180/**
181 * Parse a CSS selector and compute its specificity as {a, b, c}.
182 * a = ID selectors, b = class/attr/pseudo-class, c = type/pseudo-element
183 */
184function computeSpecificity(selector: string): { a: number; b: number; c: number } {
185 let a = 0, b = 0, c = 0;
187 // Remove :not() wrapper but count its contents
188 let cleaned = selector;
190 // Count IDs: #foo
191 const ids = cleaned.match(/#[a-zA-Z_-][\w-]*/g);
192 if (ids) a += ids.length;
194 // Count classes: .foo, attribute selectors: [attr], pseudo-classes: :hover (not ::)
195 const classes = cleaned.match(/\.[a-zA-Z_-][\w-]*/g);
196 if (classes) b += classes.length;
197 const attrs = cleaned.match(/\[[^\]]+\]/g);
198 if (attrs) b += attrs.length;
199 const pseudoClasses = cleaned.match(/(?<!:):[a-zA-Z][\w-]*/g);
200 if (pseudoClasses) b += pseudoClasses.length;
202 // Count type selectors: div, span (not * universal)
203 const types = cleaned.match(/(?:^|[\s+~>])([a-zA-Z][\w-]*)/g);
204 if (types) c += types.length;
205 // Count pseudo-elements: ::before, ::after
206 const pseudoElements = cleaned.match(/::[a-zA-Z][\w-]*/g);
207 if (pseudoElements) c += pseudoElements.length;
209 return { a, b, c };
212/**
213 * Compare specificities: returns negative if s1 < s2, positive if s1 > s2, 0 if equal.
214 */
215function compareSpecificity(
216 s1: { a: number; b: number; c: number },
217 s2: { a: number; b: number; c: number }
218): number {
219 if (s1.a !== s2.a) return s1.a - s2.a;
220 if (s1.b !== s2.b) return s1.b - s2.b;
221 return s1.c - s2.c;
224// ─── Core Functions ─────────────────────────────────────────────
226/**
227 * Inspect an element via CDP, returning full CSS cascade data.
228 */
229export async function inspectElement(
230 page: Page,
231 selector: string,
232 options?: { includeUA?: boolean }
233): Promise<InspectorResult> {
234 const session = await getOrCreateSession(page);
236 // Get document root
237 const { root } = await session.send('DOM.getDocument', { depth: 0 });
239 // Query for the element
240 let nodeId: number;
241 try {
242 const result = await session.send('DOM.querySelector', {
243 nodeId: root.nodeId,
244 selector,
245 });
246 nodeId = result.nodeId;
247 if (!nodeId) throw new Error(`Element not found: ${selector}`);
248 } catch (err: any) {
249 throw new Error(`Element not found: ${selector} — ${err.message}`);
250 }
252 // Get element attributes
253 const { node } = await session.send('DOM.describeNode', { nodeId, depth: 0 });
254 const tagName = (node.localName || node.nodeName || '').toLowerCase();
255 const attrPairs = node.attributes || [];
256 const attributes: Record<string, string> = {};
257 for (let i = 0; i < attrPairs.length; i += 2) {
258 attributes[attrPairs[i]] = attrPairs[i + 1];
259 }
260 const id = attributes.id || null;
261 const classes = attributes.class ? attributes.class.split(/\s+/).filter(Boolean) : [];
263 // Get box model
264 let boxModel = {
265 content: { x: 0, y: 0, width: 0, height: 0 },
266 padding: { top: 0, right: 0, bottom: 0, left: 0 },
267 border: { top: 0, right: 0, bottom: 0, left: 0 },
268 margin: { top: 0, right: 0, bottom: 0, left: 0 },
269 };
271 try {
272 const boxData = await session.send('DOM.getBoxModel', { nodeId });
273 const model = boxData.model;
275 // Content quad: [x1,y1, x2,y2, x3,y3, x4,y4]
276 const content = model.content;
277 const padding = model.padding;
278 const border = model.border;
279 const margin = model.margin;
281 const contentX = content[0];
282 const contentY = content[1];
283 const contentWidth = content[2] - content[0];
284 const contentHeight = content[5] - content[1];
286 boxModel = {
287 content: { x: contentX, y: contentY, width: contentWidth, height: contentHeight },
288 padding: {
289 top: content[1] - padding[1],
290 right: padding[2] - content[2],
291 bottom: padding[5] - content[5],
292 left: content[0] - padding[0],
293 },
294 border: {
295 top: padding[1] - border[1],
296 right: border[2] - padding[2],
297 bottom: border[5] - padding[5],
298 left: padding[0] - border[0],
299 },
300 margin: {
301 top: border[1] - margin[1],
302 right: margin[2] - border[2],
303 bottom: margin[5] - border[5],
304 left: border[0] - margin[0],
305 },
306 };
307 } catch (err: any) {
308 // Element may not have a box model (e.g., display:none) — CDP returns "Could not compute box model"
309 if (!err?.message?.includes('box model') && !err?.message?.includes('Could not compute')) throw err;
310 }
312 // Get matched styles
313 const matchedData = await session.send('CSS.getMatchedStylesForNode', { nodeId });
315 // Get computed styles
316 const computedData = await session.send('CSS.getComputedStyleForNode', { nodeId });
317 const computedStyles: Record<string, string> = {};
318 for (const entry of computedData.computedStyle) {
319 if (KEY_CSS_SET.has(entry.name)) {
320 computedStyles[entry.name] = entry.value;
321 }
322 }
324 // Get inline styles
325 const inlineData = await session.send('CSS.getInlineStylesForNode', { nodeId });
326 const inlineStyles: Record<string, string> = {};
327 if (inlineData.inlineStyle?.cssProperties) {
328 for (const prop of inlineData.inlineStyle.cssProperties) {
329 if (prop.name && prop.value && !prop.disabled) {
330 inlineStyles[prop.name] = prop.value;
331 }
332 }
333 }
335 // Process matched rules
336 const matchedRules: InspectorResult['matchedRules'] = [];
338 // Track all property values to mark overridden ones
339 const seenProperties = new Map<string, number>(); // property → index of highest-specificity rule
341 if (matchedData.matchedCSSRules) {
342 for (const match of matchedData.matchedCSSRules) {
343 const rule = match.rule;
344 const isUA = rule.origin === 'user-agent';
346 if (isUA && !options?.includeUA) continue;
348 // Get the matching selector text
349 let selectorText = '';
350 if (rule.selectorList?.selectors) {
351 // Use the specific matching selector
352 const matchingIdx = match.matchingSelectors?.[0] ?? 0;
353 selectorText = rule.selectorList.selectors[matchingIdx]?.text || rule.selectorList.text || '';
354 }
356 // Get source info
357 let source = 'inline';
358 let sourceLine = 0;
359 let sourceColumn = 0;
360 let styleSheetId: string | undefined;
361 let range: object | undefined;
363 if (rule.styleSheetId) {
364 styleSheetId = rule.styleSheetId;
365 // Resolve stylesheet source name
366 source = rule.origin === 'regular' ? (rule.styleSheetId || 'stylesheet') : rule.origin;
367 }
369 if (rule.style?.range) {
370 range = rule.style.range;
371 sourceLine = rule.style.range.startLine || 0;
372 sourceColumn = rule.style.range.startColumn || 0;
373 }
375 // Try to get a friendly source name from stylesheet
376 // (styleSheetId metadata is available via CDP — see stylesheet URL resolution below)
378 // Get media query if present
379 let media: string | undefined;
380 if (match.rule?.media) {
381 const mediaList = match.rule.media;
382 if (Array.isArray(mediaList) && mediaList.length > 0) {
383 media = mediaList.map((m: any) => m.text).filter(Boolean).join(', ');
384 }
385 }
387 const specificity = computeSpecificity(selectorText);
389 // Process CSS properties
390 const properties: Array<{ name: string; value: string; important: boolean; overridden: boolean }> = [];
391 if (rule.style?.cssProperties) {
392 for (const prop of rule.style.cssProperties) {
393 if (!prop.name || prop.disabled) continue;
394 // Skip internal/vendor properties unless they are in our key set
395 if (prop.name.startsWith('-') && !KEY_CSS_SET.has(prop.name)) continue;
397 properties.push({
398 name: prop.name,
399 value: prop.value || '',
400 important: prop.important || (prop.value?.includes('!important') ?? false),
401 overridden: false, // will be set later
402 });
403 }
404 }
406 matchedRules.push({
407 selector: selectorText,
408 properties,
409 source,
410 sourceLine,
411 sourceColumn,
412 specificity,
413 media,
414 userAgent: isUA,
415 styleSheetId,
416 range,
417 });
418 }
419 }
421 // Sort by specificity (highest first — these win)
422 matchedRules.sort((a, b) => -compareSpecificity(a.specificity, b.specificity));
424 // Mark overridden properties: the first rule in the sorted list (highest specificity) wins
425 for (let i = 0; i < matchedRules.length; i++) {
426 for (const prop of matchedRules[i].properties) {
427 const key = prop.name;
428 if (!seenProperties.has(key)) {
429 seenProperties.set(key, i);
430 } else {
431 // This property was already declared by a higher-specificity rule
432 // Unless this one is !important and the earlier one isn't
433 const earlierIdx = seenProperties.get(key)!;
434 const earlierRule = matchedRules[earlierIdx];
435 const earlierProp = earlierRule.properties.find(p => p.name === key);
436 if (prop.important && earlierProp && !earlierProp.important) {
437 // This !important overrides the earlier non-important
438 if (earlierProp) earlierProp.overridden = true;
439 seenProperties.set(key, i);
440 } else {
441 prop.overridden = true;
442 }
443 }
444 }
445 }
447 // Process pseudo-elements
448 const pseudoElements: InspectorResult['pseudoElements'] = [];
449 if (matchedData.pseudoElements) {
450 for (const pseudo of matchedData.pseudoElements) {
451 const pseudoType = pseudo.pseudoType || 'unknown';
452 const rules: Array<{ selector: string; properties: string }> = [];
453 if (pseudo.matches) {
454 for (const match of pseudo.matches) {
455 const rule = match.rule;
456 const sel = rule.selectorList?.text || '';
457 const props = (rule.style?.cssProperties || [])
458 .filter((p: any) => p.name && !p.disabled)
459 .map((p: any) => `${p.name}: ${p.value}`)
460 .join('; ');
461 if (props) {
462 rules.push({ selector: sel, properties: props });
463 }
464 }
465 }
466 if (rules.length > 0) {
467 pseudoElements.push({ pseudo: `::${pseudoType}`, rules });
468 }
469 }
470 }
472 // Resolve stylesheet URLs for better source info
473 // Note: CSS.getStyleSheetText is called per-rule but result is unused — the styleSheetId
474 // is opaque and CDP doesn't expose a direct URL lookup. Left as a placeholder for future
475 // enhancement (e.g., CSS.styleSheetAdded event tracking).
477 return {
478 selector,
479 tagName,
480 id,
481 classes,
482 attributes,
483 boxModel,
484 computedStyles,
485 matchedRules,
486 inlineStyles,
487 pseudoElements,
488 };
491/**
492 * Modify a CSS property on an element.
493 * Uses CSS.setStyleTexts in headed mode, falls back to inline style in headless.
494 */
495export async function modifyStyle(
496 page: Page,
497 selector: string,
498 property: string,
499 value: string
500): Promise<StyleModification> {
501 // Validate CSS property name
502 if (!/^[a-zA-Z-]+$/.test(property)) {
503 throw new Error(`Invalid CSS property name: ${property}. Only letters and hyphens allowed.`);
504 }
506 // Validate CSS value — block data exfiltration patterns
507 const DANGEROUS_CSS = /url\s*\(|expression\s*\(|@import|javascript:|data:/i;
508 if (DANGEROUS_CSS.test(value)) {
509 throw new Error('CSS value rejected: contains potentially dangerous pattern.');
510 }
512 let oldValue = '';
513 let source = 'inline';
514 let sourceLine = 0;
515 let method: 'setStyleTexts' | 'inline' = 'inline';
517 try {
518 // Try CDP approach first
519 const session = await getOrCreateSession(page);
520 const result = await inspectElement(page, selector);
521 oldValue = result.computedStyles[property] || '';
523 // Find the most-specific matching rule that has this property
524 let targetRule: InspectorResult['matchedRules'][0] | null = null;
525 for (const rule of result.matchedRules) {
526 if (rule.userAgent) continue;
527 const hasProp = rule.properties.some(p => p.name === property);
528 if (hasProp && rule.styleSheetId && rule.range) {
529 targetRule = rule;
530 break;
531 }
532 }
534 if (targetRule?.styleSheetId && targetRule.range) {
535 // Modify via CSS.setStyleTexts
536 const range = targetRule.range as any;
538 // Get current style text
539 const styleText = await session.send('CSS.getStyleSheetText', {
540 styleSheetId: targetRule.styleSheetId,
541 });
543 // Build new style text by replacing the property value
544 const currentProps = targetRule.properties;
545 const newPropsText = currentProps
546 .map(p => {
547 if (p.name === property) {
548 return `${p.name}: ${value}`;
549 }
550 return `${p.name}: ${p.value}`;
551 })
552 .join('; ');
554 try {
555 await session.send('CSS.setStyleTexts', {
556 edits: [{
557 styleSheetId: targetRule.styleSheetId,
558 range,
559 text: newPropsText,
560 }],
561 });
562 method = 'setStyleTexts';
563 source = `${targetRule.source}:${targetRule.sourceLine}`;
564 sourceLine = targetRule.sourceLine;
565 } catch (err: any) {
566 // Fall back to inline — setStyleTexts fails on immutable stylesheets or stale ranges
567 if (!err?.message?.includes('style') && !err?.message?.includes('range') && !err?.message?.includes('closed') && !err?.message?.includes('Target')) throw err;
568 }
569 }
571 if (method === 'inline') {
572 // Fallback: modify via inline style
573 await page.evaluate(
574 ([sel, prop, val]) => {
575 const el = document.querySelector(sel);
576 if (!el) throw new Error(`Element not found: ${sel}`);
577 (el as HTMLElement).style.setProperty(prop, val);
578 },
579 [selector, property, value]
580 );
581 }
582 } catch (err: any) {
583 // Full fallback: use page.evaluate for headless
584 await page.evaluate(
585 ([sel, prop, val]) => {
586 const el = document.querySelector(sel);
587 if (!el) throw new Error(`Element not found: ${sel}`);
588 (el as HTMLElement).style.setProperty(prop, val);
589 },
590 [selector, property, value]
591 );
592 }
594 const modification: StyleModification = {
595 selector,
596 property,
597 oldValue,
598 newValue: value,
599 source,
600 sourceLine,
601 timestamp: Date.now(),
602 method,
603 };
605 pushModification(modification);
606 return modification;
609/**
610 * Undo a modification by index (or last if no index given).
611 */
612export async function undoModification(page: Page, index?: number): Promise<void> {
613 const idx = index ?? modificationHistory.length - 1;
614 if (idx < 0 || idx >= modificationHistory.length) {
615 const evictedNote = modHistoryTotalPushed > MOD_HISTORY_CAP
616 ? ` (most recent ${MOD_HISTORY_CAP} only — ${modHistoryTotalPushed - MOD_HISTORY_CAP} earlier entries evicted at the cap)`
617 : '';
618 throw new Error(
619 `No modification at index ${idx}. History has ${modificationHistory.length} entries${evictedNote}.`,
620 );
621 }
623 const mod = modificationHistory[idx];
625 if (mod.method === 'setStyleTexts') {
626 // Try to restore via CDP
627 try {
628 await modifyStyle(page, mod.selector, mod.property, mod.oldValue);
629 // Remove the undo modification from history (it's a restore, not a new mod)
630 modificationHistory.pop();
631 } catch (err: any) {
632 // Fall back to inline restore — CDP may have disconnected or stylesheet changed
633 if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('style') && !err?.message?.includes('not found') && !err?.message?.includes('Element')) throw err;
634 await page.evaluate(
635 ([sel, prop, val]) => {
636 const el = document.querySelector(sel);
637 if (!el) return;
638 if (val) {
639 (el as HTMLElement).style.setProperty(prop, val);
640 } else {
641 (el as HTMLElement).style.removeProperty(prop);
642 }
643 },
644 [mod.selector, mod.property, mod.oldValue]
645 );
646 }
647 } else {
648 // Inline modification — restore or remove
649 await page.evaluate(
650 ([sel, prop, val]) => {
651 const el = document.querySelector(sel);
652 if (!el) return;
653 if (val) {
654 (el as HTMLElement).style.setProperty(prop, val);
655 } else {
656 (el as HTMLElement).style.removeProperty(prop);
657 }
658 },
659 [mod.selector, mod.property, mod.oldValue]
660 );
661 }
663 modificationHistory.splice(idx, 1);
666/**
667 * Get the full modification history.
668 */
669export function getModificationHistory(): StyleModification[] {
670 return [...modificationHistory];
673/**
674 * Diagnostic accessor for the $B memory snapshot. Returns current buffer
675 * occupancy, the cap, and how many entries have been evicted since the
676 * last reset.
677 */
678export function getModificationHistoryStats(): {
679 current: number;
680 cap: number;
681 evicted: number;
682} {
683 return {
684 current: modificationHistory.length,
685 cap: MOD_HISTORY_CAP,
686 evicted: Math.max(0, modHistoryTotalPushed - MOD_HISTORY_CAP),
687 };
690/**
691 * Reset all modifications, restoring original values.
692 */
693export async function resetModifications(page: Page): Promise<void> {
694 // Restore in reverse order
695 for (let i = modificationHistory.length - 1; i >= 0; i--) {
696 const mod = modificationHistory[i];
697 try {
698 await page.evaluate(
699 ([sel, prop, val]) => {
700 const el = document.querySelector(sel);
701 if (!el) return;
702 if (val) {
703 (el as HTMLElement).style.setProperty(prop, val);
704 } else {
705 (el as HTMLElement).style.removeProperty(prop);
706 }
707 },
708 [mod.selector, mod.property, mod.oldValue]
709 );
710 } catch (err: any) {
711 // Best effort — page may have navigated or element may be gone
712 if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('Execution context')) throw err;
713 }
714 }
715 modificationHistory.length = 0;
716 modHistoryTotalPushed = 0;
719/**
720 * Format an InspectorResult for CLI text output.
721 */
722export function formatInspectorResult(
723 result: InspectorResult,
724 options?: { includeUA?: boolean }
725): string {
726 const lines: string[] = [];
728 // Element header
729 const classStr = result.classes.length > 0 ? ` class="${result.classes.join(' ')}"` : '';
730 const idStr = result.id ? ` id="${result.id}"` : '';
731 lines.push(`Element: <${result.tagName}${idStr}${classStr}>`);
732 lines.push(`Selector: ${result.selector}`);
734 const w = Math.round(result.boxModel.content.width + result.boxModel.padding.left + result.boxModel.padding.right);
735 const h = Math.round(result.boxModel.content.height + result.boxModel.padding.top + result.boxModel.padding.bottom);
736 lines.push(`Dimensions: ${w} x ${h}`);
737 lines.push('');
739 // Box model
740 lines.push('Box Model:');
741 const bm = result.boxModel;
742 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`);
743 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`);
744 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`);
745 lines.push(` content: ${Math.round(bm.content.width)} x ${Math.round(bm.content.height)}`);
746 lines.push('');
748 // Matched rules
749 const displayRules = options?.includeUA
750 ? result.matchedRules
751 : result.matchedRules.filter(r => !r.userAgent);
753 lines.push(`Matched Rules (${displayRules.length}):`);
754 if (displayRules.length === 0) {
755 lines.push(' (none)');
756 } else {
757 for (const rule of displayRules) {
758 const propsStr = rule.properties
759 .filter(p => !p.overridden)
760 .map(p => `${p.name}: ${p.value}${p.important ? ' !important' : ''}`)
761 .join('; ');
762 if (!propsStr) continue;
763 const spec = `[${rule.specificity.a},${rule.specificity.b},${rule.specificity.c}]`;
764 lines.push(` ${rule.selector} { ${propsStr} }`);
765 lines.push(` -> ${rule.source}:${rule.sourceLine} ${spec}${rule.media ? ` @media ${rule.media}` : ''}`);
766 }
767 }
768 lines.push('');
770 // Inline styles
771 lines.push('Inline Styles:');
772 const inlineEntries = Object.entries(result.inlineStyles);
773 if (inlineEntries.length === 0) {
774 lines.push(' (none)');
775 } else {
776 const inlineStr = inlineEntries.map(([k, v]) => `${k}: ${v}`).join('; ');
777 lines.push(` ${inlineStr}`);
778 }
779 lines.push('');
781 // Computed styles (key properties, compact format)
782 lines.push('Computed (key):');
783 const cs = result.computedStyles;
784 const computedPairs: string[] = [];
785 for (const prop of KEY_CSS_PROPERTIES) {
786 if (cs[prop] !== undefined) {
787 computedPairs.push(`${prop}: ${cs[prop]}`);
788 }
789 }
790 // Group into lines of ~3 properties each
791 for (let i = 0; i < computedPairs.length; i += 3) {
792 const chunk = computedPairs.slice(i, i + 3);
793 lines.push(` ${chunk.join(' | ')}`);
794 }
796 // Pseudo-elements
797 if (result.pseudoElements.length > 0) {
798 lines.push('');
799 lines.push('Pseudo-elements:');
800 for (const pseudo of result.pseudoElements) {
801 for (const rule of pseudo.rules) {
802 lines.push(` ${pseudo.pseudo} ${rule.selector} { ${rule.properties} }`);
803 }
804 }
805 }
807 return lines.join('\n');
810/**
811 * Detach CDP session for a page (or all pages).
812 */
813export function detachSession(page?: Page): void {
814 if (page) {
815 const session = cdpSessions.get(page);
816 if (session) {
817 try { session.detach().catch(() => {}); } catch (err: any) { if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('detached')) throw err; }
818 cdpSessions.delete(page);
819 initializedPages.delete(page);
820 }
821 }
822 // Note: WeakMap doesn't support iteration, so we can't detach all.
823 // Callers with specific pages should call this per-page.