hypha-debugger 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -446,6 +446,196 @@ class DebugOverlay {
446
446
  }
447
447
  }
448
448
 
449
+ /**
450
+ * Animated AI cursor overlay.
451
+ * Shows a smooth-moving cursor with click ripple animation.
452
+ * Adapted from @page-agent/page-controller (MIT License).
453
+ *
454
+ * The cursor is injected as a fixed overlay and listens for
455
+ * custom events dispatched by the page-controller actions.
456
+ */
457
+ // SVG cursor graphics (inlined to avoid external file dependencies)
458
+ const CURSOR_BORDER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none"><g><path d="M 15 42 L 15 36.99 Q 15 31.99 23.7 31.99 L 28.05 31.99 Q 32.41 31.99 32.41 21.99 L 32.41 17 Q 32.41 12 41.09 16.95 L 76.31 37.05 Q 85 42 76.31 46.95 L 41.09 67.05 Q 32.41 72 32.41 62.01 L 32.41 57.01 Q 32.41 52.01 23.7 52.01 L 19.35 52.01 Q 15 52.01 15 47.01 Z" fill="none" stroke="currentColor" stroke-width="6" stroke-miterlimit="10"/></g></svg>`;
459
+ const CURSOR_FILL_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g style="filter: drop-shadow(rgba(0, 0, 0, 0.3) 3px 4px 4px);"><path d="M 15 42 L 15 36.99 Q 15 31.99 23.7 31.99 L 28.05 31.99 Q 32.41 31.99 32.41 21.99 L 32.41 17 Q 32.41 12 41.09 16.95 L 76.31 37.05 Q 85 42 76.31 46.95 L 41.09 67.05 Q 32.41 72 32.41 62.01 L 32.41 57.01 Q 32.41 52.01 23.7 52.01 L 19.35 52.01 Q 15 52.01 15 47.01 Z" fill="#ffffff" stroke="none"/></g></svg>`;
460
+ const CURSOR_CSS = `
461
+ .hypha-cursor {
462
+ position: fixed;
463
+ width: 50px;
464
+ height: 50px;
465
+ pointer-events: none;
466
+ z-index: 2147483646;
467
+ transition: opacity 0.2s;
468
+ opacity: 0;
469
+ }
470
+ .hypha-cursor.visible {
471
+ opacity: 1;
472
+ }
473
+ .hypha-cursor-border {
474
+ position: absolute;
475
+ width: 100%;
476
+ height: 100%;
477
+ background: linear-gradient(45deg, rgb(57, 182, 255), rgb(189, 69, 251));
478
+ mask-image: var(--cursor-border);
479
+ -webkit-mask-image: var(--cursor-border);
480
+ mask-size: 100% 100%;
481
+ -webkit-mask-size: 100% 100%;
482
+ mask-repeat: no-repeat;
483
+ -webkit-mask-repeat: no-repeat;
484
+ transform-origin: center;
485
+ transform: rotate(-135deg) scale(1.2);
486
+ margin-left: -10px;
487
+ margin-top: -14px;
488
+ }
489
+ .hypha-cursor-fill {
490
+ position: absolute;
491
+ width: 100%;
492
+ height: 100%;
493
+ background-image: var(--cursor-fill);
494
+ background-size: 100% 100%;
495
+ background-repeat: no-repeat;
496
+ transform-origin: center;
497
+ transform: rotate(-135deg) scale(1.2);
498
+ margin-left: -10px;
499
+ margin-top: -14px;
500
+ }
501
+ .hypha-cursor-ripple {
502
+ position: absolute;
503
+ width: 100%;
504
+ height: 100%;
505
+ pointer-events: none;
506
+ margin-left: -50%;
507
+ margin-top: -50%;
508
+ }
509
+ .hypha-cursor-ripple::after {
510
+ content: '';
511
+ opacity: 0;
512
+ position: absolute;
513
+ inset: 0;
514
+ border: 3px solid rgba(57, 182, 255, 1);
515
+ border-radius: 50%;
516
+ }
517
+ .hypha-cursor.clicking .hypha-cursor-ripple::after {
518
+ animation: hypha-cursor-ripple 400ms ease-out forwards;
519
+ }
520
+ @keyframes hypha-cursor-ripple {
521
+ 0% { transform: scale(0); opacity: 1; }
522
+ 100% { transform: scale(2.5); opacity: 0; }
523
+ }
524
+ `;
525
+ class AICursor {
526
+ constructor() {
527
+ this.currentX = 0;
528
+ this.currentY = 0;
529
+ this.targetX = 0;
530
+ this.targetY = 0;
531
+ this.animating = false;
532
+ this.visible = false;
533
+ this.hideTimeout = null;
534
+ // Create container (not in Shadow DOM — needs to be on top of everything)
535
+ this.container = document.createElement("div");
536
+ this.container.id = "hypha-debugger-cursor";
537
+ this.container.setAttribute("data-browser-use-ignore", "true");
538
+ this.container.setAttribute("data-page-agent-ignore", "true");
539
+ // Inject styles
540
+ const style = document.createElement("style");
541
+ style.textContent = CURSOR_CSS;
542
+ this.container.appendChild(style);
543
+ // Create cursor element
544
+ this.cursor = document.createElement("div");
545
+ this.cursor.className = "hypha-cursor";
546
+ // Set SVG as CSS custom properties (data URIs for mask-image)
547
+ const borderDataUri = "url(\"data:image/svg+xml," +
548
+ encodeURIComponent(CURSOR_BORDER_SVG) +
549
+ '")';
550
+ const fillDataUri = "url(\"data:image/svg+xml," +
551
+ encodeURIComponent(CURSOR_FILL_SVG) +
552
+ '")';
553
+ this.cursor.style.setProperty("--cursor-border", borderDataUri);
554
+ this.cursor.style.setProperty("--cursor-fill", fillDataUri);
555
+ // Ripple layer (behind cursor)
556
+ const ripple = document.createElement("div");
557
+ ripple.className = "hypha-cursor-ripple";
558
+ this.cursor.appendChild(ripple);
559
+ // Fill layer (white arrow with shadow)
560
+ const fill = document.createElement("div");
561
+ fill.className = "hypha-cursor-fill";
562
+ this.cursor.appendChild(fill);
563
+ // Border layer (gradient)
564
+ const border = document.createElement("div");
565
+ border.className = "hypha-cursor-border";
566
+ this.cursor.appendChild(border);
567
+ this.container.appendChild(this.cursor);
568
+ document.body.appendChild(this.container);
569
+ // Listen for move/click events from actions
570
+ window.addEventListener("HyphaDebugger::MovePointerTo", ((event) => {
571
+ const { x, y } = event.detail;
572
+ this.moveTo(x, y);
573
+ }));
574
+ window.addEventListener("HyphaDebugger::ClickPointer", () => {
575
+ this.triggerClickAnimation();
576
+ });
577
+ }
578
+ moveTo(x, y) {
579
+ this.targetX = x;
580
+ this.targetY = y;
581
+ // Show cursor
582
+ if (!this.visible) {
583
+ this.visible = true;
584
+ this.currentX = x;
585
+ this.currentY = y;
586
+ this.cursor.style.left = `${x}px`;
587
+ this.cursor.style.top = `${y}px`;
588
+ this.cursor.classList.add("visible");
589
+ }
590
+ // Cancel any pending hide
591
+ if (this.hideTimeout) {
592
+ clearTimeout(this.hideTimeout);
593
+ this.hideTimeout = null;
594
+ }
595
+ // Start animation loop if not running
596
+ if (!this.animating) {
597
+ this.animating = true;
598
+ this.animateLoop();
599
+ }
600
+ }
601
+ animateLoop() {
602
+ const ease = 0.18;
603
+ const dx = this.targetX - this.currentX;
604
+ const dy = this.targetY - this.currentY;
605
+ if (Math.abs(dx) > 1 || Math.abs(dy) > 1) {
606
+ this.currentX += dx * ease;
607
+ this.currentY += dy * ease;
608
+ this.cursor.style.left = `${this.currentX}px`;
609
+ this.cursor.style.top = `${this.currentY}px`;
610
+ requestAnimationFrame(() => this.animateLoop());
611
+ }
612
+ else {
613
+ // Snap to target
614
+ this.currentX = this.targetX;
615
+ this.currentY = this.targetY;
616
+ this.cursor.style.left = `${this.currentX}px`;
617
+ this.cursor.style.top = `${this.currentY}px`;
618
+ this.animating = false;
619
+ // Auto-hide cursor after 2s of inactivity
620
+ this.hideTimeout = setTimeout(() => {
621
+ this.visible = false;
622
+ this.cursor.classList.remove("visible");
623
+ }, 2000);
624
+ }
625
+ }
626
+ triggerClickAnimation() {
627
+ this.cursor.classList.remove("clicking");
628
+ // Force reflow to restart CSS animation
629
+ void this.cursor.offsetHeight;
630
+ this.cursor.classList.add("clicking");
631
+ }
632
+ destroy() {
633
+ if (this.hideTimeout)
634
+ clearTimeout(this.hideTimeout);
635
+ this.container.remove();
636
+ }
637
+ }
638
+
449
639
  /**
450
640
  * Environment detection and page metadata collection.
451
641
  */
@@ -642,7 +832,7 @@ queryDom.__schema__ = {
642
832
  required: ["selector"],
643
833
  },
644
834
  };
645
- function clickElement(selector) {
835
+ function clickElement$1(selector) {
646
836
  const el = document.querySelector(selector);
647
837
  if (!el) {
648
838
  return { success: false, message: `No element found for selector: ${selector}` };
@@ -657,7 +847,7 @@ function clickElement(selector) {
657
847
  }));
658
848
  return { success: true, message: `Clicked element: ${selector}` };
659
849
  }
660
- clickElement.__schema__ = {
850
+ clickElement$1.__schema__ = {
661
851
  name: "clickElement",
662
852
  description: "Click a DOM element matching the CSS selector.",
663
853
  parameters: {
@@ -2043,29 +2233,79 @@ function generateSkillMd(serviceFunctions, serviceUrl) {
2043
2233
  "",
2044
2234
  "# Web Debugger Skill",
2045
2235
  "",
2046
- "This skill allows you to remotely debug and interact with a web page through a set of HTTP API endpoints.",
2236
+ "This skill allows you to remotely debug and interact with a web page through HTTP API endpoints.",
2047
2237
  "",
2048
- "## How to call functions",
2238
+ "## Recommended Workflow (Index-Based Interaction)",
2239
+ "",
2240
+ "The most reliable way to interact with a page is using the smart DOM analysis:",
2241
+ "",
2242
+ "### Step 1: Observe the page",
2243
+ "```bash",
2244
+ `curl '{SERVICE_URL}/get_browser_state'`,
2245
+ "```",
2246
+ "This returns all interactive elements indexed as `[0]`, `[1]`, `[2]`, etc.",
2247
+ "Elements are detected via smart heuristics: CSS cursor, ARIA roles, event listeners, tag names.",
2248
+ "Visual highlight labels are overlaid on the page for each detected element.",
2249
+ "",
2250
+ "Example output:",
2251
+ "```",
2252
+ "[0]<a aria-label=Home>Home />",
2253
+ "[1]<input placeholder=Search... />",
2254
+ "[2]<button>Sign In />",
2255
+ "[3]<select name=language>English />",
2256
+ "[4]<div data-scrollable=\"top=200, bottom=1500\">Content area />",
2257
+ "```",
2258
+ "",
2259
+ "### Step 2: Act on elements by index",
2260
+ "```bash",
2261
+ "# Click a button (e.g. [2] Sign In):",
2262
+ `curl -X POST '{SERVICE_URL}/click_element_by_index' \\`,
2263
+ ` -H 'Content-Type: application/json' -d '{"index": 2}'`,
2264
+ "",
2265
+ "# Type into an input (e.g. [1] Search):",
2266
+ `curl -X POST '{SERVICE_URL}/input_text' \\`,
2267
+ ` -H 'Content-Type: application/json' -d '{"index": 1, "text": "hello world"}'`,
2268
+ "",
2269
+ "# Select a dropdown option (e.g. [3] Language):",
2270
+ `curl -X POST '{SERVICE_URL}/select_option' \\`,
2271
+ ` -H 'Content-Type: application/json' -d '{"index": 3, "option_text": "French"}'`,
2049
2272
  "",
2050
- "All functions are available as HTTP endpoints. Use the service URL provided in the instructions.",
2273
+ "# Scroll down:",
2274
+ `curl -X POST '{SERVICE_URL}/scroll' \\`,
2275
+ ` -H 'Content-Type: application/json' -d '{"direction": "down"}'`,
2051
2276
  "",
2052
- "**GET request** (for functions with no required parameters):",
2277
+ "# Scroll a specific container (e.g. [4]):",
2278
+ `curl -X POST '{SERVICE_URL}/scroll' \\`,
2279
+ ` -H 'Content-Type: application/json' -d '{"direction": "down", "index": 4}'`,
2053
2280
  "```",
2054
- `curl '{SERVICE_URL}/get_page_info?_mode=last' -H 'Authorization: Bearer {TOKEN}'`,
2281
+ "",
2282
+ "### Step 3: Verify",
2283
+ "```bash",
2284
+ `curl '{SERVICE_URL}/take_screenshot'`,
2055
2285
  "```",
2056
2286
  "",
2057
- "**POST request** (for functions with parameters):",
2287
+ "### Remove visual highlights (optional, for clean screenshots)",
2288
+ "```bash",
2289
+ `curl '{SERVICE_URL}/remove_highlights'`,
2058
2290
  "```",
2059
- `curl -X POST '{SERVICE_URL}/query_dom?_mode=last' \\`,
2060
- ` -H 'Authorization: Bearer {TOKEN}' \\`,
2061
- ` -H 'Content-Type: application/json' \\`,
2062
- ` -d '{"selector": "button"}'`,
2291
+ "",
2292
+ "## CSS Selector-Based Functions (Alternative)",
2293
+ "",
2294
+ "You can also use CSS selectors directly for precise targeting:",
2295
+ "```bash",
2296
+ `curl -X POST '{SERVICE_URL}/click_element' \\`,
2297
+ ` -H 'Content-Type: application/json' -d '{"selector": "button.submit"}'`,
2298
+ "",
2299
+ `curl -X POST '{SERVICE_URL}/fill_input' \\`,
2300
+ ` -H 'Content-Type: application/json' -d '{"selector": "#email", "value": "user@example.com"}'`,
2063
2301
  "```",
2064
2302
  "",
2065
- "Replace `{SERVICE_URL}` and `{TOKEN}` with the actual values from the instruction block.",
2303
+ "## How to call functions",
2304
+ "",
2305
+ "All functions are available as HTTP endpoints. Replace `{SERVICE_URL}` with the actual service URL.",
2066
2306
  "",
2067
- "**Note:** The `_mode=last` query parameter ensures the latest debugger instance is used,",
2068
- "even if multiple sessions have connected to the same workspace.",
2307
+ "- **GET** for functions with no required parameters",
2308
+ "- **POST** with JSON body for functions with parameters",
2069
2309
  "",
2070
2310
  ].join("\n");
2071
2311
  // Build the function reference
@@ -2108,8 +2348,7 @@ function generateSkillMd(serviceFunctions, serviceUrl) {
2108
2348
  }
2109
2349
  functionDocs.push("**Example:**");
2110
2350
  functionDocs.push("```bash");
2111
- functionDocs.push(`curl -X POST '{SERVICE_URL}/${name}?_mode=last' \\`);
2112
- functionDocs.push(` -H 'Authorization: Bearer {TOKEN}' \\`);
2351
+ functionDocs.push(`curl -X POST '{SERVICE_URL}/${name}' \\`);
2113
2352
  functionDocs.push(` -H 'Content-Type: application/json' \\`);
2114
2353
  functionDocs.push(` -d '${JSON.stringify(exampleParams)}'`);
2115
2354
  functionDocs.push("```");
@@ -2117,7 +2356,7 @@ function generateSkillMd(serviceFunctions, serviceUrl) {
2117
2356
  else {
2118
2357
  functionDocs.push("**Example:**");
2119
2358
  functionDocs.push("```bash");
2120
- functionDocs.push(`curl '{SERVICE_URL}/${name}?_mode=last' -H 'Authorization: Bearer {TOKEN}'`);
2359
+ functionDocs.push(`curl '{SERVICE_URL}/${name}'`);
2121
2360
  functionDocs.push("```");
2122
2361
  }
2123
2362
  }
@@ -2126,7 +2365,7 @@ function generateSkillMd(serviceFunctions, serviceUrl) {
2126
2365
  functionDocs.push("");
2127
2366
  functionDocs.push("**Example:**");
2128
2367
  functionDocs.push("```bash");
2129
- functionDocs.push(`curl '{SERVICE_URL}/${name}?_mode=last' -H 'Authorization: Bearer {TOKEN}'`);
2368
+ functionDocs.push(`curl '{SERVICE_URL}/${name}'`);
2130
2369
  functionDocs.push("```");
2131
2370
  }
2132
2371
  functionDocs.push("");
@@ -2134,239 +2373,3022 @@ function generateSkillMd(serviceFunctions, serviceUrl) {
2134
2373
  const tips = [
2135
2374
  "## Tips",
2136
2375
  "",
2137
- "- **Start with `get_page_info`** to understand the page structure, URL, title, and viewport.",
2138
- "- **Use `query_dom`** with CSS selectors to find elements before clicking or filling them.",
2139
- "- **Use `take_screenshot`** to visually verify the page state.",
2376
+ "- **Start with `get_browser_state`** — it's the best way to understand what's on the page and what you can interact with.",
2377
+ "- **Prefer index-based interaction** (`click_element_by_index`, `input_text`, `select_option`) over CSS selectors indices are more reliable across dynamic pages.",
2378
+ "- **After each action, call `get_browser_state` again** element indices change when the DOM updates.",
2379
+ "- **Use `take_screenshot`** to visually verify the page state. Call `remove_highlights` first for a clean view.",
2140
2380
  "- **Use `execute_script`** for anything not covered by the built-in functions — it runs arbitrary JavaScript.",
2381
+ "- **Use `scroll`** with an element index to scroll inside a specific container (e.g. a chat window, sidebar).",
2141
2382
  "- **Use `get_page_info` with `include_logs=true`** to check for JavaScript errors or debug output.",
2142
2383
  "- **Use `get_react_tree`** if the page uses React — it gives you component names, props, and state.",
2143
2384
  "- All POST endpoints accept JSON body with the parameter names as keys.",
2144
- "- All endpoints require the `Authorization: Bearer {TOKEN}` header.",
2145
2385
  "",
2146
2386
  ].join("\n");
2147
2387
  return [frontmatter, intro, functionDocs.join("\n"), tips].join("\n");
2148
2388
  }
2149
2389
 
2150
2390
  /**
2151
- * Core debugger class: connects to Hypha and registers the debug service.
2391
+ * @file port from browser-use
2392
+ * @see https://github.com/browser-use/browser-use/commits/main/browser_use/dom/dom_tree/index.js
2393
+ * @match 0.5.9 d51b6e73daff7165fdd3e44debd667e7f5f7fdc5
2394
+ *
2395
+ * search @edit for all the changed lines.
2396
+ *
2397
+ * @edit export
2398
+ * @edit add interactiveBlacklist interactiveWhitelist
2399
+ * @edit adjustable opacity
2400
+ * @edit direct dom ref
2401
+ * @edit @workaround input.checked
2402
+ * @edit smaller zIndex for highlight
2403
+ * @edit no need for xpath
2404
+ * @edit add `extra` field for extra data
2405
+ * @edit scrollable element detection
2406
+ * @edit add `data-browser-use-ignore` attribute
2407
+ * @edit improve `sampleRect`, filter out rects with 0 area
2408
+ * @edit exclude aria-hidden elements
2409
+ * @edit make sure attributes exist for interactive candidates.
2152
2410
  */
