opengstack 0.13.6 → 0.13.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/bin/opengstack.js +35 -90
  2. package/package.json +2 -3
  3. package/scripts/install-skills.js +29 -58
  4. package/skills/browse/bin/find-browse +21 -0
  5. package/skills/browse/bin/remote-slug +14 -0
  6. package/skills/browse/scripts/build-node-server.sh +48 -0
  7. package/skills/browse/src/activity.ts +208 -0
  8. package/skills/browse/src/browser-manager.ts +959 -0
  9. package/skills/browse/src/buffers.ts +137 -0
  10. package/skills/browse/src/bun-polyfill.cjs +109 -0
  11. package/skills/browse/src/cli.ts +678 -0
  12. package/skills/browse/src/commands.ts +128 -0
  13. package/skills/browse/src/config.ts +150 -0
  14. package/skills/browse/src/cookie-import-browser.ts +625 -0
  15. package/skills/browse/src/cookie-picker-routes.ts +230 -0
  16. package/skills/browse/src/cookie-picker-ui.ts +688 -0
  17. package/skills/browse/src/find-browse.ts +61 -0
  18. package/skills/browse/src/meta-commands.ts +550 -0
  19. package/skills/browse/src/platform.ts +17 -0
  20. package/skills/browse/src/read-commands.ts +358 -0
  21. package/skills/browse/src/server.ts +1192 -0
  22. package/skills/browse/src/sidebar-agent.ts +280 -0
  23. package/skills/browse/src/sidebar-utils.ts +21 -0
  24. package/skills/browse/src/snapshot.ts +407 -0
  25. package/skills/browse/src/url-validation.ts +95 -0
  26. package/skills/browse/src/write-commands.ts +364 -0
  27. package/skills/browse/test/activity.test.ts +120 -0
  28. package/skills/browse/test/adversarial-security.test.ts +32 -0
  29. package/skills/browse/test/browser-manager-unit.test.ts +17 -0
  30. package/skills/browse/test/bun-polyfill.test.ts +72 -0
  31. package/skills/browse/test/commands.test.ts +2075 -0
  32. package/skills/browse/test/compare-board.test.ts +342 -0
  33. package/skills/browse/test/config.test.ts +316 -0
  34. package/skills/browse/test/cookie-import-browser.test.ts +519 -0
  35. package/skills/browse/test/cookie-picker-routes.test.ts +260 -0
  36. package/skills/browse/test/file-drop.test.ts +271 -0
  37. package/skills/browse/test/find-browse.test.ts +50 -0
  38. package/skills/browse/test/findport.test.ts +191 -0
  39. package/skills/browse/test/fixtures/basic.html +33 -0
  40. package/skills/browse/test/fixtures/cursor-interactive.html +22 -0
  41. package/skills/browse/test/fixtures/dialog.html +15 -0
  42. package/skills/browse/test/fixtures/empty.html +2 -0
  43. package/skills/browse/test/fixtures/forms.html +55 -0
  44. package/skills/browse/test/fixtures/iframe.html +30 -0
  45. package/skills/browse/test/fixtures/network-idle.html +30 -0
  46. package/skills/browse/test/fixtures/qa-eval-checkout.html +108 -0
  47. package/skills/browse/test/fixtures/qa-eval-spa.html +98 -0
  48. package/skills/browse/test/fixtures/qa-eval.html +51 -0
  49. package/skills/browse/test/fixtures/responsive.html +49 -0
  50. package/skills/browse/test/fixtures/snapshot.html +55 -0
  51. package/skills/browse/test/fixtures/spa.html +24 -0
  52. package/skills/browse/test/fixtures/states.html +17 -0
  53. package/skills/browse/test/fixtures/upload.html +25 -0
  54. package/skills/browse/test/gstack-config.test.ts +138 -0
  55. package/skills/browse/test/gstack-update-check.test.ts +514 -0
  56. package/skills/browse/test/handoff.test.ts +235 -0
  57. package/skills/browse/test/path-validation.test.ts +91 -0
  58. package/skills/browse/test/platform.test.ts +37 -0
  59. package/skills/browse/test/server-auth.test.ts +65 -0
  60. package/skills/browse/test/sidebar-agent-roundtrip.test.ts +226 -0
  61. package/skills/browse/test/sidebar-agent.test.ts +199 -0
  62. package/skills/browse/test/sidebar-integration.test.ts +320 -0
  63. package/skills/browse/test/sidebar-unit.test.ts +96 -0
  64. package/skills/browse/test/snapshot.test.ts +467 -0
  65. package/skills/browse/test/state-ttl.test.ts +35 -0
  66. package/skills/browse/test/test-server.ts +57 -0
  67. package/skills/browse/test/url-validation.test.ts +72 -0
  68. package/skills/browse/test/watch.test.ts +129 -0
  69. package/skills/careful/bin/check-careful.sh +112 -0
  70. package/skills/cso/ACKNOWLEDGEMENTS.md +14 -0
  71. package/skills/freeze/bin/check-freeze.sh +79 -0
  72. package/skills/qa/references/issue-taxonomy.md +85 -0
  73. package/skills/qa/templates/qa-report-template.md +126 -0
  74. package/skills/review/TODOS-format.md +62 -0
  75. package/skills/review/checklist.md +220 -0
  76. package/skills/review/design-checklist.md +132 -0
  77. package/skills/review/greptile-triage.md +220 -0
  78. /package/{autoplan → skills/autoplan}/SKILL.md +0 -0
  79. /package/{autoplan → skills/autoplan}/SKILL.md.tmpl +0 -0
  80. /package/{benchmark → skills/benchmark}/SKILL.md +0 -0
  81. /package/{benchmark → skills/benchmark}/SKILL.md.tmpl +0 -0
  82. /package/{browse → skills/browse}/SKILL.md +0 -0
  83. /package/{browse → skills/browse}/SKILL.md.tmpl +0 -0
  84. /package/{canary → skills/canary}/SKILL.md +0 -0
  85. /package/{canary → skills/canary}/SKILL.md.tmpl +0 -0
  86. /package/{careful → skills/careful}/SKILL.md +0 -0
  87. /package/{careful → skills/careful}/SKILL.md.tmpl +0 -0
  88. /package/{codex → skills/codex}/SKILL.md +0 -0
  89. /package/{codex → skills/codex}/SKILL.md.tmpl +0 -0
  90. /package/{connect-chrome → skills/connect-chrome}/SKILL.md +0 -0
  91. /package/{connect-chrome → skills/connect-chrome}/SKILL.md.tmpl +0 -0
  92. /package/{cso → skills/cso}/SKILL.md +0 -0
  93. /package/{cso → skills/cso}/SKILL.md.tmpl +0 -0
  94. /package/{design-consultation → skills/design-consultation}/SKILL.md +0 -0
  95. /package/{design-consultation → skills/design-consultation}/SKILL.md.tmpl +0 -0
  96. /package/{design-review → skills/design-review}/SKILL.md +0 -0
  97. /package/{design-review → skills/design-review}/SKILL.md.tmpl +0 -0
  98. /package/{design-shotgun → skills/design-shotgun}/SKILL.md +0 -0
  99. /package/{design-shotgun → skills/design-shotgun}/SKILL.md.tmpl +0 -0
  100. /package/{document-release → skills/document-release}/SKILL.md +0 -0
  101. /package/{document-release → skills/document-release}/SKILL.md.tmpl +0 -0
  102. /package/{freeze → skills/freeze}/SKILL.md +0 -0
  103. /package/{freeze → skills/freeze}/SKILL.md.tmpl +0 -0
  104. /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md +0 -0
  105. /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md.tmpl +0 -0
  106. /package/{guard → skills/guard}/SKILL.md +0 -0
  107. /package/{guard → skills/guard}/SKILL.md.tmpl +0 -0
  108. /package/{investigate → skills/investigate}/SKILL.md +0 -0
  109. /package/{investigate → skills/investigate}/SKILL.md.tmpl +0 -0
  110. /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md +0 -0
  111. /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md.tmpl +0 -0
  112. /package/{office-hours → skills/office-hours}/SKILL.md +0 -0
  113. /package/{office-hours → skills/office-hours}/SKILL.md.tmpl +0 -0
  114. /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md +0 -0
  115. /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md.tmpl +0 -0
  116. /package/{plan-design-review → skills/plan-design-review}/SKILL.md +0 -0
  117. /package/{plan-design-review → skills/plan-design-review}/SKILL.md.tmpl +0 -0
  118. /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md +0 -0
  119. /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md.tmpl +0 -0
  120. /package/{qa → skills/qa}/SKILL.md +0 -0
  121. /package/{qa → skills/qa}/SKILL.md.tmpl +0 -0
  122. /package/{qa-only → skills/qa-only}/SKILL.md +0 -0
  123. /package/{qa-only → skills/qa-only}/SKILL.md.tmpl +0 -0
  124. /package/{retro → skills/retro}/SKILL.md +0 -0
  125. /package/{retro → skills/retro}/SKILL.md.tmpl +0 -0
  126. /package/{review → skills/review}/SKILL.md +0 -0
  127. /package/{review → skills/review}/SKILL.md.tmpl +0 -0
  128. /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md +0 -0
  129. /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md.tmpl +0 -0
  130. /package/{setup-deploy → skills/setup-deploy}/SKILL.md +0 -0
  131. /package/{setup-deploy → skills/setup-deploy}/SKILL.md.tmpl +0 -0
  132. /package/{ship → skills/ship}/SKILL.md +0 -0
  133. /package/{ship → skills/ship}/SKILL.md.tmpl +0 -0
  134. /package/{unfreeze → skills/unfreeze}/SKILL.md +0 -0
  135. /package/{unfreeze → skills/unfreeze}/SKILL.md.tmpl +0 -0
