@ulpi/browse 0.7.2 → 0.7.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulpi/browse",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/ulpi-io/browse"
package/skill/SKILL.md CHANGED
@@ -261,7 +261,8 @@ browse accessibility Accessibility tree snapshot (ARIA)
261
261
  ```
262
262
  browse snapshot Full accessibility tree with @refs
263
263
  browse snapshot -i Interactive elements only — terse flat list (minimal tokens)
264
- browse snapshot -i -v Interactive elements — verbose indented tree with props
264
+ browse snapshot -i -f Interactive elements — full indented tree with props
265
+ browse snapshot -i -V Interactive elements — viewport only (skip below-fold)
265
266
  browse snapshot -c Compact (no empty structural elements)
266
267
  browse snapshot -C Cursor-interactive (detect divs with cursor:pointer/onclick/tabindex)
267
268
  browse snapshot -d <N> Limit depth to N levels
package/src/cli.ts CHANGED
@@ -612,7 +612,7 @@ Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
612
612
  cookies | storage [set <k> <v>] | perf
613
613
  value <sel> | count <sel> | clipboard [write <text>]
614
614
  Visual: screenshot [path] | pdf [path] | responsive [prefix]
615
- Snapshot: snapshot [-i] [-v] [-c] [-C] [-d N] [-s sel]
615
+ Snapshot: snapshot [-i] [-f] [-V] [-c] [-C] [-d N] [-s sel]
616
616
  Find: find role|text|label|placeholder|testid <query> [name]
617
617
  Compare: diff <url1> <url2> | screenshot-diff <baseline> [current]
618
618
  Multi-step: chain (reads JSON from stdin)
@@ -639,7 +639,8 @@ Options:
639
639
 
640
640
  Snapshot flags:
641
641
  -i Interactive elements only (terse flat list by default)
642
- -v Verbosefull indented tree with props (use with -i)
642
+ -f Full — indented tree with props and children (use with -i)
643
+ -V Viewport — only elements visible in current viewport
643
644
  -c Compact — remove empty structural elements
644
645
  -C Cursor-interactive — detect divs with cursor:pointer,
645
646
  onclick, tabindex, data-action (missed by ARIA tree)
package/src/snapshot.ts CHANGED
@@ -31,8 +31,9 @@ const INTERACTIVE_ROLES = new Set([
31
31
 
32
32
  interface SnapshotOptions {
33
33
  interactive?: boolean; // -i: only interactive elements (terse flat list by default)
34
- verbose?: boolean; // -v: full indented ARIA tree with props/children (overrides -i terse default)
34
+ full?: boolean; // -f: full indented ARIA tree with props/children (overrides -i terse default)
35
35
  compact?: boolean; // -c: remove empty structural elements
36
+ viewport?: boolean; // -V: only elements visible in current viewport
36
37
  depth?: number; // -d N: limit tree depth
37
38
  selector?: string; // -s SEL: scope to CSS selector
38
39
  cursor?: boolean; // -C: detect cursor-interactive elements (divs with cursor:pointer, onclick, tabindex)
@@ -73,9 +74,13 @@ export function parseSnapshotArgs(args: string[]): SnapshotOptions {
73
74
  case '--compact':
74
75
  opts.compact = true;
75
76
  break;
76
- case '-v':
77
- case '--verbose':
78
- opts.verbose = true;
77
+ case '-f':
78
+ case '--full':
79
+ opts.full = true;
80
+ break;
81
+ case '-V':
82
+ case '--viewport':
83
+ opts.viewport = true;
79
84
  break;
80
85
  case '-C':
81
86
  case '--cursor':
@@ -422,8 +427,8 @@ export async function handleSnapshot(
422
427
  refMap.set(ref, locator);
423
428
 
424
429
  // Format output line
425
- // -i without -v: terse flat list (no indent, no props, no children)
426
- const terse = opts.interactive && !opts.verbose;
430
+ // -i without -f: terse flat list (no indent, no props, no children)
431
+ const terse = opts.interactive && !opts.full;
427
432
  let outputLine: string;
428
433
  if (terse) {
429
434
  outputLine = `@${ref} [${node.role}]`;
@@ -441,6 +446,89 @@ export async function handleSnapshot(
441
446
  output.push(outputLine);
442
447
  }
443
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
+
444
532
  // Cursor-interactive detection: supplement ARIA tree with DOM-level scan
445
533
  if (opts.cursor) {
446
534
  const result = await appendCursorElements(evalCtx, locatorRoot, opts, output, refMap, refCounter, bm);