forked from
lass.pds.lassul.us/superconfig
This repository has no description
53 kB
1994 lines
1/// <reference types="firefox-webext-browser" />
2
3/**
4 * Content script for Browser CLI Controller
5 * Optimized API for LLM agents with limited context windows
6 *
7 * @typedef {'css' | 'text' | 'aria-label' | 'placeholder'} SelectorType
8 *
9 * @typedef {object} ConsoleLog
10 * @property {string} type - Log type (log, error, warn, info, debug)
11 * @property {string} message - Log message
12 * @property {string} timestamp - ISO timestamp
13 *
14 * @typedef {object} ElementData
15 * @property {number} ref - Element reference number
16 * @property {string} role - Element role (button, link, input, etc.)
17 * @property {string} name - Accessible name
18 * @property {string} [value] - Current value (for inputs)
19 * @property {string[]} attrs - Attributes like [clickable], [disabled]
20 * @property {Element} domElement - Reference to DOM element
21 */
22
23/** @type {ConsoleLog[]} Store console logs */
24const consoleLogs = [];
25/** @type {number} */
26const MAX_CONSOLE_LOGS = 1000;
27
28/** @type {HTMLDivElement|undefined} Virtual cursor element */
29let virtualCursor;
30
31/** @type {HTMLInputElement|HTMLTextAreaElement|undefined} Last focused input element */
32let lastFocusedInput;
33
34/** @type {Map<number, Element>} Current ref to element mapping */
35let currentRefMap = new Map();
36
37/** @type {Snapshot|undefined} Last snapshot for diffing */
38let lastSnapshot;
39
40// ============================================================================
41// Snapshot and Element Classes
42// ============================================================================
43
44/**
45 * Element wrapper with LLM-friendly toString
46 */
47class SnapshotElement {
48 /**
49 * @param {ElementData} data
50 */
51 constructor(data) {
52 /** @type {number} */
53 this.ref = data.ref;
54 /** @type {string} */
55 this.role = data.role;
56 /** @type {string} */
57 this.name = data.name;
58 /** @type {string|undefined} */
59 this.value = data.value;
60 /** @type {string[]} */
61 this.attrs = data.attrs;
62 /** @type {Element} */
63 this.domElement = data.domElement;
64 }
65
66 /**
67 * LLM-friendly string representation
68 * @returns {string}
69 */
70 toString() {
71 let s = `[${this.ref}] ${this.role}`;
72 if (this.name) s += ` "${this.name}"`;
73 if (this.attrs.length > 0) s += ` [${this.attrs.join(", ")}]`;
74 if (this.value !== undefined) s += ` value="${this.value}"`;
75 return s;
76 }
77
78 /**
79 * JSON representation (without DOM element)
80 * @returns {object}
81 */
82 toJSON() {
83 return {
84 ref: this.ref,
85 role: this.role,
86 name: this.name,
87 value: this.value,
88 attrs: this.attrs,
89 };
90 }
91}
92
93/**
94 * Snapshot class with filtering and LLM-friendly output
95 */
96class Snapshot {
97 /**
98 * @param {SnapshotElement[]} elements
99 * @param {string} url
100 * @param {string} title
101 */
102 constructor(elements, url, title) {
103 /** @type {SnapshotElement[]} */
104 this.elements = elements;
105 /** @type {string} */
106 this.url = url;
107 /** @type {string} */
108 this.title = title;
109 /** @type {Map<number, SnapshotElement>} */
110 this._refMap = new Map();
111 for (const el of elements) {
112 this._refMap.set(el.ref, el);
113 }
114 }
115
116 /**
117 * Find element by ref
118 * @param {number} ref
119 * @returns {SnapshotElement|undefined}
120 */
121 find(ref) {
122 return this._refMap.get(ref);
123 }
124
125 /**
126 * Get all buttons
127 * @returns {SnapshotElement[]}
128 */
129 get buttons() {
130 return this.elements.filter((e) => e.role === "button");
131 }
132
133 /**
134 * Get all links
135 * @returns {SnapshotElement[]}
136 */
137 get links() {
138 return this.elements.filter((e) => e.role === "link");
139 }
140
141 /**
142 * Get all inputs
143 * @returns {SnapshotElement[]}
144 */
145 get inputs() {
146 return this.elements.filter(
147 (e) =>
148 ["input", "textarea", "select"].includes(e.role) ||
149 e.role.startsWith("input["),
150 );
151 }
152
153 /**
154 * Get all form elements
155 * @returns {SnapshotElement[]}
156 */
157 get forms() {
158 return this.elements.filter(
159 (e) =>
160 ["input", "textarea", "select", "checkbox", "radio"].includes(e.role) ||
161 e.role.startsWith("input["),
162 );
163 }
164
165 /**
166 * Search by text
167 * @param {string} text
168 * @returns {SnapshotElement[]}
169 */
170 search(text) {
171 const lower = text.toLowerCase();
172 return this.elements.filter(
173 (e) =>
174 e.name?.toLowerCase().includes(lower) ||
175 e.value?.toLowerCase().includes(lower),
176 );
177 }
178
179 /**
180 * Compare with another snapshot and return differences
181 * @param {Snapshot} other - Previous snapshot to compare against
182 * @returns {SnapshotDiff}
183 */
184 diff(other) {
185 return new SnapshotDiff(other, this);
186 }
187
188 /**
189 * LLM-friendly output
190 * @returns {string}
191 */
192 toString() {
193 let out = `Page: ${this.title}\nURL: ${this.url}\n\n`;
194 for (const el of this.elements) {
195 out += el.toString() + "\n";
196 }
197 return out;
198 }
199
200 /**
201 * JSON representation
202 * @returns {object}
203 */
204 toJSON() {
205 return {
206 url: this.url,
207 title: this.title,
208 elements: this.elements.map((e) => e.toJSON()),
209 };
210 }
211}
212
213/**
214 * Diff between two snapshots
215 */
216class SnapshotDiff {
217 /**
218 * @param {Snapshot} before
219 * @param {Snapshot} after
220 */
221 constructor(before, after) {
222 /** @type {boolean} */
223 this.urlChanged = before.url !== after.url;
224 /** @type {string|undefined} */
225 this.oldUrl = this.urlChanged ? before.url : undefined;
226 /** @type {string|undefined} */
227 this.newUrl = this.urlChanged ? after.url : undefined;
228
229 /** @type {boolean} */
230 this.titleChanged = before.title !== after.title;
231 /** @type {string|undefined} */
232 this.oldTitle = this.titleChanged ? before.title : undefined;
233 /** @type {string|undefined} */
234 this.newTitle = this.titleChanged ? after.title : undefined;
235
236 // Build maps by element signature (role + name) for comparison
237 /** @type {Map<string, SnapshotElement>} */
238 const beforeMap = new Map();
239 for (const el of before.elements) {
240 const key = `${el.role}|${el.name}`;
241 beforeMap.set(key, el);
242 }
243
244 /** @type {Map<string, SnapshotElement>} */
245 const afterMap = new Map();
246 for (const el of after.elements) {
247 const key = `${el.role}|${el.name}`;
248 afterMap.set(key, el);
249 }
250
251 /** @type {SnapshotElement[]} */
252 this.added = [];
253 /** @type {SnapshotElement[]} */
254 this.removed = [];
255 /** @type {Array<{element: SnapshotElement, changes: string[]}>} */
256 this.changed = [];
257
258 // Find added and changed elements
259 for (const [key, el] of afterMap) {
260 const beforeEl = beforeMap.get(key);
261 if (beforeEl) {
262 // Check for changes
263 const changes = [];
264 if (beforeEl.value !== el.value) {
265 changes.push(
266 `value: "${beforeEl.value ?? ""}" → "${el.value ?? ""}"`,
267 );
268 }
269 const beforeAttrs = beforeEl.attrs.join(",");
270 const afterAttrs = el.attrs.join(",");
271 if (beforeAttrs !== afterAttrs) {
272 changes.push(`attrs: [${beforeAttrs}] → [${afterAttrs}]`);
273 }
274 if (changes.length > 0) {
275 this.changed.push({ element: el, changes });
276 }
277 } else {
278 this.added.push(el);
279 }
280 }
281
282 // Find removed elements
283 for (const [key, el] of beforeMap) {
284 if (!afterMap.has(key)) {
285 this.removed.push(el);
286 }
287 }
288 }
289
290 /**
291 * Check if anything changed
292 * @returns {boolean}
293 */
294 get hasChanges() {
295 return (
296 this.urlChanged ||
297 this.titleChanged ||
298 this.added.length > 0 ||
299 this.removed.length > 0 ||
300 this.changed.length > 0
301 );
302 }
303
304 /**
305 * LLM-friendly output
306 * @returns {string}
307 */
308 toString() {
309 if (!this.hasChanges) {
310 return "No changes";
311 }
312
313 const lines = [];
314
315 if (this.urlChanged) {
316 lines.push(`URL: ${this.oldUrl} → ${this.newUrl}`);
317 }
318 if (this.titleChanged) {
319 lines.push(`Title: "${this.oldTitle}" → "${this.newTitle}"`);
320 }
321
322 if (this.added.length > 0) {
323 lines.push("", `Added (${this.added.length}):`);
324 for (const el of this.added) {
325 lines.push(` + ${el.toString()}`);
326 }
327 }
328
329 if (this.removed.length > 0) {
330 lines.push("", `Removed (${this.removed.length}):`);
331 for (const el of this.removed) {
332 lines.push(` - ${el.toString()}`);
333 }
334 }
335
336 if (this.changed.length > 0) {
337 lines.push("", `Changed (${this.changed.length}):`);
338 for (const { element, changes } of this.changed) {
339 lines.push(
340 ` ~ ${element.toString()}`,
341 ...changes.map((c) => ` ${c}`),
342 );
343 }
344 }
345
346 return lines.join("\n");
347 }
348
349 /**
350 * JSON representation
351 * @returns {object}
352 */
353 toJSON() {
354 return {
355 urlChanged: this.urlChanged,
356 oldUrl: this.oldUrl,
357 newUrl: this.newUrl,
358 titleChanged: this.titleChanged,
359 oldTitle: this.oldTitle,
360 newTitle: this.newTitle,
361 added: this.added.map((e) => e.toJSON()),
362 removed: this.removed.map((e) => e.toJSON()),
363 changed: this.changed.map(({ element, changes }) => ({
364 element: element.toJSON(),
365 changes,
366 })),
367 };
368 }
369}
370
371// ============================================================================
372// Virtual Cursor
373// ============================================================================
374
375/**
376 * Create and initialize the virtual cursor
377 */
378function initializeVirtualCursor() {
379 if (virtualCursor) return;
380
381 virtualCursor = document.createElement("div");
382 virtualCursor.id = "browser-cli-cursor";
383 virtualCursor.style.cssText = `
384 position: fixed;
385 width: 20px;
386 height: 20px;
387 pointer-events: none;
388 z-index: 999999;
389 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
390 opacity: 1;
391 `;
392
393 const inner = document.createElement("div");
394 inner.style.cssText = `
395 position: absolute;
396 top: 50%;
397 left: 50%;
398 transform: translate(-50%, -50%);
399 width: 8px;
400 height: 8px;
401 background: #ff6b6b;
402 border-radius: 50%;
403 box-shadow: 0 0 10px rgba(255, 107, 107, 0.5);
404 `;
405
406 const outer = document.createElement("div");
407 outer.style.cssText = `
408 position: absolute;
409 top: 50%;
410 left: 50%;
411 transform: translate(-50%, -50%);
412 width: 20px;
413 height: 20px;
414 border: 2px solid #ff6b6b;
415 border-radius: 50%;
416 animation: browser-cli-pulse 1.5s ease-out infinite;
417 `;
418
419 const style = document.createElement("style");
420 style.textContent = `
421 @keyframes browser-cli-pulse {
422 0% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
423 100% { transform: translate(-50%, -50%) scale(2); opacity: 0; }
424 }
425 @keyframes browser-cli-click {
426 0% { transform: translate(-50%, -50%) scale(1); }
427 50% { transform: translate(-50%, -50%) scale(0.8); }
428 100% { transform: translate(-50%, -50%) scale(1); }
429 }
430 `;
431
432 virtualCursor.append(inner);
433 virtualCursor.append(outer);
434 document.head.append(style);
435 document.body.append(virtualCursor);
436}
437
438/**
439 * Move virtual cursor to element with animation
440 * @param {Element} element - Target element
441 * @param {boolean} [showClick=false] - Whether to show click animation
442 */
443function moveCursorToElement(element, showClick = false) {
444 if (!virtualCursor) {
445 initializeVirtualCursor();
446 }
447
448 const rect = element.getBoundingClientRect();
449 const x = rect.left + rect.width / 2;
450 const y = rect.top + rect.height / 2;
451
452 if (virtualCursor) {
453 virtualCursor.style.left = `${x}px`;
454 virtualCursor.style.top = `${y}px`;
455 virtualCursor.style.opacity = "1";
456
457 if (showClick) {
458 const inner = virtualCursor.querySelector("div:first-child");
459 if (inner && inner instanceof HTMLElement) {
460 inner.style.animation = "browser-cli-click 0.3s ease-out";
461 setTimeout(() => {
462 inner.style.animation = "";
463 }, 300);
464 }
465 }
466 }
467}
468
469// ============================================================================
470// Console Override
471// ============================================================================
472
473/**
474 * Inject console override into page context to capture all logs
475 */
476function injectConsoleOverride() {
477 const script = document.createElement("script");
478 script.textContent = `
479 (function() {
480 const consoleMethods = ['log', 'error', 'warn', 'info', 'debug'];
481 consoleMethods.forEach(method => {
482 const original = console[method];
483 console[method] = function(...args) {
484 window.postMessage({
485 type: 'browser-cli-console',
486 method: method,
487 args: args.map(arg => {
488 try {
489 return typeof arg === 'object' ? JSON.stringify(arg) : String(arg);
490 } catch {
491 return '[Circular reference]';
492 }
493 }),
494 timestamp: new Date().toISOString()
495 }, '*');
496 original.apply(console, args);
497 };
498 });
499 })();
500 `;
501 (document.head || document.documentElement).append(script);
502 script.remove();
503}
504
505window.addEventListener("message", (event) => {
506 if (event.source !== window) return;
507
508 if (event.data && event.data.type === "browser-cli-console") {
509 consoleLogs.push({
510 type: event.data.method,
511 message: event.data.args.join(" "),
512 timestamp: event.data.timestamp,
513 });
514
515 if (consoleLogs.length > MAX_CONSOLE_LOGS) {
516 consoleLogs.shift();
517 }
518 }
519});
520
521injectConsoleOverride();
522
523/** @type {Array<keyof Console>} */
524const consoleMethods = /** @type {any} */ ([
525 "log",
526 "error",
527 "warn",
528 "info",
529 "debug",
530]);
531for (const method of consoleMethods) {
532 // @ts-ignore
533 const original = console[method];
534 // @ts-ignore
535 console[method] = function (.../** @type {any[]} */ args) {
536 consoleLogs.push({
537 type: method,
538 message: args
539 .map((arg) => {
540 try {
541 return typeof arg === "object" ? JSON.stringify(arg) : String(arg);
542 } catch {
543 return String(arg);
544 }
545 })
546 .join(" "),
547 timestamp: new Date().toISOString(),
548 });
549
550 if (consoleLogs.length > MAX_CONSOLE_LOGS) {
551 consoleLogs.shift();
552 }
553
554 original.apply(console, args);
555 };
556}
557
558// ============================================================================
559// Element Finding Functions
560// ============================================================================
561
562/**
563 * Find element by ref number
564 * @param {number} ref - Reference number from snapshot
565 * @returns {Element} Found element
566 */
567function findByRef(ref) {
568 const element = currentRefMap.get(ref);
569 if (element) {
570 // Verify element is still in DOM
571 if (document.contains(element)) {
572 return element;
573 }
574 // Element was removed, clear stale ref
575 currentRefMap.delete(ref);
576 }
577
578 // Build helpful error message with available refs
579 const available = [];
580 for (const [r, el] of currentRefMap) {
581 if (!document.contains(el)) {
582 continue;
583 }
584 const role = getElementRole(el);
585 const name = getAccessibleName(el);
586 available.push(` [${r}] ${role}${name ? ` "${name}"` : ""}`);
587 if (available.length >= 5) {
588 available.push(" ...");
589 break;
590 }
591 }
592
593 let msg = `Element [${ref}] not found.`;
594 msg +=
595 available.length > 0
596 ? ` Available:\n${available.join("\n")}`
597 : " No refs available. Call snap() first.";
598 throw new Error(msg);
599}
600
601/**
602 * Find element by CSS selector
603 * @param {string} selector - CSS selector
604 * @returns {Element} Found element
605 */
606function findBySelector(selector) {
607 try {
608 const element = document.querySelector(selector);
609 if (element) return element;
610 throw new Error(`No element matches CSS selector: ${selector}`);
611 } catch (error) {
612 if (error instanceof Error && error.name === "SyntaxError") {
613 throw new Error(`Invalid CSS selector: ${selector}`);
614 }
615 throw error;
616 }
617}
618
619/**
620 * Find element by text content
621 * @param {string} text - Text to search for
622 * @returns {Element} Found element
623 */
624function findByText(text) {
625 const xpath = text.includes('"')
626 ? `//*[contains(text(), '${text}')]`
627 : `//*[contains(text(), "${text}")]`;
628
629 const result = document.evaluate(
630 xpath,
631 document,
632 undefined,
633 XPathResult.FIRST_ORDERED_NODE_TYPE,
634 );
635 if (result.singleNodeValue && result.singleNodeValue instanceof Element) {
636 return result.singleNodeValue;
637 }
638 throw new Error(`No element found with text: ${text}`);
639}
640
641/**
642 * Find element by aria-label
643 * @param {string} label - ARIA label to search for
644 * @returns {Element} Found element
645 */
646function findByAriaLabel(label) {
647 const element = document.querySelector(`[aria-label="${CSS.escape(label)}"]`);
648 if (element) return element;
649 throw new Error(`No element found with aria-label: ${label}`);
650}
651
652/**
653 * Find element by placeholder
654 * @param {string} placeholder - Placeholder text to search for
655 * @returns {Element} Found element
656 */
657function findByPlaceholder(placeholder) {
658 const element = document.querySelector(
659 `[placeholder="${CSS.escape(placeholder)}"]`,
660 );
661 if (element) return element;
662 throw new Error(`No element found with placeholder: ${placeholder}`);
663}
664
665/**
666 * Find element based on selector type or ref
667 * @param {string|number} selector - The selector value or ref number
668 * @param {SelectorType} [selectorType='css'] - Type of selector
669 * @returns {Element} Found element
670 */
671function find(selector, selectorType = "css") {
672 // If selector is a number, treat as ref
673 if (typeof selector === "number") {
674 return findByRef(selector);
675 }
676
677 switch (selectorType) {
678 case "css": {
679 return findBySelector(selector);
680 }
681 case "text": {
682 return findByText(selector);
683 }
684 case "aria-label": {
685 return findByAriaLabel(selector);
686 }
687 case "placeholder": {
688 return findByPlaceholder(selector);
689 }
690 default: {
691 throw new Error(`Unknown selector type: ${selectorType}`);
692 }
693 }
694}
695
696// ============================================================================
697// Snapshot Generation
698// ============================================================================
699
700/**
701 * Get the role of an element
702 * @param {Element} element
703 * @returns {string}
704 */
705function getElementRole(element) {
706 const explicitRole = element.getAttribute("role");
707 if (explicitRole) return explicitRole;
708
709 const tag = element.tagName.toLowerCase();
710
711 // Map common tags to roles
712 const tagRoleMap = {
713 a: "link",
714 button: "button",
715 input: "input",
716 textarea: "textarea",
717 select: "select",
718 img: "img",
719 h1: "heading",
720 h2: "heading",
721 h3: "heading",
722 h4: "heading",
723 h5: "heading",
724 h6: "heading",
725 nav: "navigation",
726 main: "main",
727 header: "banner",
728 footer: "contentinfo",
729 aside: "complementary",
730 form: "form",
731 table: "table",
732 ul: "list",
733 ol: "list",
734 li: "listitem",
735 };
736
737 if (tag === "input") {
738 const type = element.getAttribute("type") || "text";
739 if (type === "checkbox") return "checkbox";
740 if (type === "radio") return "radio";
741 if (type === "submit" || type === "button") return "button";
742 return `input[${type}]`;
743 }
744
745 return /** @type {Record<string, string>} */ (tagRoleMap)[tag] || tag;
746}
747
748/**
749 * Get accessible name of an element
750 * @param {Element} element
751 * @returns {string}
752 */
753function getAccessibleName(element) {
754 // aria-label takes precedence
755 const ariaLabel = element.getAttribute("aria-label");
756 if (ariaLabel) return ariaLabel;
757
758 // aria-labelledby
759 const labelledBy = element.getAttribute("aria-labelledby");
760 if (labelledBy) {
761 const labelEl = document.querySelector(`#${CSS.escape(labelledBy)}`);
762 if (labelEl) return labelEl.textContent?.trim() || "";
763 }
764
765 // For inputs, check associated label
766 if (
767 element instanceof HTMLInputElement ||
768 element instanceof HTMLTextAreaElement ||
769 element instanceof HTMLSelectElement
770 ) {
771 // Check for label wrapping the input
772 const parentLabel = element.closest("label");
773 if (parentLabel) {
774 const text = parentLabel.textContent?.trim() || "";
775 return text;
776 }
777
778 // Check for label with for attribute
779 if (element.id) {
780 const label = document.querySelector(
781 `label[for="${CSS.escape(element.id)}"]`,
782 );
783 if (label) return label.textContent?.trim() || "";
784 }
785
786 // Fall back to placeholder
787 const placeholder = element.getAttribute("placeholder");
788 if (placeholder) return placeholder;
789 }
790
791 // alt text for images
792 if (element instanceof HTMLImageElement) {
793 return element.alt || "";
794 }
795
796 // title attribute
797 const title = element.getAttribute("title");
798 if (title) return title;
799
800 // Text content for buttons, links, etc.
801 const tag = element.tagName.toLowerCase();
802 if (["button", "a", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tag)) {
803 // Get text content excluding script/style elements
804 let text = "";
805 const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, {
806 acceptNode: (node) => {
807 const parent = node.parentElement;
808 if (
809 parent &&
810 ["SCRIPT", "STYLE", "NOSCRIPT"].includes(parent.tagName)
811 ) {
812 return NodeFilter.FILTER_REJECT;
813 }
814 return NodeFilter.FILTER_ACCEPT;
815 },
816 });
817 while (walker.nextNode()) {
818 text += walker.currentNode.textContent;
819 }
820 text = text.trim();
821 // Filter out CSS-looking content (starts with . or # followed by { or contains {)
822 if (text && /^\s*[.#]?[\w-]+\s*\{/.test(text)) {
823 return "";
824 }
825 return text;
826 }
827
828 return "";
829}
830
831/**
832 * Get element attributes for display
833 * @param {Element} element
834 * @returns {string[]}
835 */
836function getElementAttrs(element) {
837 const attrs = [];
838
839 // Disabled state
840 if (
841 element.hasAttribute("disabled") ||
842 element.getAttribute("aria-disabled") === "true"
843 ) {
844 attrs.push("disabled");
845 }
846
847 // Checked state for checkboxes/radios
848 if (
849 element instanceof HTMLInputElement &&
850 (element.type === "checkbox" || element.type === "radio") &&
851 element.checked
852 ) {
853 attrs.push("checked");
854 }
855
856 // Required
857 if (
858 element.hasAttribute("required") ||
859 element.getAttribute("aria-required") === "true"
860 ) {
861 attrs.push("required");
862 }
863
864 // Expanded/collapsed
865 const expanded = element.getAttribute("aria-expanded");
866 if (expanded === "true") attrs.push("expanded");
867 if (expanded === "false") attrs.push("collapsed");
868
869 // Readonly
870 if (element.hasAttribute("readonly")) {
871 attrs.push("readonly");
872 }
873
874 // Clickable (for non-obvious elements)
875 const tag = element.tagName.toLowerCase();
876 if (
877 !["a", "button", "input", "select", "textarea"].includes(tag) &&
878 (element.getAttribute("onclick") ||
879 element.getAttribute("role") === "button" ||
880 window.getComputedStyle(element).cursor === "pointer")
881 ) {
882 attrs.push("clickable");
883 }
884
885 return attrs;
886}
887
888/**
889 * Get element value
890 * @param {Element} element
891 * @returns {string|undefined}
892 */
893function getElementValue(element) {
894 if (element instanceof HTMLInputElement) {
895 if (element.type === "checkbox" || element.type === "radio") {
896 return; // Use checked attr instead
897 }
898 if (element.type === "password") {
899 return element.value ? "••••••" : "";
900 }
901 return element.value;
902 }
903
904 if (element instanceof HTMLTextAreaElement) {
905 return element.value;
906 }
907
908 if (element instanceof HTMLSelectElement) {
909 const selected = element.options[element.selectedIndex];
910 return selected ? selected.text : "";
911 }
912}
913
914/**
915 * Check if element should be included in snapshot
916 * @param {Element} element
917 * @returns {boolean}
918 */
919function shouldIncludeElement(element) {
920 // Skip hidden elements
921 const style = window.getComputedStyle(element);
922 if (style.display === "none" || style.visibility === "hidden") {
923 return false;
924 }
925
926 // Skip zero-size elements
927 const rect = element.getBoundingClientRect();
928 if (rect.width === 0 && rect.height === 0) {
929 return false;
930 }
931
932 // Skip script, style, noscript, template
933 const tag = element.tagName.toLowerCase();
934 if (
935 ["script", "style", "noscript", "template", "svg", "path"].includes(tag)
936 ) {
937 return false;
938 }
939
940 // Include interactive elements
941 if (["a", "button", "input", "textarea", "select"].includes(tag)) {
942 return true;
943 }
944
945 // Include elements with explicit roles
946 if (element.hasAttribute("role")) {
947 const role = element.getAttribute("role");
948 if (role && !["presentation", "none"].includes(role)) return true;
949 }
950
951 // Include headings
952 if (["h1", "h2", "h3", "h4", "h5", "h6"].includes(tag)) {
953 return true;
954 }
955
956 // Include landmarks
957 if (["nav", "main", "header", "footer", "aside", "form"].includes(tag)) {
958 return true;
959 }
960
961 // Include images with alt text
962 if (tag === "img" && element.getAttribute("alt")) {
963 return true;
964 }
965
966 // Include elements with onclick attribute
967 if (element.getAttribute("onclick")) {
968 return true;
969 }
970
971 // Include elements with aria-label
972 if (element.hasAttribute("aria-label")) {
973 return true;
974 }
975
976 // For cursor:pointer elements, only include if they:
977 // 1. Have a meaningful accessible name (not empty, not just whitespace)
978 // 2. Are not generic wrapper tags (div, span, g, rect, circle, etc.)
979 // 3. Or have tabindex making them keyboard-focusable
980 if (window.getComputedStyle(element).cursor === "pointer") {
981 const genericTags = [
982 "div",
983 "span",
984 "g",
985 "rect",
986 "circle",
987 "path",
988 "svg",
989 "text",
990 "br",
991 "hr",
992 "b",
993 "i",
994 "em",
995 "strong",
996 ];
997 if (genericTags.includes(tag)) {
998 // Only include generic clickable if it has tabindex or meaningful name
999 if (
1000 element.hasAttribute("tabindex") &&
1001 element.getAttribute("tabindex") !== "-1"
1002 ) {
1003 return true;
1004 }
1005 // Check for meaningful accessible name
1006 const name = getAccessibleName(element);
1007 if (name && name.length > 0 && name.length < 200 && !/^\s*$/.test(name)) {
1008 return true;
1009 }
1010 return false;
1011 }
1012 return true;
1013 }
1014
1015 return false;
1016}
1017
1018/**
1019 * @typedef {object} SnapOptions
1020 * @property {boolean} [forms] - Only form elements
1021 * @property {boolean} [links] - Only links
1022 * @property {boolean} [buttons] - Only buttons
1023 * @property {string} [text] - Filter by text content
1024 * @property {number} [near] - Elements near ref
1025 */
1026
1027/**
1028 * Generate snapshot with optional filtering
1029 * @param {SnapOptions} [options]
1030 * @returns {Snapshot}
1031 */
1032function generateSnapshot(options = {}) {
1033 const elements = [];
1034 let ref = 1;
1035
1036 // Clear old ref map
1037 currentRefMap = new Map();
1038
1039 // Collect all relevant elements
1040 const allElements = document.body.querySelectorAll("*");
1041
1042 for (const element of allElements) {
1043 if (!shouldIncludeElement(element)) continue;
1044
1045 const role = getElementRole(element);
1046 const name = getAccessibleName(element);
1047 const attrs = getElementAttrs(element);
1048 const value = getElementValue(element);
1049
1050 // Apply filters
1051 if (
1052 options.forms &&
1053 !["input", "textarea", "select", "checkbox", "radio"].includes(role) &&
1054 !role.startsWith("input[")
1055 ) {
1056 continue;
1057 }
1058
1059 if (options.links && role !== "link") continue;
1060 if (options.buttons && role !== "button") continue;
1061
1062 if (options.text) {
1063 const searchText = options.text.toLowerCase();
1064 if (
1065 !name.toLowerCase().includes(searchText) &&
1066 !(value && value.toLowerCase().includes(searchText))
1067 ) {
1068 continue;
1069 }
1070 }
1071
1072 const snapshotElement = new SnapshotElement({
1073 ref,
1074 role,
1075 name,
1076 value,
1077 attrs,
1078 domElement: element,
1079 });
1080
1081 elements.push(snapshotElement);
1082 currentRefMap.set(ref, element);
1083 ref++;
1084 }
1085
1086 // Handle "near" filter - find elements near a reference element
1087 if (options.near === undefined) {
1088 return new Snapshot(elements, window.location.href, document.title);
1089 }
1090
1091 const nearElement = currentRefMap.get(options.near);
1092 if (!nearElement) {
1093 return new Snapshot(elements, window.location.href, document.title);
1094 }
1095
1096 const nearRect = nearElement.getBoundingClientRect();
1097 const threshold = 200; // pixels
1098
1099 const filtered = elements.filter((el) => {
1100 const rect = el.domElement.getBoundingClientRect();
1101 const distance = Math.hypot(
1102 rect.left - nearRect.left,
1103 rect.top - nearRect.top,
1104 );
1105 return distance < threshold;
1106 });
1107
1108 return new Snapshot(filtered, window.location.href, document.title);
1109}
1110
1111// ============================================================================
1112// JS API - Main Functions
1113// ============================================================================
1114
1115/**
1116 * Click an element
1117 * @param {string|number} selector - Element selector or ref number
1118 * @param {SelectorType|{double?: boolean}} [selectorTypeOrOptions='css']
1119 * @returns {Promise<{clicked: string|number}>}
1120 */
1121async function click(selector, selectorTypeOrOptions = "css") {
1122 let selectorType = "css";
1123 let double = false;
1124
1125 if (typeof selectorTypeOrOptions === "object") {
1126 double = selectorTypeOrOptions.double || false;
1127 } else {
1128 selectorType = selectorTypeOrOptions;
1129 }
1130
1131 const element = /** @type {HTMLElement} */ (
1132 find(selector, /** @type {SelectorType} */ (selectorType))
1133 );
1134 moveCursorToElement(element, true);
1135 await new Promise((resolve) => setTimeout(resolve, 300));
1136
1137 if (double) {
1138 element.dispatchEvent(
1139 new MouseEvent("dblclick", {
1140 view: window,
1141 bubbles: true,
1142 cancelable: true,
1143 }),
1144 );
1145 } else {
1146 element.click();
1147 }
1148
1149 if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") {
1150 lastFocusedInput = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (
1151 element
1152 );
1153 }
1154
1155 return { clicked: selector };
1156}
1157
1158/**
1159 * Type text into an element
1160 * @param {string|number} selector - Element selector or ref number
1161 * @param {string} text - Text to type
1162 * @param {SelectorType|{clear?: boolean}} [selectorTypeOrOptions='css']
1163 * @returns {Promise<{typed: string, into: string|number}>}
1164 */
1165async function type(selector, text, selectorTypeOrOptions = "css") {
1166 let selectorType = "css";
1167 let clear = false;
1168
1169 if (typeof selectorTypeOrOptions === "object") {
1170 clear = selectorTypeOrOptions.clear || false;
1171 } else {
1172 selectorType = selectorTypeOrOptions;
1173 }
1174
1175 const element = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (
1176 find(selector, /** @type {SelectorType} */ (selectorType))
1177 );
1178
1179 if (element.tagName !== "INPUT" && element.tagName !== "TEXTAREA") {
1180 throw new Error(
1181 `Element is not an input field. Found: <${element.tagName.toLowerCase()}>`,
1182 );
1183 }
1184
1185 moveCursorToElement(element);
1186 await new Promise((resolve) => setTimeout(resolve, 300));
1187 element.focus();
1188 lastFocusedInput = element;
1189
1190 if (clear) {
1191 element.value = "";
1192 }
1193
1194 element.value = clear ? text : element.value + text;
1195 element.dispatchEvent(new Event("input", { bubbles: true }));
1196 element.dispatchEvent(new Event("change", { bubbles: true }));
1197
1198 return { typed: text, into: selector };
1199}
1200
1201/**
1202 * Hover over an element
1203 * @param {string|number} selector - Element selector or ref number
1204 * @param {SelectorType} [selectorType='css'] - Type of selector
1205 * @returns {Promise<{hovered: string|number}>}
1206 */
1207async function hover(selector, selectorType = "css") {
1208 const element = find(selector, selectorType);
1209 moveCursorToElement(element);
1210 await new Promise((resolve) => setTimeout(resolve, 300));
1211
1212 element.dispatchEvent(
1213 new MouseEvent("mouseenter", {
1214 view: window,
1215 bubbles: true,
1216 cancelable: true,
1217 }),
1218 );
1219 element.dispatchEvent(
1220 new MouseEvent("mouseover", {
1221 view: window,
1222 bubbles: true,
1223 cancelable: true,
1224 }),
1225 );
1226
1227 return { hovered: selector };
1228}
1229
1230/**
1231 * Drag from one element to another
1232 * @param {string|number} startSelector - Start element selector or ref
1233 * @param {string|number} endSelector - End element selector or ref
1234 * @param {SelectorType} [selectorType='css'] - Type of selector
1235 * @returns {Promise<{dragged: string|number, to: string|number}>}
1236 */
1237async function drag(startSelector, endSelector, selectorType = "css") {
1238 const startElement = find(startSelector, selectorType);
1239 const endElement = find(endSelector, selectorType);
1240
1241 const startRect = startElement.getBoundingClientRect();
1242 const endRect = endElement.getBoundingClientRect();
1243 const startX = startRect.left + startRect.width / 2;
1244 const startY = startRect.top + startRect.height / 2;
1245 const endX = endRect.left + endRect.width / 2;
1246 const endY = endRect.top + endRect.height / 2;
1247
1248 moveCursorToElement(startElement, true);
1249 await new Promise((resolve) => setTimeout(resolve, 300));
1250
1251 const dataTransfer = new DataTransfer();
1252 dataTransfer.effectAllowed = "all";
1253 dataTransfer.dropEffect = "move";
1254
1255 startElement.dispatchEvent(
1256 new MouseEvent("mousedown", {
1257 view: window,
1258 bubbles: true,
1259 cancelable: true,
1260 clientX: startX,
1261 clientY: startY,
1262 }),
1263 );
1264
1265 startElement.dispatchEvent(
1266 new DragEvent("dragstart", {
1267 view: window,
1268 bubbles: true,
1269 cancelable: true,
1270 clientX: startX,
1271 clientY: startY,
1272 dataTransfer,
1273 }),
1274 );
1275
1276 if (virtualCursor) {
1277 virtualCursor.style.transition = "all 0.8s cubic-bezier(0.4, 0, 0.2, 1)";
1278 moveCursorToElement(endElement);
1279 await new Promise((resolve) => setTimeout(resolve, 800));
1280 }
1281
1282 endElement.dispatchEvent(
1283 new DragEvent("dragenter", {
1284 view: window,
1285 bubbles: true,
1286 cancelable: true,
1287 clientX: endX,
1288 clientY: endY,
1289 dataTransfer,
1290 }),
1291 );
1292
1293 endElement.dispatchEvent(
1294 new DragEvent("dragover", {
1295 view: window,
1296 bubbles: true,
1297 cancelable: true,
1298 clientX: endX,
1299 clientY: endY,
1300 dataTransfer,
1301 }),
1302 );
1303
1304 await new Promise((resolve) => setTimeout(resolve, 50));
1305
1306 endElement.dispatchEvent(
1307 new DragEvent("drop", {
1308 view: window,
1309 bubbles: true,
1310 cancelable: true,
1311 clientX: endX,
1312 clientY: endY,
1313 dataTransfer,
1314 }),
1315 );
1316
1317 startElement.dispatchEvent(
1318 new DragEvent("dragend", {
1319 view: window,
1320 bubbles: true,
1321 cancelable: true,
1322 clientX: endX,
1323 clientY: endY,
1324 dataTransfer,
1325 }),
1326 );
1327
1328 if (virtualCursor) {
1329 virtualCursor.style.transition = "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)";
1330 }
1331
1332 return { dragged: startSelector, to: endSelector };
1333}
1334
1335/**
1336 * Select an option in a dropdown
1337 * @param {string|number} selector - Select element selector or ref
1338 * @param {string} option - Option value to select
1339 * @param {SelectorType} [selectorType='css'] - Type of selector
1340 * @returns {Promise<{selected: string, in: string|number}>}
1341 */
1342async function select(selector, option, selectorType = "css") {
1343 const element = /** @type {HTMLSelectElement} */ (
1344 find(selector, selectorType)
1345 );
1346 if (element.tagName !== "SELECT") {
1347 throw new Error(
1348 `Element is not a select field. Found: <${element.tagName.toLowerCase()}>`,
1349 );
1350 }
1351 moveCursorToElement(element, true);
1352 await new Promise((resolve) => setTimeout(resolve, 300));
1353 element.value = option;
1354 element.dispatchEvent(new Event("change", { bubbles: true }));
1355
1356 return { selected: option, in: selector };
1357}
1358
1359/**
1360 * Press a keyboard key
1361 * @param {string} keyName - Key to press
1362 * @returns {Snapshot}
1363 */
1364/** @type {Record<string, {code: string, keyCode: number}>} */
1365const SPECIAL_KEYS = {
1366 Enter: { code: "Enter", keyCode: 13 },
1367 Tab: { code: "Tab", keyCode: 9 },
1368 Escape: { code: "Escape", keyCode: 27 },
1369 Backspace: { code: "Backspace", keyCode: 8 },
1370 Delete: { code: "Delete", keyCode: 46 },
1371 ArrowUp: { code: "ArrowUp", keyCode: 38 },
1372 ArrowDown: { code: "ArrowDown", keyCode: 40 },
1373 ArrowLeft: { code: "ArrowLeft", keyCode: 37 },
1374 ArrowRight: { code: "ArrowRight", keyCode: 39 },
1375 Home: { code: "Home", keyCode: 36 },
1376 End: { code: "End", keyCode: 35 },
1377 PageUp: { code: "PageUp", keyCode: 33 },
1378 PageDown: { code: "PageDown", keyCode: 34 },
1379 " ": { code: "Space", keyCode: 32 },
1380 Space: { code: "Space", keyCode: 32 },
1381};
1382
1383const TEXT_INPUT_TYPES = new Set([
1384 "text",
1385 "search",
1386 "email",
1387 "password",
1388 "tel",
1389 "url",
1390 "number",
1391]);
1392
1393/**
1394 * Build keyboard event options for a key
1395 * @param {string} keyName
1396 * @returns {{key: string, code: string, keyCode: number, which: number, bubbles: boolean, cancelable: boolean, composed: boolean}}
1397 */
1398function buildKeyEventOptions(keyName) {
1399 let code = keyName;
1400 let keyCode = 0;
1401 let key = keyName;
1402
1403 if (SPECIAL_KEYS[keyName]) {
1404 code = SPECIAL_KEYS[keyName].code;
1405 keyCode = SPECIAL_KEYS[keyName].keyCode;
1406 key = keyName === "Space" ? " " : keyName;
1407 } else if (keyName.length === 1) {
1408 keyCode = keyName.codePointAt(0) || 0;
1409 if (keyName >= "a" && keyName <= "z") {
1410 code = `Key${keyName.toUpperCase()}`;
1411 keyCode = keyName.toUpperCase().codePointAt(0) || 0;
1412 } else if (keyName >= "A" && keyName <= "Z") {
1413 code = `Key${keyName}`;
1414 } else if (keyName >= "0" && keyName <= "9") {
1415 code = `Digit${keyName}`;
1416 }
1417 } else {
1418 code = keyName.charAt(0).toUpperCase() + keyName.slice(1);
1419 }
1420
1421 return {
1422 key,
1423 code,
1424 keyCode,
1425 which: keyCode,
1426 bubbles: true,
1427 cancelable: true,
1428 composed: true,
1429 };
1430}
1431
1432/**
1433 * Check if element is a text-editable input
1434 * @param {Element} element
1435 * @returns {boolean}
1436 */
1437function isTextInput(element) {
1438 if (element.tagName === "TEXTAREA") {
1439 return true;
1440 }
1441 if (element.tagName === "INPUT") {
1442 const type =
1443 /** @type {HTMLInputElement} */ (element).type?.toLowerCase() || "text";
1444 return TEXT_INPUT_TYPES.has(type);
1445 }
1446 return false;
1447}
1448
1449/**
1450 * Insert text at cursor position in input/textarea
1451 * @param {HTMLInputElement|HTMLTextAreaElement} element
1452 * @param {string} text
1453 */
1454function insertTextAtCursor(element, text) {
1455 const start = element.selectionStart || 0;
1456 const end = element.selectionEnd || 0;
1457 element.value =
1458 element.value.slice(0, start) + text + element.value.slice(end);
1459 element.selectionStart = element.selectionEnd = start + text.length;
1460 element.dispatchEvent(
1461 new Event("input", { bubbles: true, cancelable: true }),
1462 );
1463}
1464
1465/**
1466 * Try to submit the form containing an input
1467 * @param {HTMLInputElement} input
1468 * @returns {boolean} True if form was submitted
1469 */
1470function trySubmitForm(input) {
1471 const form = input.form;
1472 if (!form) {
1473 return false;
1474 }
1475
1476 // Try requestSubmit first (triggers submit event handlers)
1477 if (typeof form.requestSubmit === "function") {
1478 try {
1479 form.requestSubmit();
1480 return true;
1481 } catch {
1482 // requestSubmit can throw if form is invalid, fall through
1483 }
1484 }
1485
1486 // Fallback: find submit button and click it
1487 const submitButton = form.querySelector(
1488 'button[type="submit"], input[type="submit"], button:not([type])',
1489 );
1490 if (submitButton) {
1491 /** @type {HTMLButtonElement|HTMLInputElement} */ (submitButton).click();
1492 return true;
1493 }
1494
1495 // Last resort: dispatch submit event
1496 form.dispatchEvent(
1497 new SubmitEvent("submit", { bubbles: true, cancelable: true }),
1498 );
1499 return true;
1500}
1501
1502/**
1503 * Handle Tab key navigation
1504 * @param {Element} activeElement
1505 */
1506function handleTabKey(activeElement) {
1507 const focusableElements = /** @type {HTMLElement[]} */ ([
1508 ...document.querySelectorAll(
1509 'a[href], button, input, textarea, select, [tabindex]:not([tabindex="-1"])',
1510 ),
1511 ]).filter(
1512 (el) =>
1513 !(/** @type {HTMLButtonElement|HTMLInputElement} */ (el).disabled) &&
1514 el.offsetParent !== null,
1515 );
1516
1517 const currentIndex = focusableElements.indexOf(
1518 /** @type {HTMLElement} */ (activeElement),
1519 );
1520 if (currentIndex !== -1 && currentIndex < focusableElements.length - 1) {
1521 focusableElements[currentIndex + 1].focus();
1522 }
1523
1524 // Track newly focused input
1525 setTimeout(() => {
1526 const newActive = document.activeElement;
1527 if (
1528 newActive &&
1529 (newActive.tagName === "INPUT" || newActive.tagName === "TEXTAREA")
1530 ) {
1531 lastFocusedInput = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (
1532 newActive
1533 );
1534 }
1535 }, 50);
1536}
1537
1538/**
1539 * Handle character input in contentEditable
1540 * @param {Element} element
1541 * @param {string} char
1542 */
1543function insertCharInContentEditable(element, char) {
1544 const selection = window.getSelection();
1545 if (!selection || selection.rangeCount === 0) {
1546 return;
1547 }
1548
1549 const range = selection.getRangeAt(0);
1550 range.deleteContents();
1551 const textNode = document.createTextNode(char);
1552 range.insertNode(textNode);
1553 range.setStartAfter(textNode);
1554 range.setEndAfter(textNode);
1555 selection.removeAllRanges();
1556 selection.addRange(range);
1557 element.dispatchEvent(
1558 new Event("input", { bubbles: true, cancelable: true }),
1559 );
1560}
1561
1562/**
1563 * Simulate a key press
1564 * @param {string} keyName - Key to press (e.g., "Enter", "Tab", "a", "A")
1565 * @returns {{key: string}}
1566 */
1567function key(keyName) {
1568 // Get active element, restoring last focused input if needed
1569 let activeElement = document.activeElement || document.body;
1570 if (activeElement === document.body && lastFocusedInput) {
1571 activeElement = lastFocusedInput;
1572 lastFocusedInput.focus();
1573 }
1574
1575 const eventOptions = buildKeyEventOptions(keyName);
1576
1577 // Dispatch keydown
1578 const keydownPrevented = !activeElement.dispatchEvent(
1579 new KeyboardEvent("keydown", eventOptions),
1580 );
1581
1582 // Handle special keys
1583 if (!keydownPrevented) {
1584 if (keyName === "Enter") {
1585 if (activeElement.tagName === "INPUT" && isTextInput(activeElement)) {
1586 trySubmitForm(/** @type {HTMLInputElement} */ (activeElement));
1587 } else if (activeElement.tagName === "TEXTAREA") {
1588 insertTextAtCursor(
1589 /** @type {HTMLTextAreaElement} */ (activeElement),
1590 "\n",
1591 );
1592 }
1593 } else if (keyName === "Tab") {
1594 handleTabKey(activeElement);
1595 } else if (keyName.length === 1) {
1596 // Printable character
1597 if (isTextInput(activeElement)) {
1598 insertTextAtCursor(
1599 /** @type {HTMLInputElement|HTMLTextAreaElement} */ (activeElement),
1600 keyName,
1601 );
1602 activeElement.dispatchEvent(
1603 new Event("change", { bubbles: true, cancelable: true }),
1604 );
1605 } else if (
1606 /** @type {HTMLElement} */ (activeElement).contentEditable === "true"
1607 ) {
1608 insertCharInContentEditable(activeElement, keyName);
1609 }
1610 }
1611 }
1612
1613 // Dispatch keypress for printable characters
1614 if (keyName.length === 1) {
1615 activeElement.dispatchEvent(new KeyboardEvent("keypress", eventOptions));
1616 }
1617
1618 // Dispatch keyup
1619 activeElement.dispatchEvent(new KeyboardEvent("keyup", eventOptions));
1620
1621 return { key: keyName };
1622}
1623
1624/**
1625 * Get snapshot of the page
1626 * Returns full snapshot on first call, diff on subsequent calls (to save tokens)
1627 * @param {SnapOptions & {full?: boolean}} [options] - Filter options, use {full: true} for full snapshot
1628 * @returns {Snapshot|SnapshotDiff}
1629 */
1630function snap(options) {
1631 const snapshot = generateSnapshot(options);
1632
1633 // If full snapshot explicitly requested or options are set (filtering), return full
1634 if (
1635 options?.full ||
1636 (options && Object.keys(options).some((k) => k !== "full"))
1637 ) {
1638 if (!options || !Object.keys(options).some((k) => k !== "full")) {
1639 lastSnapshot = snapshot;
1640 }
1641 return snapshot;
1642 }
1643
1644 // First call or no previous snapshot - return full snapshot
1645 if (!lastSnapshot) {
1646 lastSnapshot = snapshot;
1647 return snapshot;
1648 }
1649
1650 // Return diff to save tokens
1651 const result = snapshot.diff(lastSnapshot);
1652 lastSnapshot = snapshot;
1653 return result;
1654}
1655
1656/**
1657 * Get console logs
1658 * @returns {{logs: ConsoleLog[]}}
1659 */
1660function logs() {
1661 return { logs: [...consoleLogs] };
1662}
1663
1664/**
1665 * Wait for DOM to stabilize (no mutations for a period)
1666 * @param {number} [stableMs=300] - How long DOM must be stable
1667 * @param {number} [timeout=10000] - Maximum wait time
1668 * @returns {Promise<{waited: string}>}
1669 */
1670function waitForIdle(stableMs = 300, timeout = 10_000) {
1671 return new Promise((resolve, reject) => {
1672 let lastMutationTime = Date.now();
1673 let resolved = false;
1674 const startTime = Date.now();
1675
1676 const observer = new MutationObserver(() => {
1677 lastMutationTime = Date.now();
1678 });
1679
1680 observer.observe(document.body, {
1681 childList: true,
1682 subtree: true,
1683 attributes: true,
1684 characterData: true,
1685 });
1686
1687 const checkStable = () => {
1688 if (resolved) {
1689 return;
1690 }
1691
1692 const now = Date.now();
1693 if (now - startTime > timeout) {
1694 resolved = true;
1695 observer.disconnect();
1696 reject(new Error("Timeout waiting for page to stabilize"));
1697 return;
1698 }
1699
1700 if (now - lastMutationTime >= stableMs) {
1701 resolved = true;
1702 observer.disconnect();
1703 resolve({ waited: "idle" });
1704 return;
1705 }
1706
1707 setTimeout(checkStable, 50);
1708 };
1709
1710 // Start checking after initial delay
1711 setTimeout(checkStable, 50);
1712 });
1713}
1714
1715/**
1716 * Wait for condition or timeout
1717 * @param {number|string} condition - ms to wait, "idle", "text", or "gone"
1718 * @param {string|number} [value] - Text to wait for, or stableMs for idle
1719 * @param {number} [timeout=10000] - Maximum wait time in ms
1720 * @returns {Promise<{waited: number|string}>}
1721 */
1722async function wait(condition, value, timeout = 10_000) {
1723 if (typeof condition === "number") {
1724 // Simple delay
1725 await new Promise((resolve) => setTimeout(resolve, condition));
1726 return { waited: condition };
1727 }
1728
1729 if (condition === "idle") {
1730 // Wait for DOM to stabilize
1731 const stableMs = typeof value === "number" ? value : 300;
1732 return waitForIdle(stableMs, timeout);
1733 }
1734
1735 if (condition === "text" && typeof value === "string") {
1736 // Wait for text to appear
1737 const startTime = Date.now();
1738 while (Date.now() - startTime < timeout) {
1739 if (document.body.textContent?.includes(value)) {
1740 return { waited: `text:${value}` };
1741 }
1742 await new Promise((resolve) => setTimeout(resolve, 100));
1743 }
1744 throw new Error(`Timeout waiting for text: "${value}"`);
1745 }
1746
1747 if (condition === "gone" && typeof value === "string") {
1748 // Wait for text to disappear
1749 const startTime = Date.now();
1750 while (Date.now() - startTime < timeout) {
1751 if (!document.body.textContent?.includes(value)) {
1752 return { waited: `gone:${value}` };
1753 }
1754 await new Promise((resolve) => setTimeout(resolve, 100));
1755 }
1756 throw new Error(`Timeout waiting for text to disappear: "${value}"`);
1757 }
1758
1759 throw new Error(`Invalid wait condition: ${condition}`);
1760}
1761
1762// ============================================================================
1763// Browser-level functions (delegate to background script)
1764// ============================================================================
1765
1766/** @type {number} */
1767let bgMessageCounter = 0;
1768
1769/**
1770 * Send a command to the background script and wait for response
1771 * @param {string} command
1772 * @param {object} [params={}]
1773 * @returns {Promise<any>}
1774 */
1775function sendToBackground(command, params = {}) {
1776 return new Promise((resolve, reject) => {
1777 const messageId = `content_${++bgMessageCounter}`;
1778
1779 /**
1780 * @param {any} response
1781 */
1782 function handleResponse(response) {
1783 if (response && response.bgMessageId === messageId) {
1784 browser.runtime.onMessage.removeListener(handleResponse);
1785 if (response.error) {
1786 reject(new Error(response.error));
1787 } else {
1788 resolve(response.result);
1789 }
1790 }
1791 }
1792
1793 browser.runtime.onMessage.addListener(handleResponse);
1794 browser.runtime.sendMessage({ command, params, bgMessageId: messageId });
1795
1796 // Timeout after 30 seconds
1797 setTimeout(() => {
1798 browser.runtime.onMessage.removeListener(handleResponse);
1799 reject(new Error("Background script timeout"));
1800 }, 30_000);
1801 });
1802}
1803
1804/**
1805 * Take a screenshot
1806 * @param {string} [path] - Output file path
1807 * @returns {Promise<string>} Path to saved screenshot
1808 */
1809async function shot(path) {
1810 const result = await sendToBackground("screenshot", { output_path: path });
1811 return result.screenshot_path || result.screenshot;
1812}
1813
1814/**
1815 * Create a new tab
1816 * @param {string} [url] - URL to open
1817 * @returns {Promise<{tabId: string, url: string}>} Tab info
1818 */
1819async function tab(url) {
1820 const result = await sendToBackground("new-tab", { url });
1821 return { tabId: result.tabId, url: result.url };
1822}
1823
1824/**
1825 * List all managed tabs
1826 * @returns {Promise<Array<{id: string, url: string, title: string, active: boolean}>>}
1827 */
1828async function tabs() {
1829 const result = await sendToBackground("list-tabs");
1830 return result.tabs;
1831}
1832
1833/**
1834 * Download a file
1835 * @param {string} url - URL to download
1836 * @param {string} [filename] - Filename (relative to downloads folder)
1837 * @returns {Promise<{downloaded: string, path: string}>}
1838 */
1839async function download(url, filename) {
1840 return sendToBackground("download", { url, filename });
1841}
1842
1843// ============================================================================
1844// Message handling for background script commands
1845// ============================================================================
1846
1847/**
1848 * Serialize result for transport
1849 * @param {any} value
1850 * @returns {any}
1851 */
1852function serializeResult(value) {
1853 if (value === null || value === undefined) {
1854 return value;
1855 }
1856
1857 // Handle Snapshot and SnapshotElement
1858 if (value instanceof Snapshot || value instanceof SnapshotElement) {
1859 return value.toJSON();
1860 }
1861
1862 // Handle arrays
1863 if (Array.isArray(value)) {
1864 return value.map((v) => serializeResult(v));
1865 }
1866
1867 // Handle objects with toJSON
1868 if (typeof value === "object" && typeof value.toJSON === "function") {
1869 return value.toJSON();
1870 }
1871
1872 // Try structuredClone for other objects
1873 try {
1874 return structuredClone(value);
1875 } catch {
1876 return String(value);
1877 }
1878}
1879
1880/**
1881 * Handle exec command - execute JavaScript with API available
1882 * @param {string} code - JavaScript code to execute
1883 * @returns {Promise<{result: any}>}
1884 */
1885async function handleExec(code) {
1886 // Create async function to allow await in code
1887 const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
1888
1889 // Wrap code to auto-return the last expression if no explicit return
1890 let wrappedCode = code.trim();
1891 if (!wrappedCode.includes("return ") && !wrappedCode.includes("return;")) {
1892 // Find the last statement and return it
1893 const lines = wrappedCode.split("\n");
1894 const lastLine = lines.pop()?.trim() || "";
1895 if (lastLine && !lastLine.endsWith(";")) {
1896 // Last line is an expression - return it
1897 wrappedCode = [...lines, `return (${lastLine})`].join("\n");
1898 } else if (lastLine) {
1899 // Last line ends with semicolon - try to return it anyway
1900 const expr = lastLine.slice(0, -1).trim();
1901 if (
1902 expr &&
1903 !expr.startsWith("const ") &&
1904 !expr.startsWith("let ") &&
1905 !expr.startsWith("var ")
1906 ) {
1907 wrappedCode = [...lines, `return (${expr})`].join("\n");
1908 }
1909 }
1910 }
1911
1912 // Make API functions available in the execution context
1913 const fn = new AsyncFunction(
1914 // Content script functions
1915 "click",
1916 "type",
1917 "hover",
1918 "drag",
1919 "select",
1920 "key",
1921 "snap",
1922 "logs",
1923 "find",
1924 "wait",
1925 // Background script functions
1926 "shot",
1927 "tab",
1928 "tabs",
1929 "download",
1930 wrappedCode,
1931 );
1932
1933 const result = await fn(
1934 // Content script functions
1935 click,
1936 type,
1937 hover,
1938 drag,
1939 select,
1940 key,
1941 snap,
1942 logs,
1943 find,
1944 wait,
1945 // Background script functions
1946 shot,
1947 tab,
1948 tabs,
1949 download,
1950 );
1951
1952 return { result: serializeResult(result) };
1953}
1954
1955browser.runtime.onMessage.addListener((message, _sender, _sendResponse) => {
1956 const { command, params, messageId } = message;
1957
1958 // Skip if this is a response to a background script request
1959 if (message.bgMessageId) {
1960 return false;
1961 }
1962
1963 if (!virtualCursor && command !== "exec") {
1964 initializeVirtualCursor();
1965 }
1966
1967 /** */
1968 async function executeCommand() {
1969 try {
1970 let result;
1971
1972 switch (command) {
1973 case "exec": {
1974 result = await handleExec(params.code);
1975 break;
1976 }
1977 default: {
1978 throw new Error(`Unknown command: ${command}`);
1979 }
1980 }
1981
1982 browser.runtime.sendMessage({ messageId, result, success: true });
1983 } catch (error) {
1984 browser.runtime.sendMessage({
1985 messageId,
1986 error: error instanceof Error ? error.message : String(error),
1987 success: false,
1988 });
1989 }
1990 }
1991
1992 executeCommand();
1993 return true;
1994});