@ulpi/browse 0.7.5 → 1.0.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.
- package/LICENSE +1 -1
- package/README.md +444 -300
- package/dist/browse.mjs +6756 -0
- package/package.json +17 -13
- package/skill/SKILL.md +114 -7
- package/bin/browse.ts +0 -11
- package/src/auth-vault.ts +0 -244
- package/src/browser-manager.ts +0 -961
- package/src/buffers.ts +0 -81
- package/src/bun.d.ts +0 -70
- package/src/cli.ts +0 -683
- package/src/commands/meta.ts +0 -748
- package/src/commands/read.ts +0 -347
- package/src/commands/write.ts +0 -484
- package/src/config.ts +0 -45
- package/src/constants.ts +0 -14
- package/src/diff.d.ts +0 -12
- package/src/domain-filter.ts +0 -140
- package/src/har.ts +0 -66
- package/src/install-skill.ts +0 -98
- package/src/png-compare.ts +0 -247
- package/src/policy.ts +0 -94
- package/src/rebrowser.d.ts +0 -7
- package/src/runtime.ts +0 -161
- package/src/sanitize.ts +0 -11
- package/src/server.ts +0 -485
- package/src/session-manager.ts +0 -192
- package/src/snapshot.ts +0 -606
- package/src/types.ts +0 -12
package/src/snapshot.ts
DELETED
|
@@ -1,606 +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
|
-
* Cursor-interactive detection (-C flag):
|
|
12
|
-
* After the normal ARIA snapshot, scans the DOM for elements that are
|
|
13
|
-
* clickable but invisible to the accessibility tree — divs with
|
|
14
|
-
* cursor:pointer, onclick, tabindex, role, or data-action attributes.
|
|
15
|
-
* These get refs and locators just like ARIA elements, so "click @e15"
|
|
16
|
-
* works seamlessly.
|
|
17
|
-
*
|
|
18
|
-
* Later: "click @e3" → look up Locator → locator.click()
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import type { Page, Frame, FrameLocator, Locator } from 'playwright';
|
|
22
|
-
import type { BrowserManager } from './browser-manager';
|
|
23
|
-
|
|
24
|
-
// Roles considered "interactive" for the -i flag
|
|
25
|
-
const INTERACTIVE_ROLES = new Set([
|
|
26
|
-
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
|
|
27
|
-
'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
|
|
28
|
-
'option', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab',
|
|
29
|
-
'treeitem',
|
|
30
|
-
]);
|
|
31
|
-
|
|
32
|
-
interface SnapshotOptions {
|
|
33
|
-
interactive?: boolean; // -i: only interactive elements (terse flat list by default)
|
|
34
|
-
full?: boolean; // -f: full indented ARIA tree with props/children (overrides -i terse default)
|
|
35
|
-
compact?: boolean; // -c: remove empty structural elements
|
|
36
|
-
viewport?: boolean; // -V: only elements visible in current viewport
|
|
37
|
-
depth?: number; // -d N: limit tree depth
|
|
38
|
-
selector?: string; // -s SEL: scope to CSS selector
|
|
39
|
-
cursor?: boolean; // -C: detect cursor-interactive elements (divs with cursor:pointer, onclick, tabindex)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface ParsedNode {
|
|
43
|
-
indent: number;
|
|
44
|
-
role: string;
|
|
45
|
-
name: string | null;
|
|
46
|
-
props: string; // e.g., "[level=1]"
|
|
47
|
-
children: string; // inline text content after ":"
|
|
48
|
-
rawLine: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Info returned from the in-page DOM scan for cursor-interactive elements */
|
|
52
|
-
interface CursorElement {
|
|
53
|
-
tag: string;
|
|
54
|
-
id: string;
|
|
55
|
-
className: string;
|
|
56
|
-
text: string;
|
|
57
|
-
reason: string; // "cursor:pointer" | "onclick" | "tabindex" | "role" | "data-action"
|
|
58
|
-
cssSelector: string; // best-effort unique CSS selector for building Locator
|
|
59
|
-
selectorIndex: number; // element's index among all matches of cssSelector in the DOM
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Parse CLI args into SnapshotOptions
|
|
64
|
-
*/
|
|
65
|
-
export function parseSnapshotArgs(args: string[]): SnapshotOptions {
|
|
66
|
-
const opts: SnapshotOptions = {};
|
|
67
|
-
for (let i = 0; i < args.length; i++) {
|
|
68
|
-
switch (args[i]) {
|
|
69
|
-
case '-i':
|
|
70
|
-
case '--interactive':
|
|
71
|
-
opts.interactive = true;
|
|
72
|
-
break;
|
|
73
|
-
case '-c':
|
|
74
|
-
case '--compact':
|
|
75
|
-
opts.compact = true;
|
|
76
|
-
break;
|
|
77
|
-
case '-f':
|
|
78
|
-
case '--full':
|
|
79
|
-
opts.full = true;
|
|
80
|
-
break;
|
|
81
|
-
case '-V':
|
|
82
|
-
case '--viewport':
|
|
83
|
-
opts.viewport = true;
|
|
84
|
-
break;
|
|
85
|
-
case '-C':
|
|
86
|
-
case '--cursor':
|
|
87
|
-
opts.cursor = true;
|
|
88
|
-
break;
|
|
89
|
-
case '-d':
|
|
90
|
-
case '--depth':
|
|
91
|
-
opts.depth = parseInt(args[++i], 10);
|
|
92
|
-
if (isNaN(opts.depth!)) throw new Error('Usage: snapshot -d <number>');
|
|
93
|
-
break;
|
|
94
|
-
case '-s':
|
|
95
|
-
case '--selector':
|
|
96
|
-
opts.selector = args[++i];
|
|
97
|
-
if (!opts.selector) throw new Error('Usage: snapshot -s <selector>');
|
|
98
|
-
break;
|
|
99
|
-
default:
|
|
100
|
-
throw new Error(`Unknown snapshot flag: ${args[i]}`);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return opts;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Parse one line of ariaSnapshot output.
|
|
108
|
-
*
|
|
109
|
-
* Format examples:
|
|
110
|
-
* - heading "Test" [level=1]
|
|
111
|
-
* - link "Link A":
|
|
112
|
-
* - /url: /a
|
|
113
|
-
* - textbox "Name"
|
|
114
|
-
* - paragraph: Some text
|
|
115
|
-
* - combobox "Role":
|
|
116
|
-
*/
|
|
117
|
-
function parseLine(line: string): ParsedNode | null {
|
|
118
|
-
// Match: (indent)(- )(role)( "name")?( [props])?(: inline)?
|
|
119
|
-
const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/);
|
|
120
|
-
if (!match) {
|
|
121
|
-
// Skip metadata lines like "- /url: /a"
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
return {
|
|
125
|
-
indent: match[1].length,
|
|
126
|
-
role: match[2],
|
|
127
|
-
name: match[3] ?? null,
|
|
128
|
-
props: match[4] || '',
|
|
129
|
-
children: match[5]?.trim() || '',
|
|
130
|
-
rawLine: line,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Native interactive tags that are already captured by the accessibility tree.
|
|
136
|
-
* We skip these in the cursor-interactive scan to avoid duplicates.
|
|
137
|
-
*/
|
|
138
|
-
const NATIVE_INTERACTIVE_TAGS = new Set([
|
|
139
|
-
'a', 'button', 'input', 'select', 'textarea', 'option', 'details', 'summary',
|
|
140
|
-
]);
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Scan the DOM for elements that look clickable/interactive but were missed
|
|
144
|
-
* by the accessibility tree. Runs inside page.evaluate().
|
|
145
|
-
*
|
|
146
|
-
* Detection heuristics:
|
|
147
|
-
* - cursor: pointer computed style
|
|
148
|
-
* - onclick attribute (or any on* event attribute)
|
|
149
|
-
* - tabindex attribute (explicitly set)
|
|
150
|
-
* - role attribute matching interactive roles
|
|
151
|
-
* - data-action, data-click, or similar data attributes
|
|
152
|
-
*
|
|
153
|
-
* Exclusions:
|
|
154
|
-
* - Native interactive tags (a, button, input, select, textarea, option)
|
|
155
|
-
* - Hidden or zero-size elements
|
|
156
|
-
* - Elements already covered by ARIA roles
|
|
157
|
-
*/
|
|
158
|
-
async function findCursorInteractiveElements(
|
|
159
|
-
evalCtx: Page | Frame,
|
|
160
|
-
scopeSelector?: string,
|
|
161
|
-
): Promise<CursorElement[]> {
|
|
162
|
-
const interactiveRolesList = [...INTERACTIVE_ROLES];
|
|
163
|
-
const nativeTagsList = [...NATIVE_INTERACTIVE_TAGS];
|
|
164
|
-
|
|
165
|
-
return await evalCtx.evaluate(
|
|
166
|
-
({ scopeSel, interactiveRoles, nativeTags }) => {
|
|
167
|
-
const root = scopeSel
|
|
168
|
-
? document.querySelector(scopeSel) || document.body
|
|
169
|
-
: document.body;
|
|
170
|
-
|
|
171
|
-
const nativeSet = new Set(nativeTags);
|
|
172
|
-
const interactiveSet = new Set(interactiveRoles);
|
|
173
|
-
const results: Array<{
|
|
174
|
-
tag: string;
|
|
175
|
-
id: string;
|
|
176
|
-
className: string;
|
|
177
|
-
text: string;
|
|
178
|
-
reason: string;
|
|
179
|
-
cssSelector: string;
|
|
180
|
-
selectorIndex: number;
|
|
181
|
-
}> = [];
|
|
182
|
-
|
|
183
|
-
// Build a set of elements already in the accessibility tree by checking
|
|
184
|
-
// native interactive tags — these will already have ARIA roles
|
|
185
|
-
const allElements = root.querySelectorAll('*');
|
|
186
|
-
|
|
187
|
-
for (let i = 0; i < allElements.length; i++) {
|
|
188
|
-
const el = allElements[i] as HTMLElement;
|
|
189
|
-
const tag = el.tagName.toLowerCase();
|
|
190
|
-
|
|
191
|
-
// Skip native interactive elements — ARIA already captures these
|
|
192
|
-
if (nativeSet.has(tag)) continue;
|
|
193
|
-
|
|
194
|
-
// Skip hidden or zero-size elements
|
|
195
|
-
if (el.offsetWidth === 0 && el.offsetHeight === 0) continue;
|
|
196
|
-
const style = getComputedStyle(el);
|
|
197
|
-
if (style.display === 'none' || style.visibility === 'hidden') continue;
|
|
198
|
-
|
|
199
|
-
// Detect reasons this element might be interactive
|
|
200
|
-
let reason = '';
|
|
201
|
-
|
|
202
|
-
// Check for role attribute matching interactive roles
|
|
203
|
-
const roleAttr = el.getAttribute('role');
|
|
204
|
-
if (roleAttr && interactiveSet.has(roleAttr)) {
|
|
205
|
-
// Elements with explicit interactive ARIA roles ARE captured by ariaSnapshot
|
|
206
|
-
// Skip these to avoid duplicates
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Check for onclick or other event handler attributes
|
|
211
|
-
if (el.hasAttribute('onclick') || el.hasAttribute('onmousedown') || el.hasAttribute('onmouseup') || el.hasAttribute('ontouchstart')) {
|
|
212
|
-
reason = 'onclick';
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Check for tabindex (explicitly set, not inherited)
|
|
216
|
-
if (!reason && el.hasAttribute('tabindex')) {
|
|
217
|
-
const tabindex = el.getAttribute('tabindex');
|
|
218
|
-
// tabindex="-1" is programmatic focus only, still worth flagging
|
|
219
|
-
reason = 'tabindex';
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Check for data-action, data-click, or similar interaction attributes
|
|
223
|
-
if (!reason) {
|
|
224
|
-
for (const attr of el.attributes) {
|
|
225
|
-
if (attr.name === 'data-action' || attr.name === 'data-click' ||
|
|
226
|
-
attr.name === 'data-handler' || attr.name === 'data-toggle' ||
|
|
227
|
-
attr.name === 'data-dismiss' || attr.name === 'data-target' ||
|
|
228
|
-
attr.name === 'data-bs-toggle' || attr.name === 'data-bs-dismiss') {
|
|
229
|
-
reason = attr.name;
|
|
230
|
-
break;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Check for cursor: pointer computed style (most common signal)
|
|
236
|
-
if (!reason && style.cursor === 'pointer') {
|
|
237
|
-
reason = 'cursor:pointer';
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (!reason) continue;
|
|
241
|
-
|
|
242
|
-
// Extract visible text (first 60 chars)
|
|
243
|
-
const text = (el.textContent || '').trim().slice(0, 60).replace(/\s+/g, ' ');
|
|
244
|
-
|
|
245
|
-
// Build a best-effort CSS selector for locator construction.
|
|
246
|
-
// Strategy: find nearest ancestor with an ID and anchor from there.
|
|
247
|
-
let cssSelector = '';
|
|
248
|
-
if (el.id) {
|
|
249
|
-
cssSelector = `#${CSS.escape(el.id)}`;
|
|
250
|
-
} else {
|
|
251
|
-
// Build the element's own selector: tag.class1.class2:nth-of-type(N)
|
|
252
|
-
let sel = tag;
|
|
253
|
-
if (el.className && typeof el.className === 'string') {
|
|
254
|
-
const classes = el.className.trim().split(/\s+/).slice(0, 3);
|
|
255
|
-
for (const cls of classes) {
|
|
256
|
-
if (cls) sel += `.${CSS.escape(cls)}`;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
// Add nth-of-type to disambiguate among siblings
|
|
260
|
-
const parent = el.parentElement;
|
|
261
|
-
if (parent) {
|
|
262
|
-
const siblings = parent.querySelectorAll(`:scope > ${tag}`);
|
|
263
|
-
if (siblings.length > 1) {
|
|
264
|
-
let nth = 1;
|
|
265
|
-
for (let s = 0; s < siblings.length; s++) {
|
|
266
|
-
if (siblings[s] === el) { nth = s + 1; break; }
|
|
267
|
-
}
|
|
268
|
-
sel += `:nth-of-type(${nth})`;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// Walk up to find nearest ancestor with an ID (max 5 levels).
|
|
273
|
-
// When scoped, skip ancestors outside the scope root to avoid
|
|
274
|
-
// generating selectors that reference IDs the scoped locator can't reach.
|
|
275
|
-
let ancestor: HTMLElement | null = el.parentElement;
|
|
276
|
-
let anchor = '';
|
|
277
|
-
let depth = 0;
|
|
278
|
-
while (ancestor && ancestor !== document.body && depth < 5) {
|
|
279
|
-
if (ancestor.id) {
|
|
280
|
-
// If scoped, only use this anchor if it's inside the scope root
|
|
281
|
-
if (!scopeSel || root.contains(ancestor)) {
|
|
282
|
-
anchor = `#${CSS.escape(ancestor.id)}`;
|
|
283
|
-
}
|
|
284
|
-
break;
|
|
285
|
-
}
|
|
286
|
-
ancestor = ancestor.parentElement;
|
|
287
|
-
depth++;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Anchor from ID'd ancestor for uniqueness, or fall back to element selector alone
|
|
291
|
-
cssSelector = anchor ? `${anchor} ${sel}` : sel;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Compute the element's actual index among all DOM matches of cssSelector.
|
|
295
|
-
// When scoped, query against the scope root so nth() aligns with
|
|
296
|
-
// Playwright's page.locator(scope).locator(cssSelector).
|
|
297
|
-
let selectorIndex = 0;
|
|
298
|
-
try {
|
|
299
|
-
const queryRoot = scopeSel ? root : document.body;
|
|
300
|
-
const allMatches = queryRoot.querySelectorAll(cssSelector);
|
|
301
|
-
for (let m = 0; m < allMatches.length; m++) {
|
|
302
|
-
if (allMatches[m] === el) { selectorIndex = m; break; }
|
|
303
|
-
}
|
|
304
|
-
} catch {}
|
|
305
|
-
|
|
306
|
-
results.push({
|
|
307
|
-
tag,
|
|
308
|
-
id: el.id || '',
|
|
309
|
-
className: typeof el.className === 'string' ? el.className.trim() : '',
|
|
310
|
-
text,
|
|
311
|
-
reason,
|
|
312
|
-
cssSelector,
|
|
313
|
-
selectorIndex,
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
return results;
|
|
318
|
-
},
|
|
319
|
-
{
|
|
320
|
-
scopeSel: scopeSelector || null,
|
|
321
|
-
interactiveRoles: interactiveRolesList,
|
|
322
|
-
nativeTags: nativeTagsList,
|
|
323
|
-
},
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Take an accessibility snapshot and build the ref map.
|
|
329
|
-
*/
|
|
330
|
-
export async function handleSnapshot(
|
|
331
|
-
args: string[],
|
|
332
|
-
bm: BrowserManager
|
|
333
|
-
): Promise<string> {
|
|
334
|
-
const opts = parseSnapshotArgs(args);
|
|
335
|
-
const page = bm.getPage();
|
|
336
|
-
// When a frame is active, scope snapshot to the frame's content
|
|
337
|
-
const locatorRoot = bm.getLocatorRoot();
|
|
338
|
-
|
|
339
|
-
// Get accessibility tree via ariaSnapshot
|
|
340
|
-
let rootLocator: Locator;
|
|
341
|
-
if (opts.selector) {
|
|
342
|
-
rootLocator = locatorRoot.locator(opts.selector);
|
|
343
|
-
const count = await rootLocator.count();
|
|
344
|
-
if (count === 0) throw new Error(`Selector not found: ${opts.selector}`);
|
|
345
|
-
} else {
|
|
346
|
-
rootLocator = locatorRoot.locator('body');
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
const ariaText = await rootLocator.ariaSnapshot();
|
|
350
|
-
// Get frame context for evaluate calls (cursor-interactive scan)
|
|
351
|
-
const evalCtx = await bm.getFrameContext() || page;
|
|
352
|
-
if (!ariaText || ariaText.trim().length === 0) {
|
|
353
|
-
bm.setRefMap(new Map());
|
|
354
|
-
// If -C is active, still scan for cursor-interactive even with empty ARIA
|
|
355
|
-
if (opts.cursor) {
|
|
356
|
-
const result = await appendCursorElements(evalCtx, locatorRoot, opts, [], new Map(), 1, bm);
|
|
357
|
-
bm.setLastSnapshot(result, args);
|
|
358
|
-
return result;
|
|
359
|
-
}
|
|
360
|
-
bm.setLastSnapshot('(no accessible elements found)', args);
|
|
361
|
-
return '(no accessible elements found)';
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Parse the ariaSnapshot output
|
|
365
|
-
const lines = ariaText.split('\n');
|
|
366
|
-
const refMap = new Map<string, Locator>();
|
|
367
|
-
const output: string[] = [];
|
|
368
|
-
let refCounter = 1;
|
|
369
|
-
|
|
370
|
-
// Track role+name occurrences for nth() disambiguation
|
|
371
|
-
const roleNameCounts = new Map<string, number>();
|
|
372
|
-
const roleNameSeen = new Map<string, number>();
|
|
373
|
-
|
|
374
|
-
// First pass: count role+name pairs for disambiguation
|
|
375
|
-
for (const line of lines) {
|
|
376
|
-
const node = parseLine(line);
|
|
377
|
-
if (!node) continue;
|
|
378
|
-
const key = `${node.role}:${node.name || ''}`;
|
|
379
|
-
roleNameCounts.set(key, (roleNameCounts.get(key) || 0) + 1);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// Second pass: assign refs and build locators
|
|
383
|
-
for (const line of lines) {
|
|
384
|
-
const node = parseLine(line);
|
|
385
|
-
if (!node) continue;
|
|
386
|
-
|
|
387
|
-
const depth = Math.floor(node.indent / 2);
|
|
388
|
-
const isInteractive = INTERACTIVE_ROLES.has(node.role);
|
|
389
|
-
|
|
390
|
-
// Always advance the seen counter for every parsed node, regardless of
|
|
391
|
-
// filtering. nth() indices must match the full (unfiltered) tree so that
|
|
392
|
-
// locators point to the correct element even when siblings are filtered out.
|
|
393
|
-
const key = `${node.role}:${node.name || ''}`;
|
|
394
|
-
const seenIndex = roleNameSeen.get(key) || 0;
|
|
395
|
-
roleNameSeen.set(key, seenIndex + 1);
|
|
396
|
-
const totalCount = roleNameCounts.get(key) || 1;
|
|
397
|
-
|
|
398
|
-
// Depth filter
|
|
399
|
-
if (opts.depth !== undefined && depth > opts.depth) continue;
|
|
400
|
-
|
|
401
|
-
// Interactive filter
|
|
402
|
-
if (opts.interactive && !isInteractive) continue;
|
|
403
|
-
|
|
404
|
-
// Compact filter: skip elements with no name and no inline content that aren't interactive
|
|
405
|
-
if (opts.compact && !isInteractive && !node.name && !node.children) continue;
|
|
406
|
-
|
|
407
|
-
// Assign ref
|
|
408
|
-
const ref = `e${refCounter++}`;
|
|
409
|
-
const indent = ' '.repeat(depth);
|
|
410
|
-
|
|
411
|
-
let locator: Locator;
|
|
412
|
-
if (opts.selector) {
|
|
413
|
-
locator = locatorRoot.locator(opts.selector).getByRole(node.role as any, {
|
|
414
|
-
name: node.name || undefined,
|
|
415
|
-
});
|
|
416
|
-
} else {
|
|
417
|
-
locator = locatorRoot.getByRole(node.role as any, {
|
|
418
|
-
name: node.name || undefined,
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// Disambiguate with nth() if multiple elements share role+name
|
|
423
|
-
if (totalCount > 1) {
|
|
424
|
-
locator = locator.nth(seenIndex);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
refMap.set(ref, locator);
|
|
428
|
-
|
|
429
|
-
// Format output line
|
|
430
|
-
// -i without -f: terse flat list (no indent, no props, no children)
|
|
431
|
-
const terse = opts.interactive && !opts.full;
|
|
432
|
-
let outputLine: string;
|
|
433
|
-
if (terse) {
|
|
434
|
-
outputLine = `@${ref} [${node.role}]`;
|
|
435
|
-
if (node.name) {
|
|
436
|
-
const name = node.name.length > 30 ? node.name.slice(0, 30) + '...' : node.name;
|
|
437
|
-
outputLine += ` "${name}"`;
|
|
438
|
-
}
|
|
439
|
-
} else {
|
|
440
|
-
outputLine = `${indent}@${ref} [${node.role}]`;
|
|
441
|
-
if (node.name) outputLine += ` "${node.name}"`;
|
|
442
|
-
if (node.props) outputLine += ` ${node.props}`;
|
|
443
|
-
if (node.children) outputLine += `: ${node.children}`;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
output.push(outputLine);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// Viewport filter: remove elements outside the visible viewport
|
|
450
|
-
// Uses a single page.evaluate() for speed — checking 189 locators individually is slow
|
|
451
|
-
if (opts.viewport) {
|
|
452
|
-
const vp = page.viewportSize();
|
|
453
|
-
if (vp) {
|
|
454
|
-
// Build a list of {ref, role, name} to check in the DOM
|
|
455
|
-
const checks = Array.from(refMap.keys()).map(ref => {
|
|
456
|
-
const line = output.find(l => l.includes(`@${ref} `));
|
|
457
|
-
const roleMatch = line?.match(/\[(\w+)\]/);
|
|
458
|
-
const nameMatch = line?.match(/"([^"]*)"/);
|
|
459
|
-
return { ref, role: roleMatch?.[1] || '', name: nameMatch?.[1] || '' };
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
const visibleRefs = await evalCtx.evaluate(
|
|
463
|
-
({ checks, vpHeight }) => {
|
|
464
|
-
const ROLE_TO_SELECTOR: Record<string, string> = {
|
|
465
|
-
link: 'a,[role="link"]',
|
|
466
|
-
button: 'button,[role="button"],input[type="button"],input[type="submit"]',
|
|
467
|
-
textbox: 'input:not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="button"]):not([type="hidden"]),textarea,[role="textbox"]',
|
|
468
|
-
checkbox: 'input[type="checkbox"],[role="checkbox"]',
|
|
469
|
-
radio: 'input[type="radio"],[role="radio"]',
|
|
470
|
-
combobox: 'select,[role="combobox"]',
|
|
471
|
-
searchbox: 'input[type="search"],[role="searchbox"]',
|
|
472
|
-
tab: '[role="tab"]',
|
|
473
|
-
switch: '[role="switch"]',
|
|
474
|
-
slider: 'input[type="range"],[role="slider"]',
|
|
475
|
-
menuitem: '[role="menuitem"]',
|
|
476
|
-
option: 'option,[role="option"]',
|
|
477
|
-
};
|
|
478
|
-
|
|
479
|
-
const visible = new Set<string>();
|
|
480
|
-
// Track which elements we've already matched per role+name
|
|
481
|
-
const roleCounts = new Map<string, number>();
|
|
482
|
-
|
|
483
|
-
for (const { ref, role, name } of checks) {
|
|
484
|
-
const selector = ROLE_TO_SELECTOR[role] || `[role="${role}"]`;
|
|
485
|
-
const all = document.querySelectorAll(selector);
|
|
486
|
-
const key = `${role}:${name}`;
|
|
487
|
-
const skip = roleCounts.get(key) || 0;
|
|
488
|
-
|
|
489
|
-
let matched = 0;
|
|
490
|
-
for (let i = 0; i < all.length; i++) {
|
|
491
|
-
const el = all[i] as HTMLElement;
|
|
492
|
-
// Match by accessible name (textContent or aria-label)
|
|
493
|
-
const accName = (el.getAttribute('aria-label') || el.textContent || '').trim();
|
|
494
|
-
// For terse mode, name may be truncated — check startsWith
|
|
495
|
-
const nameMatches = !name || accName === name ||
|
|
496
|
-
(name.endsWith('...') && accName.startsWith(name.slice(0, -3)));
|
|
497
|
-
if (!nameMatches) continue;
|
|
498
|
-
|
|
499
|
-
if (matched < skip) { matched++; continue; }
|
|
500
|
-
|
|
501
|
-
const rect = el.getBoundingClientRect();
|
|
502
|
-
if (rect.y + rect.height > 0 && rect.y < vpHeight) {
|
|
503
|
-
visible.add(ref);
|
|
504
|
-
}
|
|
505
|
-
matched++;
|
|
506
|
-
break;
|
|
507
|
-
}
|
|
508
|
-
roleCounts.set(key, skip + 1);
|
|
509
|
-
}
|
|
510
|
-
return [...visible];
|
|
511
|
-
},
|
|
512
|
-
{ checks, vpHeight: vp.height }
|
|
513
|
-
);
|
|
514
|
-
|
|
515
|
-
const visibleSet = new Set(visibleRefs);
|
|
516
|
-
const toRemove = new Set<string>();
|
|
517
|
-
for (const ref of refMap.keys()) {
|
|
518
|
-
if (!visibleSet.has(ref)) toRemove.add(ref);
|
|
519
|
-
}
|
|
520
|
-
for (const ref of toRemove) {
|
|
521
|
-
refMap.delete(ref);
|
|
522
|
-
}
|
|
523
|
-
for (let i = output.length - 1; i >= 0; i--) {
|
|
524
|
-
const match = output[i].match(/@(e\d+)/);
|
|
525
|
-
if (match && toRemove.has(match[1])) {
|
|
526
|
-
output.splice(i, 1);
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// Cursor-interactive detection: supplement ARIA tree with DOM-level scan
|
|
533
|
-
if (opts.cursor) {
|
|
534
|
-
const result = await appendCursorElements(evalCtx, locatorRoot, opts, output, refMap, refCounter, bm);
|
|
535
|
-
bm.setLastSnapshot(result, args);
|
|
536
|
-
return result;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// Store ref map and rendered snapshot on BrowserManager
|
|
540
|
-
bm.setRefMap(refMap);
|
|
541
|
-
|
|
542
|
-
if (output.length === 0) {
|
|
543
|
-
bm.setLastSnapshot('(no interactive elements found)', args);
|
|
544
|
-
return '(no interactive elements found)';
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
const rendered = output.join('\n');
|
|
548
|
-
bm.setLastSnapshot(rendered, args);
|
|
549
|
-
return rendered;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* Scan DOM for cursor-interactive elements, assign refs, append to output.
|
|
554
|
-
* Called when -C flag is active.
|
|
555
|
-
*/
|
|
556
|
-
async function appendCursorElements(
|
|
557
|
-
evalCtx: Page | Frame,
|
|
558
|
-
locatorRoot: Page | FrameLocator,
|
|
559
|
-
opts: SnapshotOptions,
|
|
560
|
-
output: string[],
|
|
561
|
-
refMap: Map<string, Locator>,
|
|
562
|
-
refCounter: number,
|
|
563
|
-
bm: BrowserManager,
|
|
564
|
-
): Promise<string> {
|
|
565
|
-
const cursorElements = await findCursorInteractiveElements(evalCtx, opts.selector);
|
|
566
|
-
|
|
567
|
-
if (cursorElements.length > 0) {
|
|
568
|
-
output.push('');
|
|
569
|
-
output.push('[cursor-interactive]');
|
|
570
|
-
|
|
571
|
-
for (const elem of cursorElements) {
|
|
572
|
-
const ref = `e${refCounter++}`;
|
|
573
|
-
|
|
574
|
-
// Build Playwright locator via CSS selector.
|
|
575
|
-
// Use nth(selectorIndex) — the actual index among all DOM matches —
|
|
576
|
-
// instead of a seen-counter which can misalign when non-cursor siblings
|
|
577
|
-
// share the same selector.
|
|
578
|
-
let baseLocator: Locator;
|
|
579
|
-
if (opts.selector) {
|
|
580
|
-
baseLocator = locatorRoot.locator(opts.selector).locator(elem.cssSelector);
|
|
581
|
-
} else {
|
|
582
|
-
baseLocator = locatorRoot.locator(elem.cssSelector);
|
|
583
|
-
}
|
|
584
|
-
const locator = baseLocator.nth(elem.selectorIndex);
|
|
585
|
-
|
|
586
|
-
refMap.set(ref, locator);
|
|
587
|
-
|
|
588
|
-
// Format: @e15 [div.cursor] "Add to cart" (cursor:pointer)
|
|
589
|
-
const tagDisplay = elem.tag + (elem.className ? '.' + elem.className.split(/\s+/)[0] : '');
|
|
590
|
-
let outputLine = `@${ref} [${tagDisplay}]`;
|
|
591
|
-
if (elem.text) outputLine += ` "${elem.text}"`;
|
|
592
|
-
outputLine += ` (${elem.reason})`;
|
|
593
|
-
|
|
594
|
-
output.push(outputLine);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// Store ref map on BrowserManager
|
|
599
|
-
bm.setRefMap(refMap);
|
|
600
|
-
|
|
601
|
-
if (output.length === 0) {
|
|
602
|
-
return '(no interactive elements found)';
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
return output.join('\n');
|
|
606
|
-
}
|
package/src/types.ts
DELETED