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,1210 @@
1
+ /**
2
+ * @btcp/core - DOM Actions
3
+ *
4
+ * Element interaction handlers using native browser APIs.
5
+ */
6
+
7
+ import type {
8
+ Command,
9
+ Response,
10
+ RefMap,
11
+ BoundingBox,
12
+ SnapshotData,
13
+ Modifier,
14
+ ValidateElementResponse,
15
+ ValidateRefsResponse,
16
+ } from './types.js';
17
+ import { createSnapshot } from './snapshot.js';
18
+ import {
19
+ DetailedError,
20
+ createElementNotFoundError,
21
+ createElementNotCompatibleError,
22
+ createTimeoutError,
23
+ createInvalidParametersError,
24
+ } from './errors.js';
25
+
26
+ /**
27
+ * DOM Actions executor
28
+ */
29
+ export class DOMActions {
30
+ private document: Document;
31
+ private window: Window;
32
+ private refMap: RefMap;
33
+ private lastSnapshotData: SnapshotData | null = null;
34
+ private overlayContainer: HTMLElement | null = null;
35
+ private scrollListener: (() => void) | null = null;
36
+ private rafId: number | null = null;
37
+
38
+ constructor(doc: Document, win: Window, refMap: RefMap) {
39
+ this.document = doc;
40
+ this.window = win;
41
+ this.refMap = refMap;
42
+ }
43
+
44
+ /**
45
+ * Execute a command and return a response
46
+ */
47
+ async execute(command: Command): Promise<Response> {
48
+ try {
49
+ const data = await this.dispatch(command);
50
+ return { id: command.id, success: true, data };
51
+ } catch (error) {
52
+ const message = error instanceof Error ? error.message : String(error);
53
+
54
+ // Include structured error data if available
55
+ if (error instanceof DetailedError) {
56
+ return {
57
+ id: command.id,
58
+ success: false,
59
+ error: message,
60
+ errorCode: error.code,
61
+ errorContext: error.context,
62
+ suggestions: error.suggestions,
63
+ };
64
+ }
65
+
66
+ return { id: command.id, success: false, error: message };
67
+ }
68
+ }
69
+
70
+ private async dispatch(command: Command): Promise<unknown> {
71
+ switch (command.action) {
72
+ case 'click':
73
+ return this.click(command.selector, {
74
+ button: command.button,
75
+ clickCount: command.clickCount,
76
+ modifiers: command.modifiers,
77
+ });
78
+
79
+ case 'dblclick':
80
+ return this.dblclick(command.selector);
81
+
82
+ case 'type':
83
+ return this.type(command.selector, command.text, {
84
+ delay: command.delay,
85
+ clear: command.clear,
86
+ });
87
+
88
+ case 'fill':
89
+ return this.fill(command.selector, command.value);
90
+
91
+ case 'clear':
92
+ return this.clear(command.selector);
93
+
94
+ case 'check':
95
+ return this.check(command.selector);
96
+
97
+ case 'uncheck':
98
+ return this.uncheck(command.selector);
99
+
100
+ case 'select':
101
+ return this.select(command.selector, command.values);
102
+
103
+ case 'focus':
104
+ return this.focus(command.selector);
105
+
106
+ case 'blur':
107
+ return this.blur(command.selector);
108
+
109
+ case 'hover':
110
+ return this.hover(command.selector);
111
+
112
+ case 'scroll':
113
+ return this.scroll(command.selector, {
114
+ x: command.x,
115
+ y: command.y,
116
+ direction: command.direction,
117
+ amount: command.amount,
118
+ });
119
+
120
+ case 'scrollIntoView':
121
+ return this.scrollIntoView(command.selector, command.block);
122
+
123
+ case 'snapshot':
124
+ return this.snapshot({
125
+ selector: command.selector,
126
+ maxDepth: command.maxDepth,
127
+ includeHidden: command.includeHidden,
128
+ interactive: command.interactive,
129
+ compact: command.compact,
130
+ all: command.all,
131
+ format: command.format,
132
+ grep: command.grep,
133
+ });
134
+
135
+ case 'querySelector':
136
+ return this.querySelector(command.selector);
137
+
138
+ case 'querySelectorAll':
139
+ return this.querySelectorAll(command.selector);
140
+
141
+ case 'getText':
142
+ return this.getText(command.selector);
143
+
144
+ case 'getAttribute':
145
+ return this.getAttribute(command.selector, command.attribute);
146
+
147
+ case 'getProperty':
148
+ return this.getProperty(command.selector, command.property);
149
+
150
+ case 'getBoundingBox':
151
+ return this.getBoundingBox(command.selector);
152
+
153
+ case 'isVisible':
154
+ return this.isVisible(command.selector);
155
+
156
+ case 'isEnabled':
157
+ return this.isEnabled(command.selector);
158
+
159
+ case 'isChecked':
160
+ return this.isChecked(command.selector);
161
+
162
+ case 'press':
163
+ return this.press(command.key, command.selector, command.modifiers);
164
+
165
+ case 'keyDown':
166
+ return this.keyDown(command.key);
167
+
168
+ case 'keyUp':
169
+ return this.keyUp(command.key);
170
+
171
+ case 'wait':
172
+ return this.wait(command.selector, {
173
+ state: command.state,
174
+ timeout: command.timeout,
175
+ });
176
+
177
+ case 'evaluate':
178
+ return this.evaluate(command.script, command.args);
179
+
180
+ case 'validateElement':
181
+ return this.validateElement(command.selector, {
182
+ expectedType: command.expectedType,
183
+ capabilities: command.capabilities,
184
+ });
185
+
186
+ case 'validateRefs':
187
+ return this.validateRefs(command.refs);
188
+
189
+ case 'highlight':
190
+ return this.highlight();
191
+
192
+ case 'clearHighlight':
193
+ return this.clearHighlight();
194
+
195
+ default:
196
+ throw new Error(`Unknown action: ${(command as Command).action}`);
197
+ }
198
+ }
199
+
200
+ // --- Element Resolution ---
201
+
202
+ private getElement(selector: string): Element {
203
+ const element = this.queryElement(selector);
204
+ if (!element) {
205
+ const isRef = selector.startsWith('@ref:');
206
+ const similarSelectors = isRef ? [] : this.findSimilarSelectors(selector);
207
+ const nearbyElements = this.getNearbyInteractiveElements();
208
+
209
+ throw createElementNotFoundError(selector, {
210
+ similarSelectors,
211
+ nearbyElements: nearbyElements.slice(0, 5),
212
+ isRef,
213
+ });
214
+ }
215
+ return element;
216
+ }
217
+
218
+ /**
219
+ * Find selectors similar to the given selector
220
+ */
221
+ private findSimilarSelectors(selector: string): Array<{ selector: string; role: string; name: string }> {
222
+ const results: Array<{ selector: string; role: string; name: string }> = [];
223
+
224
+ try {
225
+ // Try to extract ID or class from selector
226
+ const idMatch = selector.match(/#([a-zA-Z0-9_-]+)/);
227
+ const classMatch = selector.match(/\.([a-zA-Z0-9_-]+)/);
228
+
229
+ if (idMatch) {
230
+ // Look for similar IDs
231
+ const targetId = idMatch[1].toLowerCase();
232
+ const allElements = this.document.querySelectorAll('[id]');
233
+ allElements.forEach(el => {
234
+ const elId = el.id.toLowerCase();
235
+ if (elId !== targetId && (elId.includes(targetId) || targetId.includes(elId))) {
236
+ const role = el.getAttribute('role') || el.tagName.toLowerCase();
237
+ const name = el.textContent?.trim().substring(0, 30) || el.getAttribute('aria-label') || '';
238
+ results.push({
239
+ selector: `#${el.id}`,
240
+ role,
241
+ name,
242
+ });
243
+ }
244
+ });
245
+ }
246
+
247
+ if (classMatch && results.length < 3) {
248
+ // Look for similar classes
249
+ const targetClass = classMatch[1].toLowerCase();
250
+ const allElements = this.document.querySelectorAll('[class]');
251
+ allElements.forEach(el => {
252
+ const classes = Array.from(el.classList).map(c => c.toLowerCase());
253
+ const similarClass = classes.find(c => c !== targetClass && (c.includes(targetClass) || targetClass.includes(c)));
254
+ if (similarClass) {
255
+ const role = el.getAttribute('role') || el.tagName.toLowerCase();
256
+ const name = el.textContent?.trim().substring(0, 30) || el.getAttribute('aria-label') || '';
257
+ results.push({
258
+ selector: `.${similarClass}`,
259
+ role,
260
+ name,
261
+ });
262
+ }
263
+ });
264
+ }
265
+ } catch (e) {
266
+ // Ignore errors in similarity search
267
+ }
268
+
269
+ return results.slice(0, 3);
270
+ }
271
+
272
+ /**
273
+ * Get nearby interactive elements
274
+ */
275
+ private getNearbyInteractiveElements(): Array<{ ref: string; role: string; name: string }> {
276
+ const results: Array<{ ref: string; role: string; name: string }> = [];
277
+
278
+ try {
279
+ const interactiveSelectors = [
280
+ 'button',
281
+ 'a[href]',
282
+ 'input',
283
+ 'textarea',
284
+ 'select',
285
+ '[role="button"]',
286
+ '[role="link"]',
287
+ '[tabindex]'
288
+ ];
289
+
290
+ const elements = this.document.querySelectorAll(interactiveSelectors.join(','));
291
+
292
+ elements.forEach(el => {
293
+ if (el instanceof HTMLElement) {
294
+ const style = this.window.getComputedStyle(el);
295
+ const isVisible = style.display !== 'none' && style.visibility !== 'hidden';
296
+
297
+ if (isVisible) {
298
+ const ref = this.refMap.generateRef(el);
299
+ const role = el.getAttribute('role') || el.tagName.toLowerCase();
300
+ const name = el.textContent?.trim().substring(0, 30) ||
301
+ el.getAttribute('aria-label') ||
302
+ (el as HTMLInputElement).value?.substring(0, 30) ||
303
+ (el as HTMLInputElement).placeholder ||
304
+ '';
305
+
306
+ results.push({ ref, role, name });
307
+ }
308
+ }
309
+ });
310
+ } catch (e) {
311
+ // Ignore errors in nearby element search
312
+ }
313
+
314
+ return results.slice(0, 10);
315
+ }
316
+
317
+ /**
318
+ * Get available actions for an element based on its type
319
+ */
320
+ private getAvailableActionsForElement(element: Element): string[] {
321
+ const actions: string[] = [];
322
+
323
+ // All elements can be queried and inspected
324
+ actions.push('querySelector', 'getText', 'getAttribute', 'getProperty', 'getBoundingBox', 'isVisible');
325
+
326
+ // Clickable elements
327
+ if (
328
+ element instanceof HTMLButtonElement ||
329
+ element instanceof HTMLAnchorElement ||
330
+ element.getAttribute('role') === 'button' ||
331
+ element.getAttribute('role') === 'link' ||
332
+ element.hasAttribute('onclick')
333
+ ) {
334
+ actions.push('click', 'dblclick', 'hover');
335
+ }
336
+
337
+ // Input elements
338
+ if (element instanceof HTMLInputElement) {
339
+ actions.push('fill', 'clear', 'focus', 'blur', 'isEnabled');
340
+
341
+ if (element.type === 'checkbox' || element.type === 'radio') {
342
+ actions.push('check', 'uncheck', 'isChecked');
343
+ } else {
344
+ actions.push('type');
345
+ }
346
+ }
347
+
348
+ // Textarea elements
349
+ if (element instanceof HTMLTextAreaElement) {
350
+ actions.push('type', 'fill', 'clear', 'focus', 'blur');
351
+ }
352
+
353
+ // Select elements
354
+ if (element instanceof HTMLSelectElement) {
355
+ actions.push('select', 'focus', 'blur');
356
+ }
357
+
358
+ // Focusable elements
359
+ if (element instanceof HTMLElement) {
360
+ actions.push('focus', 'blur', 'scroll', 'scrollIntoView', 'press');
361
+ }
362
+
363
+ return actions;
364
+ }
365
+
366
+ private queryElement(selector: string): Element | null {
367
+ // Check if it's a ref
368
+ if (selector.startsWith('@ref:')) {
369
+ return this.refMap.get(selector);
370
+ }
371
+
372
+ // CSS selector
373
+ return this.document.querySelector(selector);
374
+ }
375
+
376
+ private queryElements(selector: string): Element[] {
377
+ if (selector.startsWith('@ref:')) {
378
+ const el = this.refMap.get(selector);
379
+ return el ? [el] : [];
380
+ }
381
+ return Array.from(this.document.querySelectorAll(selector));
382
+ }
383
+
384
+ // --- Actions ---
385
+
386
+ private async click(
387
+ selector: string,
388
+ options: { button?: 'left' | 'right' | 'middle'; clickCount?: number; modifiers?: Modifier[] } = {}
389
+ ): Promise<{ clicked: true }> {
390
+ const element = this.getElement(selector);
391
+ const { button = 'left', clickCount = 1, modifiers = [] } = options;
392
+
393
+ if (element instanceof HTMLElement) {
394
+ element.focus();
395
+ }
396
+
397
+ const buttonCode = button === 'right' ? 2 : button === 'middle' ? 1 : 0;
398
+ const eventInit: MouseEventInit = {
399
+ bubbles: true,
400
+ cancelable: true,
401
+ button: buttonCode,
402
+ altKey: modifiers.includes('Alt'),
403
+ ctrlKey: modifiers.includes('Control'),
404
+ metaKey: modifiers.includes('Meta'),
405
+ shiftKey: modifiers.includes('Shift'),
406
+ };
407
+
408
+ for (let i = 0; i < clickCount; i++) {
409
+ element.dispatchEvent(new MouseEvent('mousedown', eventInit));
410
+ element.dispatchEvent(new MouseEvent('mouseup', eventInit));
411
+ element.dispatchEvent(new MouseEvent('click', eventInit));
412
+ }
413
+
414
+ return { clicked: true };
415
+ }
416
+
417
+ private async dblclick(selector: string): Promise<{ clicked: true }> {
418
+ const element = this.getElement(selector);
419
+ element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
420
+ return { clicked: true };
421
+ }
422
+
423
+ private async type(
424
+ selector: string,
425
+ text: string,
426
+ options: { delay?: number; clear?: boolean } = {}
427
+ ): Promise<{ typed: true }> {
428
+ const element = this.getElement(selector);
429
+
430
+ if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
431
+ const actualType = element.tagName.toLowerCase();
432
+ const availableActions = this.getAvailableActionsForElement(element);
433
+
434
+ throw createElementNotCompatibleError(
435
+ selector,
436
+ 'type',
437
+ actualType,
438
+ ['input', 'textarea'],
439
+ availableActions
440
+ );
441
+ }
442
+
443
+ element.focus();
444
+
445
+ if (options.clear) {
446
+ element.value = '';
447
+ element.dispatchEvent(new Event('input', { bubbles: true }));
448
+ }
449
+
450
+ for (const char of text) {
451
+ element.dispatchEvent(new KeyboardEvent('keydown', { key: char, bubbles: true }));
452
+ element.dispatchEvent(new KeyboardEvent('keypress', { key: char, bubbles: true }));
453
+ element.value += char;
454
+ element.dispatchEvent(new Event('input', { bubbles: true }));
455
+ element.dispatchEvent(new KeyboardEvent('keyup', { key: char, bubbles: true }));
456
+
457
+ if (options.delay) {
458
+ await this.sleep(options.delay);
459
+ }
460
+ }
461
+
462
+ element.dispatchEvent(new Event('change', { bubbles: true }));
463
+ return { typed: true };
464
+ }
465
+
466
+ private async fill(selector: string, value: string): Promise<{ filled: true }> {
467
+ const element = this.getElement(selector);
468
+
469
+ if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
470
+ const actualType = element.tagName.toLowerCase();
471
+ const availableActions = this.getAvailableActionsForElement(element);
472
+
473
+ throw createElementNotCompatibleError(
474
+ selector,
475
+ 'fill',
476
+ actualType,
477
+ ['input', 'textarea'],
478
+ availableActions
479
+ );
480
+ }
481
+
482
+ element.focus();
483
+ element.value = value;
484
+ element.dispatchEvent(new Event('input', { bubbles: true }));
485
+ element.dispatchEvent(new Event('change', { bubbles: true }));
486
+
487
+ return { filled: true };
488
+ }
489
+
490
+ private async clear(selector: string): Promise<{ cleared: true }> {
491
+ const element = this.getElement(selector);
492
+
493
+ if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
494
+ element.value = '';
495
+ element.dispatchEvent(new Event('input', { bubbles: true }));
496
+ element.dispatchEvent(new Event('change', { bubbles: true }));
497
+ }
498
+
499
+ return { cleared: true };
500
+ }
501
+
502
+ private async check(selector: string): Promise<{ checked: true }> {
503
+ const element = this.getElement(selector);
504
+
505
+ if (!(element instanceof HTMLInputElement)) {
506
+ const actualType = element.tagName.toLowerCase();
507
+ const availableActions = this.getAvailableActionsForElement(element);
508
+
509
+ throw createElementNotCompatibleError(
510
+ selector,
511
+ 'check',
512
+ actualType,
513
+ ['input[type=checkbox]', 'input[type=radio]'],
514
+ availableActions
515
+ );
516
+ }
517
+
518
+ if (!element.checked) {
519
+ element.click();
520
+ }
521
+
522
+ return { checked: true };
523
+ }
524
+
525
+ private async uncheck(selector: string): Promise<{ unchecked: true }> {
526
+ const element = this.getElement(selector);
527
+
528
+ if (!(element instanceof HTMLInputElement)) {
529
+ const actualType = element.tagName.toLowerCase();
530
+ const availableActions = this.getAvailableActionsForElement(element);
531
+
532
+ throw createElementNotCompatibleError(
533
+ selector,
534
+ 'uncheck',
535
+ actualType,
536
+ ['input[type=checkbox]'],
537
+ availableActions
538
+ );
539
+ }
540
+
541
+ if (element.checked) {
542
+ element.click();
543
+ }
544
+
545
+ return { unchecked: true };
546
+ }
547
+
548
+ private async select(selector: string, values: string | string[]): Promise<{ selected: string[] }> {
549
+ const element = this.getElement(selector);
550
+
551
+ if (!(element instanceof HTMLSelectElement)) {
552
+ const actualType = element.tagName.toLowerCase();
553
+ const availableActions = this.getAvailableActionsForElement(element);
554
+
555
+ throw createElementNotCompatibleError(
556
+ selector,
557
+ 'select',
558
+ actualType,
559
+ ['select'],
560
+ availableActions
561
+ );
562
+ }
563
+
564
+ const valueArray = Array.isArray(values) ? values : [values];
565
+
566
+ for (const option of element.options) {
567
+ option.selected = valueArray.includes(option.value);
568
+ }
569
+
570
+ element.dispatchEvent(new Event('change', { bubbles: true }));
571
+
572
+ return { selected: valueArray };
573
+ }
574
+
575
+ private async focus(selector: string): Promise<{ focused: true }> {
576
+ const element = this.getElement(selector);
577
+
578
+ if (element instanceof HTMLElement) {
579
+ element.focus();
580
+ }
581
+
582
+ return { focused: true };
583
+ }
584
+
585
+ private async blur(selector: string): Promise<{ blurred: true }> {
586
+ const element = this.getElement(selector);
587
+
588
+ if (element instanceof HTMLElement) {
589
+ element.blur();
590
+ }
591
+
592
+ return { blurred: true };
593
+ }
594
+
595
+ private async hover(selector: string): Promise<{ hovered: true }> {
596
+ const element = this.getElement(selector);
597
+
598
+ element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
599
+ element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
600
+
601
+ return { hovered: true };
602
+ }
603
+
604
+ private async scroll(
605
+ selector: string | undefined,
606
+ options: { x?: number; y?: number; direction?: string; amount?: number }
607
+ ): Promise<{ scrolled: true }> {
608
+ // Validate parameter combinations
609
+ const hasXY = options.x !== undefined || options.y !== undefined;
610
+ const hasDirection = options.direction !== undefined;
611
+
612
+ if (hasXY && hasDirection) {
613
+ throw createInvalidParametersError(
614
+ 'Scroll command has conflicting parameters',
615
+ ['x/y', 'direction'],
616
+ 'Use either { x, y } for absolute scrolling OR { direction, amount } for relative scrolling, not both'
617
+ );
618
+ }
619
+
620
+ let deltaX = options.x ?? 0;
621
+ let deltaY = options.y ?? 0;
622
+
623
+ if (options.direction) {
624
+ const amount = options.amount ?? 100;
625
+ switch (options.direction) {
626
+ case 'up': deltaY = -amount; break;
627
+ case 'down': deltaY = amount; break;
628
+ case 'left': deltaX = -amount; break;
629
+ case 'right': deltaX = amount; break;
630
+ }
631
+ }
632
+
633
+ if (selector) {
634
+ const element = this.getElement(selector);
635
+ element.scrollBy(deltaX, deltaY);
636
+ } else {
637
+ this.window.scrollBy(deltaX, deltaY);
638
+ }
639
+
640
+ return { scrolled: true };
641
+ }
642
+
643
+ private async scrollIntoView(
644
+ selector: string,
645
+ block: 'start' | 'center' | 'end' | 'nearest' = 'center'
646
+ ): Promise<{ scrolled: true }> {
647
+ const element = this.getElement(selector);
648
+ element.scrollIntoView({ behavior: 'smooth', block });
649
+ return { scrolled: true };
650
+ }
651
+
652
+ private async snapshot(options: {
653
+ selector?: string;
654
+ maxDepth?: number;
655
+ includeHidden?: boolean;
656
+ interactive?: boolean;
657
+ compact?: boolean;
658
+ all?: boolean;
659
+ format?: 'tree' | 'html';
660
+ grep?: string | { pattern: string; ignoreCase?: boolean; invert?: boolean; fixedStrings?: boolean };
661
+ }): Promise<string> {
662
+ const root = options.selector
663
+ ? this.getElement(options.selector)
664
+ : this.document.body;
665
+
666
+ const snapshotData = createSnapshot(this.document, this.refMap, {
667
+ root,
668
+ maxDepth: options.maxDepth,
669
+ includeHidden: options.includeHidden,
670
+ interactive: options.interactive,
671
+ compact: options.compact,
672
+ all: options.all,
673
+ format: options.format,
674
+ grep: options.grep,
675
+ });
676
+
677
+ // Store snapshot data for highlight command (preserve refs internally)
678
+ this.lastSnapshotData = snapshotData;
679
+
680
+ // Return only the tree string
681
+ return snapshotData.tree;
682
+ }
683
+
684
+ private async querySelector(selector: string): Promise<{ found: boolean; ref?: string }> {
685
+ const element = this.queryElement(selector);
686
+ if (!element) {
687
+ return { found: false };
688
+ }
689
+
690
+ const ref = this.refMap.generateRef(element);
691
+ return { found: true, ref };
692
+ }
693
+
694
+ private async querySelectorAll(selector: string): Promise<{ count: number; refs: string[] }> {
695
+ const elements = this.queryElements(selector);
696
+ const refs = elements.map((el) => this.refMap.generateRef(el));
697
+ return { count: elements.length, refs };
698
+ }
699
+
700
+ private async getText(selector: string): Promise<{ text: string | null }> {
701
+ const element = this.getElement(selector);
702
+ return { text: element.textContent };
703
+ }
704
+
705
+ private async getAttribute(selector: string, attribute: string): Promise<{ value: string | null }> {
706
+ const element = this.getElement(selector);
707
+ return { value: element.getAttribute(attribute) };
708
+ }
709
+
710
+ private async getProperty(selector: string, property: string): Promise<{ value: unknown }> {
711
+ const element = this.getElement(selector);
712
+ return { value: (element as unknown as Record<string, unknown>)[property] };
713
+ }
714
+
715
+ private async getBoundingBox(selector: string): Promise<{ box: BoundingBox }> {
716
+ const element = this.getElement(selector);
717
+ const rect = element.getBoundingClientRect();
718
+ return {
719
+ box: {
720
+ x: rect.x,
721
+ y: rect.y,
722
+ width: rect.width,
723
+ height: rect.height,
724
+ },
725
+ };
726
+ }
727
+
728
+ private async isVisible(selector: string): Promise<{ visible: boolean }> {
729
+ const element = this.queryElement(selector);
730
+ if (!element || !(element instanceof HTMLElement)) {
731
+ return { visible: false };
732
+ }
733
+
734
+ const style = this.window.getComputedStyle(element);
735
+ const visible =
736
+ style.display !== 'none' &&
737
+ style.visibility !== 'hidden' &&
738
+ style.opacity !== '0';
739
+
740
+ return { visible };
741
+ }
742
+
743
+ private async isEnabled(selector: string): Promise<{ enabled: boolean }> {
744
+ const element = this.getElement(selector);
745
+ const enabled = !(element as HTMLInputElement).disabled;
746
+ return { enabled };
747
+ }
748
+
749
+ private async isChecked(selector: string): Promise<{ checked: boolean }> {
750
+ const element = this.getElement(selector);
751
+ const checked = (element as HTMLInputElement).checked ?? false;
752
+ return { checked };
753
+ }
754
+
755
+ private async press(
756
+ key: string,
757
+ selector?: string,
758
+ modifiers: Modifier[] = []
759
+ ): Promise<{ pressed: true }> {
760
+ const target = selector
761
+ ? this.getElement(selector)
762
+ : this.document.activeElement || this.document.body;
763
+
764
+ const eventInit: KeyboardEventInit = {
765
+ key,
766
+ code: key,
767
+ bubbles: true,
768
+ cancelable: true,
769
+ altKey: modifiers.includes('Alt'),
770
+ ctrlKey: modifiers.includes('Control'),
771
+ metaKey: modifiers.includes('Meta'),
772
+ shiftKey: modifiers.includes('Shift'),
773
+ };
774
+
775
+ target.dispatchEvent(new KeyboardEvent('keydown', eventInit));
776
+ target.dispatchEvent(new KeyboardEvent('keypress', eventInit));
777
+ target.dispatchEvent(new KeyboardEvent('keyup', eventInit));
778
+
779
+ return { pressed: true };
780
+ }
781
+
782
+ private async keyDown(key: string): Promise<{ down: true }> {
783
+ const target = this.document.activeElement || this.document.body;
784
+ target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
785
+ return { down: true };
786
+ }
787
+
788
+ private async keyUp(key: string): Promise<{ up: true }> {
789
+ const target = this.document.activeElement || this.document.body;
790
+ target.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
791
+ return { up: true };
792
+ }
793
+
794
+ private async wait(
795
+ selector?: string,
796
+ options: { state?: string; timeout?: number } = {}
797
+ ): Promise<{ waited: true }> {
798
+ const { state = 'visible', timeout = 5000 } = options;
799
+
800
+ if (!selector) {
801
+ await this.sleep(timeout);
802
+ return { waited: true };
803
+ }
804
+
805
+ const startTime = Date.now();
806
+ let lastState: { attached: boolean; visible: boolean; enabled: boolean } | undefined;
807
+
808
+ while (Date.now() - startTime < timeout) {
809
+ const element = this.queryElement(selector);
810
+
811
+ // Track element state for error reporting
812
+ if (element instanceof HTMLElement) {
813
+ const style = this.window.getComputedStyle(element);
814
+ lastState = {
815
+ attached: true,
816
+ visible: style.display !== 'none' && style.visibility !== 'hidden',
817
+ enabled: !(element as HTMLInputElement).disabled,
818
+ };
819
+ } else if (element) {
820
+ lastState = {
821
+ attached: true,
822
+ visible: false,
823
+ enabled: true,
824
+ };
825
+ }
826
+
827
+ let conditionMet = false;
828
+ switch (state) {
829
+ case 'attached':
830
+ conditionMet = element !== null;
831
+ break;
832
+ case 'detached':
833
+ conditionMet = element === null;
834
+ break;
835
+ case 'visible':
836
+ if (element instanceof HTMLElement) {
837
+ const style = this.window.getComputedStyle(element);
838
+ conditionMet =
839
+ style.display !== 'none' &&
840
+ style.visibility !== 'hidden';
841
+ }
842
+ break;
843
+ case 'hidden':
844
+ conditionMet =
845
+ !element ||
846
+ (element instanceof HTMLElement &&
847
+ this.window.getComputedStyle(element).display === 'none');
848
+ break;
849
+ case 'enabled':
850
+ conditionMet = element !== null && !(element as HTMLInputElement).disabled;
851
+ break;
852
+ }
853
+
854
+ if (conditionMet) {
855
+ return { waited: true };
856
+ }
857
+
858
+ await this.sleep(100);
859
+ }
860
+
861
+ // Provide detailed timeout error with current state
862
+ throw createTimeoutError(selector, state, lastState);
863
+ }
864
+
865
+ private async evaluate(script: string, args?: unknown[]): Promise<{ result: unknown }> {
866
+ const fn = new Function(...(args?.map((_, i) => `arg${i}`) || []), `return (${script})`);
867
+ const result = fn.call(this.window, ...(args || []));
868
+ return { result };
869
+ }
870
+
871
+ /**
872
+ * Validate element capabilities before attempting an action
873
+ */
874
+ private async validateElement(
875
+ selector: string,
876
+ options: {
877
+ expectedType?: 'input' | 'textarea' | 'button' | 'link' | 'select';
878
+ capabilities?: Array<'clickable' | 'editable' | 'checkable' | 'hoverable'>;
879
+ } = {}
880
+ ): Promise<ValidateElementResponse> {
881
+ const element = this.getElement(selector);
882
+ const actualRole = element.getAttribute('role') || element.tagName.toLowerCase();
883
+ const actualType = element instanceof HTMLInputElement ? element.type : undefined;
884
+
885
+ // Get element capabilities
886
+ const capabilities = this.getAvailableActionsForElement(element);
887
+
888
+ // Get element state
889
+ const style = element instanceof HTMLElement ? this.window.getComputedStyle(element) : null;
890
+ const state = {
891
+ attached: true,
892
+ visible: style ? style.display !== 'none' && style.visibility !== 'hidden' : false,
893
+ enabled: !(element as HTMLInputElement).disabled,
894
+ };
895
+
896
+ // Check type compatibility
897
+ let compatible = true;
898
+ let suggestion: string | undefined;
899
+
900
+ if (options.expectedType) {
901
+ const typeMatch =
902
+ options.expectedType === actualRole ||
903
+ (options.expectedType === 'input' && element instanceof HTMLInputElement) ||
904
+ (options.expectedType === 'textarea' && element instanceof HTMLTextAreaElement) ||
905
+ (options.expectedType === 'button' && element instanceof HTMLButtonElement) ||
906
+ (options.expectedType === 'link' && element instanceof HTMLAnchorElement) ||
907
+ (options.expectedType === 'select' && element instanceof HTMLSelectElement);
908
+
909
+ if (!typeMatch) {
910
+ compatible = false;
911
+ suggestion = `Element is ${actualRole}, not ${options.expectedType}. Available actions: ${capabilities.slice(0, 5).join(', ')}`;
912
+ }
913
+ }
914
+
915
+ // Check capability requirements
916
+ if (options.capabilities && compatible) {
917
+ const capabilityMap: Record<string, boolean> = {
918
+ clickable:
919
+ element instanceof HTMLButtonElement ||
920
+ element instanceof HTMLAnchorElement ||
921
+ element.getAttribute('role') === 'button' ||
922
+ element.hasAttribute('onclick'),
923
+ editable:
924
+ element instanceof HTMLInputElement ||
925
+ element instanceof HTMLTextAreaElement,
926
+ checkable:
927
+ element instanceof HTMLInputElement &&
928
+ (element.type === 'checkbox' || element.type === 'radio'),
929
+ hoverable: element instanceof HTMLElement,
930
+ };
931
+
932
+ for (const cap of options.capabilities) {
933
+ if (!capabilityMap[cap]) {
934
+ compatible = false;
935
+ suggestion = `Element does not support capability: ${cap}. Available actions: ${capabilities.slice(0, 5).join(', ')}`;
936
+ break;
937
+ }
938
+ }
939
+ }
940
+
941
+ return {
942
+ compatible,
943
+ actualRole,
944
+ actualType,
945
+ capabilities,
946
+ state,
947
+ suggestion,
948
+ };
949
+ }
950
+
951
+ /**
952
+ * Validate that refs are still valid
953
+ */
954
+ private async validateRefs(refs: string[]): Promise<ValidateRefsResponse> {
955
+ const valid: string[] = [];
956
+ const invalid: string[] = [];
957
+ const reasons: Record<string, string> = {};
958
+
959
+ for (const ref of refs) {
960
+ const element = this.refMap.get(ref);
961
+
962
+ if (element) {
963
+ // Check if element is still in the DOM
964
+ if (this.document.contains(element)) {
965
+ valid.push(ref);
966
+ } else {
967
+ invalid.push(ref);
968
+ reasons[ref] = 'Element has been removed from the DOM';
969
+ }
970
+ } else {
971
+ invalid.push(ref);
972
+ reasons[ref] = 'Ref not found. Refs are cleared on each snapshot() call.';
973
+ }
974
+ }
975
+
976
+ return {
977
+ valid,
978
+ invalid,
979
+ reasons,
980
+ };
981
+ }
982
+
983
+ /**
984
+ * Display visual overlay labels for interactive elements
985
+ */
986
+ private async highlight(): Promise<{ highlighted: number }> {
987
+ // Verify snapshot exists
988
+ if (!this.lastSnapshotData || !this.lastSnapshotData.refs) {
989
+ throw new Error('No snapshot data available. Please run snapshot() command first.');
990
+ }
991
+
992
+ // Clear any existing highlights
993
+ this.clearExistingOverlay();
994
+
995
+ // Create overlay container with absolute positioning covering entire document
996
+ this.overlayContainer = this.document.createElement('div');
997
+ this.overlayContainer.id = 'btcp-highlight-overlay';
998
+ this.overlayContainer.style.cssText = `
999
+ position: absolute;
1000
+ top: 0;
1001
+ left: 0;
1002
+ width: ${this.document.documentElement.scrollWidth}px;
1003
+ height: ${this.document.documentElement.scrollHeight}px;
1004
+ pointer-events: none;
1005
+ z-index: 999999;
1006
+ contain: layout style paint;
1007
+ `;
1008
+
1009
+ let highlightedCount = 0;
1010
+
1011
+ // Create border overlays and labels for each ref
1012
+ for (const [ref, _refData] of Object.entries(this.lastSnapshotData.refs)) {
1013
+ const element = this.refMap.get(ref);
1014
+
1015
+ // Skip if element no longer exists or is disconnected
1016
+ if (!element || !element.isConnected) {
1017
+ continue;
1018
+ }
1019
+
1020
+ try {
1021
+ // Get current bounding box (element might have moved)
1022
+ const bbox = element.getBoundingClientRect();
1023
+
1024
+ // Skip elements with no dimensions
1025
+ if (bbox.width === 0 && bbox.height === 0) {
1026
+ continue;
1027
+ }
1028
+
1029
+ // Create border overlay
1030
+ const border = this.document.createElement('div');
1031
+ border.className = 'btcp-ref-border';
1032
+ border.dataset.ref = ref;
1033
+ border.style.cssText = `
1034
+ position: absolute;
1035
+ width: ${bbox.width}px;
1036
+ height: ${bbox.height}px;
1037
+ transform: translate3d(${bbox.left + this.window.scrollX}px, ${bbox.top + this.window.scrollY}px, 0);
1038
+ border: 2px solid rgba(59, 130, 246, 0.8);
1039
+ border-radius: 2px;
1040
+ box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2);
1041
+ pointer-events: none;
1042
+ will-change: transform;
1043
+ contain: layout style paint;
1044
+ `;
1045
+
1046
+ // Create label
1047
+ const label = this.document.createElement('div');
1048
+ label.className = 'btcp-ref-label';
1049
+ label.dataset.ref = ref;
1050
+ // Extract number from ref (e.g., "@ref:5" -> "5")
1051
+ label.textContent = ref.replace('@ref:', '');
1052
+ label.style.cssText = `
1053
+ position: absolute;
1054
+ transform: translate3d(${bbox.left + this.window.scrollX}px, ${bbox.top + this.window.scrollY}px, 0);
1055
+ background: rgba(59, 130, 246, 0.9);
1056
+ color: white;
1057
+ padding: 2px 6px;
1058
+ border-radius: 3px;
1059
+ font-family: monospace;
1060
+ font-size: 11px;
1061
+ font-weight: bold;
1062
+ box-shadow: 0 2px 4px rgba(0,0,0,0.3);
1063
+ pointer-events: none;
1064
+ white-space: nowrap;
1065
+ will-change: transform;
1066
+ contain: layout style paint;
1067
+ `;
1068
+
1069
+ this.overlayContainer.appendChild(border);
1070
+ this.overlayContainer.appendChild(label);
1071
+ highlightedCount++;
1072
+ } catch (error) {
1073
+ // Skip elements that throw errors
1074
+ continue;
1075
+ }
1076
+ }
1077
+
1078
+ // Inject overlay into page
1079
+ this.document.body.appendChild(this.overlayContainer);
1080
+
1081
+ // Set up scroll listener with rAF throttling
1082
+ let ticking = false;
1083
+ this.scrollListener = () => {
1084
+ if (!ticking) {
1085
+ this.rafId = this.window.requestAnimationFrame(() => {
1086
+ this.updateHighlightPositions();
1087
+ ticking = false;
1088
+ });
1089
+ ticking = true;
1090
+ }
1091
+ };
1092
+
1093
+ // Use passive listener for better scroll performance
1094
+ this.window.addEventListener('scroll', this.scrollListener, { passive: true });
1095
+
1096
+ return { highlighted: highlightedCount };
1097
+ }
1098
+
1099
+ /**
1100
+ * Invalidate snapshot data (called on navigation or manual clear)
1101
+ */
1102
+ public invalidateSnapshot(): void {
1103
+ this.lastSnapshotData = null;
1104
+ this.clearHighlight();
1105
+ }
1106
+
1107
+ /**
1108
+ * Update highlight positions on scroll (GPU-accelerated)
1109
+ */
1110
+ private updateHighlightPositions(): void {
1111
+ if (!this.overlayContainer || !this.lastSnapshotData) {
1112
+ return;
1113
+ }
1114
+
1115
+ // Batch DOM reads
1116
+ const updates: Array<{ element: HTMLElement; x: number; y: number; width?: number; height?: number }> = [];
1117
+
1118
+ // Read phase - get all bounding boxes first
1119
+ const borders = this.overlayContainer.querySelectorAll('.btcp-ref-border');
1120
+ const labels = this.overlayContainer.querySelectorAll('.btcp-ref-label');
1121
+
1122
+ borders.forEach((borderEl) => {
1123
+ const ref = (borderEl as HTMLElement).dataset.ref;
1124
+ if (!ref) return;
1125
+
1126
+ const element = this.refMap.get(ref);
1127
+ if (!element || !element.isConnected) return;
1128
+
1129
+ const bbox = element.getBoundingClientRect();
1130
+ if (bbox.width === 0 && bbox.height === 0) return;
1131
+
1132
+ updates.push({
1133
+ element: borderEl as HTMLElement,
1134
+ x: bbox.left + this.window.scrollX,
1135
+ y: bbox.top + this.window.scrollY,
1136
+ width: bbox.width,
1137
+ height: bbox.height,
1138
+ });
1139
+ });
1140
+
1141
+ labels.forEach((labelEl) => {
1142
+ const ref = (labelEl as HTMLElement).dataset.ref;
1143
+ if (!ref) return;
1144
+
1145
+ const element = this.refMap.get(ref);
1146
+ if (!element || !element.isConnected) return;
1147
+
1148
+ const bbox = element.getBoundingClientRect();
1149
+ if (bbox.width === 0 && bbox.height === 0) return;
1150
+
1151
+ updates.push({
1152
+ element: labelEl as HTMLElement,
1153
+ x: bbox.left + this.window.scrollX,
1154
+ y: bbox.top + this.window.scrollY,
1155
+ });
1156
+ });
1157
+
1158
+ // Write phase - update transforms
1159
+ updates.forEach(({ element, x, y, width, height }) => {
1160
+ element.style.transform = `translate3d(${x}px, ${y}px, 0)`;
1161
+ if (width !== undefined && height !== undefined) {
1162
+ element.style.width = `${width}px`;
1163
+ element.style.height = `${height}px`;
1164
+ }
1165
+ });
1166
+ }
1167
+
1168
+ /**
1169
+ * Remove visual overlay labels
1170
+ */
1171
+ private async clearHighlight(): Promise<{ cleared: true }> {
1172
+ this.clearExistingOverlay();
1173
+ return { cleared: true };
1174
+ }
1175
+
1176
+ /**
1177
+ * Remove existing overlay if it exists
1178
+ */
1179
+ private clearExistingOverlay(): void {
1180
+ // Remove scroll listener
1181
+ if (this.scrollListener) {
1182
+ this.window.removeEventListener('scroll', this.scrollListener);
1183
+ this.scrollListener = null;
1184
+ }
1185
+
1186
+ // Cancel any pending animation frame
1187
+ if (this.rafId !== null) {
1188
+ this.window.cancelAnimationFrame(this.rafId);
1189
+ this.rafId = null;
1190
+ }
1191
+
1192
+ // Remove overlay container
1193
+ if (this.overlayContainer && this.overlayContainer.parentNode) {
1194
+ this.overlayContainer.parentNode.removeChild(this.overlayContainer);
1195
+ }
1196
+ this.overlayContainer = null;
1197
+
1198
+ // Also remove any orphaned overlays
1199
+ const existingOverlay = this.document.getElementById('btcp-highlight-overlay');
1200
+ if (existingOverlay && existingOverlay.parentNode) {
1201
+ existingOverlay.parentNode.removeChild(existingOverlay);
1202
+ }
1203
+ }
1204
+
1205
+ // --- Utilities ---
1206
+
1207
+ private sleep(ms: number): Promise<void> {
1208
+ return new Promise((resolve) => setTimeout(resolve, ms));
1209
+ }
1210
+ }