btcp-browser-agent 0.1.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 (117) hide show
  1. package/CLAUDE.md +230 -0
  2. package/LICENSE +21 -0
  3. package/README.md +309 -0
  4. package/SKILL.md +143 -0
  5. package/SNAPSHOT_IMPROVEMENTS.md +302 -0
  6. package/USAGE.md +146 -0
  7. package/dist/index.d.ts +34 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +35 -0
  10. package/dist/index.js.map +1 -0
  11. package/docs/browser-cli-design.md +500 -0
  12. package/examples/chrome-extension/CHANGELOG.md +210 -0
  13. package/examples/chrome-extension/DEBUG.md +231 -0
  14. package/examples/chrome-extension/ERROR_FIXED.md +147 -0
  15. package/examples/chrome-extension/QUICK_TEST.md +189 -0
  16. package/examples/chrome-extension/README.md +149 -0
  17. package/examples/chrome-extension/SESSION_ONLY_MODE.md +305 -0
  18. package/examples/chrome-extension/TEST_WITH_YOUR_TABS.md +97 -0
  19. package/examples/chrome-extension/build.js +43 -0
  20. package/examples/chrome-extension/manifest.json +37 -0
  21. package/examples/chrome-extension/package-lock.json +1063 -0
  22. package/examples/chrome-extension/package.json +21 -0
  23. package/examples/chrome-extension/popup.html +195 -0
  24. package/examples/chrome-extension/src/background.ts +12 -0
  25. package/examples/chrome-extension/src/content.ts +7 -0
  26. package/examples/chrome-extension/src/popup.ts +303 -0
  27. package/examples/chrome-extension/src/scenario-google-github.ts +389 -0
  28. package/examples/chrome-extension/test-page.html +127 -0
  29. package/examples/chrome-extension/tests/README.md +206 -0
  30. package/examples/chrome-extension/tests/scenario-google-to-github-star.ts +380 -0
  31. package/examples/chrome-extension/tsconfig.json +14 -0
  32. package/examples/snapshots/README.md +207 -0
  33. package/examples/snapshots/amazon-com-detail.html +9528 -0
  34. package/examples/snapshots/amazon-com-detail.snapshot.txt +997 -0
  35. package/examples/snapshots/convert-snapshots.ts +97 -0
  36. package/examples/snapshots/edition-cnn-com.html +13292 -0
  37. package/examples/snapshots/edition-cnn-com.snapshot.txt +562 -0
  38. package/examples/snapshots/github-com-microsoft-vscode.html +2916 -0
  39. package/examples/snapshots/github-com-microsoft-vscode.snapshot.txt +455 -0
  40. package/examples/snapshots/google-search.html +20012 -0
  41. package/examples/snapshots/google-search.snapshot.txt +195 -0
  42. package/examples/snapshots/metadata.json +86 -0
  43. package/examples/snapshots/npr-org-templates.html +2031 -0
  44. package/examples/snapshots/npr-org-templates.snapshot.txt +224 -0
  45. package/examples/snapshots/stackoverflow-com.html +5216 -0
  46. package/examples/snapshots/stackoverflow-com.snapshot.txt +2404 -0
  47. package/examples/snapshots/test-all-mode.html +46 -0
  48. package/examples/snapshots/test-all-mode.snapshot.txt +5 -0
  49. package/examples/snapshots/validate.test.ts +296 -0
  50. package/package.json +65 -0
  51. package/packages/cli/package.json +42 -0
  52. package/packages/cli/src/__tests__/cli.test.ts +434 -0
  53. package/packages/cli/src/__tests__/errors.test.ts +226 -0
  54. package/packages/cli/src/__tests__/executor.test.ts +275 -0
  55. package/packages/cli/src/__tests__/formatter.test.ts +260 -0
  56. package/packages/cli/src/__tests__/parser.test.ts +288 -0
  57. package/packages/cli/src/__tests__/suggestions.test.ts +255 -0
  58. package/packages/cli/src/commands/back.ts +22 -0
  59. package/packages/cli/src/commands/check.ts +33 -0
  60. package/packages/cli/src/commands/clear.ts +33 -0
  61. package/packages/cli/src/commands/click.ts +32 -0
  62. package/packages/cli/src/commands/closetab.ts +31 -0
  63. package/packages/cli/src/commands/eval.ts +41 -0
  64. package/packages/cli/src/commands/fill.ts +30 -0
  65. package/packages/cli/src/commands/focus.ts +33 -0
  66. package/packages/cli/src/commands/forward.ts +22 -0
  67. package/packages/cli/src/commands/goto.ts +34 -0
  68. package/packages/cli/src/commands/help.ts +162 -0
  69. package/packages/cli/src/commands/hover.ts +34 -0
  70. package/packages/cli/src/commands/index.ts +129 -0
  71. package/packages/cli/src/commands/newtab.ts +35 -0
  72. package/packages/cli/src/commands/press.ts +40 -0
  73. package/packages/cli/src/commands/reload.ts +25 -0
  74. package/packages/cli/src/commands/screenshot.ts +27 -0
  75. package/packages/cli/src/commands/scroll.ts +64 -0
  76. package/packages/cli/src/commands/select.ts +35 -0
  77. package/packages/cli/src/commands/snapshot.ts +21 -0
  78. package/packages/cli/src/commands/tab.ts +32 -0
  79. package/packages/cli/src/commands/tabs.ts +26 -0
  80. package/packages/cli/src/commands/text.ts +27 -0
  81. package/packages/cli/src/commands/title.ts +17 -0
  82. package/packages/cli/src/commands/type.ts +38 -0
  83. package/packages/cli/src/commands/uncheck.ts +33 -0
  84. package/packages/cli/src/commands/url.ts +17 -0
  85. package/packages/cli/src/commands/wait.ts +54 -0
  86. package/packages/cli/src/errors.ts +164 -0
  87. package/packages/cli/src/executor.ts +68 -0
  88. package/packages/cli/src/formatter.ts +215 -0
  89. package/packages/cli/src/index.ts +257 -0
  90. package/packages/cli/src/parser.ts +195 -0
  91. package/packages/cli/src/suggestions.ts +207 -0
  92. package/packages/cli/src/terminal/Terminal.ts +365 -0
  93. package/packages/cli/src/terminal/index.ts +5 -0
  94. package/packages/cli/src/types.ts +155 -0
  95. package/packages/cli/tsconfig.json +20 -0
  96. package/packages/core/package.json +35 -0
  97. package/packages/core/src/actions.ts +1210 -0
  98. package/packages/core/src/errors.ts +296 -0
  99. package/packages/core/src/index.test.ts +638 -0
  100. package/packages/core/src/index.ts +220 -0
  101. package/packages/core/src/ref-map.ts +107 -0
  102. package/packages/core/src/snapshot.ts +873 -0
  103. package/packages/core/src/types.ts +536 -0
  104. package/packages/core/tsconfig.json +23 -0
  105. package/packages/extension/README.md +129 -0
  106. package/packages/extension/package.json +43 -0
  107. package/packages/extension/src/background.ts +888 -0
  108. package/packages/extension/src/content.ts +172 -0
  109. package/packages/extension/src/index.ts +579 -0
  110. package/packages/extension/src/session-manager.ts +385 -0
  111. package/packages/extension/src/session-types.ts +144 -0
  112. package/packages/extension/src/types.ts +162 -0
  113. package/packages/extension/tsconfig.json +28 -0
  114. package/src/index.ts +64 -0
  115. package/tsconfig.build.json +12 -0
  116. package/tsconfig.json +26 -0
  117. package/vitest.config.ts +13 -0
