opengstack 0.13.7 → 0.13.9
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.
- package/bin/opengstack.js +35 -90
- package/package.json +2 -3
- package/scripts/install-skills.js +47 -58
- package/skills/browse/bin/find-browse +21 -0
- package/skills/browse/bin/remote-slug +14 -0
- package/skills/browse/scripts/build-node-server.sh +48 -0
- package/skills/browse/src/activity.ts +208 -0
- package/skills/browse/src/browser-manager.ts +959 -0
- package/skills/browse/src/buffers.ts +137 -0
- package/skills/browse/src/bun-polyfill.cjs +109 -0
- package/skills/browse/src/cli.ts +678 -0
- package/skills/browse/src/commands.ts +128 -0
- package/skills/browse/src/config.ts +150 -0
- package/skills/browse/src/cookie-import-browser.ts +625 -0
- package/skills/browse/src/cookie-picker-routes.ts +230 -0
- package/skills/browse/src/cookie-picker-ui.ts +688 -0
- package/skills/browse/src/find-browse.ts +61 -0
- package/skills/browse/src/meta-commands.ts +550 -0
- package/skills/browse/src/platform.ts +17 -0
- package/skills/browse/src/read-commands.ts +358 -0
- package/skills/browse/src/server.ts +1192 -0
- package/skills/browse/src/sidebar-agent.ts +280 -0
- package/skills/browse/src/sidebar-utils.ts +21 -0
- package/skills/browse/src/snapshot.ts +407 -0
- package/skills/browse/src/url-validation.ts +95 -0
- package/skills/browse/src/write-commands.ts +364 -0
- package/skills/browse/test/activity.test.ts +120 -0
- package/skills/browse/test/adversarial-security.test.ts +32 -0
- package/skills/browse/test/browser-manager-unit.test.ts +17 -0
- package/skills/browse/test/bun-polyfill.test.ts +72 -0
- package/skills/browse/test/commands.test.ts +2075 -0
- package/skills/browse/test/compare-board.test.ts +342 -0
- package/skills/browse/test/config.test.ts +316 -0
- package/skills/browse/test/cookie-import-browser.test.ts +519 -0
- package/skills/browse/test/cookie-picker-routes.test.ts +260 -0
- package/skills/browse/test/file-drop.test.ts +271 -0
- package/skills/browse/test/find-browse.test.ts +50 -0
- package/skills/browse/test/findport.test.ts +191 -0
- package/skills/browse/test/fixtures/basic.html +33 -0
- package/skills/browse/test/fixtures/cursor-interactive.html +22 -0
- package/skills/browse/test/fixtures/dialog.html +15 -0
- package/skills/browse/test/fixtures/empty.html +2 -0
- package/skills/browse/test/fixtures/forms.html +55 -0
- package/skills/browse/test/fixtures/iframe.html +30 -0
- package/skills/browse/test/fixtures/network-idle.html +30 -0
- package/skills/browse/test/fixtures/qa-eval-checkout.html +108 -0
- package/skills/browse/test/fixtures/qa-eval-spa.html +98 -0
- package/skills/browse/test/fixtures/qa-eval.html +51 -0
- package/skills/browse/test/fixtures/responsive.html +49 -0
- package/skills/browse/test/fixtures/snapshot.html +55 -0
- package/skills/browse/test/fixtures/spa.html +24 -0
- package/skills/browse/test/fixtures/states.html +17 -0
- package/skills/browse/test/fixtures/upload.html +25 -0
- package/skills/browse/test/gstack-config.test.ts +138 -0
- package/skills/browse/test/gstack-update-check.test.ts +514 -0
- package/skills/browse/test/handoff.test.ts +235 -0
- package/skills/browse/test/path-validation.test.ts +91 -0
- package/skills/browse/test/platform.test.ts +37 -0
- package/skills/browse/test/server-auth.test.ts +65 -0
- package/skills/browse/test/sidebar-agent-roundtrip.test.ts +226 -0
- package/skills/browse/test/sidebar-agent.test.ts +199 -0
- package/skills/browse/test/sidebar-integration.test.ts +320 -0
- package/skills/browse/test/sidebar-unit.test.ts +96 -0
- package/skills/browse/test/snapshot.test.ts +467 -0
- package/skills/browse/test/state-ttl.test.ts +35 -0
- package/skills/browse/test/test-server.ts +57 -0
- package/skills/browse/test/url-validation.test.ts +72 -0
- package/skills/browse/test/watch.test.ts +129 -0
- package/skills/careful/bin/check-careful.sh +112 -0
- package/skills/cso/ACKNOWLEDGEMENTS.md +14 -0
- package/skills/freeze/bin/check-freeze.sh +79 -0
- package/skills/qa/references/issue-taxonomy.md +85 -0
- package/skills/qa/templates/qa-report-template.md +126 -0
- package/skills/review/TODOS-format.md +62 -0
- package/skills/review/checklist.md +220 -0
- package/skills/review/design-checklist.md +132 -0
- package/skills/review/greptile-triage.md +220 -0
- /package/{autoplan → skills/autoplan}/SKILL.md +0 -0
- /package/{autoplan → skills/autoplan}/SKILL.md.tmpl +0 -0
- /package/{benchmark → skills/benchmark}/SKILL.md +0 -0
- /package/{benchmark → skills/benchmark}/SKILL.md.tmpl +0 -0
- /package/{browse → skills/browse}/SKILL.md +0 -0
- /package/{browse → skills/browse}/SKILL.md.tmpl +0 -0
- /package/{canary → skills/canary}/SKILL.md +0 -0
- /package/{canary → skills/canary}/SKILL.md.tmpl +0 -0
- /package/{careful → skills/careful}/SKILL.md +0 -0
- /package/{careful → skills/careful}/SKILL.md.tmpl +0 -0
- /package/{codex → skills/codex}/SKILL.md +0 -0
- /package/{codex → skills/codex}/SKILL.md.tmpl +0 -0
- /package/{connect-chrome → skills/connect-chrome}/SKILL.md +0 -0
- /package/{connect-chrome → skills/connect-chrome}/SKILL.md.tmpl +0 -0
- /package/{cso → skills/cso}/SKILL.md +0 -0
- /package/{cso → skills/cso}/SKILL.md.tmpl +0 -0
- /package/{design-consultation → skills/design-consultation}/SKILL.md +0 -0
- /package/{design-consultation → skills/design-consultation}/SKILL.md.tmpl +0 -0
- /package/{design-review → skills/design-review}/SKILL.md +0 -0
- /package/{design-review → skills/design-review}/SKILL.md.tmpl +0 -0
- /package/{design-shotgun → skills/design-shotgun}/SKILL.md +0 -0
- /package/{design-shotgun → skills/design-shotgun}/SKILL.md.tmpl +0 -0
- /package/{document-release → skills/document-release}/SKILL.md +0 -0
- /package/{document-release → skills/document-release}/SKILL.md.tmpl +0 -0
- /package/{freeze → skills/freeze}/SKILL.md +0 -0
- /package/{freeze → skills/freeze}/SKILL.md.tmpl +0 -0
- /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md +0 -0
- /package/{gstack-upgrade → skills/gstack-upgrade}/SKILL.md.tmpl +0 -0
- /package/{guard → skills/guard}/SKILL.md +0 -0
- /package/{guard → skills/guard}/SKILL.md.tmpl +0 -0
- /package/{investigate → skills/investigate}/SKILL.md +0 -0
- /package/{investigate → skills/investigate}/SKILL.md.tmpl +0 -0
- /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md +0 -0
- /package/{land-and-deploy → skills/land-and-deploy}/SKILL.md.tmpl +0 -0
- /package/{office-hours → skills/office-hours}/SKILL.md +0 -0
- /package/{office-hours → skills/office-hours}/SKILL.md.tmpl +0 -0
- /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md +0 -0
- /package/{plan-ceo-review → skills/plan-ceo-review}/SKILL.md.tmpl +0 -0
- /package/{plan-design-review → skills/plan-design-review}/SKILL.md +0 -0
- /package/{plan-design-review → skills/plan-design-review}/SKILL.md.tmpl +0 -0
- /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md +0 -0
- /package/{plan-eng-review → skills/plan-eng-review}/SKILL.md.tmpl +0 -0
- /package/{qa → skills/qa}/SKILL.md +0 -0
- /package/{qa → skills/qa}/SKILL.md.tmpl +0 -0
- /package/{qa-only → skills/qa-only}/SKILL.md +0 -0
- /package/{qa-only → skills/qa-only}/SKILL.md.tmpl +0 -0
- /package/{retro → skills/retro}/SKILL.md +0 -0
- /package/{retro → skills/retro}/SKILL.md.tmpl +0 -0
- /package/{review → skills/review}/SKILL.md +0 -0
- /package/{review → skills/review}/SKILL.md.tmpl +0 -0
- /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md +0 -0
- /package/{setup-browser-cookies → skills/setup-browser-cookies}/SKILL.md.tmpl +0 -0
- /package/{setup-deploy → skills/setup-deploy}/SKILL.md +0 -0
- /package/{setup-deploy → skills/setup-deploy}/SKILL.md.tmpl +0 -0
- /package/{ship → skills/ship}/SKILL.md +0 -0
- /package/{ship → skills/ship}/SKILL.md.tmpl +0 -0
- /package/{unfreeze → skills/unfreeze}/SKILL.md +0 -0
- /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
|
+
}
|