2153
- class HyphaDebugger {
2154
- constructor(config) {
2155
- this.overlay = null;
2156
- this.server = null;
2157
- this.serviceInfo = null;
2158
- this.config = {
2159
- server_url: config.server_url,
2160
- workspace: config.workspace ?? "",
2161
- token: config.token ?? "",
2162
- service_id: config.service_id ?? "web-debugger",
2163
- service_name: config.service_name ?? "Web Debugger",
2164
- show_ui: config.show_ui ?? true,
2165
- visibility: config.visibility ?? "public",
2166
- };
2167
- }
2168
- async start() {
2169
- // Install console capture early
2170
- installConsoleCapture();
2171
- // Guard against double-injection
2172
- const w = window;
2173
- if (w.__HYPHA_DEBUGGER__?.instance) {
2174
- console.warn("[hypha-debugger] Already running, returning existing session.");
2175
- return w.__HYPHA_DEBUGGER__.session;
2176
- }
2177
- // Show UI if enabled
2178
- if (this.config.show_ui) {
2179
- this.overlay = new DebugOverlay();
2180
- this.overlay.setStatus("disconnected");
2181
- this.overlay.setInfo({ Status: "Connecting..." });
2182
- }
2183
- try {
2184
- // Get the connectToServer function
2185
- const connect = this.getConnectToServer();
2186
- // Connect to Hypha server
2187
- const connectConfig = {
2188
- server_url: this.config.server_url,
2189
- };
2190
- if (this.config.workspace)
2191
- connectConfig.workspace = this.config.workspace;
2192
- if (this.config.token)
2193
- connectConfig.token = this.config.token;
2194
- this.server = await connect(connectConfig);
2195
- // Register debug service
2196
- this.serviceInfo = await this.server.registerService(this.buildServiceDefinition());
2197
- // Update overlay and build session
2198
- const session = await this.updateSession();
2199
- if (this.overlay) {
2200
- this.overlay.addLog("Service registered", "result");
2201
- }
2202
- // Store globally
2203
- w.__HYPHA_DEBUGGER__ = w.__HYPHA_DEBUGGER__ ?? {};
2204
- w.__HYPHA_DEBUGGER__.instance = this;
2205
- return session;
2206
- }
2207
- catch (err) {
2208
- console.error("[hypha-debugger] Failed to start:", err);
2209
- if (this.overlay) {
2210
- this.overlay.setStatus("error");
2211
- this.overlay.setInfo({
2212
- Status: "Error",
2213
- Error: err.message ?? String(err),
2214
- });
2215
- }
2216
- throw err;
2217
- }
2218
- }
2219
- async destroy() {
2220
- try {
2221
- if (this.serviceInfo && this.server) {
2222
- await this.server.unregisterService(this.serviceInfo.id);
2223
- }
2224
- }
2225
- catch {
2226
- // Ignore unregister errors on cleanup
2227
- }
2228
- this.overlay?.destroy();
2229
- this.overlay = null;
2230
- const w = window;
2231
- if (w.__HYPHA_DEBUGGER__) {
2232
- delete w.__HYPHA_DEBUGGER__.instance;
2233
- delete w.__HYPHA_DEBUGGER__.session;
2234
- }
2235
- }
2236
- /**
2237
- * Generate token, build service URL, update overlay instructions, and
2238
- * return a DebugSession.
2239
- */
2240
- async updateSession(extra) {
2241
- const fullServiceId = this.serviceInfo?.id ?? this.config.service_id;
2242
- const sessionToken = await this.server.generateToken();
2243
- const serviceUrl = this.buildServiceUrl(fullServiceId);
2244
- const workspace = this.server.config?.workspace ?? "";
2245
- if (this.overlay) {
2246
- this.overlay.setStatus("connected");
2247
- this.overlay.setInfo({
2248
- Status: "Connected",
2249
- Server: this.config.server_url,
2250
- ...extra,
2251
- });
2252
- this.overlay.setInstructions(this.buildInstructionBlock(serviceUrl, sessionToken));
2253
- }
2254
- console.log(`[hypha-debugger] Service URL: ${serviceUrl}`);
2255
- console.log(`[hypha-debugger] Token: ${sessionToken}`);
2256
- console.log(`[hypha-debugger] Test:\n curl '${serviceUrl}/get_page_info?_mode=last' -H 'Authorization: Bearer ${sessionToken}'`);
2257
- const session = {
2258
- service_id: fullServiceId,
2259
- workspace,
2260
- server: this.server,
2261
- service_url: serviceUrl,
2262
- token: sessionToken,
2263
- destroy: () => this.destroy(),
2264
- };
2265
- // Always update global session
2266
- const w = window;
2267
- w.__HYPHA_DEBUGGER__ = w.__HYPHA_DEBUGGER__ ?? {};
2268
- w.__HYPHA_DEBUGGER__.session = session;
2269
- return session;
2270
- }
2271
- /**
2272
- * Build a stable, predictable service URL.
2273
- * Strips the clientId prefix so the URL uses only the bare service name.
2274
- * Callers append ?_mode=last to resolve the most recent instance.
2275
- */
2276
- buildServiceUrl(serviceId) {
2277
- const base = this.config.server_url.replace(/\/+$/, "");
2278
- const slashIdx = serviceId.indexOf("/");
2279
- if (slashIdx !== -1) {
2280
- const workspace = serviceId.substring(0, slashIdx);
2281
- const svcPart = serviceId.substring(slashIdx + 1);
2282
- // Strip clientId: "abc123:web-debugger" → "web-debugger"
2283
- const colonIdx = svcPart.indexOf(":");
2284
- const svcName = colonIdx !== -1 ? svcPart.substring(colonIdx + 1) : svcPart;
2285
- return `${base}/${workspace}/services/${svcName}`;
2286
- }
2287
- return `${base}/services/${serviceId}`;
2288
- }
2289
- getHyphaModule() {
2290
- // Check the static import (works when hypha-rpc is bundled or npm-installed)
2291
- if (hyphaRpc.connectToServer)
2292
- return hyphaRpc;
2293
- // hypha-rpc re-exports under a namespace
2294
- if (hyphaRpc.hyphaWebsocketClient?.connectToServer)
2295
- return hyphaRpc.hyphaWebsocketClient;
2296
- // Fall back to global (when hypha-rpc loaded via separate script tag)
2297
- const w = window;
2298
- if (w.hyphaWebsocketClient?.connectToServer)
2299
- return w.hyphaWebsocketClient;
2300
- throw new Error("hypha-rpc not found. Install it via npm or load it via: " +
2301
- '<script src="https://cdn.jsdelivr.net/npm/hypha-rpc@0.20.97/dist/hypha-rpc-websocket.min.js"></script>');
2302
- }
2303
- getConnectToServer() {
2304
- return this.getHyphaModule().connectToServer;
2305
- }
2306
- buildServiceDefinition() {
2307
- return {
2308
- id: this.config.service_id,
2309
- name: this.config.service_name,
2310
- type: "debugger",
2311
- description: "Remote web page debugger. Allows inspecting DOM, taking screenshots, executing JavaScript, and interacting with the page.",
2312
- config: {
2313
- visibility: this.config.visibility,
2314
- },
2315
- get_page_info: this.wrapFn(getPageInfo, "get_page_info"),
2316
- get_html: this.wrapFn(getHtml, "get_html"),
2317
- query_dom: this.wrapFn(queryDom, "query_dom"),
2318
- click_element: this.wrapFn(clickElement, "click_element"),
2319
- fill_input: this.wrapFn(fillInput, "fill_input"),
2320
- scroll_to: this.wrapFn(scrollTo, "scroll_to"),
2321
- take_screenshot: this.wrapFn(takeScreenshot, "take_screenshot"),
2322
- execute_script: this.wrapFn(executeScript, "execute_script"),
2323
- navigate: this.wrapFn(navigate, "navigate"),
2324
- get_react_tree: this.wrapFn(getReactTree, "get_react_tree"),
2325
- get_skill_md: this.wrapFn(this.createGetSkillMd(), "get_skill_md"),
2326
- };
2327
- }
2328
- createGetSkillMd() {
2329
- const fn = () => {
2330
- // Build a schema-only map (avoid calling buildServiceDefinition which would recurse)
2331
- const schemaFns = {};
2332
- const fns = {
2333
- get_page_info: getPageInfo, get_html: getHtml,
2334
- query_dom: queryDom, click_element: clickElement, fill_input: fillInput,
2335
- scroll_to: scrollTo, take_screenshot: takeScreenshot,
2336
- execute_script: executeScript, navigate: navigate,
2337
- get_react_tree: getReactTree,
2338
- };
2339
- for (const [name, f] of Object.entries(fns)) {
2340
- if (f.__schema__)
2341
- schemaFns[name] = f;
2342
- }
2343
- this.serviceInfo
2344
- ? this.buildServiceUrl(this.serviceInfo.id ?? this.config.service_id)
2345
- : "{SERVICE_URL}";
2346
- return generateSkillMd(schemaFns);
2347
- };
2348
- fn.__schema__ = {
2349
- name: "getSkillMd",
2350
- description: "Get the SKILL.md document describing all available debugger functions, their parameters, and usage examples. Follows the agentskills.io specification.",
2351
- parameters: {
2352
- type: "object",
2353
- properties: {},
2354
- },
2355
- };
2356
- return fn;
2357
- }
2358
- /** Build the instruction block for the overlay panel. */
2359
- buildInstructionBlock(serviceUrl, token) {
2360
- return [
2361
- `SERVICE_URL="${serviceUrl}"`,
2362
- `TOKEN="${token}"`,
2363
- ``,
2364
- `# Quick test:`,
2365
- `curl "$SERVICE_URL/get_page_info?_mode=last" -H "Authorization: Bearer $TOKEN"`,
2366
- ``,
2367
- `# Full API docs:`,
2368
- `curl "$SERVICE_URL/get_skill_md?_mode=last" -H "Authorization: Bearer $TOKEN"`,
2369
- ].join("\n");
2411
+
2412
+ var domTree = (
2413
+ args = {
2414
+ doHighlightElements: true,
2415
+ focusHighlightIndex: -1,
2416
+ viewportExpansion: 0,
2417
+ debugMode: false,
2418
+
2419
+ /**
2420
+ * @edit
2421
+ */
2422
+ /** @type {Element[]} */
2423
+ interactiveBlacklist: [],
2424
+ /** @type {Element[]} */
2425
+ interactiveWhitelist: [],
2426
+ highlightOpacity: 0.1,
2427
+ highlightLabelOpacity: 0.5,
2428
+ }
2429
+ ) => {
2430
+ /**
2431
+ * @edit
2432
+ */
2433
+ const { interactiveBlacklist, interactiveWhitelist, highlightOpacity, highlightLabelOpacity } =
2434
+ args;
2435
+
2436
+ const { doHighlightElements, focusHighlightIndex, viewportExpansion, debugMode } = args;
2437
+ let highlightIndex = 0; // Reset highlight index
2438
+
2439
+ /**
2440
+ * @edit add `extra` field for extra data
2441
+ */
2442
+ const extraData = new WeakMap();
2443
+ function addExtraData(element, data) {
2444
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) return
2445
+ extraData.set(element, { ...extraData.get(element), ...data });
2446
+ }
2447
+
2448
+ // Add caching mechanisms at the top level
2449
+ const DOM_CACHE = {
2450
+ boundingRects: new WeakMap(),
2451
+ clientRects: new WeakMap(),
2452
+ computedStyles: new WeakMap(),
2453
+ clearCache: () => {
2454
+ DOM_CACHE.boundingRects = new WeakMap();
2455
+ DOM_CACHE.clientRects = new WeakMap();
2456
+ DOM_CACHE.computedStyles = new WeakMap();
2457
+ },
2458
+ };
2459
+
2460
+ /**
2461
+ * Gets the cached bounding rect for an element.
2462
+ *
2463
+ * @param {HTMLElement} element - The element to get the bounding rect for.
2464
+ * @returns {DOMRect | null} The cached bounding rect, or null if the element is not found.
2465
+ */
2466
+ function getCachedBoundingRect(element) {
2467
+ if (!element) return null
2468
+
2469
+ if (DOM_CACHE.boundingRects.has(element)) {
2470
+ return DOM_CACHE.boundingRects.get(element)
2471
+ }
2472
+
2473
+ const rect = element.getBoundingClientRect();
2474
+
2475
+ if (rect) {
2476
+ DOM_CACHE.boundingRects.set(element, rect);
2477
+ }
2478
+ return rect
2479
+ }
2480
+
2481
+ /**
2482
+ * Gets the cached computed style for an element.
2483
+ *
2484
+ * @param {HTMLElement} element - The element to get the computed style for.
2485
+ * @returns {CSSStyleDeclaration | null} The cached computed style, or null if the element is not found.
2486
+ */
2487
+ function getCachedComputedStyle(element) {
2488
+ if (!element) return null
2489
+
2490
+ if (DOM_CACHE.computedStyles.has(element)) {
2491
+ return DOM_CACHE.computedStyles.get(element)
2492
+ }
2493
+
2494
+ const style = window.getComputedStyle(element);
2495
+
2496
+ if (style) {
2497
+ DOM_CACHE.computedStyles.set(element, style);
2498
+ }
2499
+ return style
2500
+ }
2501
+
2502
+ /**
2503
+ * Gets the cached client rects for an element.
2504
+ *
2505
+ * @param {HTMLElement} element - The element to get the client rects for.
2506
+ * @returns {DOMRectList | null} The cached client rects, or null if the element is not found.
2507
+ */
2508
+ function getCachedClientRects(element) {
2509
+ if (!element) return null
2510
+
2511
+ if (DOM_CACHE.clientRects.has(element)) {
2512
+ return DOM_CACHE.clientRects.get(element)
2513
+ }
2514
+
2515
+ const rects = element.getClientRects();
2516
+
2517
+ if (rects) {
2518
+ DOM_CACHE.clientRects.set(element, rects);
2519
+ }
2520
+ return rects
2521
+ }
2522
+
2523
+ /**
2524
+ * Hash map of DOM nodes indexed by their highlight index.
2525
+ *
2526
+ * @type {Object<string, any>}
2527
+ */
2528
+ const DOM_HASH_MAP = {};
2529
+
2530
+ const ID = { current: 0 };
2531
+
2532
+ const HIGHLIGHT_CONTAINER_ID = 'playwright-highlight-container';
2533
+
2534
+ // // Initialize once and reuse
2535
+ // const viewportObserver = new IntersectionObserver(
2536
+ // (entries) => {
2537
+ // entries.forEach(entry => {
2538
+ // elementVisibilityMap.set(entry.target, entry.isIntersecting);
2539
+ // });
2540
+ // },
2541
+ // { rootMargin: `${viewportExpansion}px` }
2542
+ // );
2543
+
2544
+ /**
2545
+ * Highlights an element in the DOM and returns the index of the next element.
2546
+ *
2547
+ * @param {HTMLElement} element - The element to highlight.
2548
+ * @param {number} index - The index of the element.
2549
+ * @param {HTMLElement | null} parentIframe - The parent iframe node.
2550
+ * @returns {number} The index of the next element.
2551
+ */
2552
+ function highlightElement(element, index, parentIframe = null) {
2553
+ if (!element) return index
2554
+
2555
+ const overlays = [];
2556
+ /**
2557
+ * @type {HTMLElement | null}
2558
+ */
2559
+ let label = null;
2560
+ let labelWidth = 20;
2561
+ let labelHeight = 16;
2562
+ let cleanupFn = null;
2563
+
2564
+ try {
2565
+ // Create or get highlight container
2566
+ let container = document.getElementById(HIGHLIGHT_CONTAINER_ID);
2567
+ if (!container) {
2568
+ container = document.createElement('div');
2569
+ container.id = HIGHLIGHT_CONTAINER_ID;
2570
+ container.style.position = 'fixed';
2571
+ container.style.pointerEvents = 'none';
2572
+ container.style.top = '0';
2573
+ container.style.left = '0';
2574
+ container.style.width = '100%';
2575
+ container.style.height = '100%';
2576
+
2577
+ /**
2578
+ * @edit smaller zIndex for highlight
2579
+ */
2580
+ // Use the maximum valid value in zIndex to ensure the element is not blocked by overlapping elements.
2581
+ // container.style.zIndex = "2147483647";
2582
+ container.style.zIndex = '2147483640';
2583
+
2584
+ container.style.backgroundColor = 'transparent';
2585
+ document.body.appendChild(container);
2586
+ }
2587
+
2588
+ // Get element client rects
2589
+ const rects = element.getClientRects(); // Use getClientRects()
2590
+
2591
+ if (!rects || rects.length === 0) return index // Exit if no rects
2592
+
2593
+ // Generate a color based on the index
2594
+ const colors = [
2595
+ '#FF0000',
2596
+ '#00FF00',
2597
+ '#0000FF',
2598
+ '#FFA500',
2599
+ '#800080',
2600
+ '#008080',
2601
+ '#FF69B4',
2602
+ '#4B0082',
2603
+ '#FF4500',
2604
+ '#2E8B57',
2605
+ '#DC143C',
2606
+ '#4682B4',
2607
+ ];
2608
+ const colorIndex = index % colors.length;
2609
+ let baseColor = colors[colorIndex];
2610
+
2611
+ /**
2612
+ * @edit adjustable opacity
2613
+ */
2614
+ // const backgroundColor = baseColor + "1A"; // 10% opacity version of the color
2615
+ const backgroundColor =
2616
+ baseColor +
2617
+ Math.floor(highlightOpacity * 255)
2618
+ .toString(16)
2619
+ .padStart(2, '0');
2620
+ baseColor =
2621
+ baseColor +
2622
+ Math.floor(highlightLabelOpacity * 255)
2623
+ .toString(16)
2624
+ .padStart(2, '0');
2625
+
2626
+ // Get iframe offset if necessary
2627
+ let iframeOffset = { x: 0, y: 0 };
2628
+ if (parentIframe) {
2629
+ const iframeRect = parentIframe.getBoundingClientRect(); // Keep getBoundingClientRect for iframe offset
2630
+ iframeOffset.x = iframeRect.left;
2631
+ iframeOffset.y = iframeRect.top;
2632
+ }
2633
+
2634
+ // Create fragment to hold overlay elements
2635
+ const fragment = document.createDocumentFragment();
2636
+
2637
+ // Create highlight overlays for each client rect
2638
+ for (const rect of rects) {
2639
+ if (rect.width === 0 || rect.height === 0) continue // Skip empty rects
2640
+
2641
+ const overlay = document.createElement('div');
2642
+ overlay.style.position = 'fixed';
2643
+ overlay.style.border = `2px solid ${baseColor}`;
2644
+ overlay.style.backgroundColor = backgroundColor;
2645
+ overlay.style.pointerEvents = 'none';
2646
+ overlay.style.boxSizing = 'border-box';
2647
+
2648
+ const top = rect.top + iframeOffset.y;
2649
+ const left = rect.left + iframeOffset.x;
2650
+
2651
+ overlay.style.top = `${top}px`;
2652
+ overlay.style.left = `${left}px`;
2653
+ overlay.style.width = `${rect.width}px`;
2654
+ overlay.style.height = `${rect.height}px`;
2655
+
2656
+ fragment.appendChild(overlay);
2657
+ overlays.push({ element: overlay, initialRect: rect }); // Store overlay and its rect
2658
+ }
2659
+
2660
+ // Create and position a single label relative to the first rect
2661
+ const firstRect = rects[0];
2662
+ label = document.createElement('div');
2663
+ label.className = 'playwright-highlight-label';
2664
+ label.style.position = 'fixed';
2665
+ label.style.background = baseColor;
2666
+ label.style.color = 'white';
2667
+ label.style.padding = '1px 4px';
2668
+ label.style.borderRadius = '4px';
2669
+ label.style.fontSize = `${Math.min(12, Math.max(8, firstRect.height / 2))}px`;
2670
+ label.textContent = index.toString();
2671
+
2672
+ labelWidth = label.offsetWidth > 0 ? label.offsetWidth : labelWidth; // Update actual width if possible
2673
+ labelHeight = label.offsetHeight > 0 ? label.offsetHeight : labelHeight; // Update actual height if possible
2674
+
2675
+ const firstRectTop = firstRect.top + iframeOffset.y;
2676
+ const firstRectLeft = firstRect.left + iframeOffset.x;
2677
+
2678
+ let labelTop = firstRectTop + 2;
2679
+ let labelLeft = firstRectLeft + firstRect.width - labelWidth - 2;
2680
+
2681
+ // Adjust label position if first rect is too small
2682
+ if (firstRect.width < labelWidth + 4 || firstRect.height < labelHeight + 4) {
2683
+ labelTop = firstRectTop - labelHeight - 2;
2684
+ labelLeft = firstRectLeft + firstRect.width - labelWidth; // Align with right edge
2685
+ if (labelLeft < iframeOffset.x) labelLeft = firstRectLeft; // Prevent going off-left
2686
+ }
2687
+
2688
+ // Ensure label stays within viewport bounds slightly better
2689
+ labelTop = Math.max(0, Math.min(labelTop, window.innerHeight - labelHeight));
2690
+ labelLeft = Math.max(0, Math.min(labelLeft, window.innerWidth - labelWidth));
2691
+
2692
+ label.style.top = `${labelTop}px`;
2693
+ label.style.left = `${labelLeft}px`;
2694
+
2695
+ fragment.appendChild(label);
2696
+
2697
+ // Update positions on scroll/resize
2698
+ const updatePositions = () => {
2699
+ const newRects = element.getClientRects(); // Get fresh rects
2700
+ let newIframeOffset = { x: 0, y: 0 };
2701
+
2702
+ if (parentIframe) {
2703
+ const iframeRect = parentIframe.getBoundingClientRect(); // Keep getBoundingClientRect for iframe
2704
+ newIframeOffset.x = iframeRect.left;
2705
+ newIframeOffset.y = iframeRect.top;
2706
+ }
2707
+
2708
+ // Update each overlay
2709
+ overlays.forEach((overlayData, i) => {
2710
+ if (i < newRects.length) {
2711
+ // Check if rect still exists
2712
+ const newRect = newRects[i];
2713
+ const newTop = newRect.top + newIframeOffset.y;
2714
+ const newLeft = newRect.left + newIframeOffset.x;
2715
+
2716
+ overlayData.element.style.top = `${newTop}px`;
2717
+ overlayData.element.style.left = `${newLeft}px`;
2718
+ overlayData.element.style.width = `${newRect.width}px`;
2719
+ overlayData.element.style.height = `${newRect.height}px`;
2720
+ overlayData.element.style.display =
2721
+ newRect.width === 0 || newRect.height === 0 ? 'none' : 'block';
2722
+ } else {
2723
+ // If fewer rects now, hide extra overlays
2724
+ overlayData.element.style.display = 'none';
2725
+ }
2726
+ });
2727
+
2728
+ // If there are fewer new rects than overlays, hide the extras
2729
+ if (newRects.length < overlays.length) {
2730
+ for (let i = newRects.length; i < overlays.length; i++) {
2731
+ overlays[i].element.style.display = 'none';
2732
+ }
2733
+ }
2734
+
2735
+ // Update label position based on the first new rect
2736
+ if (label && newRects.length > 0) {
2737
+ const firstNewRect = newRects[0];
2738
+ const firstNewRectTop = firstNewRect.top + newIframeOffset.y;
2739
+ const firstNewRectLeft = firstNewRect.left + newIframeOffset.x;
2740
+
2741
+ let newLabelTop = firstNewRectTop + 2;
2742
+ let newLabelLeft = firstNewRectLeft + firstNewRect.width - labelWidth - 2;
2743
+
2744
+ if (firstNewRect.width < labelWidth + 4 || firstNewRect.height < labelHeight + 4) {
2745
+ newLabelTop = firstNewRectTop - labelHeight - 2;
2746
+ newLabelLeft = firstNewRectLeft + firstNewRect.width - labelWidth;
2747
+ if (newLabelLeft < newIframeOffset.x) newLabelLeft = firstNewRectLeft;
2748
+ }
2749
+
2750
+ // Ensure label stays within viewport bounds
2751
+ newLabelTop = Math.max(0, Math.min(newLabelTop, window.innerHeight - labelHeight));
2752
+ newLabelLeft = Math.max(0, Math.min(newLabelLeft, window.innerWidth - labelWidth));
2753
+
2754
+ label.style.top = `${newLabelTop}px`;
2755
+ label.style.left = `${newLabelLeft}px`;
2756
+ label.style.display = 'block';
2757
+ } else if (label) {
2758
+ // Hide label if element has no rects anymore
2759
+ label.style.display = 'none';
2760
+ }
2761
+ };
2762
+
2763
+ const throttleFunction = (func, delay) => {
2764
+ let lastCall = 0;
2765
+ return (...args) => {
2766
+ const now = performance.now();
2767
+ if (now - lastCall < delay) return
2768
+ lastCall = now;
2769
+ return func(...args)
2770
+ }
2771
+ };
2772
+
2773
+ const throttledUpdatePositions = throttleFunction(updatePositions, 16); // ~60fps
2774
+ window.addEventListener('scroll', throttledUpdatePositions, true);
2775
+ window.addEventListener('resize', throttledUpdatePositions);
2776
+
2777
+ // Add cleanup function
2778
+ cleanupFn = () => {
2779
+ window.removeEventListener('scroll', throttledUpdatePositions, true);
2780
+ window.removeEventListener('resize', throttledUpdatePositions);
2781
+ // Remove overlay elements if needed
2782
+ overlays.forEach((overlay) => overlay.element.remove());
2783
+ if (label) label.remove();
2784
+ };
2785
+
2786
+ // Then add fragment to container in one operation
2787
+ container.appendChild(fragment);
2788
+
2789
+ return index + 1
2790
+ } finally {
2791
+ // Store cleanup function for later use
2792
+ if (cleanupFn) {
2793
+ (window._highlightCleanupFunctions = window._highlightCleanupFunctions || []).push(
2794
+ cleanupFn
2795
+ );
2796
+ }
2797
+ }
2798
+ }
2799
+
2800
+ /**
2801
+ * @edit scrollable element detection
2802
+ * Checks if an element is scrollable. if so, return the scrollable distance on each direction (left right top bottom). if not return null.
2803
+ * @note distance smaller than 4 will be considered as not scrollable.
2804
+ * @note only check block elements, not inline elements.
2805
+ */
2806
+ function isScrollableElement(element) {
2807
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) {
2808
+ return null // Not a valid element
2809
+ }
2810
+
2811
+ const style = getCachedComputedStyle(element);
2812
+ if (!style) return null
2813
+
2814
+ // Check if the element is a block-level element
2815
+ const display = style.display;
2816
+ if (display === 'inline' || display === 'inline-block') {
2817
+ return null // Not a block-level element
2818
+ }
2819
+
2820
+ // Check overflow properties
2821
+ const overflowX = style.overflowX;
2822
+ const overflowY = style.overflowY;
2823
+
2824
+ // Check scrollable distances
2825
+ const scrollableX = overflowX === 'auto' || overflowX === 'scroll';
2826
+ const scrollableY = overflowY === 'auto' || overflowY === 'scroll';
2827
+
2828
+ if (!scrollableX && !scrollableY) {
2829
+ return null // Not scrollable in any direction
2830
+ }
2831
+
2832
+ const scrollWidth = element.scrollWidth - element.clientWidth;
2833
+ const scrollHeight = element.scrollHeight - element.clientHeight;
2834
+
2835
+ // Consider small distances as not scrollable
2836
+ const threshold = 4;
2837
+
2838
+ if (scrollWidth < threshold && scrollHeight < threshold) {
2839
+ return null // Not scrollable
2840
+ }
2841
+
2842
+ if (!scrollableY && scrollWidth < threshold) {
2843
+ return null // Not scrollable horizontally
2844
+ }
2845
+
2846
+ if (!scrollableX && scrollHeight < threshold) {
2847
+ return null // Not scrollable vertically
2848
+ }
2849
+
2850
+ const distanceToTop = element.scrollTop;
2851
+ const distanceToLeft = element.scrollLeft;
2852
+ const distanceToRight = element.scrollWidth - element.clientWidth - element.scrollLeft;
2853
+ const distanceToBottom = element.scrollHeight - element.clientHeight - element.scrollTop;
2854
+
2855
+ const scrollData = {
2856
+ top: distanceToTop,
2857
+ right: distanceToRight,
2858
+ bottom: distanceToBottom,
2859
+ left: distanceToLeft,
2860
+ };
2861
+
2862
+ // Store extra data for the element
2863
+ addExtraData(element, {
2864
+ scrollable: true,
2865
+ scrollData: scrollData,
2866
+ });
2867
+
2868
+ return scrollData
2869
+ }
2870
+
2871
+ /**
2872
+ * Checks if a text node is visible.
2873
+ *
2874
+ * @param {Text} textNode - The text node to check.
2875
+ * @returns {boolean} Whether the text node is visible.
2876
+ */
2877
+ function isTextNodeVisible(textNode) {
2878
+ try {
2879
+ // Special case: when viewportExpansion is -1, consider all text nodes as visible
2880
+ if (viewportExpansion === -1) {
2881
+ // Still check parent visibility for basic filtering
2882
+ const parentElement = textNode.parentElement;
2883
+ if (!parentElement) return false
2884
+
2885
+ try {
2886
+ return parentElement.checkVisibility({
2887
+ checkOpacity: true,
2888
+ checkVisibilityCSS: true,
2889
+ })
2890
+ } catch (e) {
2891
+ // Fallback if checkVisibility is not supported
2892
+ const style = window.getComputedStyle(parentElement);
2893
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
2894
+ }
2895
+ }
2896
+
2897
+ const range = document.createRange();
2898
+ range.selectNodeContents(textNode);
2899
+ const rects = range.getClientRects(); // Use getClientRects for Range
2900
+
2901
+ if (!rects || rects.length === 0) {
2902
+ return false
2903
+ }
2904
+
2905
+ let isAnyRectVisible = false;
2906
+ let isAnyRectInViewport = false;
2907
+
2908
+ for (const rect of rects) {
2909
+ // Check size
2910
+ if (rect.width > 0 && rect.height > 0) {
2911
+ isAnyRectVisible = true;
2912
+
2913
+ // Viewport check for this rect
2914
+ if (
2915
+ !(
2916
+ rect.bottom < -viewportExpansion ||
2917
+ rect.top > window.innerHeight + viewportExpansion ||
2918
+ rect.right < -viewportExpansion ||
2919
+ rect.left > window.innerWidth + viewportExpansion
2920
+ )
2921
+ ) {
2922
+ isAnyRectInViewport = true;
2923
+ break // Found a visible rect in viewport, no need to check others
2924
+ }
2925
+ }
2926
+ }
2927
+
2928
+ if (!isAnyRectVisible || !isAnyRectInViewport) {
2929
+ return false
2930
+ }
2931
+
2932
+ // Check parent visibility
2933
+ const parentElement = textNode.parentElement;
2934
+ if (!parentElement) return false
2935
+
2936
+ try {
2937
+ return parentElement.checkVisibility({
2938
+ checkOpacity: true,
2939
+ checkVisibilityCSS: true,
2940
+ })
2941
+ } catch (e) {
2942
+ // Fallback if checkVisibility is not supported
2943
+ const style = window.getComputedStyle(parentElement);
2944
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
2945
+ }
2946
+ } catch (e) {
2947
+ console.warn('Error checking text node visibility:', e);
2948
+ return false
2949
+ }
2950
+ }
2951
+
2952
+ /**
2953
+ * Checks if an element is accepted.
2954
+ *
2955
+ * @param {HTMLElement} element - The element to check.
2956
+ * @returns {boolean} Whether the element is accepted.
2957
+ */
2958
+ function isElementAccepted(element) {
2959
+ if (!element || !element.tagName) return false
2960
+
2961
+ // Always accept body and common container elements
2962
+ const alwaysAccept = new Set([
2963
+ 'body',
2964
+ 'div',
2965
+ 'main',
2966
+ 'article',
2967
+ 'section',
2968
+ 'nav',
2969
+ 'header',
2970
+ 'footer',
2971
+ ]);
2972
+ const tagName = element.tagName.toLowerCase();
2973
+
2974
+ if (alwaysAccept.has(tagName)) return true
2975
+
2976
+ const leafElementDenyList = new Set([
2977
+ 'svg',
2978
+ 'script',
2979
+ 'style',
2980
+ 'link',
2981
+ 'meta',
2982
+ 'noscript',
2983
+ 'template',
2984
+ ]);
2985
+
2986
+ return !leafElementDenyList.has(tagName)
2987
+ }
2988
+
2989
+ /**
2990
+ * Checks if an element is visible.
2991
+ *
2992
+ * @param {HTMLElement} element - The element to check.
2993
+ * @returns {boolean} Whether the element is visible.
2994
+ */
2995
+ function isElementVisible(element) {
2996
+ const style = getCachedComputedStyle(element);
2997
+ return (
2998
+ element.offsetWidth > 0 &&
2999
+ element.offsetHeight > 0 &&
3000
+ style?.visibility !== 'hidden' &&
3001
+ style?.display !== 'none'
3002
+ )
3003
+ }
3004
+
3005
+ /**
3006
+ * Checks if an element is interactive.
3007
+ *
3008
+ * lots of comments, and uncommented code - to show the logic of what we already tried
3009
+ *
3010
+ * One of the things we tried at the beginning was also to use event listeners, and other fancy class, style stuff -> what actually worked best was just combining most things with computed cursor style :)
3011
+ *
3012
+ * @param {HTMLElement} element - The element to check.
3013
+ */
3014
+ function isInteractiveElement(element) {
3015
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) {
3016
+ return false
3017
+ }
3018
+
3019
+ /**
3020
+ * @edit add interactiveBlacklist interactiveWhitelist
3021
+ */
3022
+ if (interactiveBlacklist.includes(element)) {
3023
+ return false // Skip blacklisted elements
3024
+ }
3025
+ if (interactiveWhitelist.includes(element)) {
3026
+ return true // Skip whitelisted elements
3027
+ }
3028
+
3029
+ // Cache the tagName and style lookups
3030
+ const tagName = element.tagName.toLowerCase();
3031
+ const style = getCachedComputedStyle(element);
3032
+
3033
+ // Define interactive cursors
3034
+ const interactiveCursors = new Set([
3035
+ 'pointer', // Link/clickable elements
3036
+ 'move', // Movable elements
3037
+ 'text', // Text selection
3038
+ 'grab', // Grabbable elements
3039
+ 'grabbing', // Currently grabbing
3040
+ 'cell', // Table cell selection
3041
+ 'copy', // Copy operation
3042
+ 'alias', // Alias creation
3043
+ 'all-scroll', // Scrollable content
3044
+ 'col-resize', // Column resize
3045
+ 'context-menu', // Context menu available
3046
+ 'crosshair', // Precise selection
3047
+ 'e-resize', // East resize
3048
+ 'ew-resize', // East-west resize
3049
+ 'help', // Help available
3050
+ 'n-resize', // North resize
3051
+ 'ne-resize', // Northeast resize
3052
+ 'nesw-resize', // Northeast-southwest resize
3053
+ 'ns-resize', // North-south resize
3054
+ 'nw-resize', // Northwest resize
3055
+ 'nwse-resize', // Northwest-southeast resize
3056
+ 'row-resize', // Row resize
3057
+ 's-resize', // South resize
3058
+ 'se-resize', // Southeast resize
3059
+ 'sw-resize', // Southwest resize
3060
+ 'vertical-text', // Vertical text selection
3061
+ 'w-resize', // West resize
3062
+ 'zoom-in', // Zoom in
3063
+ 'zoom-out', // Zoom out
3064
+ ]);
3065
+
3066
+ // Define non-interactive cursors
3067
+ const nonInteractiveCursors = new Set([
3068
+ 'not-allowed', // Action not allowed
3069
+ 'no-drop', // Drop not allowed
3070
+ 'wait', // Processing
3071
+ 'progress', // In progress
3072
+ 'initial', // Initial value
3073
+ 'inherit', // Inherited value
3074
+ //? Let's just include all potentially clickable elements that are not specifically blocked
3075
+ // 'none', // No cursor
3076
+ // 'default', // Default cursor
3077
+ // 'auto', // Browser default
3078
+ ]);
3079
+
3080
+ /**
3081
+ * Checks if an element has an interactive pointer.
3082
+ *
3083
+ * @param {HTMLElement} element - The element to check.
3084
+ * @returns {boolean} Whether the element has an interactive pointer.
3085
+ */
3086
+ function doesElementHaveInteractivePointer(element) {
3087
+ if (element.tagName.toLowerCase() === 'html') return false
3088
+
3089
+ if (style?.cursor && interactiveCursors.has(style.cursor)) return true
3090
+
3091
+ return false
3092
+ }
3093
+
3094
+ let isInteractiveCursor = doesElementHaveInteractivePointer(element);
3095
+
3096
+ // Genius fix for almost all interactive elements
3097
+ if (isInteractiveCursor) {
3098
+ return true
3099
+ }
3100
+
3101
+ const interactiveElements = new Set([
3102
+ 'a', // Links
3103
+ 'button', // Buttons
3104
+ 'input', // All input types (text, checkbox, radio, etc.)
3105
+ 'select', // Dropdown menus
3106
+ 'textarea', // Text areas
3107
+ 'details', // Expandable details
3108
+ 'summary', // Summary element (clickable part of details)
3109
+ 'label', // Form labels (often clickable)
3110
+ 'option', // Select options
3111
+ 'optgroup', // Option groups
3112
+ 'fieldset', // Form fieldsets (can be interactive with legend)
3113
+ 'legend', // Fieldset legends
3114
+ ]);
3115
+
3116
+ // Define explicit disable attributes and properties
3117
+ const explicitDisableTags = new Set([
3118
+ 'disabled', // Standard disabled attribute
3119
+ // 'aria-disabled', // ARIA disabled state
3120
+ 'readonly', // Read-only state
3121
+ // 'aria-readonly', // ARIA read-only state
3122
+ // 'aria-hidden', // Hidden from accessibility
3123
+ // 'hidden', // Hidden attribute
3124
+ // 'inert', // Inert attribute
3125
+ // 'aria-inert', // ARIA inert state
3126
+ // 'tabindex="-1"', // Removed from tab order
3127
+ // 'aria-hidden="true"' // Hidden from screen readers
3128
+ ]);
3129
+
3130
+ // handle inputs, select, checkbox, radio, textarea, button and make sure they are not cursor style disabled/not-allowed
3131
+ if (interactiveElements.has(tagName)) {
3132
+ // Check for non-interactive cursor
3133
+ if (style?.cursor && nonInteractiveCursors.has(style.cursor)) {
3134
+ return false
3135
+ }
3136
+
3137
+ // Check for explicit disable attributes
3138
+ for (const disableTag of explicitDisableTags) {
3139
+ if (
3140
+ element.hasAttribute(disableTag) ||
3141
+ element.getAttribute(disableTag) === 'true' ||
3142
+ element.getAttribute(disableTag) === ''
3143
+ ) {
3144
+ return false
3145
+ }
3146
+ }
3147
+
3148
+ // Check for disabled property on form elements
3149
+ if (element.disabled) {
3150
+ return false
3151
+ }
3152
+
3153
+ // Check for readonly property on form elements
3154
+ if (element.readOnly) {
3155
+ return false
3156
+ }
3157
+
3158
+ // Check for inert property
3159
+ if (element.inert) {
3160
+ return false
3161
+ }
3162
+
3163
+ return true
3164
+ }
3165
+
3166
+ const role = element.getAttribute('role');
3167
+ const ariaRole = element.getAttribute('aria-role');
3168
+
3169
+ // Check for contenteditable attribute
3170
+ if (element.getAttribute('contenteditable') === 'true' || element.isContentEditable) {
3171
+ return true
3172
+ }
3173
+
3174
+ // Added enhancement to capture dropdown interactive elements
3175
+ if (
3176
+ element.classList &&
3177
+ (element.classList.contains('button') ||
3178
+ element.classList.contains('dropdown-toggle') ||
3179
+ element.getAttribute('data-index') ||
3180
+ element.getAttribute('data-toggle') === 'dropdown' ||
3181
+ element.getAttribute('aria-haspopup') === 'true')
3182
+ ) {
3183
+ return true
3184
+ }
3185
+
3186
+ const interactiveRoles = new Set([
3187
+ 'button', // Directly clickable element
3188
+ // 'link', // Clickable link
3189
+ 'menu', // Menu container (ARIA menus)
3190
+ 'menubar', // Menu bar container
3191
+ 'menuitem', // Clickable menu item
3192
+ 'menuitemradio', // Radio-style menu item (selectable)
3193
+ 'menuitemcheckbox', // Checkbox-style menu item (toggleable)
3194
+ 'radio', // Radio button (selectable)
3195
+ 'checkbox', // Checkbox (toggleable)
3196
+ 'tab', // Tab (clickable to switch content)
3197
+ 'switch', // Toggle switch (clickable to change state)
3198
+ 'slider', // Slider control (draggable)
3199
+ 'spinbutton', // Number input with up/down controls
3200
+ 'combobox', // Dropdown with text input
3201
+ 'searchbox', // Search input field
3202
+ 'textbox', // Text input field
3203
+ 'listbox', // Selectable list
3204
+ 'option', // Selectable option in a list
3205
+ 'scrollbar', // Scrollable control
3206
+ ]);
3207
+
3208
+ // Basic role/attribute checks
3209
+ const hasInteractiveRole =
3210
+ interactiveElements.has(tagName) ||
3211
+ (role && interactiveRoles.has(role)) ||
3212
+ (ariaRole && interactiveRoles.has(ariaRole));
3213
+
3214
+ if (hasInteractiveRole) return true
3215
+
3216
+ // check whether element has event listeners by window.getEventListeners
3217
+ try {
3218
+ if (typeof getEventListeners === 'function') {
3219
+ const listeners = getEventListeners(element);
3220
+ const mouseEvents = ['click', 'mousedown', 'mouseup', 'dblclick'];
3221
+ for (const eventType of mouseEvents) {
3222
+ if (listeners[eventType] && listeners[eventType].length > 0) {
3223
+ return true // Found a mouse interaction listener
3224
+ }
3225
+ }
3226
+ }
3227
+
3228
+ const getEventListenersForNode =
3229
+ element?.ownerDocument?.defaultView?.getEventListenersForNode ||
3230
+ window.getEventListenersForNode;
3231
+ if (typeof getEventListenersForNode === 'function') {
3232
+ const listeners = getEventListenersForNode(element);
3233
+ const interactionEvents = [
3234
+ 'click',
3235
+ 'mousedown',
3236
+ 'mouseup',
3237
+ 'keydown',
3238
+ 'keyup',
3239
+ 'submit',
3240
+ 'change',
3241
+ 'input',
3242
+ 'focus',
3243
+ 'blur',
3244
+ ];
3245
+ for (const eventType of interactionEvents) {
3246
+ for (const listener of listeners) {
3247
+ if (listener.type === eventType) {
3248
+ return true // Found a common interaction listener
3249
+ }
3250
+ }
3251
+ }
3252
+ }
3253
+ // Fallback: Check common event attributes if getEventListeners is not available (getEventListeners doesn't work in page.evaluate context)
3254
+ const commonMouseAttrs = ['onclick', 'onmousedown', 'onmouseup', 'ondblclick'];
3255
+ for (const attr of commonMouseAttrs) {
3256
+ if (element.hasAttribute(attr) || typeof element[attr] === 'function') {
3257
+ return true
3258
+ }
3259
+ }
3260
+ } catch (e) {
3261
+ // console.warn(`Could not check event listeners for ${element.tagName}:`, e);
3262
+ // If checking listeners fails, rely on other checks
3263
+ }
3264
+
3265
+ /**
3266
+ * @edit scrollable element detection
3267
+ */
3268
+ if (isScrollableElement(element)) {
3269
+ return true
3270
+ }
3271
+
3272
+ return false
3273
+ }
3274
+
3275
+ /**
3276
+ * Checks if an element is the topmost element at its position.
3277
+ *
3278
+ * @param {HTMLElement} element - The element to check.
3279
+ * @returns {boolean} Whether the element is the topmost element at its position.
3280
+ */
3281
+ function isTopElement(element) {
3282
+ // Special case: when viewportExpansion is -1, consider all elements as "top" elements
3283
+ if (viewportExpansion === -1) {
3284
+ return true
3285
+ }
3286
+
3287
+ const rects = getCachedClientRects(element); // Replace element.getClientRects()
3288
+
3289
+ if (!rects || rects.length === 0) {
3290
+ return false // No geometry, cannot be top
3291
+ }
3292
+
3293
+ let isAnyRectInViewport = false;
3294
+ for (const rect of rects) {
3295
+ // Use the same logic as isInExpandedViewport check
3296
+ if (
3297
+ rect.width > 0 &&
3298
+ rect.height > 0 &&
3299
+ !(
3300
+ // Only check non-empty rects
3301
+ (
3302
+ rect.bottom < -viewportExpansion ||
3303
+ rect.top > window.innerHeight + viewportExpansion ||
3304
+ rect.right < -viewportExpansion ||
3305
+ rect.left > window.innerWidth + viewportExpansion
3306
+ )
3307
+ )
3308
+ ) {
3309
+ isAnyRectInViewport = true;
3310
+ break
3311
+ }
3312
+ }
3313
+
3314
+ if (!isAnyRectInViewport) {
3315
+ return false // All rects are outside the viewport area
3316
+ }
3317
+
3318
+ // Find the correct document context and root element
3319
+ let doc = element.ownerDocument;
3320
+
3321
+ // If we're in an iframe, elements are considered top by default
3322
+ if (doc !== window.document) {
3323
+ return true
3324
+ }
3325
+
3326
+ /**
3327
+ * @edit improve `sampleRect`, filter out rects with 0 area
3328
+ */
3329
+ // find a rect that has width and height as sample
3330
+ let rect = Array.from(rects).find((r) => r.width > 0 && r.height > 0);
3331
+ if (!rect) {
3332
+ return false // No valid rect found
3333
+ }
3334
+
3335
+ // For shadow DOM, we need to check within its own root context
3336
+ const shadowRoot = element.getRootNode();
3337
+ if (shadowRoot instanceof ShadowRoot) {
3338
+ const centerX = rect.left + rect.width / 2;
3339
+ const centerY = rect.top + rect.height / 2;
3340
+
3341
+ try {
3342
+ const topEl = shadowRoot.elementFromPoint(centerX, centerY);
3343
+ if (!topEl) return false
3344
+
3345
+ let current = topEl;
3346
+ while (current && current !== shadowRoot) {
3347
+ if (current === element) return true
3348
+ current = current.parentElement;
3349
+ }
3350
+ return false
3351
+ } catch (e) {
3352
+ return true
3353
+ }
3354
+ }
3355
+
3356
+ const margin = 5;
3357
+
3358
+ // For elements in viewport, check if they're topmost. Do the check in the
3359
+ // center of the element and at the corners to ensure we catch more cases.
3360
+ const checkPoints = [
3361
+ // Initially only this was used, but it was not enough
3362
+ { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
3363
+ { x: rect.left + margin, y: rect.top + margin }, // top left
3364
+ // { x: rect.right - margin, y: rect.top + margin }, // top right
3365
+ // { x: rect.left + margin, y: rect.bottom - margin }, // bottom left
3366
+ { x: rect.right - margin, y: rect.bottom - margin }, // bottom right
3367
+ ];
3368
+
3369
+ return checkPoints.some(({ x, y }) => {
3370
+ try {
3371
+ const topEl = document.elementFromPoint(x, y);
3372
+ if (!topEl) return false
3373
+
3374
+ let current = topEl;
3375
+ while (current && current !== document.documentElement) {
3376
+ if (current === element) return true
3377
+ current = current.parentElement;
3378
+ }
3379
+ return false
3380
+ } catch (e) {
3381
+ return true
3382
+ }
3383
+ })
3384
+ }
3385
+
3386
+ /**
3387
+ * Checks if an element is within the expanded viewport.
3388
+ *
3389
+ * @param {HTMLElement} element - The element to check.
3390
+ * @param {number} viewportExpansion - The viewport expansion.
3391
+ * @returns {boolean} Whether the element is within the expanded viewport.
3392
+ */
3393
+ function isInExpandedViewport(element, viewportExpansion) {
3394
+ if (viewportExpansion === -1) {
3395
+ return true
3396
+ }
3397
+
3398
+ const rects = element.getClientRects(); // Use getClientRects
3399
+
3400
+ if (!rects || rects.length === 0) {
3401
+ // Fallback to getBoundingClientRect if getClientRects is empty,
3402
+ // useful for elements like <svg> that might not have client rects but have a bounding box.
3403
+ const boundingRect = getCachedBoundingRect(element);
3404
+ if (!boundingRect || boundingRect.width === 0 || boundingRect.height === 0) {
3405
+ return false
3406
+ }
3407
+ return !(
3408
+ boundingRect.bottom < -viewportExpansion ||
3409
+ boundingRect.top > window.innerHeight + viewportExpansion ||
3410
+ boundingRect.right < -viewportExpansion ||
3411
+ boundingRect.left > window.innerWidth + viewportExpansion
3412
+ )
3413
+ }
3414
+
3415
+ // Check if *any* client rect is within the viewport
3416
+ for (const rect of rects) {
3417
+ if (rect.width === 0 || rect.height === 0) continue // Skip empty rects
3418
+
3419
+ if (
3420
+ !(
3421
+ rect.bottom < -viewportExpansion ||
3422
+ rect.top > window.innerHeight + viewportExpansion ||
3423
+ rect.right < -viewportExpansion ||
3424
+ rect.left > window.innerWidth + viewportExpansion
3425
+ )
3426
+ ) {
3427
+ return true // Found at least one rect in the viewport
3428
+ }
3429
+ }
3430
+
3431
+ return false // No rects were found in the viewport
3432
+ }
3433
+
3434
+ // /**
3435
+ // * Gets the effective scroll of an element.
3436
+ // *
3437
+ // * @param {HTMLElement} element - The element to get the effective scroll for.
3438
+ // * @returns {Object} The effective scroll of the element.
3439
+ // */
3440
+ // function getEffectiveScroll(element) {
3441
+ // let currentEl = element;
3442
+ // let scrollX = 0;
3443
+ // let scrollY = 0;
3444
+
3445
+ // while (currentEl && currentEl !== document.documentElement) {
3446
+ // if (currentEl.scrollLeft || currentEl.scrollTop) {
3447
+ // scrollX += currentEl.scrollLeft;
3448
+ // scrollY += currentEl.scrollTop;
3449
+ // }
3450
+ // currentEl = currentEl.parentElement;
3451
+ // }
3452
+
3453
+ // scrollX += window.scrollX;
3454
+ // scrollY += window.scrollY;
3455
+
3456
+ // return { scrollX, scrollY };
3457
+ // }
3458
+
3459
+ /**
3460
+ * Checks if an element is an interactive candidate.
3461
+ *
3462
+ * @param {HTMLElement} element - The element to check.
3463
+ * @returns {boolean} Whether the element is an interactive candidate.
3464
+ */
3465
+ function isInteractiveCandidate(element) {
3466
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) return false
3467
+
3468
+ const tagName = element.tagName.toLowerCase();
3469
+
3470
+ // Fast-path for common interactive elements
3471
+ const interactiveElements = new Set([
3472
+ 'a',
3473
+ 'button',
3474
+ 'input',
3475
+ 'select',
3476
+ 'textarea',
3477
+ 'details',
3478
+ 'summary',
3479
+ 'label',
3480
+ ]);
3481
+
3482
+ if (interactiveElements.has(tagName)) return true
3483
+
3484
+ // Quick attribute checks without getting full lists
3485
+ const hasQuickInteractiveAttr =
3486
+ element.hasAttribute('onclick') ||
3487
+ element.hasAttribute('role') ||
3488
+ element.hasAttribute('tabindex') ||
3489
+ element.hasAttribute('aria-') ||
3490
+ element.hasAttribute('data-action') ||
3491
+ element.getAttribute('contenteditable') === 'true';
3492
+
3493
+ return hasQuickInteractiveAttr
3494
+ }
3495
+
3496
+ // --- Define constants for distinct interaction check ---
3497
+ const DISTINCT_INTERACTIVE_TAGS = new Set([
3498
+ 'a',
3499
+ 'button',
3500
+ 'input',
3501
+ 'select',
3502
+ 'textarea',
3503
+ 'summary',
3504
+ 'details',
3505
+ 'label',
3506
+ 'option',
3507
+ ]);
3508
+ const INTERACTIVE_ROLES = new Set([
3509
+ 'button',
3510
+ 'link',
3511
+ 'menuitem',
3512
+ 'menuitemradio',
3513
+ 'menuitemcheckbox',
3514
+ 'radio',
3515
+ 'checkbox',
3516
+ 'tab',
3517
+ 'switch',
3518
+ 'slider',
3519
+ 'spinbutton',
3520
+ 'combobox',
3521
+ 'searchbox',
3522
+ 'textbox',
3523
+ 'listbox',
3524
+ 'option',
3525
+ 'scrollbar',
3526
+ ]);
3527
+
3528
+ /**
3529
+ * Heuristically determines if an element should be considered as independently interactive,
3530
+ * even if it's nested inside another interactive container.
3531
+ *
3532
+ * This function helps detect deeply nested actionable elements (e.g., menu items within a button)
3533
+ * that may not be picked up by strict interactivity checks.
3534
+ *
3535
+ * @param {HTMLElement} element - The element to check.
3536
+ * @returns {boolean} Whether the element is heuristically interactive.
3537
+ */
3538
+ function isHeuristicallyInteractive(element) {
3539
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) return false
3540
+
3541
+ // Skip non-visible elements early for performance
3542
+ if (!isElementVisible(element)) return false
3543
+
3544
+ // Check for common attributes that often indicate interactivity
3545
+ const hasInteractiveAttributes =
3546
+ element.hasAttribute('role') ||
3547
+ element.hasAttribute('tabindex') ||
3548
+ element.hasAttribute('onclick') ||
3549
+ typeof element.onclick === 'function';
3550
+
3551
+ // Check for semantic class names suggesting interactivity
3552
+ const hasInteractiveClass = /\b(btn|clickable|menu|item|entry|link)\b/i.test(
3553
+ element.className || ''
3554
+ );
3555
+
3556
+ // Determine whether the element is inside a known interactive container
3557
+ const isInKnownContainer = Boolean(
3558
+ element.closest('button,a,[role="button"],.menu,.dropdown,.list,.toolbar')
3559
+ );
3560
+
3561
+ // Ensure the element has at least one visible child (to avoid marking empty wrappers)
3562
+ const hasVisibleChildren = [...element.children].some(isElementVisible);
3563
+
3564
+ // Avoid highlighting elements whose parent is <body> (top-level wrappers)
3565
+ const isParentBody = element.parentElement && element.parentElement.isSameNode(document.body);
3566
+
3567
+ return (
3568
+ (isInteractiveElement(element) || hasInteractiveAttributes || hasInteractiveClass) &&
3569
+ hasVisibleChildren &&
3570
+ isInKnownContainer &&
3571
+ !isParentBody
3572
+ )
3573
+ }
3574
+
3575
+ /**
3576
+ * Checks if an element likely represents a distinct interaction
3577
+ * separate from its parent (if the parent is also interactive).
3578
+ *
3579
+ * @param {HTMLElement} element - The element to check.
3580
+ * @returns {boolean} Whether the element is a distinct interaction.
3581
+ */
3582
+ function isElementDistinctInteraction(element) {
3583
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) {
3584
+ return false
3585
+ }
3586
+
3587
+ const tagName = element.tagName.toLowerCase();
3588
+ const role = element.getAttribute('role');
3589
+
3590
+ // Check if it's an iframe - always distinct boundary
3591
+ if (tagName === 'iframe') {
3592
+ return true
3593
+ }
3594
+
3595
+ // Check tag name
3596
+ if (DISTINCT_INTERACTIVE_TAGS.has(tagName)) {
3597
+ return true
3598
+ }
3599
+ // Check interactive roles
3600
+ if (role && INTERACTIVE_ROLES.has(role)) {
3601
+ return true
3602
+ }
3603
+ // Check contenteditable
3604
+ if (element.isContentEditable || element.getAttribute('contenteditable') === 'true') {
3605
+ return true
3606
+ }
3607
+ // Check for common testing/automation attributes
3608
+ if (
3609
+ element.hasAttribute('data-testid') ||
3610
+ element.hasAttribute('data-cy') ||
3611
+ element.hasAttribute('data-test')
3612
+ ) {
3613
+ return true
3614
+ }
3615
+ // Check for explicit onclick handler (attribute or property)
3616
+ if (element.hasAttribute('onclick') || typeof element.onclick === 'function') {
3617
+ return true
3618
+ }
3619
+
3620
+ // return false
3621
+
3622
+ // Check for other common interaction event listeners
3623
+ try {
3624
+ const getEventListenersForNode =
3625
+ element?.ownerDocument?.defaultView?.getEventListenersForNode ||
3626
+ window.getEventListenersForNode;
3627
+ if (typeof getEventListenersForNode === 'function') {
3628
+ const listeners = getEventListenersForNode(element);
3629
+ const interactionEvents = [
3630
+ 'click',
3631
+ 'mousedown',
3632
+ 'mouseup',
3633
+ 'keydown',
3634
+ 'keyup',
3635
+ 'submit',
3636
+ 'change',
3637
+ 'input',
3638
+ 'focus',
3639
+ 'blur',
3640
+ ];
3641
+ for (const eventType of interactionEvents) {
3642
+ for (const listener of listeners) {
3643
+ if (listener.type === eventType) {
3644
+ return true // Found a common interaction listener
3645
+ }
3646
+ }
3647
+ }
3648
+ }
3649
+ // Fallback: Check common event attributes if getEventListeners is not available (getEventListenersForNode doesn't work in page.evaluate context)
3650
+ const commonEventAttrs = [
3651
+ 'onmousedown',
3652
+ 'onmouseup',
3653
+ 'onkeydown',
3654
+ 'onkeyup',
3655
+ 'onsubmit',
3656
+ 'onchange',
3657
+ 'oninput',
3658
+ 'onfocus',
3659
+ 'onblur',
3660
+ ];
3661
+ if (commonEventAttrs.some((attr) => element.hasAttribute(attr))) {
3662
+ return true
3663
+ }
3664
+ } catch (e) {
3665
+ // console.warn(`Could not check event listeners for ${element.tagName}:`, e);
3666
+ // If checking listeners fails, rely on other checks
3667
+ }
3668
+
3669
+ // if the element is not strictly interactive but appears clickable based on heuristic signals
3670
+ if (isHeuristicallyInteractive(element)) {
3671
+ return true
3672
+ }
3673
+
3674
+ // Default to false: if it's interactive but doesn't match above,
3675
+ // assume it triggers the same action as the parent.
3676
+ return false
3677
+ }
3678
+ // --- End distinct interaction check ---
3679
+
3680
+ /**
3681
+ * Handles the logic for deciding whether to highlight an element and performing the highlight.
3682
+ * @param {
3683
+ {
3684
+ tagName: string;
3685
+ attributes: Record<string, string>;
3686
+ xpath: any;
3687
+ children: never[];
3688
+ isVisible?: boolean;
3689
+ isTopElement?: boolean;
3690
+ isInteractive?: boolean;
3691
+ isInViewport?: boolean;
3692
+ highlightIndex?: number;
3693
+ shadowRoot?: boolean;
3694
+ }} nodeData - The node data object.
3695
+ * @param {HTMLElement} node - The node to highlight.
3696
+ * @param {HTMLElement | null} parentIframe - The parent iframe node.
3697
+ * @param {boolean} isParentHighlighted - Whether the parent node is highlighted.
3698
+ * @returns {boolean} Whether the element was highlighted.
3699
+ */
3700
+ function handleHighlighting(nodeData, node, parentIframe, isParentHighlighted) {
3701
+ if (!nodeData.isInteractive) return false // Not interactive, definitely don't highlight
3702
+
3703
+ let shouldHighlight = false;
3704
+ if (!isParentHighlighted) {
3705
+ // Parent wasn't highlighted, this interactive node can be highlighted.
3706
+ shouldHighlight = true;
3707
+ } else {
3708
+ // Parent *was* highlighted. Only highlight this node if it represents a distinct interaction.
3709
+ if (isElementDistinctInteraction(node)) {
3710
+ shouldHighlight = true;
3711
+ } else {
3712
+ // console.log(`Skipping highlight for ${nodeData.tagName} (parent highlighted)`);
3713
+ shouldHighlight = false;
3714
+ }
3715
+ }
3716
+
3717
+ if (shouldHighlight) {
3718
+ // Check viewport status before assigning index and highlighting
3719
+ nodeData.isInViewport = isInExpandedViewport(node, viewportExpansion);
3720
+
3721
+ // When viewportExpansion is -1, all interactive elements should get a highlight index
3722
+ // regardless of viewport status
3723
+ if (nodeData.isInViewport || viewportExpansion === -1) {
3724
+ nodeData.highlightIndex = highlightIndex++;
3725
+
3726
+ if (doHighlightElements) {
3727
+ if (focusHighlightIndex >= 0) {
3728
+ if (focusHighlightIndex === nodeData.highlightIndex) {
3729
+ highlightElement(node, nodeData.highlightIndex, parentIframe);
3730
+ }
3731
+ } else {
3732
+ highlightElement(node, nodeData.highlightIndex, parentIframe);
3733
+ }
3734
+ return true // Successfully highlighted
3735
+ }
3736
+ }
3737
+ }
3738
+
3739
+ return false // Did not highlight
3740
+ }
3741
+
3742
+ /**
3743
+ * Creates a node data object for a given node and its descendants.
3744
+ *
3745
+ * @param {HTMLElement} node - The node to process.
3746
+ * @param {HTMLElement | null} parentIframe - The parent iframe node.
3747
+ * @param {boolean} isParentHighlighted - Whether the parent node is highlighted.
3748
+ * @returns {string | null} The ID of the node data object, or null if the node is not processed.
3749
+ */
3750
+ function buildDomTree(node, parentIframe = null, isParentHighlighted = false) {
3751
+ // Fast rejection checks first
3752
+ if (
3753
+ !node ||
3754
+ node.id === HIGHLIGHT_CONTAINER_ID ||
3755
+ (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.TEXT_NODE)
3756
+ ) {
3757
+ return null
3758
+ }
3759
+
3760
+ if (!node || node.id === HIGHLIGHT_CONTAINER_ID) {
3761
+ return null
3762
+ }
3763
+
3764
+ /**
3765
+ * @edit add `data-browser-use-ignore` attribute
3766
+ */
3767
+ if (node.dataset?.browserUseIgnore === 'true' || node.dataset?.pageAgentIgnore === 'true') {
3768
+ return null // Skip this node and its children
3769
+ }
3770
+
3771
+ /**
3772
+ * @edit exclude aria-hidden elements
3773
+ */
3774
+ if (node.getAttribute && node.getAttribute('aria-hidden') === 'true') {
3775
+ return null // Skip this node and its children
3776
+ }
3777
+
3778
+ // Special handling for root node (body)
3779
+ if (node === document.body) {
3780
+ const nodeData = {
3781
+ tagName: 'body',
3782
+ attributes: {},
3783
+ xpath: '/body',
3784
+ children: [],
3785
+ };
3786
+
3787
+ // Process children of body
3788
+ for (const child of node.childNodes) {
3789
+ const domElement = buildDomTree(child, parentIframe, false); // Body's children have no highlighted parent initially
3790
+ if (domElement) nodeData.children.push(domElement);
3791
+ }
3792
+
3793
+ const id = `${ID.current++}`;
3794
+ DOM_HASH_MAP[id] = nodeData;
3795
+ return id
3796
+ }
3797
+
3798
+ // Early bailout for non-element nodes except text
3799
+ if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.TEXT_NODE) {
3800
+ return null
3801
+ }
3802
+
3803
+ // Process text nodes
3804
+ if (node.nodeType === Node.TEXT_NODE) {
3805
+ const textContent = node.textContent?.trim();
3806
+ if (!textContent) {
3807
+ return null
3808
+ }
3809
+
3810
+ // Only check visibility for text nodes that might be visible
3811
+ const parentElement = node.parentElement;
3812
+ if (!parentElement || parentElement.tagName.toLowerCase() === 'script') {
3813
+ return null
3814
+ }
3815
+
3816
+ const id = `${ID.current++}`;
3817
+ DOM_HASH_MAP[id] = {
3818
+ type: 'TEXT_NODE',
3819
+ text: textContent,
3820
+ isVisible: isTextNodeVisible(node),
3821
+ };
3822
+ return id
3823
+ }
3824
+
3825
+ // Quick checks for element nodes
3826
+ if (node.nodeType === Node.ELEMENT_NODE && !isElementAccepted(node)) {
3827
+ return null
3828
+ }
3829
+
3830
+ // Early viewport check - only filter out elements clearly outside viewport
3831
+ // The getBoundingClientRect() of the Shadow DOM host element may return width/height = 0
3832
+ if (viewportExpansion !== -1 && !node.shadowRoot) {
3833
+ const rect = getCachedBoundingRect(node); // Keep for initial quick check
3834
+ const style = getCachedComputedStyle(node);
3835
+
3836
+ // Skip viewport check for fixed/sticky elements as they may appear anywhere
3837
+ const isFixedOrSticky = style && (style.position === 'fixed' || style.position === 'sticky');
3838
+
3839
+ // Check if element has actual dimensions using offsetWidth/Height (quick check)
3840
+ const hasSize = node.offsetWidth > 0 || node.offsetHeight > 0;
3841
+
3842
+ // Use getBoundingClientRect for the quick OUTSIDE check.
3843
+ // isInExpandedViewport will do the more accurate check later if needed.
3844
+ if (
3845
+ !rect ||
3846
+ (!isFixedOrSticky &&
3847
+ !hasSize &&
3848
+ (rect.bottom < -viewportExpansion ||
3849
+ rect.top > window.innerHeight + viewportExpansion ||
3850
+ rect.right < -viewportExpansion ||
3851
+ rect.left > window.innerWidth + viewportExpansion))
3852
+ ) {
3853
+ // console.log("Skipping node outside viewport (quick check):", node.tagName, rect);
3854
+ return null
3855
+ }
3856
+ }
3857
+
3858
+ /**
3859
+ * @type {
3860
+ {
3861
+ tagName: string;
3862
+ attributes: Record<string, string | null>;
3863
+ xpath: any;
3864
+ children: never[];
3865
+ isVisible?: boolean;
3866
+ isTopElement?: boolean;
3867
+ isInteractive?: boolean;
3868
+ isInViewport?: boolean;
3869
+ highlightIndex?: number;
3870
+ shadowRoot?: boolean;
3871
+ }
3872
+ } nodeData - The node data object.
3873
+ */
3874
+ const nodeData = {
3875
+ tagName: node.tagName.toLowerCase(),
3876
+ attributes: {},
3877
+
3878
+ /**
3879
+ * @edit no need for xpath
3880
+ */
3881
+ // xpath: getXPathTree(node, true),
3882
+
3883
+ children: [],
3884
+ };
3885
+
3886
+ // Get attributes for interactive elements or potential text containers
3887
+ if (
3888
+ isInteractiveCandidate(node) ||
3889
+ node.tagName.toLowerCase() === 'iframe' ||
3890
+ node.tagName.toLowerCase() === 'body'
3891
+ ) {
3892
+ const attributeNames = node.getAttributeNames?.() || [];
3893
+ for (const name of attributeNames) {
3894
+ const value = node.getAttribute(name);
3895
+ nodeData.attributes[name] = value;
3896
+ }
3897
+
3898
+ /**
3899
+ * @edit @workaround input.checked
3900
+ */
3901
+ if (
3902
+ node.tagName.toLowerCase() === 'input' &&
3903
+ (node.type === 'checkbox' || node.type === 'radio')
3904
+ ) {
3905
+ nodeData.attributes.checked = node.checked ? 'true' : 'false'; // Store as string for consistency
3906
+ }
3907
+ }
3908
+
3909
+ let nodeWasHighlighted = false;
3910
+ // Perform visibility, interactivity, and highlighting checks
3911
+ if (node.nodeType === Node.ELEMENT_NODE) {
3912
+ nodeData.isVisible = isElementVisible(node); // isElementVisible uses offsetWidth/Height, which is fine
3913
+ if (nodeData.isVisible) {
3914
+ nodeData.isTopElement = isTopElement(node);
3915
+
3916
+ // Special handling for ARIA menu containers - check interactivity even if not top element
3917
+ const role = node.getAttribute('role');
3918
+ const isMenuContainer = role === 'menu' || role === 'menubar' || role === 'listbox';
3919
+
3920
+ if (nodeData.isTopElement || isMenuContainer) {
3921
+ nodeData.isInteractive = isInteractiveElement(node);
3922
+ // Call the dedicated highlighting function
3923
+ nodeWasHighlighted = handleHighlighting(nodeData, node, parentIframe, isParentHighlighted);
3924
+
3925
+ /**
3926
+ * @edit direct dom ref
3927
+ */
3928
+ nodeData.ref = node;
3929
+
3930
+ /**
3931
+ * @edit make sure attributes exist for interactive candidates.
3932
+ * @note if the element failed the isInteractiveCandidate, attributes would be empty.
3933
+ */
3934
+ if (nodeData.isInteractive && Object.keys(nodeData.attributes).length === 0) {
3935
+ const attributeNames = node.getAttributeNames?.() || [];
3936
+ for (const name of attributeNames) {
3937
+ const value = node.getAttribute(name);
3938
+ nodeData.attributes[name] = value;
3939
+ }
3940
+ }
3941
+ }
3942
+ }
3943
+ }
3944
+
3945
+ // Process children, with special handling for iframes and rich text editors
3946
+ if (node.tagName) {
3947
+ const tagName = node.tagName.toLowerCase();
3948
+
3949
+ // Handle iframes
3950
+ if (tagName === 'iframe') {
3951
+ try {
3952
+ const iframeDoc = node.contentDocument || node.contentWindow?.document;
3953
+ if (iframeDoc) {
3954
+ for (const child of iframeDoc.childNodes) {
3955
+ const domElement = buildDomTree(child, node, false);
3956
+ if (domElement) nodeData.children.push(domElement);
3957
+ }
3958
+ }
3959
+ } catch (e) {
3960
+ console.warn('Unable to access iframe:', e);
3961
+ }
3962
+ }
3963
+ // Handle rich text editors and contenteditable elements
3964
+ else if (
3965
+ node.isContentEditable ||
3966
+ node.getAttribute('contenteditable') === 'true' ||
3967
+ node.id === 'tinymce' ||
3968
+ node.classList.contains('mce-content-body') ||
3969
+ (tagName === 'body' && node.getAttribute('data-id')?.startsWith('mce_'))
3970
+ ) {
3971
+ // Process all child nodes to capture formatted text
3972
+ for (const child of node.childNodes) {
3973
+ const domElement = buildDomTree(child, parentIframe, nodeWasHighlighted);
3974
+ if (domElement) nodeData.children.push(domElement);
3975
+ }
3976
+ } else {
3977
+ // Handle shadow DOM
3978
+ if (node.shadowRoot) {
3979
+ nodeData.shadowRoot = true;
3980
+ for (const child of node.shadowRoot.childNodes) {
3981
+ const domElement = buildDomTree(child, parentIframe, nodeWasHighlighted);
3982
+ if (domElement) nodeData.children.push(domElement);
3983
+ }
3984
+ }
3985
+ // Handle regular elements
3986
+ for (const child of node.childNodes) {
3987
+ // Pass the highlighted status of the *current* node to its children
3988
+ const passHighlightStatusToChild = nodeWasHighlighted || isParentHighlighted;
3989
+ const domElement = buildDomTree(child, parentIframe, passHighlightStatusToChild);
3990
+ if (domElement) nodeData.children.push(domElement);
3991
+ }
3992
+ }
3993
+ }
3994
+
3995
+ // Skip empty anchor tags only if they have no dimensions and no children
3996
+ if (nodeData.tagName === 'a' && nodeData.children.length === 0 && !nodeData.attributes.href) {
3997
+ // Check if the anchor has actual dimensions
3998
+ const rect = getCachedBoundingRect(node);
3999
+ const hasSize =
4000
+ (rect && rect.width > 0 && rect.height > 0) || node.offsetWidth > 0 || node.offsetHeight > 0;
4001
+
4002
+ if (!hasSize) {
4003
+ return null
4004
+ }
4005
+ }
4006
+
4007
+ /**
4008
+ * @edit add `extra` field for extra data
4009
+ */
4010
+ nodeData.extra = extraData.get(node) || null;
4011
+
4012
+ const id = `${ID.current++}`;
4013
+ DOM_HASH_MAP[id] = nodeData;
4014
+ return id
4015
+ }
4016
+
4017
+ const rootId = buildDomTree(document.body);
4018
+
4019
+ // Clear the cache before starting
4020
+ DOM_CACHE.clearCache();
4021
+
4022
+ return { rootId, map: DOM_HASH_MAP }
4023
+ };
4024
+
4025
+ /**
4026
+ * DOM tree utilities: build flat tree, convert to string, manage highlights.
4027
+ * Adapted from @page-agent/page-controller (MIT License).
4028
+ */
4029
+ const DEFAULT_VIEWPORT_EXPANSION = -1;
4030
+ function resolveViewportExpansion(viewportExpansion) {
4031
+ return viewportExpansion ?? DEFAULT_VIEWPORT_EXPANSION;
4032
+ }
4033
+ const newElementsCache = new WeakMap();
4034
+ function getFlatTree(config) {
4035
+ const viewportExpansion = resolveViewportExpansion(config.viewportExpansion);
4036
+ const interactiveBlacklist = [];
4037
+ for (const item of config.interactiveBlacklist || []) {
4038
+ if (typeof item === "function") {
4039
+ interactiveBlacklist.push(item());
4040
+ }
4041
+ else {
4042
+ interactiveBlacklist.push(item);
4043
+ }
4044
+ }
4045
+ const interactiveWhitelist = [];
4046
+ for (const item of config.interactiveWhitelist || []) {
4047
+ if (typeof item === "function") {
4048
+ interactiveWhitelist.push(item());
4049
+ }
4050
+ else {
4051
+ interactiveWhitelist.push(item);
4052
+ }
4053
+ }
4054
+ const elements = domTree({
4055
+ doHighlightElements: true,
4056
+ debugMode: true,
4057
+ focusHighlightIndex: -1,
4058
+ viewportExpansion,
4059
+ interactiveBlacklist,
4060
+ interactiveWhitelist,
4061
+ highlightOpacity: config.highlightOpacity ?? 0.0,
4062
+ highlightLabelOpacity: config.highlightLabelOpacity ?? 0.1,
4063
+ });
4064
+ for (const nodeId in elements.map) {
4065
+ const node = elements.map[nodeId];
4066
+ if (node.isInteractive && node.ref) {
4067
+ const ref = node.ref;
4068
+ if (!newElementsCache.has(ref)) {
4069
+ newElementsCache.set(ref, window.location.href);
4070
+ node.isNew = true;
4071
+ }
4072
+ }
4073
+ }
4074
+ return elements;
4075
+ }
4076
+ // ---- flatTreeToString ----
4077
+ const globRegexCache = new Map();
4078
+ function globToRegex(pattern) {
4079
+ let regex = globRegexCache.get(pattern);
4080
+ if (!regex) {
4081
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
4082
+ regex = new RegExp(`^${escaped.replace(/\*/g, ".*")}$`);
4083
+ globRegexCache.set(pattern, regex);
4084
+ }
4085
+ return regex;
4086
+ }
4087
+ function matchAttributes(attrs, patterns) {
4088
+ const result = {};
4089
+ for (const pattern of patterns) {
4090
+ if (pattern.includes("*")) {
4091
+ const regex = globToRegex(pattern);
4092
+ for (const key of Object.keys(attrs)) {
4093
+ if (regex.test(key) && attrs[key].trim()) {
4094
+ result[key] = attrs[key].trim();
4095
+ }
4096
+ }
4097
+ }
4098
+ else {
4099
+ const value = attrs[pattern];
4100
+ if (value && value.trim()) {
4101
+ result[pattern] = value.trim();
4102
+ }
4103
+ }
4104
+ }
4105
+ return result;
4106
+ }
4107
+ function flatTreeToString(flatTree, includeAttributes) {
4108
+ const DEFAULT_INCLUDE_ATTRIBUTES = [
4109
+ "title",
4110
+ "type",
4111
+ "checked",
4112
+ "name",
4113
+ "role",
4114
+ "value",
4115
+ "placeholder",
4116
+ "data-date-format",
4117
+ "alt",
4118
+ "aria-label",
4119
+ "aria-expanded",
4120
+ "data-state",
4121
+ "aria-checked",
4122
+ "id",
4123
+ "for",
4124
+ "target",
4125
+ "aria-haspopup",
4126
+ "aria-controls",
4127
+ "aria-owns",
4128
+ "contenteditable",
4129
+ ];
4130
+ const includeAttrs = [
4131
+ ...(includeAttributes || []),
4132
+ ...DEFAULT_INCLUDE_ATTRIBUTES,
4133
+ ];
4134
+ const capTextLength = (text, maxLength) => {
4135
+ if (text.length > maxLength) {
4136
+ return text.substring(0, maxLength) + "...";
4137
+ }
4138
+ return text;
4139
+ };
4140
+ const buildTreeNode = (nodeId) => {
4141
+ const node = flatTree.map[nodeId];
4142
+ if (!node)
4143
+ return null;
4144
+ if (node.type === "TEXT_NODE") {
4145
+ const textNode = node;
4146
+ return {
4147
+ type: "text",
4148
+ text: textNode.text,
4149
+ isVisible: textNode.isVisible,
4150
+ parent: null,
4151
+ children: [],
4152
+ };
4153
+ }
4154
+ else {
4155
+ const elementNode = node;
4156
+ const children = [];
4157
+ if (elementNode.children) {
4158
+ for (const childId of elementNode.children) {
4159
+ const child = buildTreeNode(childId);
4160
+ if (child) {
4161
+ children.push(child);
4162
+ }
4163
+ }
4164
+ }
4165
+ return {
4166
+ type: "element",
4167
+ tagName: elementNode.tagName,
4168
+ attributes: elementNode.attributes ?? {},
4169
+ isVisible: elementNode.isVisible ?? false,
4170
+ isInteractive: elementNode.isInteractive ?? false,
4171
+ isTopElement: elementNode.isTopElement ?? false,
4172
+ isNew: elementNode.isNew ?? false,
4173
+ highlightIndex: elementNode.highlightIndex,
4174
+ parent: null,
4175
+ children,
4176
+ extra: elementNode.extra ?? {},
4177
+ };
4178
+ }
4179
+ };
4180
+ const setParentReferences = (node, parent = null) => {
4181
+ node.parent = parent;
4182
+ for (const child of node.children) {
4183
+ setParentReferences(child, node);
4184
+ }
4185
+ };
4186
+ const rootNode = buildTreeNode(flatTree.rootId);
4187
+ if (!rootNode)
4188
+ return "";
4189
+ setParentReferences(rootNode);
4190
+ const hasParentWithHighlightIndex = (node) => {
4191
+ let current = node.parent;
4192
+ while (current) {
4193
+ if (current.type === "element" &&
4194
+ current.highlightIndex !== undefined) {
4195
+ return true;
4196
+ }
4197
+ current = current.parent;
4198
+ }
4199
+ return false;
4200
+ };
4201
+ const processNode = (node, depth, result) => {
4202
+ let nextDepth = depth;
4203
+ const depthStr = "\t".repeat(depth);
4204
+ if (node.type === "element") {
4205
+ if (node.highlightIndex !== undefined) {
4206
+ nextDepth += 1;
4207
+ const text = getAllTextTillNextClickableElement(node);
4208
+ let attributesHtmlStr = "";
4209
+ if (includeAttrs.length > 0 && node.attributes) {
4210
+ const attributesToInclude = matchAttributes(node.attributes, includeAttrs);
4211
+ const keys = Object.keys(attributesToInclude);
4212
+ if (keys.length > 1) {
4213
+ const keysToRemove = new Set();
4214
+ const seenValues = {};
4215
+ for (const key of keys) {
4216
+ const value = attributesToInclude[key];
4217
+ if (value.length > 5) {
4218
+ if (value in seenValues) {
4219
+ keysToRemove.add(key);
4220
+ }
4221
+ else {
4222
+ seenValues[value] = key;
4223
+ }
4224
+ }
4225
+ }
4226
+ for (const key of keysToRemove) {
4227
+ delete attributesToInclude[key];
4228
+ }
4229
+ }
4230
+ if (attributesToInclude.role === node.tagName) {
4231
+ delete attributesToInclude.role;
4232
+ }
4233
+ const attrsToRemoveIfTextMatches = [
4234
+ "aria-label",
4235
+ "placeholder",
4236
+ "title",
4237
+ ];
4238
+ for (const attr of attrsToRemoveIfTextMatches) {
4239
+ if (attributesToInclude[attr] &&
4240
+ attributesToInclude[attr].toLowerCase().trim() ===
4241
+ text.toLowerCase().trim()) {
4242
+ delete attributesToInclude[attr];
4243
+ }
4244
+ }
4245
+ if (Object.keys(attributesToInclude).length > 0) {
4246
+ attributesHtmlStr = Object.entries(attributesToInclude)
4247
+ .map(([key, value]) => `${key}=${capTextLength(value, 20)}`)
4248
+ .join(" ");
4249
+ }
4250
+ }
4251
+ const highlightIndicator = node.isNew
4252
+ ? `*[${node.highlightIndex}]`
4253
+ : `[${node.highlightIndex}]`;
4254
+ let line = `${depthStr}${highlightIndicator}<${node.tagName ?? ""}`;
4255
+ if (attributesHtmlStr) {
4256
+ line += ` ${attributesHtmlStr}`;
4257
+ }
4258
+ if (node.extra) {
4259
+ if (node.extra.scrollable) {
4260
+ let scrollDataText = "";
4261
+ if (node.extra.scrollData?.left)
4262
+ scrollDataText += `left=${node.extra.scrollData.left}, `;
4263
+ if (node.extra.scrollData?.top)
4264
+ scrollDataText += `top=${node.extra.scrollData.top}, `;
4265
+ if (node.extra.scrollData?.right)
4266
+ scrollDataText += `right=${node.extra.scrollData.right}, `;
4267
+ if (node.extra.scrollData?.bottom)
4268
+ scrollDataText += `bottom=${node.extra.scrollData.bottom}`;
4269
+ line += ` data-scrollable="${scrollDataText}"`;
4270
+ }
4271
+ }
4272
+ if (text) {
4273
+ const trimmedText = text.trim();
4274
+ if (!attributesHtmlStr) {
4275
+ line += " ";
4276
+ }
4277
+ line += `>${trimmedText}`;
4278
+ }
4279
+ else if (!attributesHtmlStr) {
4280
+ line += " ";
4281
+ }
4282
+ line += " />";
4283
+ result.push(line);
4284
+ }
4285
+ for (const child of node.children) {
4286
+ processNode(child, nextDepth, result);
4287
+ }
4288
+ }
4289
+ else if (node.type === "text") {
4290
+ if (hasParentWithHighlightIndex(node)) {
4291
+ return;
4292
+ }
4293
+ if (node.parent &&
4294
+ node.parent.type === "element" &&
4295
+ node.parent.isVisible &&
4296
+ node.parent.isTopElement) {
4297
+ result.push(`${depthStr}${node.text ?? ""}`);
4298
+ }
4299
+ }
4300
+ };
4301
+ const result = [];
4302
+ processNode(rootNode, 0, result);
4303
+ return result.join("\n");
4304
+ }
4305
+ const getAllTextTillNextClickableElement = (node, maxDepth = -1) => {
4306
+ const textParts = [];
4307
+ const collectText = (currentNode, currentDepth) => {
4308
+ if (maxDepth !== -1 && currentDepth > maxDepth) {
4309
+ return;
4310
+ }
4311
+ if (currentNode.type === "element" &&
4312
+ currentNode !== node &&
4313
+ currentNode.highlightIndex !== undefined) {
4314
+ return;
4315
+ }
4316
+ if (currentNode.type === "text" && currentNode.text) {
4317
+ textParts.push(currentNode.text);
4318
+ }
4319
+ else if (currentNode.type === "element") {
4320
+ for (const child of currentNode.children) {
4321
+ collectText(child, currentDepth + 1);
4322
+ }
4323
+ }
4324
+ };
4325
+ collectText(node, 0);
4326
+ return textParts.join("\n").trim();
4327
+ };
4328
+ function getSelectorMap(flatTree) {
4329
+ const selectorMap = new Map();
4330
+ const keys = Object.keys(flatTree.map);
4331
+ for (const key of keys) {
4332
+ const node = flatTree.map[key];
4333
+ if (node.isInteractive && typeof node.highlightIndex === "number") {
4334
+ selectorMap.set(node.highlightIndex, node);
4335
+ }
4336
+ }
4337
+ return selectorMap;
4338
+ }
4339
+ function getElementTextMap(simplifiedHTML) {
4340
+ const lines = simplifiedHTML
4341
+ .split("\n")
4342
+ .map((line) => line.trim())
4343
+ .filter((line) => line.length > 0);
4344
+ const elementTextMap = new Map();
4345
+ for (const line of lines) {
4346
+ const regex = /^\[(\d+)\]<[^>]+>([^<]*)/;
4347
+ const match = regex.exec(line);
4348
+ if (match) {
4349
+ const index = parseInt(match[1], 10);
4350
+ elementTextMap.set(index, line);
4351
+ }
4352
+ }
4353
+ return elementTextMap;
4354
+ }
4355
+ function cleanUpHighlights() {
4356
+ const cleanupFunctions = window._highlightCleanupFunctions || [];
4357
+ for (const cleanup of cleanupFunctions) {
4358
+ if (typeof cleanup === "function") {
4359
+ cleanup();
4360
+ }
4361
+ }
4362
+ window._highlightCleanupFunctions = [];
4363
+ }
4364
+
4365
+ async function waitFor(seconds) {
4366
+ await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
4367
+ }
4368
+ function getElementByIndex(selectorMap, index) {
4369
+ const interactiveNode = selectorMap.get(index);
4370
+ if (!interactiveNode) {
4371
+ throw new Error(`No interactive element found at index ${index}`);
4372
+ }
4373
+ const element = interactiveNode.ref;
4374
+ if (!element) {
4375
+ throw new Error(`Element at index ${index} does not have a reference`);
4376
+ }
4377
+ if (!(element instanceof HTMLElement)) {
4378
+ throw new Error(`Element at index ${index} is not an HTMLElement`);
4379
+ }
4380
+ return element;
4381
+ }
4382
+ let lastClickedElement = null;
4383
+ function blurLastClickedElement() {
4384
+ if (lastClickedElement) {
4385
+ lastClickedElement.blur();
4386
+ lastClickedElement.dispatchEvent(new MouseEvent("mouseout", { bubbles: true, cancelable: true }));
4387
+ lastClickedElement = null;
4388
+ }
4389
+ }
4390
+ async function scrollIntoViewIfNeeded(element) {
4391
+ // Check if element is already in viewport
4392
+ const rect = element.getBoundingClientRect();
4393
+ const inViewport = rect.top >= 0 &&
4394
+ rect.bottom <= window.innerHeight &&
4395
+ rect.left >= 0 &&
4396
+ rect.right <= window.innerWidth;
4397
+ if (!inViewport) {
4398
+ element.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
4399
+ // Wait for smooth scroll animation to settle
4400
+ await waitFor(0.4);
4401
+ }
4402
+ }
4403
+ /** Move the visual AI cursor to the center of an element. */
4404
+ async function movePointerToElement(element) {
4405
+ const rect = element.getBoundingClientRect();
4406
+ const x = rect.left + rect.width / 2;
4407
+ const y = rect.top + rect.height / 2;
4408
+ window.dispatchEvent(new CustomEvent("HyphaDebugger::MovePointerTo", { detail: { x, y } }));
4409
+ await waitFor(0.3); // wait for cursor animation
4410
+ }
4411
+ async function clickElement(element) {
4412
+ blurLastClickedElement();
4413
+ lastClickedElement = element;
4414
+ await scrollIntoViewIfNeeded(element);
4415
+ await movePointerToElement(element);
4416
+ // Trigger click ripple animation
4417
+ window.dispatchEvent(new CustomEvent("HyphaDebugger::ClickPointer"));
4418
+ await waitFor(0.05);
4419
+ // hover
4420
+ element.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true, cancelable: true }));
4421
+ element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, cancelable: true }));
4422
+ // mouse sequence
4423
+ element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
4424
+ element.focus();
4425
+ element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
4426
+ element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
4427
+ await waitFor(0.2);
4428
+ }
4429
+ // Lazy-initialized to avoid "window is not defined" in Node/SSR
4430
+ let _nativeInputValueSetter = null;
4431
+ let _nativeTextAreaValueSetter = null;
4432
+ function getNativeInputValueSetter() {
4433
+ if (!_nativeInputValueSetter) {
4434
+ _nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
4435
+ }
4436
+ return _nativeInputValueSetter;
4437
+ }
4438
+ function getNativeTextAreaValueSetter() {
4439
+ if (!_nativeTextAreaValueSetter) {
4440
+ _nativeTextAreaValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value").set;
4441
+ }
4442
+ return _nativeTextAreaValueSetter;
4443
+ }
4444
+ async function inputTextElement(element, text) {
4445
+ const isContentEditable = element.isContentEditable;
4446
+ if (!(element instanceof HTMLInputElement) &&
4447
+ !(element instanceof HTMLTextAreaElement) &&
4448
+ !isContentEditable) {
4449
+ throw new Error("Element is not an input, textarea, or contenteditable");
4450
+ }
4451
+ await clickElement(element);
4452
+ if (isContentEditable) {
4453
+ // Clear
4454
+ if (element.dispatchEvent(new InputEvent("beforeinput", {
4455
+ bubbles: true,
4456
+ cancelable: true,
4457
+ inputType: "deleteContent",
4458
+ }))) {
4459
+ element.innerText = "";
4460
+ element.dispatchEvent(new InputEvent("input", {
4461
+ bubbles: true,
4462
+ inputType: "deleteContent",
4463
+ }));
4464
+ }
4465
+ // Insert
4466
+ if (element.dispatchEvent(new InputEvent("beforeinput", {
4467
+ bubbles: true,
4468
+ cancelable: true,
4469
+ inputType: "insertText",
4470
+ data: text,
4471
+ }))) {
4472
+ element.innerText = text;
4473
+ element.dispatchEvent(new InputEvent("input", {
4474
+ bubbles: true,
4475
+ inputType: "insertText",
4476
+ data: text,
4477
+ }));
4478
+ }
4479
+ element.dispatchEvent(new Event("change", { bubbles: true }));
4480
+ element.blur();
4481
+ }
4482
+ else if (element instanceof HTMLTextAreaElement) {
4483
+ getNativeTextAreaValueSetter().call(element, text);
4484
+ }
4485
+ else {
4486
+ getNativeInputValueSetter().call(element, text);
4487
+ }
4488
+ if (!isContentEditable) {
4489
+ element.dispatchEvent(new Event("input", { bubbles: true }));
4490
+ }
4491
+ await waitFor(0.1);
4492
+ blurLastClickedElement();
4493
+ }
4494
+ async function selectOptionElement(selectElement, optionText) {
4495
+ if (!(selectElement instanceof HTMLSelectElement)) {
4496
+ throw new Error("Element is not a select element");
4497
+ }
4498
+ await scrollIntoViewIfNeeded(selectElement);
4499
+ // Move cursor to element
4500
+ const rect = selectElement.getBoundingClientRect();
4501
+ window.dispatchEvent(new CustomEvent("HyphaDebugger::MovePointerTo", {
4502
+ detail: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
4503
+ }));
4504
+ await waitFor(0.3);
4505
+ window.dispatchEvent(new CustomEvent("HyphaDebugger::ClickPointer"));
4506
+ const options = Array.from(selectElement.options);
4507
+ const option = options.find((opt) => opt.textContent?.trim() === optionText.trim());
4508
+ if (!option) {
4509
+ throw new Error(`Option with text "${optionText}" not found in select element`);
4510
+ }
4511
+ selectElement.value = option.value;
4512
+ selectElement.dispatchEvent(new Event("change", { bubbles: true }));
4513
+ await waitFor(0.1);
4514
+ }
4515
+ async function scrollVertically(down, scroll_amount, element) {
4516
+ if (element) {
4517
+ let currentElement = element;
4518
+ let scrollSuccess = false;
4519
+ let scrolledElement = null;
4520
+ let scrollDelta = 0;
4521
+ let attempts = 0;
4522
+ const dy = scroll_amount;
4523
+ while (currentElement && attempts < 10) {
4524
+ const computedStyle = window.getComputedStyle(currentElement);
4525
+ const hasScrollableY = /(auto|scroll|overlay)/.test(computedStyle.overflowY);
4526
+ const canScrollVertically = currentElement.scrollHeight > currentElement.clientHeight;
4527
+ if (hasScrollableY && canScrollVertically) {
4528
+ const beforeScroll = currentElement.scrollTop;
4529
+ const maxScroll = currentElement.scrollHeight - currentElement.clientHeight;
4530
+ let scrollAmount = dy / 3;
4531
+ if (scrollAmount > 0) {
4532
+ scrollAmount = Math.min(scrollAmount, maxScroll - beforeScroll);
4533
+ }
4534
+ else {
4535
+ scrollAmount = Math.max(scrollAmount, -beforeScroll);
4536
+ }
4537
+ currentElement.scrollTop = beforeScroll + scrollAmount;
4538
+ const afterScroll = currentElement.scrollTop;
4539
+ const actualScrollDelta = afterScroll - beforeScroll;
4540
+ if (Math.abs(actualScrollDelta) > 0.5) {
4541
+ scrollSuccess = true;
4542
+ scrolledElement = currentElement;
4543
+ scrollDelta = actualScrollDelta;
4544
+ break;
4545
+ }
4546
+ }
4547
+ if (currentElement === document.body ||
4548
+ currentElement === document.documentElement) {
4549
+ break;
4550
+ }
4551
+ currentElement = currentElement.parentElement;
4552
+ attempts++;
4553
+ }
4554
+ if (scrollSuccess) {
4555
+ return `Scrolled container (${scrolledElement?.tagName}) by ${scrollDelta}px`;
4556
+ }
4557
+ else {
4558
+ return `No scrollable container found for element (${element.tagName})`;
4559
+ }
4560
+ }
4561
+ // Page-level scrolling
4562
+ const dy = scroll_amount;
4563
+ const bigEnough = (el) => el.clientHeight >= window.innerHeight * 0.5;
4564
+ const canScroll = (el) => el &&
4565
+ /(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) &&
4566
+ el.scrollHeight > el.clientHeight &&
4567
+ bigEnough(el);
4568
+ let el = document.activeElement;
4569
+ while (el && !canScroll(el) && el !== document.body)
4570
+ el = el.parentElement;
4571
+ el = canScroll(el)
4572
+ ? el
4573
+ : Array.from(document.querySelectorAll("*")).find(canScroll) ||
4574
+ document.scrollingElement ||
4575
+ document.documentElement;
4576
+ if (el === document.scrollingElement ||
4577
+ el === document.documentElement ||
4578
+ el === document.body) {
4579
+ const scrollBefore = window.scrollY;
4580
+ window.scrollBy(0, dy);
4581
+ const scrollAfter = window.scrollY;
4582
+ const scrolled = scrollAfter - scrollBefore;
4583
+ if (Math.abs(scrolled) < 1) {
4584
+ return dy > 0
4585
+ ? "Already at the bottom of the page."
4586
+ : "Already at the top of the page.";
4587
+ }
4588
+ const scrollMax = document.documentElement.scrollHeight - window.innerHeight;
4589
+ const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1;
4590
+ const reachedTop = dy < 0 && scrollAfter <= 1;
4591
+ if (reachedBottom)
4592
+ return `Scrolled page by ${scrolled}px. Reached the bottom.`;
4593
+ if (reachedTop)
4594
+ return `Scrolled page by ${scrolled}px. Reached the top.`;
4595
+ return `Scrolled page by ${scrolled}px.`;
4596
+ }
4597
+ else {
4598
+ const scrollBefore = el.scrollTop;
4599
+ const scrollMax = el.scrollHeight - el.clientHeight;
4600
+ el.scrollBy({ top: dy, behavior: "smooth" });
4601
+ await waitFor(0.1);
4602
+ const scrollAfter = el.scrollTop;
4603
+ const scrolled = scrollAfter - scrollBefore;
4604
+ if (Math.abs(scrolled) < 1) {
4605
+ return dy > 0
4606
+ ? `Already at the bottom of container (${el.tagName}).`
4607
+ : `Already at the top of container (${el.tagName}).`;
4608
+ }
4609
+ const reachedBottom = dy > 0 && scrollAfter >= scrollMax - 1;
4610
+ const reachedTop = dy < 0 && scrollAfter <= 1;
4611
+ if (reachedBottom)
4612
+ return `Scrolled container (${el.tagName}) by ${scrolled}px. Reached the bottom.`;
4613
+ if (reachedTop)
4614
+ return `Scrolled container (${el.tagName}) by ${scrolled}px. Reached the top.`;
4615
+ return `Scrolled container (${el.tagName}) by ${scrolled}px.`;
4616
+ }
4617
+ }
4618
+ async function scrollHorizontally(right, scroll_amount, element) {
4619
+ if (element) {
4620
+ let currentElement = element;
4621
+ let scrollSuccess = false;
4622
+ let scrolledElement = null;
4623
+ let scrollDelta = 0;
4624
+ let attempts = 0;
4625
+ const dx = right ? scroll_amount : -scroll_amount;
4626
+ while (currentElement && attempts < 10) {
4627
+ const computedStyle = window.getComputedStyle(currentElement);
4628
+ const hasScrollableX = /(auto|scroll|overlay)/.test(computedStyle.overflowX);
4629
+ const canScrollHorizontally = currentElement.scrollWidth > currentElement.clientWidth;
4630
+ if (hasScrollableX && canScrollHorizontally) {
4631
+ const beforeScroll = currentElement.scrollLeft;
4632
+ const maxScroll = currentElement.scrollWidth - currentElement.clientWidth;
4633
+ let scrollAmount = dx / 3;
4634
+ if (scrollAmount > 0) {
4635
+ scrollAmount = Math.min(scrollAmount, maxScroll - beforeScroll);
4636
+ }
4637
+ else {
4638
+ scrollAmount = Math.max(scrollAmount, -beforeScroll);
4639
+ }
4640
+ currentElement.scrollLeft = beforeScroll + scrollAmount;
4641
+ const afterScroll = currentElement.scrollLeft;
4642
+ const actualScrollDelta = afterScroll - beforeScroll;
4643
+ if (Math.abs(actualScrollDelta) > 0.5) {
4644
+ scrollSuccess = true;
4645
+ scrolledElement = currentElement;
4646
+ scrollDelta = actualScrollDelta;
4647
+ break;
4648
+ }
4649
+ }
4650
+ if (currentElement === document.body ||
4651
+ currentElement === document.documentElement) {
4652
+ break;
4653
+ }
4654
+ currentElement = currentElement.parentElement;
4655
+ attempts++;
4656
+ }
4657
+ if (scrollSuccess) {
4658
+ return `Scrolled container (${scrolledElement?.tagName}) horizontally by ${scrollDelta}px`;
4659
+ }
4660
+ else {
4661
+ return `No horizontally scrollable container found for element (${element.tagName})`;
4662
+ }
4663
+ }
4664
+ // Page-level horizontal scroll
4665
+ const dx = right ? scroll_amount : -scroll_amount;
4666
+ const bigEnough = (el) => el.clientWidth >= window.innerWidth * 0.5;
4667
+ const canScroll = (el) => el &&
4668
+ /(auto|scroll|overlay)/.test(getComputedStyle(el).overflowX) &&
4669
+ el.scrollWidth > el.clientWidth &&
4670
+ bigEnough(el);
4671
+ let el = document.activeElement;
4672
+ while (el && !canScroll(el) && el !== document.body)
4673
+ el = el.parentElement;
4674
+ el = canScroll(el)
4675
+ ? el
4676
+ : Array.from(document.querySelectorAll("*")).find(canScroll) ||
4677
+ document.scrollingElement ||
4678
+ document.documentElement;
4679
+ if (el === document.scrollingElement ||
4680
+ el === document.documentElement ||
4681
+ el === document.body) {
4682
+ const scrollBefore = window.scrollX;
4683
+ const scrollMax = document.documentElement.scrollWidth - window.innerWidth;
4684
+ window.scrollBy(dx, 0);
4685
+ const scrollAfter = window.scrollX;
4686
+ const scrolled = scrollAfter - scrollBefore;
4687
+ if (Math.abs(scrolled) < 1) {
4688
+ return dx > 0
4689
+ ? "Already at the right edge of the page."
4690
+ : "Already at the left edge of the page.";
4691
+ }
4692
+ const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1;
4693
+ const reachedLeft = dx < 0 && scrollAfter <= 1;
4694
+ if (reachedRight)
4695
+ return `Scrolled page by ${scrolled}px. Reached the right edge.`;
4696
+ if (reachedLeft)
4697
+ return `Scrolled page by ${scrolled}px. Reached the left edge.`;
4698
+ return `Scrolled page horizontally by ${scrolled}px.`;
4699
+ }
4700
+ else {
4701
+ const scrollBefore = el.scrollLeft;
4702
+ const scrollMax = el.scrollWidth - el.clientWidth;
4703
+ el.scrollBy({ left: dx, behavior: "smooth" });
4704
+ await waitFor(0.1);
4705
+ const scrollAfter = el.scrollLeft;
4706
+ const scrolled = scrollAfter - scrollBefore;
4707
+ if (Math.abs(scrolled) < 1) {
4708
+ return dx > 0
4709
+ ? `Already at the right edge of container (${el.tagName}).`
4710
+ : `Already at the left edge of container (${el.tagName}).`;
4711
+ }
4712
+ const reachedRight = dx > 0 && scrollAfter >= scrollMax - 1;
4713
+ const reachedLeft = dx < 0 && scrollAfter <= 1;
4714
+ if (reachedRight)
4715
+ return `Scrolled container (${el.tagName}) by ${scrolled}px. Reached the right edge.`;
4716
+ if (reachedLeft)
4717
+ return `Scrolled container (${el.tagName}) by ${scrolled}px. Reached the left edge.`;
4718
+ return `Scrolled container (${el.tagName}) horizontally by ${scrolled}px.`;
4719
+ }
4720
+ }
4721
+
4722
+ /**
4723
+ * Page info utilities: viewport, scroll position, page dimensions.
4724
+ * Adapted from @page-agent/page-controller (MIT License).
4725
+ */
4726
+ function getPageScrollInfo() {
4727
+ const viewport_width = window.innerWidth;
4728
+ const viewport_height = window.innerHeight;
4729
+ const page_width = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth || 0);
4730
+ const page_height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight || 0);
4731
+ const scroll_x = window.scrollX ||
4732
+ window.pageXOffset ||
4733
+ document.documentElement.scrollLeft ||
4734
+ 0;
4735
+ const scroll_y = window.scrollY ||
4736
+ window.pageYOffset ||
4737
+ document.documentElement.scrollTop ||
4738
+ 0;
4739
+ const pixels_below = Math.max(0, page_height - (window.innerHeight + scroll_y));
4740
+ const pixels_right = Math.max(0, page_width - (window.innerWidth + scroll_x));
4741
+ return {
4742
+ viewport_width,
4743
+ viewport_height,
4744
+ page_width,
4745
+ page_height,
4746
+ scroll_x,
4747
+ scroll_y,
4748
+ pixels_above: scroll_y,
4749
+ pixels_below,
4750
+ pages_above: viewport_height > 0 ? scroll_y / viewport_height : 0,
4751
+ pages_below: viewport_height > 0 ? pixels_below / viewport_height : 0,
4752
+ total_pages: viewport_height > 0 ? page_height / viewport_height : 0,
4753
+ current_page_position: scroll_y / Math.max(1, page_height - viewport_height),
4754
+ pixels_left: scroll_x,
4755
+ pixels_right,
4756
+ };
4757
+ }
4758
+
4759
+ /**
4760
+ * PageController: manages DOM state and element interactions.
4761
+ * Adapted from @page-agent/page-controller (MIT License).
4762
+ *
4763
+ * This wraps the smart DOM analysis (interactive element detection,
4764
+ * indexed element map) and provides an API for external agents.
4765
+ */
4766
+ class PageController {
4767
+ constructor(config = {}) {
4768
+ this.flatTree = null;
4769
+ this.selectorMap = new Map();
4770
+ this.elementTextMap = new Map();
4771
+ this.simplifiedHTML = "";
4772
+ this.isIndexed = false;
4773
+ this.config = config;
4774
+ }
4775
+ /**
4776
+ * Get structured browser state for LLM consumption.
4777
+ * Builds the DOM tree, highlights interactive elements, and returns
4778
+ * a simplified text representation with numeric indices.
4779
+ */
4780
+ async getBrowserState() {
4781
+ const url = window.location.href;
4782
+ const title = document.title;
4783
+ const pi = getPageScrollInfo();
4784
+ const viewportExpansion = resolveViewportExpansion(this.config.viewportExpansion);
4785
+ await this.updateTree();
4786
+ const content = this.simplifiedHTML;
4787
+ const titleLine = `Current Page: [${title}](${url})`;
4788
+ const pageInfoLine = `Page info: ${pi.viewport_width}x${pi.viewport_height}px viewport, ${pi.page_width}x${pi.page_height}px total, ${pi.pages_above.toFixed(1)} pages above, ${pi.pages_below.toFixed(1)} pages below, at ${(pi.current_page_position * 100).toFixed(0)}%`;
4789
+ const elementsLabel = viewportExpansion === -1
4790
+ ? "Interactive elements (full page):"
4791
+ : "Interactive elements (viewport):";
4792
+ const hasContentAbove = pi.pixels_above > 4;
4793
+ const scrollHintAbove = hasContentAbove && viewportExpansion !== -1
4794
+ ? `... ${pi.pixels_above} pixels above - scroll to see more ...`
4795
+ : "[Start of page]";
4796
+ const header = `${titleLine}\n${pageInfoLine}\n\n${elementsLabel}\n\n${scrollHintAbove}`;
4797
+ const hasContentBelow = pi.pixels_below > 4;
4798
+ const footer = hasContentBelow && viewportExpansion !== -1
4799
+ ? `... ${pi.pixels_below} pixels below - scroll to see more ...`
4800
+ : "[End of page]";
4801
+ return {
4802
+ url,
4803
+ title,
4804
+ header,
4805
+ content,
4806
+ footer,
4807
+ element_count: this.selectorMap.size,
4808
+ };
4809
+ }
4810
+ /**
4811
+ * Update DOM tree, returns simplified HTML for LLM.
4812
+ */
4813
+ async updateTree() {
4814
+ cleanUpHighlights();
4815
+ this.flatTree = getFlatTree(this.config);
4816
+ this.simplifiedHTML = flatTreeToString(this.flatTree, this.config.includeAttributes);
4817
+ this.selectorMap.clear();
4818
+ this.selectorMap = getSelectorMap(this.flatTree);
4819
+ this.elementTextMap.clear();
4820
+ this.elementTextMap = getElementTextMap(this.simplifiedHTML);
4821
+ this.isIndexed = true;
4822
+ return this.simplifiedHTML;
4823
+ }
4824
+ async cleanUpHighlights() {
4825
+ cleanUpHighlights();
4826
+ }
4827
+ assertIndexed() {
4828
+ if (!this.isIndexed) {
4829
+ throw new Error("DOM tree not indexed yet. Call get_browser_state first.");
4830
+ }
4831
+ }
4832
+ /** Clean up highlights after performing an action. */
4833
+ cleanUpAfterAction() {
4834
+ cleanUpHighlights();
4835
+ }
4836
+ async clickElement(index) {
4837
+ try {
4838
+ this.assertIndexed();
4839
+ const element = getElementByIndex(this.selectorMap, index);
4840
+ const elemText = this.elementTextMap.get(index);
4841
+ this.cleanUpAfterAction();
4842
+ await clickElement(element);
4843
+ if (element instanceof HTMLAnchorElement &&
4844
+ element.target === "_blank") {
4845
+ return {
4846
+ success: true,
4847
+ message: `Clicked element (${elemText ?? index}). Link opened in a new tab.`,
4848
+ };
4849
+ }
4850
+ return {
4851
+ success: true,
4852
+ message: `Clicked element (${elemText ?? index}).`,
4853
+ };
4854
+ }
4855
+ catch (error) {
4856
+ return {
4857
+ success: false,
4858
+ message: `Failed to click element: ${error}`,
4859
+ };
4860
+ }
4861
+ }
4862
+ async inputText(index, text) {
4863
+ try {
4864
+ this.assertIndexed();
4865
+ const element = getElementByIndex(this.selectorMap, index);
4866
+ const elemText = this.elementTextMap.get(index);
4867
+ this.cleanUpAfterAction();
4868
+ await inputTextElement(element, text);
4869
+ return {
4870
+ success: true,
4871
+ message: `Input text "${text}" into element (${elemText ?? index}).`,
4872
+ };
4873
+ }
4874
+ catch (error) {
4875
+ return {
4876
+ success: false,
4877
+ message: `Failed to input text: ${error}`,
4878
+ };
4879
+ }
4880
+ }
4881
+ async selectOption(index, optionText) {
4882
+ try {
4883
+ this.assertIndexed();
4884
+ const element = getElementByIndex(this.selectorMap, index);
4885
+ const elemText = this.elementTextMap.get(index);
4886
+ this.cleanUpAfterAction();
4887
+ await selectOptionElement(element, optionText);
4888
+ return {
4889
+ success: true,
4890
+ message: `Selected option "${optionText}" in element (${elemText ?? index}).`,
4891
+ };
4892
+ }
4893
+ catch (error) {
4894
+ return {
4895
+ success: false,
4896
+ message: `Failed to select option: ${error}`,
4897
+ };
4898
+ }
4899
+ }
4900
+ async scroll(options) {
4901
+ try {
4902
+ this.assertIndexed();
4903
+ this.cleanUpAfterAction();
4904
+ const { direction, amount, index } = options;
4905
+ const element = index !== undefined
4906
+ ? getElementByIndex(this.selectorMap, index)
4907
+ : null;
4908
+ let message;
4909
+ if (direction === "left" || direction === "right") {
4910
+ const pixels = amount ?? window.innerWidth * 0.8;
4911
+ message = await scrollHorizontally(direction === "right", pixels, element);
4912
+ }
4913
+ else {
4914
+ const pixels = amount ?? window.innerHeight * 0.8;
4915
+ const scrollAmount = direction === "down" ? pixels : -pixels;
4916
+ message = await scrollVertically(direction === "down", scrollAmount, element);
4917
+ }
4918
+ return { success: true, message };
4919
+ }
4920
+ catch (error) {
4921
+ return {
4922
+ success: false,
4923
+ message: `Failed to scroll: ${error}`,
4924
+ };
4925
+ }
4926
+ }
4927
+ dispose() {
4928
+ cleanUpHighlights();
4929
+ this.flatTree = null;
4930
+ this.selectorMap.clear();
4931
+ this.elementTextMap.clear();
4932
+ this.simplifiedHTML = "";
4933
+ this.isIndexed = false;
4934
+ }
4935
+ }
4936
+
4937
+ /**
4938
+ * Hypha RPC service wrappers for the PageController.
4939
+ *
4940
+ * These functions are schema-annotated for AI agent / LLM tool calling.
4941
+ * They provide smart DOM analysis with indexed interactive elements,
4942
+ * enabling agents to interact with pages by element index instead of
4943
+ * fragile CSS selectors.
4944
+ */
4945
+ // Singleton — shared across all service calls
4946
+ let controller = null;
4947
+ function getController() {
4948
+ if (!controller) {
4949
+ controller = new PageController({
4950
+ viewportExpansion: -1, // full page by default
4951
+ highlightOpacity: 0.1, // 10% fill on element boxes
4952
+ highlightLabelOpacity: 0.5, // 50% opacity on number labels + borders
4953
+ });
4954
+ }
4955
+ return controller;
4956
+ }
4957
+ /**
4958
+ * Get the current browser state: page info, scroll position, and a
4959
+ * simplified HTML representation with all interactive elements indexed
4960
+ * as [0], [1], [2], etc. Use the indices to call click_element_by_index,
4961
+ * input_text, select_option, or scroll.
4962
+ */
4963
+ async function getBrowserState(viewport_only) {
4964
+ const ctrl = getController();
4965
+ if (viewport_only !== undefined) {
4966
+ ctrl.config.viewportExpansion = viewport_only ? 0 : -1;
4967
+ }
4968
+ return ctrl.getBrowserState();
4969
+ }
4970
+ getBrowserState.__schema__ = {
4971
+ name: "getBrowserState",
4972
+ description: "Get the current page state with all interactive elements indexed as [0], [1], [2], etc. " +
4973
+ "Returns a simplified HTML representation optimized for LLM consumption. " +
4974
+ "Interactive elements (buttons, links, inputs, scrollable areas) are detected via smart heuristics " +
4975
+ "(CSS cursor, ARIA roles, event listeners, tag names). " +
4976
+ "Use the returned indices with click_element_by_index, input_text, select_option, or scroll. " +
4977
+ "Call this first to understand the page before performing any actions.",
4978
+ parameters: {
4979
+ type: "object",
4980
+ properties: {
4981
+ viewport_only: {
4982
+ type: "boolean",
4983
+ description: "If true, only return elements visible in the current viewport. Default: false (full page).",
4984
+ },
4985
+ },
4986
+ },
4987
+ };
4988
+ /**
4989
+ * Click an interactive element by its index from get_browser_state.
4990
+ */
4991
+ async function clickElementByIndex(index) {
4992
+ return getController().clickElement(index);
4993
+ }
4994
+ clickElementByIndex.__schema__ = {
4995
+ name: "clickElementByIndex",
4996
+ description: "Click an interactive element by its numeric index from get_browser_state output. " +
4997
+ "Simulates a full mouse event sequence (hover, mousedown, focus, mouseup, click) " +
4998
+ "to trigger all event listeners including React/Vue handlers.",
4999
+ parameters: {
5000
+ type: "object",
5001
+ properties: {
5002
+ index: {
5003
+ type: "number",
5004
+ description: "The element index from get_browser_state (e.g. 0 for [0], 5 for [5]).",
5005
+ },
5006
+ },
5007
+ required: ["index"],
5008
+ },
5009
+ };
5010
+ /**
5011
+ * Type text into an input, textarea, or contenteditable element by index.
5012
+ */
5013
+ async function inputText(index, text) {
5014
+ return getController().inputText(index, text);
5015
+ }
5016
+ inputText.__schema__ = {
5017
+ name: "inputText",
5018
+ description: "Type text into an input, textarea, or contenteditable element by its index. " +
5019
+ "Replaces existing content. Works with React controlled components, " +
5020
+ "contenteditable editors (LinkedIn, Quill), and native inputs.",
5021
+ parameters: {
5022
+ type: "object",
5023
+ properties: {
5024
+ index: {
5025
+ type: "number",
5026
+ description: "The element index from get_browser_state.",
5027
+ },
5028
+ text: {
5029
+ type: "string",
5030
+ description: "The text to type into the element.",
5031
+ },
5032
+ },
5033
+ required: ["index", "text"],
5034
+ },
5035
+ };
5036
+ /**
5037
+ * Select a dropdown option by element index and option text.
5038
+ */
5039
+ async function selectOption(index, option_text) {
5040
+ return getController().selectOption(index, option_text);
5041
+ }
5042
+ selectOption.__schema__ = {
5043
+ name: "selectOption",
5044
+ description: "Select a dropdown option in a <select> element by its index and the visible option text.",
5045
+ parameters: {
5046
+ type: "object",
5047
+ properties: {
5048
+ index: {
5049
+ type: "number",
5050
+ description: "The <select> element index from get_browser_state.",
5051
+ },
5052
+ option_text: {
5053
+ type: "string",
5054
+ description: "The visible text of the option to select (case-sensitive, trimmed).",
5055
+ },
5056
+ },
5057
+ required: ["index", "option_text"],
5058
+ },
5059
+ };
5060
+ /**
5061
+ * Scroll the page or a specific scrollable container.
5062
+ */
5063
+ async function scroll(direction, amount, index) {
5064
+ return getController().scroll({ direction, amount, index });
5065
+ }
5066
+ scroll.__schema__ = {
5067
+ name: "scroll",
5068
+ description: "Scroll the page or a specific scrollable container. " +
5069
+ "If index is provided, scrolls the nearest scrollable ancestor of that element. " +
5070
+ "Otherwise scrolls the page or the largest scrollable container.",
5071
+ parameters: {
5072
+ type: "object",
5073
+ properties: {
5074
+ direction: {
5075
+ type: "string",
5076
+ enum: ["up", "down", "left", "right"],
5077
+ description: "Scroll direction.",
5078
+ },
5079
+ amount: {
5080
+ type: "number",
5081
+ description: "Scroll amount in pixels. Default: ~80% of viewport height (vertical) or width (horizontal).",
5082
+ },
5083
+ index: {
5084
+ type: "number",
5085
+ description: "Optional element index. If provided, scrolls the nearest scrollable ancestor of this element.",
5086
+ },
5087
+ },
5088
+ required: ["direction"],
5089
+ },
5090
+ };
5091
+ /**
5092
+ * Remove all visual element highlights/labels from the page.
5093
+ */
5094
+ async function removeHighlights() {
5095
+ getController().cleanUpHighlights();
5096
+ return { success: true, message: "Highlights removed." };
5097
+ }
5098
+ removeHighlights.__schema__ = {
5099
+ name: "removeHighlights",
5100
+ description: "Remove all visual element index labels/highlights from the page. " +
5101
+ "Useful after taking a screenshot if you want a clean view.",
5102
+ parameters: {
5103
+ type: "object",
5104
+ properties: {},
5105
+ },
5106
+ };
5107
+ /**
5108
+ * Dispose the page controller (for cleanup).
5109
+ */
5110
+ function disposeController() {
5111
+ if (controller) {
5112
+ controller.dispose();
5113
+ controller = null;
5114
+ }
5115
+ }
5116
+
5117
+ /**
5118
+ * Core debugger class: connects to Hypha and registers the debug service.
5119
+ */
5120
+ /** Generate a cryptographically random hex string of `bytes` bytes. */
5121
+ function randomHex(bytes = 8) {
5122
+ const arr = new Uint8Array(bytes);
5123
+ crypto.getRandomValues(arr);
5124
+ return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
5125
+ }
5126
+ class HyphaDebugger {
5127
+ constructor(config) {
5128
+ this.overlay = null;
5129
+ this.cursor = null;
5130
+ this.server = null;
5131
+ this.serviceInfo = null;
5132
+ const requireToken = config.require_token ?? false;
5133
+ // Always append random suffix unless user provided a custom id.
5134
+ let serviceId = config.service_id ?? "web-debugger";
5135
+ if (!config.service_id) {
5136
+ serviceId = `web-debugger-${randomHex(16)}`;
5137
+ }
5138
+ // Derive visibility: require_token mode → protected, no-token → unlisted.
5139
+ // An explicit config.visibility always takes precedence.
5140
+ const visibility = config.visibility ?? (requireToken ? "protected" : "unlisted");
5141
+ this.config = {
5142
+ server_url: config.server_url,
5143
+ workspace: config.workspace ?? "",
5144
+ token: config.token ?? "",
5145
+ service_id: serviceId,
5146
+ service_name: config.service_name ?? "Web Debugger",
5147
+ show_ui: config.show_ui ?? true,
5148
+ visibility,
5149
+ require_token: requireToken,
5150
+ };
5151
+ }
5152
+ async start() {
5153
+ // Install console capture early
5154
+ installConsoleCapture();
5155
+ // Guard against double-injection
5156
+ const w = window;
5157
+ if (w.__HYPHA_DEBUGGER__?.instance) {
5158
+ console.warn("[hypha-debugger] Already running, returning existing session.");
5159
+ return w.__HYPHA_DEBUGGER__.session;
5160
+ }
5161
+ // Show UI if enabled
5162
+ if (this.config.show_ui) {
5163
+ this.overlay = new DebugOverlay();
5164
+ this.overlay.setStatus("disconnected");
5165
+ this.overlay.setInfo({ Status: "Connecting..." });
5166
+ // Initialize animated AI cursor
5167
+ this.cursor = new AICursor();
5168
+ }
5169
+ try {
5170
+ // Get the connectToServer function
5171
+ const connect = this.getConnectToServer();
5172
+ // Connect to Hypha server
5173
+ const connectConfig = {
5174
+ server_url: this.config.server_url,
5175
+ };
5176
+ if (this.config.workspace)
5177
+ connectConfig.workspace = this.config.workspace;
5178
+ if (this.config.token)
5179
+ connectConfig.token = this.config.token;
5180
+ this.server = await connect(connectConfig);
5181
+ // Register debug service
5182
+ this.serviceInfo = await this.server.registerService(this.buildServiceDefinition());
5183
+ // Update overlay and build session
5184
+ const session = await this.updateSession();
5185
+ if (this.overlay) {
5186
+ this.overlay.addLog("Service registered", "result");
5187
+ }
5188
+ // Store globally
5189
+ w.__HYPHA_DEBUGGER__ = w.__HYPHA_DEBUGGER__ ?? {};
5190
+ w.__HYPHA_DEBUGGER__.instance = this;
5191
+ return session;
5192
+ }
5193
+ catch (err) {
5194
+ console.error("[hypha-debugger] Failed to start:", err);
5195
+ if (this.overlay) {
5196
+ this.overlay.setStatus("error");
5197
+ this.overlay.setInfo({
5198
+ Status: "Error",
5199
+ Error: err.message ?? String(err),
5200
+ });
5201
+ }
5202
+ throw err;
5203
+ }
5204
+ }
5205
+ async destroy() {
5206
+ try {
5207
+ if (this.serviceInfo && this.server) {
5208
+ await this.server.unregisterService(this.serviceInfo.id);
5209
+ }
5210
+ }
5211
+ catch {
5212
+ // Ignore unregister errors on cleanup
5213
+ }
5214
+ disposeController();
5215
+ this.cursor?.destroy();
5216
+ this.cursor = null;
5217
+ this.overlay?.destroy();
5218
+ this.overlay = null;
5219
+ const w = window;
5220
+ if (w.__HYPHA_DEBUGGER__) {
5221
+ delete w.__HYPHA_DEBUGGER__.instance;
5222
+ delete w.__HYPHA_DEBUGGER__.session;
5223
+ }
5224
+ }
5225
+ /**
5226
+ * Generate token, build service URL, update overlay instructions, and
5227
+ * return a DebugSession.
5228
+ */
5229
+ async updateSession(extra) {
5230
+ const fullServiceId = this.serviceInfo?.id ?? this.config.service_id;
5231
+ const serviceUrl = this.buildServiceUrl(fullServiceId);
5232
+ const workspace = this.server.config?.workspace ?? "";
5233
+ // In no-token mode the URL itself is the secret — skip token generation.
5234
+ const sessionToken = this.config.require_token
5235
+ ? await this.server.generateToken({ expires_in: 86400 })
5236
+ : "";
5237
+ if (this.overlay) {
5238
+ this.overlay.setStatus("connected");
5239
+ this.overlay.setInfo({
5240
+ Status: "Connected",
5241
+ Server: this.config.server_url,
5242
+ ...extra,
5243
+ });
5244
+ this.overlay.setInstructions(this.buildInstructionBlock(serviceUrl, sessionToken));
5245
+ }
5246
+ console.log(`[hypha-debugger] Service URL: ${serviceUrl}`);
5247
+ if (sessionToken) {
5248
+ console.log(`[hypha-debugger] Token: ${sessionToken}`);
5249
+ console.log(`[hypha-debugger] Test:\n curl '${serviceUrl}/get_page_info' -H 'Authorization: Bearer ${sessionToken}'`);
5250
+ }
5251
+ else {
5252
+ console.log(`[hypha-debugger] Test:\n curl '${serviceUrl}/get_page_info'`);
5253
+ }
5254
+ const session = {
5255
+ service_id: fullServiceId,
5256
+ workspace,
5257
+ server: this.server,
5258
+ service_url: serviceUrl,
5259
+ token: sessionToken,
5260
+ destroy: () => this.destroy(),
5261
+ };
5262
+ // Always update global session
5263
+ const w = window;
5264
+ w.__HYPHA_DEBUGGER__ = w.__HYPHA_DEBUGGER__ ?? {};
5265
+ w.__HYPHA_DEBUGGER__.session = session;
5266
+ return session;
5267
+ }
5268
+ /**
5269
+ * Build a stable, predictable service URL.
5270
+ * Strips the clientId prefix so the URL uses only the bare service name.
5271
+ * Callers append ?_mode=last to resolve the most recent instance.
5272
+ */
5273
+ buildServiceUrl(serviceId) {
5274
+ const base = this.config.server_url.replace(/\/+$/, "");
5275
+ const slashIdx = serviceId.indexOf("/");
5276
+ if (slashIdx !== -1) {
5277
+ const workspace = serviceId.substring(0, slashIdx);
5278
+ const svcPart = serviceId.substring(slashIdx + 1);
5279
+ // Strip clientId: "abc123:web-debugger" → "web-debugger"
5280
+ const colonIdx = svcPart.indexOf(":");
5281
+ const svcName = colonIdx !== -1 ? svcPart.substring(colonIdx + 1) : svcPart;
5282
+ return `${base}/${workspace}/services/${svcName}`;
5283
+ }
5284
+ return `${base}/services/${serviceId}`;
5285
+ }
5286
+ getHyphaModule() {
5287
+ // Check the static import (works when hypha-rpc is bundled or npm-installed)
5288
+ if (hyphaRpc.connectToServer)
5289
+ return hyphaRpc;
5290
+ // hypha-rpc re-exports under a namespace
5291
+ if (hyphaRpc.hyphaWebsocketClient?.connectToServer)
5292
+ return hyphaRpc.hyphaWebsocketClient;
5293
+ // Fall back to global (when hypha-rpc loaded via separate script tag)
5294
+ const w = window;
5295
+ if (w.hyphaWebsocketClient?.connectToServer)
5296
+ return w.hyphaWebsocketClient;
5297
+ throw new Error("hypha-rpc not found. Install it via npm or load it via: " +
5298
+ '<script src="https://cdn.jsdelivr.net/npm/hypha-rpc@0.20.97/dist/hypha-rpc-websocket.min.js"></script>');
5299
+ }
5300
+ getConnectToServer() {
5301
+ return this.getHyphaModule().connectToServer;
5302
+ }
5303
+ buildServiceDefinition() {
5304
+ return {
5305
+ id: this.config.service_id,
5306
+ name: this.config.service_name,
5307
+ type: "debugger",
5308
+ description: "Remote web page debugger. Allows inspecting DOM, taking screenshots, executing JavaScript, and interacting with the page.",
5309
+ config: {
5310
+ visibility: this.config.visibility,
5311
+ },
5312
+ get_page_info: this.wrapFn(getPageInfo, "get_page_info"),
5313
+ get_html: this.wrapFn(getHtml, "get_html"),
5314
+ query_dom: this.wrapFn(queryDom, "query_dom"),
5315
+ click_element: this.wrapFn(clickElement$1, "click_element"),
5316
+ fill_input: this.wrapFn(fillInput, "fill_input"),
5317
+ scroll_to: this.wrapFn(scrollTo, "scroll_to"),
5318
+ take_screenshot: this.wrapFn(takeScreenshot, "take_screenshot"),
5319
+ execute_script: this.wrapFn(executeScript, "execute_script"),
5320
+ navigate: this.wrapFn(navigate, "navigate"),
5321
+ get_react_tree: this.wrapFn(getReactTree, "get_react_tree"),
5322
+ // Smart DOM analysis + index-based interaction (from page-controller)
5323
+ get_browser_state: this.wrapFn(getBrowserState, "get_browser_state"),
5324
+ click_element_by_index: this.wrapFn(clickElementByIndex, "click_element_by_index"),
5325
+ input_text: this.wrapFn(inputText, "input_text"),
5326
+ select_option: this.wrapFn(selectOption, "select_option"),
5327
+ scroll: this.wrapFn(scroll, "scroll"),
5328
+ remove_highlights: this.wrapFn(removeHighlights, "remove_highlights"),
5329
+ get_skill_md: this.wrapFn(this.createGetSkillMd(), "get_skill_md"),
5330
+ };
5331
+ }
5332
+ createGetSkillMd() {
5333
+ const fn = () => {
5334
+ // Build a schema-only map (avoid calling buildServiceDefinition which would recurse)
5335
+ const schemaFns = {};
5336
+ const fns = {
5337
+ get_page_info: getPageInfo, get_html: getHtml,
5338
+ query_dom: queryDom, click_element: clickElement$1, fill_input: fillInput,
5339
+ scroll_to: scrollTo, take_screenshot: takeScreenshot,
5340
+ execute_script: executeScript, navigate: navigate,
5341
+ get_react_tree: getReactTree,
5342
+ // Smart DOM analysis + index-based interaction
5343
+ get_browser_state: getBrowserState,
5344
+ click_element_by_index: clickElementByIndex,
5345
+ input_text: inputText, select_option: selectOption,
5346
+ scroll: scroll, remove_highlights: removeHighlights,
5347
+ };
5348
+ for (const [name, f] of Object.entries(fns)) {
5349
+ if (f.__schema__)
5350
+ schemaFns[name] = f;
5351
+ }
5352
+ this.serviceInfo
5353
+ ? this.buildServiceUrl(this.serviceInfo.id ?? this.config.service_id)
5354
+ : "{SERVICE_URL}";
5355
+ return generateSkillMd(schemaFns);
5356
+ };
5357
+ fn.__schema__ = {
5358
+ name: "getSkillMd",
5359
+ description: "Get the SKILL.md document describing all available debugger functions, their parameters, and usage examples. Follows the agentskills.io specification.",
5360
+ parameters: {
5361
+ type: "object",
5362
+ properties: {},
5363
+ },
5364
+ };
5365
+ return fn;
5366
+ }
5367
+ /** Build the instruction block for the overlay panel. */
5368
+ buildInstructionBlock(serviceUrl, token) {
5369
+ const auth = token ? ` -H "Authorization: Bearer $TOKEN"` : "";
5370
+ const lines = [
5371
+ `# Hypha Remote Debugger — Web Page`,
5372
+ `# A debugger is attached to a live web page.`,
5373
+ `# You can remotely inspect, interact with, and control this page via the HTTP API below.`,
5374
+ `#`,
5375
+ `# RECOMMENDED WORKFLOW (index-based, most reliable):`,
5376
+ `# 1. get_browser_state → see all interactive elements as [0], [1], [2], ...`,
5377
+ `# 2. click_element_by_index / input_text / select_option / scroll → act by index`,
5378
+ `# 3. take_screenshot → verify the result visually`,
5379
+ `#`,
5380
+ `# Also available: get_page_info, get_html, query_dom, click_element, fill_input,`,
5381
+ `# scroll_to, execute_script, navigate, get_react_tree, remove_highlights`,
5382
+ `#`,
5383
+ `# POST endpoints accept JSON body with parameter names as keys.`,
5384
+ ``,
5385
+ `SERVICE_URL="${serviceUrl}"`,
5386
+ ];
5387
+ if (token) {
5388
+ lines.push(`TOKEN="${token}"`);
5389
+ }
5390
+ lines.push(``, `# 1. Get interactive elements (smart DOM analysis with indexed elements):`, `curl "$SERVICE_URL/get_browser_state"${auth}`, ``, `# 2. Click element by index (e.g. click [3]):`, `curl -X POST "$SERVICE_URL/click_element_by_index"${auth} -H "Content-Type: application/json" -d '{"index": 3}'`, ``, `# 3. Type into an input by index:`, `curl -X POST "$SERVICE_URL/input_text"${auth} -H "Content-Type: application/json" -d '{"index": 5, "text": "hello"}'`, ``, `# Take a screenshot:`, `curl "$SERVICE_URL/take_screenshot"${auth}`, ``, `# Execute JavaScript remotely:`, `curl -X POST "$SERVICE_URL/execute_script"${auth} -H "Content-Type: application/json" -d '{"code": "document.title"}'`, ``, `# Full API docs:`, `curl "$SERVICE_URL/get_skill_md"${auth}`);
5391
+ return lines.join("\n");
2370
5392
  }
2371
5393
  /** Wrap a service function with logging and kwargs-to-positional-args support. */
2372
5394
  wrapFn(fn, name) {
@@ -2454,7 +5476,8 @@ async function startDebugger(config) {
2454
5476
  /**
2455
5477
  * Auto-start: when loaded via <script> tag, automatically start the debugger.
2456
5478
  * Configuration can be provided via data-* attributes on the script tag:
2457
- * data-server-url, data-workspace, data-token, data-service-id, data-no-ui
5479
+ * data-server-url, data-workspace, data-token, data-service-id, data-no-ui,
5480
+ * data-require-token
2458
5481
  *
2459
5482
  * Set data-manual to disable auto-start.
2460
5483
  */
@@ -2492,6 +5515,9 @@ function autoStart() {
2492
5515
  if (scriptEl?.hasAttribute("data-no-ui")) {
2493
5516
  config.show_ui = false;
2494
5517
  }
5518
+ if (scriptEl?.hasAttribute("data-require-token")) {
5519
+ config.require_token = true;
5520
+ }
2495
5521
  startDebugger(config).catch((err) => {
2496
5522
  console.error("[hypha-debugger] Auto-start failed:", err);
2497
5523
  });