@@ -0,0 +1,407 @@
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
+ */
19
+
20
+ import type { Page, Frame, Locator } from 'playwright';
21
+ import type { BrowserManager, RefEntry } from './browser-manager';
22
+ import * as Diff from 'diff';
23
+ import { TEMP_DIR, isPathWithin } from './platform';
24
+
25
+ // Roles considered "interactive" for the -i flag
26
+ const INTERACTIVE_ROLES = new Set([
27
+ 'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
28
+ 'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
29
+ 'option', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab',
30
+ 'treeitem',
31
+ ]);
32
+
33
+ interface SnapshotOptions {
34
+ interactive?: boolean; // -i: only interactive elements
35
+ compact?: boolean; // -c: remove empty structural elements
36
+ depth?: number; // -d N: limit tree depth
37
+ selector?: string; // -s SEL: scope to CSS selector
38
+ diff?: boolean; // -D / --diff: diff against last snapshot
39
+ annotate?: boolean; // -a / --annotate: annotated screenshot
40
+ outputPath?: string; // -o / --output: path for annotated screenshot
41
+ cursorInteractive?: boolean; // -C / --cursor-interactive: scan cursor:pointer etc.
42
+ }
43
+
44
+ /**
45
+ * Snapshot flag metadata — single source of truth for CLI parsing and doc generation.
46
+ *
47
+ * Imported by:
48
+ * - gen-skill-docs.ts (generates {{SNAPSHOT_FLAGS}} tables)
49
+ * - skill-parser.ts (validates flags in SKILL.md examples)
50
+ */
51
+ export const SNAPSHOT_FLAGS: Array<{
52
+ short: string;
53
+ long: string;
54
+ description: string;
55
+ takesValue?: boolean;
56
+ valueHint?: string;
57
+ optionKey: keyof SnapshotOptions;
58
+ }> = [
59
+ { short: '-i', long: '--interactive', description: 'Interactive elements only (buttons, links, inputs) with @e refs', optionKey: 'interactive' },
60
+ { short: '-c', long: '--compact', description: 'Compact (no empty structural nodes)', optionKey: 'compact' },
61
+ { short: '-d', long: '--depth', description: 'Limit tree depth (0 = root only, default: unlimited)', takesValue: true, valueHint: '<N>', optionKey: 'depth' },
62
+ { short: '-s', long: '--selector', description: 'Scope to CSS selector', takesValue: true, valueHint: '<sel>', optionKey: 'selector' },
63
+ { short: '-D', long: '--diff', description: 'Unified diff against previous snapshot (first call stores baseline)', optionKey: 'diff' },
64
+ { short: '-a', long: '--annotate', description: 'Annotated screenshot with red overlay boxes and ref labels', optionKey: 'annotate' },
65
+ { short: '-o', long: '--output', description: 'Output path for annotated screenshot (default: <temp>/browse-annotated.png)', takesValue: true, valueHint: '<path>', optionKey: 'outputPath' },
66
+ { short: '-C', long: '--cursor-interactive', description: 'Cursor-interactive elements (@c refs — divs with pointer, onclick)', optionKey: 'cursorInteractive' },
67
+ ];
68
+
69
+ interface ParsedNode {
70
+ indent: number;
71
+ role: string;
72
+ name: string | null;
73
+ props: string; // e.g., "[level=1]"
74
+ children: string; // inline text content after ":"
75
+ rawLine: string;
76
+ }
77
+
78
+ /**
79
+ * Parse CLI args into SnapshotOptions — driven by SNAPSHOT_FLAGS metadata.
80
+ */
81
+ export function parseSnapshotArgs(args: string[]): SnapshotOptions {
82
+ const opts: SnapshotOptions = {};
83
+ for (let i = 0; i < args.length; i++) {
84
+ const flag = SNAPSHOT_FLAGS.find(f => f.short === args[i] || f.long === args[i]);
85
+ if (!flag) throw new Error(`Unknown snapshot flag: ${args[i]}`);
86
+ if (flag.takesValue) {
87
+ const value = args[++i];
88
+ if (!value) throw new Error(`Usage: snapshot ${flag.short} <value>`);
89
+ if (flag.optionKey === 'depth') {
90
+ (opts as any)[flag.optionKey] = parseInt(value, 10);
91
+ if (isNaN(opts.depth!)) throw new Error('Usage: snapshot -d <number>');
92
+ } else {
93
+ (opts as any)[flag.optionKey] = value;
94
+ }
95
+ } else {
96
+ (opts as any)[flag.optionKey] = true;
97
+ }
98
+ }
99
+ return opts;
100
+ }
101
+
102
+ /**
103
+ * Parse one line of ariaSnapshot output.
104
+ *
105
+ * Format examples:
106
+ * - heading "Test" [level=1]
107
+ * - link "Link A":
108
+ * - /url: /a
109
+ * - textbox "Name"
110
+ * - paragraph: Some text
111
+ * - combobox "Role":
112
+ */
113
+ function parseLine(line: string): ParsedNode | null {
114
+ // Match: (indent)(- )(role)( "name")?( [props])?(: inline)?
115
+ const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/);
116
+ if (!match) {
117
+ // Skip metadata lines like "- /url: /a"
118
+ return null;
119
+ }
120
+ return {
121
+ indent: match[1].length,
122
+ role: match[2],
123
+ name: match[3] ?? null,
124
+ props: match[4] || '',
125
+ children: match[5]?.trim() || '',
126
+ rawLine: line,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Take an accessibility snapshot and build the ref map.
132
+ */
133
+ export async function handleSnapshot(
134
+ args: string[],
135
+ bm: BrowserManager
136
+ ): Promise<string> {
137
+ const opts = parseSnapshotArgs(args);
138
+ const page = bm.getPage();
139
+ // Frame-aware target for accessibility tree
140
+ const target = bm.getActiveFrameOrPage();
141
+ const inFrame = bm.getFrame() !== null;
142
+
143
+ // Get accessibility tree via ariaSnapshot
144
+ let rootLocator: Locator;
145
+ if (opts.selector) {
146
+ rootLocator = target.locator(opts.selector);
147
+ const count = await rootLocator.count();
148
+ if (count === 0) throw new Error(`Selector not found: ${opts.selector}`);
149
+ } else {
150
+ rootLocator = target.locator('body');
151
+ }
152
+
153
+ const ariaText = await rootLocator.ariaSnapshot();
154
+ if (!ariaText || ariaText.trim().length === 0) {
155
+ bm.setRefMap(new Map());
156
+ return '(no accessible elements found)';
157
+ }
158
+
159
+ // Parse the ariaSnapshot output
160
+ const lines = ariaText.split('\n');
161
+ const refMap = new Map<string, RefEntry>();
162
+ const output: string[] = [];
163
+ let refCounter = 1;
164
+
165
+ // Track role+name occurrences for nth() disambiguation
166
+ const roleNameCounts = new Map<string, number>();
167
+ const roleNameSeen = new Map<string, number>();
168
+
169
+ // First pass: count role+name pairs for disambiguation
170
+ for (const line of lines) {
171
+ const node = parseLine(line);
172
+ if (!node) continue;
173
+ const key = `${node.role}:${node.name || ''}`;
174
+ roleNameCounts.set(key, (roleNameCounts.get(key) || 0) + 1);
175
+ }
176
+
177
+ // Second pass: assign refs and build locators
178
+ for (const line of lines) {
179
+ const node = parseLine(line);
180
+ if (!node) continue;
181
+
182
+ const depth = Math.floor(node.indent / 2);
183
+ const isInteractive = INTERACTIVE_ROLES.has(node.role);
184
+
185
+ // Depth filter
186
+ if (opts.depth !== undefined && depth > opts.depth) continue;
187
+
188
+ // Interactive filter: skip non-interactive but still count for locator indices
189
+ if (opts.interactive && !isInteractive) {
190
+ // Still track for nth() counts
191
+ const key = `${node.role}:${node.name || ''}`;
192
+ roleNameSeen.set(key, (roleNameSeen.get(key) || 0) + 1);
193
+ continue;
194
+ }
195
+
196
+ // Compact filter: skip elements with no name and no inline content that aren't interactive
197
+ if (opts.compact && !isInteractive && !node.name && !node.children) continue;
198
+
199
+ // Assign ref
200
+ const ref = `e${refCounter++}`;
201
+ const indent = ' '.repeat(depth);
202
+
203
+ // Build Playwright locator
204
+ const key = `${node.role}:${node.name || ''}`;
205
+ const seenIndex = roleNameSeen.get(key) || 0;
206
+ roleNameSeen.set(key, seenIndex + 1);
207
+ const totalCount = roleNameCounts.get(key) || 1;
208
+
209
+ let locator: Locator;
210
+ if (opts.selector) {
211
+ locator = target.locator(opts.selector).getByRole(node.role as any, {
212
+ name: node.name || undefined,
213
+ });
214
+ } else {
215
+ locator = target.getByRole(node.role as any, {
216
+ name: node.name || undefined,
217
+ });
218
+ }
219
+
220
+ // Disambiguate with nth() if multiple elements share role+name
221
+ if (totalCount > 1) {
222
+ locator = locator.nth(seenIndex);
223
+ }
224
+
225
+ refMap.set(ref, { locator, role: node.role, name: node.name || '' });
226
+
227
+ // Format output line
228
+ let outputLine = `${indent}@${ref} [${node.role}]`;
229
+ if (node.name) outputLine += ` "${node.name}"`;
230
+ if (node.props) outputLine += ` ${node.props}`;
231
+ if (node.children) outputLine += `: ${node.children}`;
232
+
233
+ output.push(outputLine);
234
+ }
235
+
236
+ // ─── Cursor-interactive scan (-C) ─────────────────────────
237
+ if (opts.cursorInteractive) {
238
+ try {
239
+ const cursorElements = await target.evaluate(() => {
240
+ const STANDARD_INTERACTIVE = new Set([
241
+ 'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY', 'DETAILS',
242
+ ]);
243
+
244
+ const results: Array<{ selector: string; text: string; reason: string }> = [];
245
+ const allElements = document.querySelectorAll('*');
246
+
247
+ for (const el of allElements) {
248
+ // Skip standard interactive elements (already in ARIA tree)
249
+ if (STANDARD_INTERACTIVE.has(el.tagName)) continue;
250
+ // Skip hidden elements
251
+ if (!(el as HTMLElement).offsetParent && el.tagName !== 'BODY') continue;
252
+
253
+ const style = getComputedStyle(el);
254
+ const hasCursorPointer = style.cursor === 'pointer';
255
+ const hasOnclick = el.hasAttribute('onclick');
256
+ const hasTabindex = el.hasAttribute('tabindex') && parseInt(el.getAttribute('tabindex')!, 10) >= 0;
257
+ const hasRole = el.hasAttribute('role');
258
+
259
+ if (!hasCursorPointer && !hasOnclick && !hasTabindex) continue;
260
+ // Skip if it has an ARIA role (likely already captured)
261
+ if (hasRole) continue;
262
+
263
+ // Build deterministic nth-child CSS path
264
+ const parts: string[] = [];
265
+ let current: Element | null = el;
266
+ while (current && current !== document.documentElement) {
267
+ const parent = current.parentElement;
268
+ if (!parent) break;
269
+ const siblings = [...parent.children];
270
+ const index = siblings.indexOf(current) + 1;
271
+ parts.unshift(`${current.tagName.toLowerCase()}:nth-child(${index})`);
272
+ current = parent;
273
+ }
274
+ const selector = parts.join(' > ');
275
+
276
+ const text = (el as HTMLElement).innerText?.trim().slice(0, 80) || el.tagName.toLowerCase();
277
+ const reasons: string[] = [];
278
+ if (hasCursorPointer) reasons.push('cursor:pointer');
279
+ if (hasOnclick) reasons.push('onclick');
280
+ if (hasTabindex) reasons.push(`tabindex=${el.getAttribute('tabindex')}`);
281
+
282
+ results.push({ selector, text, reason: reasons.join(', ') });
283
+ }
284
+ return results;
285
+ });
286
+
287
+ if (cursorElements.length > 0) {
288
+ output.push('');
289
+ output.push('── cursor-interactive (not in ARIA tree) ──');
290
+ let cRefCounter = 1;
291
+ for (const elem of cursorElements) {
292
+ const ref = `c${cRefCounter++}`;
293
+ const locator = target.locator(elem.selector);
294
+ refMap.set(ref, { locator, role: 'cursor-interactive', name: elem.text });
295
+ output.push(`@${ref} [${elem.reason}] "${elem.text}"`);
296
+ }
297
+ }
298
+ } catch {
299
+ output.push('');
300
+ output.push('(cursor scan failed — CSP restriction)');
301
+ }
302
+ }
303
+
304
+ // Store ref map on BrowserManager
305
+ bm.setRefMap(refMap);
306
+
307
+ if (output.length === 0) {
308
+ return '(no interactive elements found)';
309
+ }
310
+
311
+ const snapshotText = output.join('\n');
312
+
313
+ // ─── Annotated screenshot (-a) ────────────────────────────
314
+ if (opts.annotate) {
315
+ const screenshotPath = opts.outputPath || `${TEMP_DIR}/browse-annotated.png`;
316
+ // Validate output path (consistent with screenshot/pdf/responsive)
317
+ const resolvedPath = require('path').resolve(screenshotPath);
318
+ const safeDirs = [TEMP_DIR, process.cwd()];
319
+ if (!safeDirs.some((dir: string) => isPathWithin(resolvedPath, dir))) {
320
+ throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
321
+ }
322
+ try {
323
+ // Inject overlay divs at each ref's bounding box
324
+ const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number } }> = [];
325
+ for (const [ref, entry] of refMap) {
326
+ try {
327
+ const box = await entry.locator.boundingBox({ timeout: 1000 });
328
+ if (box) {
329
+ boxes.push({ ref: `@${ref}`, box });
330
+ }
331
+ } catch {
332
+ // Element may be offscreen or hidden — skip
333
+ }
334
+ }
335
+
336
+ await page.evaluate((boxes) => {
337
+ for (const { ref, box } of boxes) {
338
+ const overlay = document.createElement('div');
339
+ overlay.className = '__browse_annotation__';
340
+ overlay.style.cssText = `
341
+ position: absolute; top: ${box.y}px; left: ${box.x}px;
342
+ width: ${box.width}px; height: ${box.height}px;
343
+ border: 2px solid red; background: rgba(255,0,0,0.1);
344
+ pointer-events: none; z-index: 99999;
345
+ font-size: 10px; color: red; font-weight: bold;
346
+ `;
347
+ const label = document.createElement('span');
348
+ label.textContent = ref;
349
+ label.style.cssText = 'position: absolute; top: -14px; left: 0; background: red; color: white; padding: 0 3px; font-size: 10px;';
350
+ overlay.appendChild(label);
351
+ document.body.appendChild(overlay);
352
+ }
353
+ }, boxes);
354
+
355
+ await page.screenshot({ path: screenshotPath, fullPage: true });
356
+
357
+ // Always remove overlays
358
+ await page.evaluate(() => {
359
+ document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove());
360
+ });
361
+
362
+ output.push('');
363
+ output.push(`[annotated screenshot: ${screenshotPath}]`);
364
+ } catch {
365
+ // Remove overlays even on screenshot failure
366
+ try {
367
+ await page.evaluate(() => {
368
+ document.querySelectorAll('.__browse_annotation__').forEach(el => el.remove());
369
+ });
370
+ } catch {}
371
+ }
372
+ }
373
+
374
+ // ─── Diff mode (-D) ───────────────────────────────────────
375
+ if (opts.diff) {
376
+ const lastSnapshot = bm.getLastSnapshot();
377
+ if (!lastSnapshot) {
378
+ bm.setLastSnapshot(snapshotText);
379
+ return snapshotText + '\n\n(no previous snapshot to diff against — this snapshot stored as baseline)';
380
+ }
381
+
382
+ const changes = Diff.diffLines(lastSnapshot, snapshotText);
383
+ const diffOutput: string[] = ['--- previous snapshot', '+++ current snapshot', ''];
384
+
385
+ for (const part of changes) {
386
+ const prefix = part.added ? '+' : part.removed ? '-' : ' ';
387
+ const diffLines = part.value.split('\n').filter(l => l.length > 0);
388
+ for (const line of diffLines) {
389
+ diffOutput.push(`${prefix} ${line}`);
390
+ }
391
+ }
392
+
393
+ bm.setLastSnapshot(snapshotText);
394
+ return diffOutput.join('\n');
395
+ }
396
+
397
+ // Store for future diffs
398
+ bm.setLastSnapshot(snapshotText);
399
+
400
+ // Add frame context header when operating inside an iframe
401
+ if (inFrame) {
402
+ const frameUrl = bm.getFrame()?.url() ?? 'unknown';
403
+ output.unshift(`[Context: iframe src="${frameUrl}"]`);
404
+ }
405
+
406
+ return output.join('\n');
407
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * URL validation for navigation commands — blocks dangerous schemes and cloud metadata endpoints.
3
+ * Localhost and private IPs are allowed (primary use case: QA testing local dev servers).
4
+ */
5
+
6
+ const BLOCKED_METADATA_HOSTS = new Set([
7
+ '169.254.169.254', // AWS/GCP/Azure instance metadata
8
+ 'fd00::', // IPv6 unique local (metadata in some cloud setups)
9
+ 'metadata.google.internal', // GCP metadata
10
+ 'metadata.azure.internal', // Azure IMDS
11
+ ]);
12
+
13
+ /**
14
+ * Normalize hostname for blocklist comparison:
15
+ * - Strip trailing dot (DNS fully-qualified notation)
16
+ * - Strip IPv6 brackets (URL.hostname includes [] for IPv6)
17
+ * - Resolve hex (0xA9FEA9FE) and decimal (2852039166) IP representations
18
+ */
19
+ function normalizeHostname(hostname: string): string {
20
+ // Strip IPv6 brackets
21
+ let h = hostname.startsWith('[') && hostname.endsWith(']')
22
+ ? hostname.slice(1, -1)
23
+ : hostname;
24
+ // Strip trailing dot
25
+ if (h.endsWith('.')) h = h.slice(0, -1);
26
+ return h;
27
+ }
28
+
29
+ /**
30
+ * Check if a hostname resolves to the link-local metadata IP 169.254.169.254.
31
+ * Catches hex (0xA9FEA9FE), decimal (2852039166), and octal (0251.0376.0251.0376) forms.
32
+ */
33
+ function isMetadataIp(hostname: string): boolean {
34
+ // Try to parse as a numeric IP via URL constructor — it normalizes all forms
35
+ try {
36
+ const probe = new URL(`http://${hostname}`);
37
+ const normalized = probe.hostname;
38
+ if (BLOCKED_METADATA_HOSTS.has(normalized)) return true;
39
+ // Also check after stripping trailing dot
40
+ if (normalized.endsWith('.') && BLOCKED_METADATA_HOSTS.has(normalized.slice(0, -1))) return true;
41
+ } catch {
42
+ // Not a valid hostname — can't be a metadata IP
43
+ }
44
+ return false;
45
+ }
46
+
47
+ /**
48
+ * Resolve a hostname to its IP addresses and check if any resolve to blocked metadata IPs.
49
+ * Mitigates DNS rebinding: even if the hostname looks safe, the resolved IP might not be.
50
+ */
51
+ async function resolvesToBlockedIp(hostname: string): Promise<boolean> {
52
+ try {
53
+ const dns = await import('node:dns');
54
+ const { resolve4 } = dns.promises;
55
+ const addresses = await resolve4(hostname);
56
+ return addresses.some(addr => BLOCKED_METADATA_HOSTS.has(addr));
57
+ } catch {
58
+ // DNS resolution failed — not a rebinding risk
59
+ return false;
60
+ }
61
+ }
62
+
63
+ export async function validateNavigationUrl(url: string): Promise<void> {
64
+ let parsed: URL;
65
+ try {
66
+ parsed = new URL(url);
67
+ } catch {
68
+ throw new Error(`Invalid URL: ${url}`);
69
+ }
70
+
71
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
72
+ throw new Error(
73
+ `Blocked: scheme "${parsed.protocol}" is not allowed. Only http: and https: URLs are permitted.`
74
+ );
75
+ }
76
+
77
+ const hostname = normalizeHostname(parsed.hostname.toLowerCase());
78
+
79
+ if (BLOCKED_METADATA_HOSTS.has(hostname) || isMetadataIp(hostname)) {
80
+ throw new Error(
81
+ `Blocked: ${parsed.hostname} is a cloud metadata endpoint. Access is denied for security.`
82
+ );
83
+ }
84
+
85
+ // DNS rebinding protection: resolve hostname and check if it points to metadata IPs.
86
+ // Skip for loopback/private IPs — they can't be DNS-rebinded and the async DNS
87
+ // resolution adds latency that breaks concurrent E2E tests under load.
88
+ const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
89
+ const isPrivateNet = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/.test(hostname);
90
+ if (!isLoopback && !isPrivateNet && await resolvesToBlockedIp(hostname)) {
91
+ throw new Error(
92
+ `Blocked: ${parsed.hostname} resolves to a cloud metadata IP. Possible DNS rebinding attack.`
93
+ );
94
+ }
95
+ }