opengstack 0.13.10 → 0.14.0

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 (151) hide show
  1. package/{skills/land-and-deploy/SKILL.md → commands/autoplan.md} +0 -16
  2. package/{skills/benchmark/SKILL.md → commands/benchmark.md} +0 -17
  3. package/{skills/browse/SKILL.md → commands/browse.md} +0 -17
  4. package/{skills/ship/SKILL.md → commands/canary.md} +0 -18
  5. package/{skills/careful/SKILL.md → commands/careful.md} +0 -20
  6. package/{skills/canary/SKILL.md → commands/codex.md} +0 -17
  7. package/{skills/connect-chrome/SKILL.md → commands/connect-chrome.md} +0 -15
  8. package/commands/cso.md +72 -0
  9. package/commands/design-consultation.md +72 -0
  10. package/commands/design-review.md +72 -0
  11. package/commands/design-shotgun.md +72 -0
  12. package/commands/document-release.md +72 -0
  13. package/{skills/freeze/SKILL.md → commands/freeze.md} +0 -26
  14. package/{skills/gstack-upgrade/SKILL.md → commands/gstack-upgrade.md} +0 -14
  15. package/{skills/guard/SKILL.md → commands/guard.md} +0 -31
  16. package/commands/investigate.md +72 -0
  17. package/commands/land-and-deploy.md +72 -0
  18. package/commands/office-hours.md +72 -0
  19. package/commands/plan-ceo-review.md +72 -0
  20. package/commands/plan-design-review.md +72 -0
  21. package/commands/plan-eng-review.md +72 -0
  22. package/commands/qa-only.md +72 -0
  23. package/commands/qa.md +72 -0
  24. package/commands/retro.md +72 -0
  25. package/commands/review.md +72 -0
  26. package/{skills/setup-browser-cookies/SKILL.md → commands/setup-browser-cookies.md} +0 -14
  27. package/commands/setup-deploy.md +72 -0
  28. package/commands/ship.md +72 -0
  29. package/{skills/unfreeze/SKILL.md → commands/unfreeze.md} +0 -12
  30. package/package.json +4 -4
  31. package/scripts/install-commands.js +45 -0
  32. package/skills/autoplan/SKILL.md +0 -96
  33. package/skills/autoplan/SKILL.md.tmpl +0 -694
  34. package/skills/benchmark/SKILL.md.tmpl +0 -222
  35. package/skills/browse/SKILL.md.tmpl +0 -131
  36. package/skills/browse/bin/find-browse +0 -21
  37. package/skills/browse/bin/remote-slug +0 -14
  38. package/skills/browse/scripts/build-node-server.sh +0 -48
  39. package/skills/browse/src/activity.ts +0 -208
  40. package/skills/browse/src/browser-manager.ts +0 -959
  41. package/skills/browse/src/buffers.ts +0 -137
  42. package/skills/browse/src/bun-polyfill.cjs +0 -109
  43. package/skills/browse/src/cli.ts +0 -678
  44. package/skills/browse/src/commands.ts +0 -128
  45. package/skills/browse/src/config.ts +0 -150
  46. package/skills/browse/src/cookie-import-browser.ts +0 -625
  47. package/skills/browse/src/cookie-picker-routes.ts +0 -230
  48. package/skills/browse/src/cookie-picker-ui.ts +0 -688
  49. package/skills/browse/src/find-browse.ts +0 -61
  50. package/skills/browse/src/meta-commands.ts +0 -550
  51. package/skills/browse/src/platform.ts +0 -17
  52. package/skills/browse/src/read-commands.ts +0 -358
  53. package/skills/browse/src/server.ts +0 -1192
  54. package/skills/browse/src/sidebar-agent.ts +0 -280
  55. package/skills/browse/src/sidebar-utils.ts +0 -21
  56. package/skills/browse/src/snapshot.ts +0 -407
  57. package/skills/browse/src/url-validation.ts +0 -95
  58. package/skills/browse/src/write-commands.ts +0 -364
  59. package/skills/browse/test/activity.test.ts +0 -120
  60. package/skills/browse/test/adversarial-security.test.ts +0 -32
  61. package/skills/browse/test/browser-manager-unit.test.ts +0 -17
  62. package/skills/browse/test/bun-polyfill.test.ts +0 -72
  63. package/skills/browse/test/commands.test.ts +0 -2075
  64. package/skills/browse/test/compare-board.test.ts +0 -342
  65. package/skills/browse/test/config.test.ts +0 -316
  66. package/skills/browse/test/cookie-import-browser.test.ts +0 -519
  67. package/skills/browse/test/cookie-picker-routes.test.ts +0 -260
  68. package/skills/browse/test/file-drop.test.ts +0 -271
  69. package/skills/browse/test/find-browse.test.ts +0 -50
  70. package/skills/browse/test/findport.test.ts +0 -191
  71. package/skills/browse/test/fixtures/basic.html +0 -33
  72. package/skills/browse/test/fixtures/cursor-interactive.html +0 -22
  73. package/skills/browse/test/fixtures/dialog.html +0 -15
  74. package/skills/browse/test/fixtures/empty.html +0 -2
  75. package/skills/browse/test/fixtures/forms.html +0 -55
  76. package/skills/browse/test/fixtures/iframe.html +0 -30
  77. package/skills/browse/test/fixtures/network-idle.html +0 -30
  78. package/skills/browse/test/fixtures/qa-eval-checkout.html +0 -108
  79. package/skills/browse/test/fixtures/qa-eval-spa.html +0 -98
  80. package/skills/browse/test/fixtures/qa-eval.html +0 -51
  81. package/skills/browse/test/fixtures/responsive.html +0 -49
  82. package/skills/browse/test/fixtures/snapshot.html +0 -55
  83. package/skills/browse/test/fixtures/spa.html +0 -24
  84. package/skills/browse/test/fixtures/states.html +0 -17
  85. package/skills/browse/test/fixtures/upload.html +0 -25
  86. package/skills/browse/test/gstack-config.test.ts +0 -138
  87. package/skills/browse/test/gstack-update-check.test.ts +0 -514
  88. package/skills/browse/test/handoff.test.ts +0 -235
  89. package/skills/browse/test/path-validation.test.ts +0 -91
  90. package/skills/browse/test/platform.test.ts +0 -37
  91. package/skills/browse/test/server-auth.test.ts +0 -65
  92. package/skills/browse/test/sidebar-agent-roundtrip.test.ts +0 -226
  93. package/skills/browse/test/sidebar-agent.test.ts +0 -199
  94. package/skills/browse/test/sidebar-integration.test.ts +0 -320
  95. package/skills/browse/test/sidebar-unit.test.ts +0 -96
  96. package/skills/browse/test/snapshot.test.ts +0 -467
  97. package/skills/browse/test/state-ttl.test.ts +0 -35
  98. package/skills/browse/test/test-server.ts +0 -57
  99. package/skills/browse/test/url-validation.test.ts +0 -72
  100. package/skills/browse/test/watch.test.ts +0 -129
  101. package/skills/canary/SKILL.md.tmpl +0 -212
  102. package/skills/careful/SKILL.md.tmpl +0 -56
  103. package/skills/careful/bin/check-careful.sh +0 -112
  104. package/skills/codex/SKILL.md +0 -90
  105. package/skills/codex/SKILL.md.tmpl +0 -417
  106. package/skills/connect-chrome/SKILL.md.tmpl +0 -195
  107. package/skills/cso/ACKNOWLEDGEMENTS.md +0 -14
  108. package/skills/cso/SKILL.md +0 -93
  109. package/skills/cso/SKILL.md.tmpl +0 -606
  110. package/skills/design-consultation/SKILL.md +0 -94
  111. package/skills/design-consultation/SKILL.md.tmpl +0 -415
  112. package/skills/design-review/SKILL.md +0 -94
  113. package/skills/design-review/SKILL.md.tmpl +0 -290
  114. package/skills/design-shotgun/SKILL.md +0 -91
  115. package/skills/design-shotgun/SKILL.md.tmpl +0 -285
  116. package/skills/document-release/SKILL.md +0 -91
  117. package/skills/document-release/SKILL.md.tmpl +0 -359
  118. package/skills/freeze/SKILL.md.tmpl +0 -77
  119. package/skills/freeze/bin/check-freeze.sh +0 -79
  120. package/skills/gstack-upgrade/SKILL.md.tmpl +0 -222
  121. package/skills/guard/SKILL.md.tmpl +0 -77
  122. package/skills/investigate/SKILL.md +0 -105
  123. package/skills/investigate/SKILL.md.tmpl +0 -194
  124. package/skills/land-and-deploy/SKILL.md.tmpl +0 -881
  125. package/skills/office-hours/SKILL.md +0 -96
  126. package/skills/office-hours/SKILL.md.tmpl +0 -645
  127. package/skills/plan-ceo-review/SKILL.md +0 -94
  128. package/skills/plan-ceo-review/SKILL.md.tmpl +0 -811
  129. package/skills/plan-design-review/SKILL.md +0 -92
  130. package/skills/plan-design-review/SKILL.md.tmpl +0 -446
  131. package/skills/plan-eng-review/SKILL.md +0 -93
  132. package/skills/plan-eng-review/SKILL.md.tmpl +0 -303
  133. package/skills/qa/SKILL.md +0 -95
  134. package/skills/qa/SKILL.md.tmpl +0 -316
  135. package/skills/qa/references/issue-taxonomy.md +0 -85
  136. package/skills/qa/templates/qa-report-template.md +0 -126
  137. package/skills/qa-only/SKILL.md +0 -89
  138. package/skills/qa-only/SKILL.md.tmpl +0 -101
  139. package/skills/retro/SKILL.md +0 -89
  140. package/skills/retro/SKILL.md.tmpl +0 -820
  141. package/skills/review/SKILL.md +0 -92
  142. package/skills/review/SKILL.md.tmpl +0 -281
  143. package/skills/review/TODOS-format.md +0 -62
  144. package/skills/review/checklist.md +0 -220
  145. package/skills/review/design-checklist.md +0 -132
  146. package/skills/review/greptile-triage.md +0 -220
  147. package/skills/setup-browser-cookies/SKILL.md.tmpl +0 -81
  148. package/skills/setup-deploy/SKILL.md +0 -92
  149. package/skills/setup-deploy/SKILL.md.tmpl +0 -215
  150. package/skills/ship/SKILL.md.tmpl +0 -636
  151. package/skills/unfreeze/SKILL.md.tmpl +0 -36
@@ -1,407 +0,0 @@
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
- }
@@ -1,95 +0,0 @@
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
- }