This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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});