Skill Tracer
Skills › gstack › browse › snapshot.ts

snapshot.ts

656 lines · browse/src/snapshot.ts at 3f79d81

1/**
2 * Snapshot command — accessibility tree with ref-based element selection
3 *
4 * Architecture (Locator map — no DOM mutation):
5 * 1. page.locator(scope).ariaSnapshot() → YAML-like accessibility tree
6 * 2. Parse tree, assign refs @e1, @e2, ...
7 * 3. Build Playwright Locator for each ref (getByRole + nth)
8 * 4. Store Map<string, Locator> on BrowserManager
9 * 5. Return compact text output with refs prepended
10 *
11 * Extended features:
12 * --diff / -D: Compare against last snapshot, return unified diff
13 * --annotate / -a: Screenshot with overlay boxes at each @ref
14 * --output / -o: Output path for annotated screenshot
15 * -C / --cursor-interactive: Scan for cursor:pointer/onclick/tabindex elements
16 *
17 * Later: "click @e3" → look up Locator → locator.click()
18 */
20import type { Page, Frame, Locator } from 'playwright';
21import type { TabSession, RefEntry } from './tab-session';
22import * as Diff from 'diff';
23import { TEMP_DIR, isPathWithin } from './platform';
24import { escapeEnvelopeSentinels } from './content-security';
25import { stripLoneSurrogates } from './sanitize';
26import { guardScreenshotPath } from './screenshot-size-guard';
27import { SNAPSHOT_FLAGS, type SnapshotOptions } from './snapshot-flags';
29export { SNAPSHOT_FLAGS } from './snapshot-flags';
31// Roles considered "interactive" for the -i flag
32const INTERACTIVE_ROLES = new Set([
33 'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
34 'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
35 'option', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab',
36 'treeitem',
37]);
39interface ParsedNode {
40 indent: number;
41 role: string;
42 name: string | null;
43 props: string; // e.g., "[level=1]"
44 children: string; // inline text content after ":"
45 rawLine: string;
48/**
49 * Parse CLI args into SnapshotOptions — driven by SNAPSHOT_FLAGS metadata.
50 */
51export function parseSnapshotArgs(args: string[]): SnapshotOptions {
52 const opts: SnapshotOptions = {};
53 for (let i = 0; i < args.length; i++) {
54 const flag = SNAPSHOT_FLAGS.find(f => f.short === args[i] || f.long === args[i]);
55 if (!flag) throw new Error(`Unknown snapshot flag: ${args[i]}`);
56 if (flag.takesValue) {
57 const value = args[++i];
58 if (!value) throw new Error(`Usage: snapshot ${flag.short} <value>`);
59 if (flag.optionKey === 'depth') {
60 (opts as any)[flag.optionKey] = parseInt(value, 10);
61 if (isNaN(opts.depth!)) throw new Error('Usage: snapshot -d <number>');
62 } else {
63 (opts as any)[flag.optionKey] = value;
64 }
65 } else {
66 (opts as any)[flag.optionKey] = true;
67 }
68 }
69 return opts;
72/**
73 * Parse one line of ariaSnapshot output.
74 *
75 * Format examples:
76 * - heading "Test" [level=1]
77 * - link "Link A":
78 * - /url: /a
79 * - textbox "Name"
80 * - paragraph: Some text
81 * - combobox "Role":
82 */
83function parseLine(line: string): ParsedNode | null {
84 // Match: (indent)(- )(role)( "name")?( [props])?(: inline)?
85 const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/);
86 if (!match) {
87 // Skip metadata lines like "- /url: /a"
88 return null;
89 }
90 return {
91 indent: match[1].length,
92 role: match[2],
93 name: match[3] ?? null,
94 props: match[4] || '',
95 children: match[5]?.trim() || '',
96 rawLine: line,
97 };
100/**
101 * Take an accessibility snapshot and build the ref map.
102 */
103export async function handleSnapshot(
104 args: string[],
105 session: TabSession,
106 securityOpts?: { splitForScoped?: boolean },
107): Promise<string> {
108 const opts = parseSnapshotArgs(args);
109 const page = session.getPage();
110 // Frame-aware target for accessibility tree
111 const target = session.getActiveFrameOrPage();
112 const inFrame = session.getFrame() !== null;
114 // Get accessibility tree via ariaSnapshot
115 let rootLocator: Locator;
116 if (opts.selector) {
117 rootLocator = target.locator(opts.selector);
118 const count = await rootLocator.count();
119 if (count === 0) throw new Error(`Selector not found: ${opts.selector}`);
120 } else {
121 rootLocator = target.locator('body');
122 }
124 const ariaText = await rootLocator.ariaSnapshot();
125 if (!ariaText || ariaText.trim().length === 0) {
126 session.setRefMap(new Map());
127 return '(no accessible elements found)';
128 }
130 // Parse the ariaSnapshot output
131 const lines = ariaText.split('\n');
132 const refMap = new Map<string, RefEntry>();
133 const output: string[] = [];
134 let refCounter = 1;
136 // Track role+name occurrences for nth() disambiguation
137 const roleNameCounts = new Map<string, number>();
138 const roleNameSeen = new Map<string, number>();
140 // First pass: count role+name pairs for disambiguation
141 for (const line of lines) {
142 const node = parseLine(line);
143 if (!node) continue;
144 const key = `${node.role}:${node.name || ''}`;
145 roleNameCounts.set(key, (roleNameCounts.get(key) || 0) + 1);
146 }
148 // Second pass: assign refs and build locators
149 for (const line of lines) {
150 const node = parseLine(line);
151 if (!node) continue;
153 const depth = Math.floor(node.indent / 2);
154 const isInteractive = INTERACTIVE_ROLES.has(node.role);
156 // Depth filter
157 if (opts.depth !== undefined && depth > opts.depth) continue;
159 // Interactive filter: skip non-interactive but still count for locator indices
160 if (opts.interactive && !isInteractive) {
161 // Still track for nth() counts
162 const key = `${node.role}:${node.name || ''}`;
163 roleNameSeen.set(key, (roleNameSeen.get(key) || 0) + 1);
164 continue;
165 }
167 // Compact filter: skip elements with no name and no inline content that aren't interactive
168 if (opts.compact && !isInteractive && !node.name && !node.children) continue;
170 // Assign ref
171 const ref = `e${refCounter++}`;
172 const indent = ' '.repeat(depth);
174 // Build Playwright locator
175 const key = `${node.role}:${node.name || ''}`;
176 const seenIndex = roleNameSeen.get(key) || 0;
177 roleNameSeen.set(key, seenIndex + 1);
178 const totalCount = roleNameCounts.get(key) || 1;
180 let locator: Locator;
181 if (opts.selector) {
182 locator = target.locator(opts.selector).getByRole(node.role as any, {
183 name: node.name || undefined,
184 });
185 } else {
186 locator = target.getByRole(node.role as any, {
187 name: node.name || undefined,
188 });
189 }
191 // Disambiguate with nth() if multiple elements share role+name
192 if (totalCount > 1) {
193 locator = locator.nth(seenIndex);
194 }
196 refMap.set(ref, { locator, role: node.role, name: node.name || '' });
198 // Format output line
199 let outputLine = `${indent}@${ref} [${node.role}]`;
200 if (node.name) outputLine += ` "${node.name}"`;
201 if (node.props) outputLine += ` ${node.props}`;
202 if (node.children) outputLine += `: ${node.children}`;
204 output.push(outputLine);
205 }
207 // ─── Cursor-interactive scan (-C, or auto with -i) ────────
208 // Auto-enable cursor scan when interactive mode is on — agents asking for
209 // interactive elements should always see clickable non-ARIA items too.
210 if (opts.interactive && !opts.cursorInteractive) {
211 opts.cursorInteractive = true;
212 }
213 if (opts.cursorInteractive) {
214 try {
215 const cursorElements = await target.evaluate(() => {
216 const STANDARD_INTERACTIVE = new Set([
217 'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY', 'DETAILS',
218 ]);
220 const results: Array<{ selector: string; text: string; reason: string }> = [];
221 const allElements = document.querySelectorAll('*');
223 for (const el of allElements) {
224 // Skip standard interactive elements (already in ARIA tree)
225 if (STANDARD_INTERACTIVE.has(el.tagName)) continue;
226 // Skip hidden elements
227 if (!(el as HTMLElement).offsetParent && el.tagName !== 'BODY') continue;
229 const style = getComputedStyle(el);
230 const hasCursorPointer = style.cursor === 'pointer';
231 const hasOnclick = el.hasAttribute('onclick');
232 const hasTabindex = el.hasAttribute('tabindex') && parseInt(el.getAttribute('tabindex')!, 10) >= 0;
233 const hasRole = el.hasAttribute('role');
235 // Check if element is inside a floating container (portal/popover/dropdown)
236 const isInFloating = (() => {
237 let parent: Element | null = el;
238 while (parent && parent !== document.documentElement) {
239 const pStyle = getComputedStyle(parent);
240 const isFloating = (pStyle.position === 'fixed' || pStyle.position === 'absolute') &&
241 parseInt(pStyle.zIndex || '0', 10) >= 10;
242 const hasPortalAttr = parent.hasAttribute('data-floating-ui-portal') ||
243 parent.hasAttribute('data-radix-popper-content-wrapper') ||
244 parent.hasAttribute('data-radix-portal') ||
245 parent.hasAttribute('data-popper-placement') ||
246 parent.getAttribute('role') === 'listbox' ||
247 parent.getAttribute('role') === 'menu';
248 if (isFloating || hasPortalAttr) return true;
249 parent = parent.parentElement;
250 }
251 return false;
252 })();
254 if (!hasCursorPointer && !hasOnclick && !hasTabindex) {
255 // For elements inside floating containers, also check for role="option"/"menuitem"
256 if (isInFloating && hasRole) {
257 const role = el.getAttribute('role');
258 if (role !== 'option' && role !== 'menuitem' && role !== 'menuitemcheckbox' && role !== 'menuitemradio') continue;
259 } else {
260 continue;
261 }
262 }
263 // Skip elements with ARIA roles UNLESS they're inside a floating container
264 // (floating container items may be missed by the accessibility tree)
265 if (hasRole && !isInFloating) continue;
267 // Build deterministic nth-child CSS path
268 const parts: string[] = [];
269 let current: Element | null = el;
270 while (current && current !== document.documentElement) {
271 const parent: Element | null = current.parentElement;
272 if (!parent) break;
273 const siblings = [...parent.children];
274 const index = siblings.indexOf(current) + 1;
275 parts.unshift(`${current.tagName.toLowerCase()}:nth-child(${index})`);
276 current = parent;
277 }
278 const selector = parts.join(' > ');
280 const text = (el as HTMLElement).innerText?.trim().slice(0, 80) || el.tagName.toLowerCase();
281 const reasons: string[] = [];
282 if (isInFloating) reasons.push('popover-child');
283 if (hasCursorPointer) reasons.push('cursor:pointer');
284 if (hasOnclick) reasons.push('onclick');
285 if (hasTabindex) reasons.push(`tabindex=${el.getAttribute('tabindex')}`);
286 if (hasRole) reasons.push(`role=${el.getAttribute('role')}`);
288 results.push({ selector, text, reason: reasons.join(', ') });
289 }
290 return results;
291 });
293 if (cursorElements.length > 0) {
294 output.push('');
295 output.push('── cursor-interactive (not in ARIA tree) ──');
296 let cRefCounter = 1;
297 for (const elem of cursorElements) {
298 const ref = `c${cRefCounter++}`;
299 const locator = target.locator(elem.selector);
300 refMap.set(ref, { locator, role: 'cursor-interactive', name: elem.text });
301 output.push(`@${ref} [${elem.reason}] "${elem.text}"`);
302 }
303 }
304 } catch (err: any) {
305 // Cursor scan fails on pages with strict CSP or when page has navigated
306 if (!err?.message?.includes('Execution context') && !err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('Content Security')) throw err;
307 output.push('');
308 output.push('(cursor scan failed — CSP restriction)');
309 }
310 }
312 // Store ref map on BrowserManager
313 session.setRefMap(refMap);
315 if (output.length === 0) {
316 return '(no interactive elements found)';
317 }
319 const snapshotText = output.join('\n');
321 // `-o` only means something to the two modes that PRODUCE an image. Passed
322 // alone it used to be silently ignored: exit 0, no file, no explanation —
323 // which reads as "the screenshot feature is broken" rather than "you forgot a
324 // flag", and cost a real debugging session before anyone noticed. Kept as a
325 // variable so the diff-mode returns below (which bypass `output`) can carry
326 // it too — diff mode must not regress to the silent-ignore behavior.
327 const outputIgnoredWarning = (opts.outputPath && !opts.annotate && !opts.heatmap)
328 ? `[warning] -o/--output was ignored: it names the output file for an annotated (-a/--annotate) or heatmap (-H/--heatmap) screenshot. For a plain screenshot use: browse screenshot ${opts.outputPath}`
329 : '';
330 if (outputIgnoredWarning) {
331 output.push(outputIgnoredWarning);
332 }
334 // ─── Annotated screenshot (-a) ────────────────────────────
335 if (opts.annotate) {
336 const screenshotPath = opts.outputPath || `${TEMP_DIR}/browse-annotated.png`;
337 // Validate output path — resolve symlinks to prevent symlink traversal attacks
338 {
339 const nodePath = require('path') as typeof import('path');
340 const nodeFs = require('fs') as typeof import('fs');
341 const absolute = nodePath.resolve(screenshotPath);
342 const safeDirs = [TEMP_DIR, process.cwd()].map((d: string) => {
343 try { return nodeFs.realpathSync(d); } catch (err: any) { if (err?.code !== 'ENOENT') throw err; return d; }
344 });
345 let realPath: string;
346 try {
347 realPath = nodeFs.realpathSync(absolute);
348 } catch (err: any) {
349 if (err.code === 'ENOENT') {
350 try {
351 const dir = nodeFs.realpathSync(nodePath.dirname(absolute));
352 realPath = nodePath.join(dir, nodePath.basename(absolute));
353 } catch (err2: any) {
354 if (err2?.code !== 'ENOENT') throw err2;
355 realPath = absolute;
356 }
357 } else {
358 throw new Error(`Cannot resolve real path: ${screenshotPath} (${err.code})`);
359 }
360 }
361 if (!safeDirs.some((dir: string) => isPathWithin(realPath, dir))) {
362 throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
363 }
364 }
365 try {
366 // Inject overlay divs at each ref's bounding box
367 const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number } }> = [];
368 const ambiguousRefs: string[] = [];
369 const skippedRefs: string[] = [];
370 for (const [ref, entry] of refMap) {
371 try {
372 // A ref's locator can resolve to MORE than one element, and Playwright
373 // strict mode throws on that. It happens whenever a node has no
374 // accessible name: the locator degrades to `getByRole(role)` with no
375 // name filter, and the `.nth()` disambiguation above cannot help
376 // because its count comes from the FILTERED aria snapshot while
377 // getByRole matches the unfiltered DOM. Measured on a real page: the
378 // tree surfaced 2 unnamed paragraphs, the DOM had 9. Landmarks
379 // (banner/main/contentinfo) and paragraphs are correctly unnamed per
380 // ARIA, so this is the common case, not an edge.
381 //
382 // The exact nth-resolved locator stays the primary path; `.first()`
383 // is the AMBIGUITY FALLBACK only, and every fallback use is counted
384 // so first-match annotation is never silent. Before this, ONE such
385 // ref aborted the entire annotated screenshot (see the catch below) —
386 // which silently cost /qa, /canary and /land-and-deploy the
387 // screenshots their reports reference.
388 let locator = entry.locator;
389 const matchCount = await locator.count();
390 if (matchCount > 1) {
391 ambiguousRefs.push(`@${ref}`);
392 locator = locator.first();
393 }
394 const box = await locator.boundingBox({ timeout: 1000 });
395 if (box) {
396 boxes.push({ ref: `@${ref}`, box });
397 } else {
398 skippedRefs.push(`@${ref}`);
399 }
400 } catch (err: any) {
401 // Element may be offscreen, hidden, or page navigated — skip.
402 //
403 // The allowlist is deliberately not exhaustive-by-message any more: a
404 // box we cannot measure is a box we do not draw, never a reason to
405 // lose every other annotation on the page. The heatmap path below has
406 // always used a bare `catch {}` for exactly this reason; annotate was
407 // the only path that could be killed by a single unmeasurable ref.
408 skippedRefs.push(`@${ref}`);
409 if (process.env.BROWSE_DEBUG) console.error(`[annotate] skipped @${ref}: ${err?.message?.split('\n')[0]}`);
410 }
411 }
413 await page.evaluate((boxes) => {
414 for (const { ref, box } of boxes) {
415 const overlay = document.createElement('div');
416 overlay.className = '__browse_annotation__';
417 overlay.style.cssText = `
418 position: absolute; top: ${box.y}px; left: ${box.x}px;
419 width: ${box.width}px; height: ${box.height}px;
420 border: 2px solid red; background: rgba(255,0,0,0.1);
421 pointer-events: none; z-index: 99999;
422 font-size: 10px; color: red; font-weight: bold;
423 `;
424 const label = document.createElement('span');
425 label.textContent = ref;
426 label.style.cssText = 'position: absolute; top: -14px; left: 0; background: red; color: white; padding: 0 3px; font-size: 10px;';
427 overlay.appendChild(label);
428 document.body.appendChild(overlay);
429 }
430 }, boxes);
432 await page.screenshot({ path: screenshotPath, fullPage: true });
433 await guardScreenshotPath(screenshotPath);
435 // Always remove overlays
436 await page.evaluate(() => {
437 document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove());
438 });
440 output.push('');
441 output.push(`[annotated screenshot: ${screenshotPath}]`);
442 // Ambiguity and skips are visible, not buried behind BROWSE_DEBUG: a
443 // first-match box or a missing box changes what the screenshot claims.
444 if (ambiguousRefs.length || skippedRefs.length) {
445 const cap = (arr: string[]) => arr.slice(0, 8).join(', ') + (arr.length > 8 ? `, +${arr.length - 8} more` : '');
446 const parts: string[] = [];
447 if (ambiguousRefs.length) parts.push(`${ambiguousRefs.length} ambiguous (first-match): ${cap(ambiguousRefs)}`);
448 if (skippedRefs.length) parts.push(`${skippedRefs.length} skipped: ${cap(skippedRefs)}`);
449 output.push(`[annotated: ${parts.join(' | ')}]`);
450 }
451 } catch (err: any) {
452 // Remove overlays even on screenshot failure — but only swallow page/browser errors
453 if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('Execution context') && !err?.message?.includes('screenshot')) throw err;
454 try {
455 await page.evaluate(() => {
456 document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove());
457 });
458 } catch (err2: any) {
459 if (!err2?.message?.includes('closed') && !err2?.message?.includes('Target') && !err2?.message?.includes('Execution context')) throw err2;
460 }
461 }
462 }
464 // ─── Heatmap mode (-H) ──────────────────────────────────────
465 if (opts.heatmap) {
466 const heatmapPath = opts.outputPath || `${TEMP_DIR}/browse-heatmap.png`;
467 // Validate output path
468 {
469 const nodePath = require('path') as typeof import('path');
470 const nodeFs = require('fs') as typeof import('fs');
471 const absolute = nodePath.resolve(heatmapPath);
472 const safeDirs = [TEMP_DIR, process.cwd()].map((d: string) => {
473 try { return nodeFs.realpathSync(d); } catch (err: any) { if (err?.code !== 'ENOENT') throw err; return d; }
474 });
475 let realPath: string;
476 try {
477 realPath = nodeFs.realpathSync(absolute);
478 } catch (err: any) {
479 if (err.code === 'ENOENT') {
480 try {
481 const dir = nodeFs.realpathSync(nodePath.dirname(absolute));
482 realPath = nodePath.join(dir, nodePath.basename(absolute));
483 } catch (err2: any) {
484 if (err2?.code !== 'ENOENT') throw err2;
485 realPath = absolute;
486 }
487 } else {
488 throw new Error(`Cannot resolve real path: ${heatmapPath} (${err.code})`);
489 }
490 }
491 if (!safeDirs.some((dir: string) => isPathWithin(realPath, dir))) {
492 throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
493 }
494 }
496 // Parse and validate color map
497 const VALID_COLORS = new Set(['green', 'yellow', 'red', 'blue', 'orange', 'gray']);
498 const COLOR_MAP: Record<string, { border: string; bg: string }> = {
499 green: { border: '#00b400', bg: 'rgba(0,180,0,0.15)' },
500 yellow: { border: '#ffb400', bg: 'rgba(255,180,0,0.15)' },
501 red: { border: '#ff0000', bg: 'rgba(255,0,0,0.15)' },
502 blue: { border: '#0066ff', bg: 'rgba(0,102,255,0.15)' },
503 orange: { border: '#ff6600', bg: 'rgba(255,102,0,0.15)' },
504 gray: { border: '#888888', bg: 'rgba(136,136,136,0.15)' },
505 };
507 let colorAssignments: Record<string, string>;
508 try {
509 const parsed = JSON.parse(opts.heatmap);
510 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
511 throw new Error('not an object');
512 }
513 colorAssignments = parsed;
514 } catch {
515 throw new Error('Invalid heatmap JSON. Expected object: \'{"@e1":"green","@e3":"red"}\'');
516 }
518 // Validate colors
519 for (const [ref, color] of Object.entries(colorAssignments)) {
520 if (!VALID_COLORS.has(color)) {
521 throw new Error(`Invalid heatmap color "${color}" for ${ref}. Valid: ${[...VALID_COLORS].join(', ')}`);
522 }
523 }
525 try {
526 const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number }; color: string }> = [];
527 for (const [refKey, color] of Object.entries(colorAssignments)) {
528 const cleanRef = refKey.startsWith('@') ? refKey.slice(1) : refKey;
529 const entry = refMap.get(cleanRef);
530 if (!entry) continue; // Skip refs not found on page
531 try {
532 const box = await entry.locator.boundingBox({ timeout: 1000 });
533 if (box) {
534 const colors = COLOR_MAP[color] || COLOR_MAP.gray;
535 boxes.push({ ref: `@${cleanRef}`, box, color: JSON.stringify(colors) });
536 }
537 } catch {
538 // Element may be offscreen or hidden — skip
539 }
540 }
542 await page.evaluate((boxes) => {
543 for (const { ref, box, color } of boxes) {
544 const colors = JSON.parse(color);
545 const overlay = document.createElement('div');
546 overlay.className = '__browse_heatmap__';
547 overlay.style.cssText = `
548 position: absolute; top: ${box.y}px; left: ${box.x}px;
549 width: ${box.width}px; height: ${box.height}px;
550 border: 2px solid ${colors.border}; background: ${colors.bg};
551 pointer-events: none; z-index: 99999;
552 font-size: 10px; color: ${colors.border}; font-weight: bold;
553 `;
554 const label = document.createElement('span');
555 label.textContent = ref;
556 label.style.cssText = `position: absolute; top: -14px; left: 0; background: ${colors.border}; color: white; padding: 0 3px; font-size: 10px;`;
557 overlay.appendChild(label);
558 document.body.appendChild(overlay);
559 }
560 }, boxes);
562 await page.screenshot({ path: heatmapPath, fullPage: true });
563 await guardScreenshotPath(heatmapPath);
565 // Remove heatmap overlays
566 await page.evaluate(() => {
567 document.querySelectorAll('.__browse_heatmap__').forEach(el => el.remove());
568 });
570 output.push('');
571 output.push(`[heatmap screenshot: ${heatmapPath}]`);
572 } catch (err: any) {
573 // Cleanup on failure
574 try {
575 await page.evaluate(() => {
576 document.querySelectorAll('.__browse_heatmap__').forEach(el => el.remove());
577 });
578 } catch {}
579 if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('Execution context') && !err?.message?.includes('screenshot')) throw err;
580 }
581 }
583 // ─── Diff mode (-D) ───────────────────────────────────────
584 if (opts.diff) {
585 const lastSnapshot = session.getLastSnapshot();
586 if (!lastSnapshot) {
587 session.setLastSnapshot(snapshotText);
588 return snapshotText + '\n\n(no previous snapshot to diff against — this snapshot stored as baseline)'
589 + (outputIgnoredWarning ? '\n' + outputIgnoredWarning : '');
590 }
592 const changes = Diff.diffLines(lastSnapshot, snapshotText);
593 const diffOutput: string[] = ['--- previous snapshot', '+++ current snapshot', ''];
595 for (const part of changes) {
596 const prefix = part.added ? '+' : part.removed ? '-' : ' ';
597 const diffLines = part.value.split('\n').filter(l => l.length > 0);
598 for (const line of diffLines) {
599 diffOutput.push(`${prefix} ${line}`);
600 }
601 }
603 session.setLastSnapshot(snapshotText);
604 return stripLoneSurrogates(diffOutput.join('\n')
605 + (outputIgnoredWarning ? '\n' + outputIgnoredWarning : ''));
606 }
608 // Store for future diffs
609 session.setLastSnapshot(snapshotText);
611 // Add frame context header when operating inside an iframe
612 if (inFrame) {
613 const frameUrl = session.getFrame()?.url() ?? 'unknown';
614 output.unshift(`[Context: iframe src="${frameUrl}"]`);
615 }
617 // Split output for scoped tokens: trusted refs + untrusted text
618 if (securityOpts?.splitForScoped) {
619 const trustedRefs: string[] = [];
620 const untrustedLines: string[] = [];
622 for (const line of output) {
623 // Lines starting with @ref are interactive elements (trusted metadata)
624 const refMatch = line.match(/^(\s*)@(e\d+|c\d+)\s+\[([^\]]+)\]\s*(.*)/);
625 if (refMatch) {
626 const [, indent, ref, role, rest] = refMatch;
627 // Truncate element name/content to 50 chars for trusted section
628 const nameMatch = rest.match(/^"(.+?)"/);
629 let truncName = nameMatch ? nameMatch[1] : rest.trim();
630 if (truncName.length > 50) truncName = truncName.slice(0, 47) + '...';
631 trustedRefs.push(`${indent}@${ref} [${role}] "${truncName}"`);
632 }
633 // All lines go to untrusted section (full content)
634 untrustedLines.push(line);
635 }
637 const parts: string[] = [];
638 if (trustedRefs.length > 0) {
639 parts.push('INTERACTIVE ELEMENTS (trusted — use these @refs for click/fill):');
640 parts.push(...trustedRefs);
641 parts.push('');
642 }
643 // Defuse any envelope sentinel that appears inside the page's own
644 // accessibility text. Without this, a page whose rendered content
645 // contains the literal `═══ END UNTRUSTED WEB CONTENT ═══` string
646 // can close the envelope early and forge a fake "trusted" block
647 // for the LLM. Same escape that wrapUntrustedPageContent applies.
648 const safeUntrusted = untrustedLines.map(escapeEnvelopeSentinels);
649 parts.push('═══ BEGIN UNTRUSTED WEB CONTENT ═══');
650 parts.push(...safeUntrusted);
651 parts.push('═══ END UNTRUSTED WEB CONTENT ═══');
652 return stripLoneSurrogates(parts.join('\n'));
653 }
655 return stripLoneSurrogates(output.join('\n'));