@@ -0,0 +1,873 @@
1
+ /**
2
+ * @btcp/core - DOM Snapshot
3
+ *
4
+ * Generates a flat accessibility snapshot of the DOM.
5
+ * Produces a compact, AI-friendly list of interactive elements.
6
+ */
7
+
8
+ import type { SnapshotData, RefMap } from './types.js';
9
+
10
+ /**
11
+ * Get HTML element constructors from window (works in both browser and jsdom)
12
+ */
13
+ function getHTMLConstructors(element: Element) {
14
+ const win = element.ownerDocument.defaultView;
15
+ if (!win) {
16
+ return {
17
+ HTMLElement: null,
18
+ HTMLInputElement: null,
19
+ HTMLTextAreaElement: null,
20
+ HTMLSelectElement: null,
21
+ HTMLAnchorElement: null,
22
+ HTMLButtonElement: null,
23
+ HTMLImageElement: null,
24
+ };
25
+ }
26
+ return {
27
+ HTMLElement: win.HTMLElement,
28
+ HTMLInputElement: win.HTMLInputElement,
29
+ HTMLTextAreaElement: win.HTMLTextAreaElement,
30
+ HTMLSelectElement: win.HTMLSelectElement,
31
+ HTMLAnchorElement: win.HTMLAnchorElement,
32
+ HTMLButtonElement: win.HTMLButtonElement,
33
+ HTMLImageElement: win.HTMLImageElement,
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Grep options (mirrors Unix grep flags)
39
+ */
40
+ interface GrepOptions {
41
+ /** Pattern to search for */
42
+ pattern: string;
43
+ /** Case-insensitive matching (grep -i) */
44
+ ignoreCase?: boolean;
45
+ /** Invert match - return non-matching lines (grep -v) */
46
+ invert?: boolean;
47
+ /** Treat pattern as fixed string, not regex (grep -F) */
48
+ fixedStrings?: boolean;
49
+ }
50
+
51
+ interface SnapshotOptions {
52
+ root?: Element;
53
+ maxDepth?: number;
54
+ includeHidden?: boolean;
55
+ interactive?: boolean;
56
+ compact?: boolean;
57
+ all?: boolean;
58
+ format?: 'tree' | 'html';
59
+ /** Grep filter - string pattern or options object */
60
+ grep?: string | GrepOptions;
61
+ }
62
+
63
+ const TRUNCATE_LIMITS = {
64
+ ELEMENT_NAME: 50,
65
+ TEXT_SHORT: 80,
66
+ TEXT_LONG: 120,
67
+ ERROR_MESSAGE: 100,
68
+ URL: 150,
69
+ } as const;
70
+
71
+ // Role mappings for implicit ARIA roles
72
+ const IMPLICIT_ROLES: Record<string, string> = {
73
+ A: 'link',
74
+ ARTICLE: 'article',
75
+ ASIDE: 'complementary',
76
+ BUTTON: 'button',
77
+ DIALOG: 'dialog',
78
+ FOOTER: 'contentinfo',
79
+ FORM: 'form',
80
+ H1: 'heading',
81
+ H2: 'heading',
82
+ H3: 'heading',
83
+ H4: 'heading',
84
+ H5: 'heading',
85
+ H6: 'heading',
86
+ HEADER: 'banner',
87
+ IMG: 'img',
88
+ INPUT: 'textbox',
89
+ LI: 'listitem',
90
+ MAIN: 'main',
91
+ NAV: 'navigation',
92
+ OL: 'list',
93
+ OPTION: 'option',
94
+ PROGRESS: 'progressbar',
95
+ SECTION: 'region',
96
+ SELECT: 'combobox',
97
+ TABLE: 'table',
98
+ TBODY: 'rowgroup',
99
+ TD: 'cell',
100
+ TEXTAREA: 'textbox',
101
+ TH: 'columnheader',
102
+ THEAD: 'rowgroup',
103
+ TR: 'row',
104
+ UL: 'list',
105
+ };
106
+
107
+ // Input type to role mapping
108
+ const INPUT_ROLES: Record<string, string> = {
109
+ button: 'button',
110
+ checkbox: 'checkbox',
111
+ email: 'textbox',
112
+ number: 'spinbutton',
113
+ password: 'textbox',
114
+ radio: 'radio',
115
+ range: 'slider',
116
+ search: 'searchbox',
117
+ submit: 'button',
118
+ tel: 'textbox',
119
+ text: 'textbox',
120
+ url: 'textbox',
121
+ };
122
+
123
+ /**
124
+ * Get the ARIA role for an element
125
+ */
126
+ function getRole(element: Element): string | null {
127
+ const explicitRole = element.getAttribute('role');
128
+ if (explicitRole) return explicitRole;
129
+
130
+ const tagName = element.tagName;
131
+
132
+ // Special handling for headings - include level
133
+ if (tagName.match(/^H[1-6]$/)) {
134
+ const level = tagName[1];
135
+ return `heading level=${level}`;
136
+ }
137
+
138
+ // Special handling for inputs
139
+ if (tagName === 'INPUT') {
140
+ const type = (element as HTMLInputElement).type || 'text';
141
+ return INPUT_ROLES[type] || 'textbox';
142
+ }
143
+
144
+ return IMPLICIT_ROLES[tagName] || null;
145
+ }
146
+
147
+ /**
148
+ * Get input type and validation attributes
149
+ */
150
+ function getInputAttributes(element: Element): string {
151
+ const constructors = getHTMLConstructors(element);
152
+
153
+ const isInput = constructors.HTMLInputElement && element instanceof constructors.HTMLInputElement;
154
+ const isTextArea = constructors.HTMLTextAreaElement && element instanceof constructors.HTMLTextAreaElement;
155
+
156
+ if (!(isInput || isTextArea)) {
157
+ return '';
158
+ }
159
+
160
+ const attrs: string[] = [];
161
+
162
+ if (isInput && (element as HTMLInputElement).type && (element as HTMLInputElement).type !== 'text') {
163
+ attrs.push(`type=${(element as HTMLInputElement).type}`);
164
+ }
165
+
166
+ if ((element as HTMLInputElement | HTMLTextAreaElement).required) attrs.push('required');
167
+ if (element.getAttribute('aria-invalid') === 'true') attrs.push('invalid');
168
+
169
+ if (isInput) {
170
+ const input = element as HTMLInputElement;
171
+ if (input.minLength > 0) attrs.push(`minlength=${input.minLength}`);
172
+ if (input.maxLength >= 0 && input.maxLength < 524288) attrs.push(`maxlength=${input.maxLength}`);
173
+ if (input.pattern) attrs.push(`pattern=${input.pattern}`);
174
+ if (input.min) attrs.push(`min=${input.min}`);
175
+ if (input.max) attrs.push(`max=${input.max}`);
176
+ }
177
+
178
+ return attrs.length > 0 ? ` [${attrs.join(' ')}]` : '';
179
+ }
180
+
181
+ /**
182
+ * Check if element is in viewport
183
+ */
184
+ function isInViewport(element: Element, window: Window): boolean {
185
+ const rect = element.getBoundingClientRect();
186
+ return (
187
+ rect.top >= 0 &&
188
+ rect.left >= 0 &&
189
+ rect.bottom <= (window.innerHeight || window.document.documentElement.clientHeight) &&
190
+ rect.right <= (window.innerWidth || window.document.documentElement.clientWidth)
191
+ );
192
+ }
193
+
194
+ /**
195
+ * Get label from enclosing <label> element
196
+ */
197
+ function getEnclosingLabel(element: Element): string {
198
+ const label = element.closest('label');
199
+ if (label) {
200
+ const clone = label.cloneNode(true) as HTMLElement;
201
+ const inputs = clone.querySelectorAll('input, textarea, select');
202
+ inputs.forEach(input => input.remove());
203
+ return clone.textContent?.trim() || '';
204
+ }
205
+ return '';
206
+ }
207
+
208
+ /**
209
+ * Get label for button elements
210
+ */
211
+ function getButtonLabel(element: HTMLButtonElement | HTMLInputElement): string {
212
+ const constructors = getHTMLConstructors(element);
213
+ const isInputElement = constructors.HTMLInputElement && element instanceof constructors.HTMLInputElement;
214
+
215
+ const ariaLabel = element.getAttribute('aria-label');
216
+ if (ariaLabel) return ariaLabel.trim();
217
+
218
+ const labelledBy = element.getAttribute('aria-labelledby');
219
+ if (labelledBy) {
220
+ const labels = labelledBy
221
+ .split(/\s+/)
222
+ .map((id) => element.ownerDocument.getElementById(id)?.textContent?.trim())
223
+ .filter(Boolean);
224
+ if (labels.length) return labels.join(' ');
225
+ }
226
+
227
+ const textContent = element.textContent?.trim();
228
+ if (textContent) return textContent;
229
+
230
+ if (isInputElement && ['submit', 'button', 'reset'].includes((element as HTMLInputElement).type)) {
231
+ if (element.value) return element.value;
232
+ }
233
+
234
+ const title = element.getAttribute('title');
235
+ if (title) return title.trim();
236
+
237
+ return '';
238
+ }
239
+
240
+ /**
241
+ * Get label for link elements
242
+ */
243
+ function getLinkLabel(element: HTMLAnchorElement): string {
244
+ const ariaLabel = element.getAttribute('aria-label');
245
+ if (ariaLabel) return ariaLabel.trim();
246
+
247
+ const labelledBy = element.getAttribute('aria-labelledby');
248
+ if (labelledBy) {
249
+ const labels = labelledBy
250
+ .split(/\s+/)
251
+ .map((id) => element.ownerDocument.getElementById(id)?.textContent?.trim())
252
+ .filter(Boolean);
253
+ if (labels.length) return labels.join(' ');
254
+ }
255
+
256
+ const textContent = element.textContent?.trim();
257
+ if (textContent) return textContent;
258
+
259
+ const title = element.getAttribute('title');
260
+ if (title) return title.trim();
261
+
262
+ const href = element.getAttribute('href');
263
+ if (href) {
264
+ const path = href.split('?')[0].split('#')[0];
265
+ const segments = path.split('/').filter(Boolean);
266
+ const lastSegment = segments[segments.length - 1] || segments[segments.length - 2];
267
+ if (lastSegment) {
268
+ return lastSegment.replace(/[-_]/g, ' ').replace(/\.\w+$/, '');
269
+ }
270
+ }
271
+
272
+ return '';
273
+ }
274
+
275
+ /**
276
+ * Get label for input/textarea/select elements
277
+ */
278
+ function getFormControlLabel(element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement): string {
279
+ const ariaLabel = element.getAttribute('aria-label');
280
+ if (ariaLabel) return ariaLabel.trim();
281
+
282
+ const labelledBy = element.getAttribute('aria-labelledby');
283
+ if (labelledBy) {
284
+ const labels = labelledBy
285
+ .split(/\s+/)
286
+ .map((id) => element.ownerDocument.getElementById(id)?.textContent?.trim())
287
+ .filter(Boolean);
288
+ if (labels.length) return labels.join(' ');
289
+ }
290
+
291
+ const id = element.getAttribute('id');
292
+ if (id) {
293
+ const label = element.ownerDocument.querySelector(`label[for="${id}"]`);
294
+ if (label) {
295
+ const labelText = label.textContent?.trim();
296
+ if (labelText) return labelText;
297
+ }
298
+ }
299
+
300
+ const enclosingLabel = getEnclosingLabel(element);
301
+ if (enclosingLabel) return enclosingLabel;
302
+
303
+ const title = element.getAttribute('title');
304
+ if (title) return title.trim();
305
+
306
+ return '';
307
+ }
308
+
309
+ /**
310
+ * Get label for image elements
311
+ */
312
+ function getImageLabel(element: HTMLImageElement): string {
313
+ const ariaLabel = element.getAttribute('aria-label');
314
+ if (ariaLabel) return ariaLabel.trim();
315
+
316
+ const labelledBy = element.getAttribute('aria-labelledby');
317
+ if (labelledBy) {
318
+ const labels = labelledBy
319
+ .split(/\s+/)
320
+ .map((id) => element.ownerDocument.getElementById(id)?.textContent?.trim())
321
+ .filter(Boolean);
322
+ if (labels.length) return labels.join(' ');
323
+ }
324
+
325
+ const alt = element.getAttribute('alt');
326
+ if (alt) return alt.trim();
327
+
328
+ const title = element.getAttribute('title');
329
+ if (title) return title.trim();
330
+
331
+ const src = element.getAttribute('src');
332
+ if (src) {
333
+ const filename = src.split('/').pop()?.split('?')[0].replace(/\.\w+$/, '');
334
+ if (filename) return filename.replace(/[-_]/g, ' ');
335
+ }
336
+
337
+ return '';
338
+ }
339
+
340
+ /**
341
+ * Get accessible name for an element
342
+ */
343
+ function getAccessibleName(element: Element): string {
344
+ const constructors = getHTMLConstructors(element);
345
+
346
+ const isButton = constructors.HTMLButtonElement && element instanceof constructors.HTMLButtonElement;
347
+ const isInputButton = constructors.HTMLInputElement &&
348
+ element instanceof constructors.HTMLInputElement &&
349
+ ['button', 'submit', 'reset'].includes((element as HTMLInputElement).type);
350
+
351
+ if (isButton || isInputButton) {
352
+ return getButtonLabel(element as HTMLButtonElement | HTMLInputElement);
353
+ }
354
+
355
+ const isAnchor = constructors.HTMLAnchorElement && element instanceof constructors.HTMLAnchorElement;
356
+ if (isAnchor) {
357
+ return getLinkLabel(element as HTMLAnchorElement);
358
+ }
359
+
360
+ const isInput = constructors.HTMLInputElement && element instanceof constructors.HTMLInputElement;
361
+ const isTextArea = constructors.HTMLTextAreaElement && element instanceof constructors.HTMLTextAreaElement;
362
+ const isSelect = constructors.HTMLSelectElement && element instanceof constructors.HTMLSelectElement;
363
+
364
+ if (isInput || isTextArea || isSelect) {
365
+ return getFormControlLabel(element as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement);
366
+ }
367
+
368
+ const isImage = constructors.HTMLImageElement && element instanceof constructors.HTMLImageElement;
369
+ if (isImage) {
370
+ return getImageLabel(element as HTMLImageElement);
371
+ }
372
+
373
+ const ariaLabel = element.getAttribute('aria-label');
374
+ if (ariaLabel) return ariaLabel.trim();
375
+
376
+ const labelledBy = element.getAttribute('aria-labelledby');
377
+ if (labelledBy) {
378
+ const labels = labelledBy
379
+ .split(/\s+/)
380
+ .map((id) => element.ownerDocument.getElementById(id)?.textContent?.trim())
381
+ .filter(Boolean);
382
+ if (labels.length) return labels.join(' ');
383
+ }
384
+
385
+ const textContent = element.textContent?.trim();
386
+ if (textContent) return textContent;
387
+
388
+ return '';
389
+ }
390
+
391
+ /**
392
+ * Check if element or any of its ancestors are hidden
393
+ * This checks the full ancestor chain for proper visibility detection
394
+ */
395
+ function isVisible(element: Element, checkAncestors: boolean = true): boolean {
396
+ const win = element.ownerDocument.defaultView;
397
+ if (!win) return true;
398
+
399
+ const HTMLElementConstructor = win.HTMLElement;
400
+ if (!(element instanceof HTMLElementConstructor)) return true;
401
+
402
+ // Check inline styles first for performance
403
+ const inlineDisplay = element.style.display;
404
+ const inlineVisibility = element.style.visibility;
405
+ if (inlineDisplay === 'none') return false;
406
+ if (inlineVisibility === 'hidden') return false;
407
+
408
+ // Check computed styles (but be defensive about failures)
409
+ try {
410
+ const style = win.getComputedStyle(element);
411
+ if (style) {
412
+ if (style.display === 'none') return false;
413
+ if (style.visibility === 'hidden') return false;
414
+ if (style.opacity === '0') return false;
415
+ }
416
+ } catch (e) {
417
+ // If getComputedStyle fails (e.g., on intermediate pages), assume visible
418
+ // This is safer than assuming hidden
419
+ }
420
+
421
+ if (element.hidden) return false;
422
+
423
+ // Check ancestors if requested (for proper visibility detection)
424
+ if (checkAncestors && element.parentElement) {
425
+ return isVisible(element.parentElement, true);
426
+ }
427
+
428
+ return true;
429
+ }
430
+
431
+ /**
432
+ * Check if element is interactive
433
+ */
434
+ function isInteractive(element: Element): boolean {
435
+ if (element.tagName === 'A' && !element.hasAttribute('href')) {
436
+ return false;
437
+ }
438
+
439
+ const role = getRole(element);
440
+ if (!role) return false;
441
+
442
+ const interactiveRoles = [
443
+ 'button',
444
+ 'link',
445
+ 'textbox',
446
+ 'checkbox',
447
+ 'radio',
448
+ 'combobox',
449
+ 'listbox',
450
+ 'menuitem',
451
+ 'option',
452
+ 'slider',
453
+ 'spinbutton',
454
+ 'switch',
455
+ 'tab',
456
+ 'searchbox',
457
+ ];
458
+
459
+ return interactiveRoles.includes(role);
460
+ }
461
+
462
+ /**
463
+ * Truncate string with context-aware limits
464
+ */
465
+ function truncateByType(str: string, type: keyof typeof TRUNCATE_LIMITS): string {
466
+ const maxLength = TRUNCATE_LIMITS[type];
467
+ const cleaned = str.replace(/\s+/g, ' ').trim();
468
+ if (cleaned.length <= maxLength) return cleaned;
469
+ return cleaned.slice(0, maxLength - 3) + '...';
470
+ }
471
+
472
+ /**
473
+ * Escape CSS identifiers
474
+ */
475
+ function cssEscape(value: string): string {
476
+ return value.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
477
+ }
478
+
479
+ /**
480
+ * Generate a CSS selector for an element
481
+ */
482
+ function generateSelector(element: Element): string {
483
+ try {
484
+ const win = element.ownerDocument.defaultView;
485
+ const escape = (win && 'CSS' in win && win.CSS && 'escape' in win.CSS)
486
+ ? (s: string) => win.CSS.escape(s)
487
+ : cssEscape;
488
+
489
+ if (element.id) {
490
+ try {
491
+ return `#${escape(element.id)}`;
492
+ } catch {
493
+ // ID escaping failed, fall through
494
+ }
495
+ }
496
+
497
+ const testId = element.getAttribute('data-testid');
498
+ if (testId) {
499
+ return `[data-testid="${testId}"]`;
500
+ }
501
+
502
+ const parts: string[] = [];
503
+ let current: Element | null = element;
504
+
505
+ while (current && current !== element.ownerDocument.body) {
506
+ let selector = current.tagName.toLowerCase();
507
+
508
+ if (current.className && typeof current.className === 'string') {
509
+ try {
510
+ const classes = current.className.trim().split(/\s+/).filter(c => c.length < 30 && c.length > 0);
511
+ if (classes.length) {
512
+ selector += `.${classes.slice(0, 2).map(c => escape(c)).join('.')}`;
513
+ }
514
+ } catch {
515
+ // Class escaping failed
516
+ }
517
+ }
518
+
519
+ const parent = current.parentElement;
520
+ if (parent) {
521
+ const siblings = Array.from(parent.children).filter(
522
+ (s) => s.tagName === current!.tagName
523
+ );
524
+ if (siblings.length > 1) {
525
+ const index = siblings.indexOf(current) + 1;
526
+ selector += `:nth-of-type(${index})`;
527
+ }
528
+ }
529
+
530
+ parts.unshift(selector);
531
+ current = current.parentElement;
532
+
533
+ if (parts.length >= 4) break;
534
+ }
535
+
536
+ return parts.join(' > ');
537
+ } catch {
538
+ return generateSimpleSelector(element);
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Generate a simple fallback selector
544
+ */
545
+ function generateSimpleSelector(element: Element): string {
546
+ const tag = element.tagName.toLowerCase();
547
+ const parent = element.parentElement;
548
+
549
+ if (!parent) return tag;
550
+
551
+ const siblings = Array.from(parent.children).filter(
552
+ (s) => s.tagName === element.tagName
553
+ );
554
+
555
+ if (siblings.length === 1) return tag;
556
+
557
+ const index = siblings.indexOf(element) + 1;
558
+ return `${tag}:nth-of-type(${index})`;
559
+ }
560
+
561
+ // Semantic HTML tags worth preserving in xpath
562
+ const SEMANTIC_TAGS = new Set([
563
+ 'main', 'nav', 'header', 'footer', 'article', 'section', 'aside',
564
+ 'form', 'table', 'ul', 'ol', 'li', 'dialog', 'menu',
565
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
566
+ 'a', 'button', 'input', 'select', 'textarea', 'label',
567
+ 'figure', 'figcaption', 'details', 'summary'
568
+ ]);
569
+
570
+ // Class name patterns that are semantically meaningful
571
+ const SEMANTIC_CLASS_PATTERNS = [
572
+ /^(nav|menu|header|footer|sidebar|content|main|search|login|signup|cart|modal|dialog)/i,
573
+ /^(btn|button|link|tab|card|list|item|form|input|field)/i,
574
+ /^(primary|secondary|active|selected|disabled|error|success|warning)/i,
575
+ /^(container|wrapper|row|col|grid)$/i,
576
+ ];
577
+
578
+ /**
579
+ * Check if a class name is semantically meaningful
580
+ */
581
+ function isSemanticClass(className: string): boolean {
582
+ // Skip utility classes (too short, or common CSS framework classes)
583
+ if (className.length < 3 || className.length > 25) return false;
584
+ if (/^[a-z]-/.test(className)) return false; // Tailwind-like single letter prefix
585
+ if (/^(mt|mb|ml|mr|mx|my|pt|pb|pl|pr|px|py|w-|h-|flex|grid|text-|bg-|border)/i.test(className)) return false;
586
+
587
+ return SEMANTIC_CLASS_PATTERNS.some(pattern => pattern.test(className));
588
+ }
589
+
590
+ /**
591
+ * Get the best semantic class from an element
592
+ */
593
+ function getSemanticClass(element: Element): string | null {
594
+ if (!element.className || typeof element.className !== 'string') return null;
595
+
596
+ const classes = element.className.trim().split(/\s+/).filter(c => c.length > 0);
597
+ const semantic = classes.find(c => isSemanticClass(c));
598
+
599
+ return semantic || null;
600
+ }
601
+
602
+ /**
603
+ * Build a semantic xpath for an element
604
+ * Format: /body/main#content/nav.primary/ul/li[2]/a.nav-link
605
+ */
606
+ function buildSemanticXPath(element: Element): string {
607
+ const parts: string[] = [];
608
+ let current: Element | null = element;
609
+ const body = element.ownerDocument.body;
610
+
611
+ while (current && current !== body && current.parentElement) {
612
+ const tag = current.tagName.toLowerCase();
613
+ const id = current.id;
614
+ const semanticClass = getSemanticClass(current);
615
+ const isSemanticTag = SEMANTIC_TAGS.has(tag);
616
+
617
+ // Build the segment
618
+ let segment = '';
619
+
620
+ // Always include semantic tags, skip generic div/span unless they have id/class
621
+ if (isSemanticTag || id || semanticClass) {
622
+ segment = tag;
623
+
624
+ // Add id if present (most specific)
625
+ if (id && id.length < 30 && !/^\d/.test(id) && !/[^a-zA-Z0-9_-]/.test(id)) {
626
+ segment += `#${id}`;
627
+ }
628
+ // Add semantic class if no id
629
+ else if (semanticClass) {
630
+ segment += `.${semanticClass}`;
631
+ }
632
+
633
+ // Add index if there are siblings with same tag
634
+ const parent = current.parentElement;
635
+ if (parent) {
636
+ const siblings = Array.from(parent.children).filter(s => s.tagName === current!.tagName);
637
+ if (siblings.length > 1) {
638
+ const index = siblings.indexOf(current) + 1;
639
+ segment += `[${index}]`;
640
+ }
641
+ }
642
+
643
+ parts.unshift(segment);
644
+ }
645
+
646
+ current = current.parentElement;
647
+
648
+ // Limit depth to keep xpath readable
649
+ if (parts.length >= 6) break;
650
+ }
651
+
652
+ // Always start with body for context
653
+ if (parts.length === 0) {
654
+ return '/' + element.tagName.toLowerCase();
655
+ }
656
+
657
+ return '/' + parts.join('/');
658
+ }
659
+
660
+ /**
661
+ * Generate flat snapshot of the DOM
662
+ */
663
+ export function createSnapshot(
664
+ document: Document,
665
+ refMap: RefMap,
666
+ options: SnapshotOptions = {}
667
+ ): SnapshotData {
668
+ const {
669
+ root = document.body,
670
+ maxDepth = 50,
671
+ includeHidden = false,
672
+ interactive = true,
673
+ all = false,
674
+ format = 'tree',
675
+ grep: grepPattern
676
+ } = options;
677
+
678
+ // Fast path for HTML format - return raw body HTML without processing
679
+ if (format === 'html') {
680
+ const bodyHTML = document.body?.outerHTML || '';
681
+
682
+ return {
683
+ tree: bodyHTML,
684
+ refs: {},
685
+ metadata: {
686
+ totalInteractiveElements: 0,
687
+ capturedElements: 0,
688
+ quality: 'high',
689
+ warnings: ['Raw HTML format - no filtering or ref generation applied']
690
+ }
691
+ };
692
+ }
693
+
694
+ refMap.clear();
695
+
696
+ const win = document.defaultView || window;
697
+ const refs: SnapshotData['refs'] = {};
698
+ const lines: string[] = [];
699
+ let refCounter = 0;
700
+
701
+ // Collect all elements
702
+ const elements: Element[] = [];
703
+
704
+ function collectElements(element: Element, depth: number): void {
705
+ if (depth > maxDepth) return;
706
+ // Only check element-level visibility, not ancestors (we're already traversing the tree)
707
+ if (!includeHidden && !isVisible(element, false)) return;
708
+
709
+ elements.push(element);
710
+
711
+ for (const child of element.children) {
712
+ collectElements(child, depth + 1);
713
+ }
714
+ }
715
+
716
+ collectElements(root, 0);
717
+
718
+ // Filter and process elements
719
+ let totalInteractive = 0;
720
+ let capturedInteractive = 0;
721
+
722
+ for (const element of elements) {
723
+ const role = getRole(element);
724
+ const isInteractiveElement = isInteractive(element);
725
+
726
+ if (isInteractiveElement) totalInteractive++;
727
+
728
+ // Skip non-interactive in interactive mode
729
+ if (interactive && !isInteractiveElement) continue;
730
+
731
+ // Skip elements without role in non-all mode
732
+ if (!all && !role) continue;
733
+
734
+ const name = getAccessibleName(element);
735
+
736
+ // Build line
737
+ let line = '';
738
+
739
+ if (role) {
740
+ const roleUpper = role.toUpperCase();
741
+ line = roleUpper;
742
+
743
+ if (name) {
744
+ line += ` "${truncateByType(name, 'ELEMENT_NAME')}"`;
745
+ }
746
+
747
+ // Generate ref for interactive elements
748
+ if (isInteractiveElement) {
749
+ const ref = `@ref:${refCounter++}`;
750
+ refMap.set(ref, element);
751
+ line += ` ${ref}`;
752
+ capturedInteractive++;
753
+
754
+ try {
755
+ const bbox = element.getBoundingClientRect();
756
+ refs[ref] = {
757
+ selector: generateSelector(element),
758
+ role: role.split(' ')[0],
759
+ name: name || undefined,
760
+ bbox: {
761
+ x: Math.round(bbox.x),
762
+ y: Math.round(bbox.y),
763
+ width: Math.round(bbox.width),
764
+ height: Math.round(bbox.height)
765
+ },
766
+ inViewport: isInViewport(element, win)
767
+ };
768
+ } catch {
769
+ refs[ref] = {
770
+ selector: generateSimpleSelector(element),
771
+ role: role.split(' ')[0],
772
+ name: name || undefined
773
+ };
774
+ }
775
+ }
776
+
777
+ // Add input attributes
778
+ line += getInputAttributes(element);
779
+
780
+ // Add state info
781
+ const states: string[] = [];
782
+ if (element.hasAttribute('disabled')) states.push('disabled');
783
+ if ((element as HTMLInputElement).checked) states.push('checked');
784
+ if (element.getAttribute('aria-expanded') === 'true') states.push('expanded');
785
+ if (element.getAttribute('aria-selected') === 'true') states.push('selected');
786
+
787
+ if (states.length) line += ` (${states.join(', ')})`;
788
+
789
+ // Add semantic xpath
790
+ const xpath = buildSemanticXPath(element);
791
+ line += ` ${xpath}`;
792
+
793
+ lines.push(line);
794
+ }
795
+ }
796
+
797
+ // Build header
798
+ const pageHeader = `PAGE: ${document.location?.href || 'about:blank'} | ${document.title || 'Untitled'} | viewport=${win.innerWidth}x${win.innerHeight}`;
799
+
800
+ // Apply grep filter if specified (supports Unix grep options)
801
+ let filteredLines = lines;
802
+ let grepDisplayPattern = '';
803
+
804
+ if (grepPattern) {
805
+ // Parse grep options
806
+ const grepOpts = typeof grepPattern === 'string'
807
+ ? { pattern: grepPattern }
808
+ : grepPattern;
809
+
810
+ const { pattern, ignoreCase = false, invert = false, fixedStrings = false } = grepOpts;
811
+ grepDisplayPattern = pattern;
812
+
813
+ // Build regex
814
+ let regexPattern = fixedStrings
815
+ ? pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape regex chars
816
+ : pattern;
817
+
818
+ const flags = ignoreCase ? 'i' : '';
819
+
820
+ try {
821
+ const regex = new RegExp(regexPattern, flags);
822
+ filteredLines = lines.filter(line => {
823
+ const matches = regex.test(line);
824
+ return invert ? !matches : matches;
825
+ });
826
+ } catch {
827
+ // Invalid regex, fall back to string matching
828
+ filteredLines = lines.filter(line => {
829
+ const matches = ignoreCase
830
+ ? line.toLowerCase().includes(pattern.toLowerCase())
831
+ : line.includes(pattern);
832
+ return invert ? !matches : matches;
833
+ });
834
+ }
835
+ }
836
+
837
+ // Build snapshot header with grep info if applicable
838
+ let snapshotHeader = `SNAPSHOT: elements=${elements.length} refs=${capturedInteractive}`;
839
+ if (grepPattern) {
840
+ snapshotHeader += ` grep=${grepDisplayPattern} matches=${filteredLines.length}`;
841
+ }
842
+
843
+ const output = [pageHeader, snapshotHeader, '', ...filteredLines].join('\n');
844
+
845
+ // Detect problematic page states
846
+ const warnings: string[] = [];
847
+ const viewportArea = win.innerWidth * win.innerHeight;
848
+
849
+ if (viewportArea === 0) {
850
+ warnings.push('Viewport not initialized (0x0) - page may be loading or redirecting');
851
+ }
852
+
853
+ if (capturedInteractive === 0 && totalInteractive === 0 && elements.length < 10) {
854
+ warnings.push('Page appears to be empty or transitional - wait for content to load');
855
+ }
856
+
857
+ if (document.location?.href.includes('RotateCookies') ||
858
+ document.location?.href.includes('ServiceLogin') ||
859
+ document.location?.href.includes('/blank')) {
860
+ warnings.push('Detected intermediate/redirect page - snapshot may not contain meaningful content');
861
+ }
862
+
863
+ return {
864
+ tree: output,
865
+ refs,
866
+ metadata: {
867
+ totalInteractiveElements: totalInteractive,
868
+ capturedElements: capturedInteractive,
869
+ quality: viewportArea === 0 || capturedInteractive === 0 ? 'low' : capturedInteractive < totalInteractive * 0.5 ? 'medium' : 'high',
870
+ warnings: warnings.length > 0 ? warnings : undefined
871
+ }
872
+ };
873
+ }