pi-chrome 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,8 +4,6 @@ const POLL_ERROR_BACKOFF_MS = 2000;
4
4
  let polling = false;
5
5
 
6
6
  function armKeepaliveAlarm() {
7
- // MV3 service workers can be suspended; alarms are the supported way to
8
- // wake the extension again. Chrome's minimum period is 0.5 minutes.
9
7
  chrome.alarms.create("pi-bridge-keepalive", { periodInMinutes: 0.5 });
10
8
  }
11
9
 
@@ -41,17 +39,12 @@ async function pollLoop() {
41
39
  polling = true;
42
40
  try {
43
41
  while (true) {
44
- // Long-poll /next continuously. The bridge holds the request for up to ~25s when no
45
- // command is pending and returns {type:"none"}; we immediately re-issue the fetch so
46
- // commands sent while the SW is otherwise idle still get picked up promptly. The open
47
- // fetch also keeps the MV3 service worker alive between alarm wake-ups.
48
42
  const response = await fetch(`${BRIDGE_URL}/next?name=${encodeURIComponent(CLIENT_NAME)}`, {
49
43
  cache: "no-store",
50
44
  });
51
45
  if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`);
52
46
  const payload = await response.json();
53
47
  if (payload.type === "command") await handleCommand(payload.command);
54
- // Otherwise (type:"none"), loop and re-issue the long-poll.
55
48
  }
56
49
  } catch (error) {
57
50
  await sleep(POLL_ERROR_BACKOFF_MS);
@@ -107,49 +100,57 @@ async function dispatch(action, params) {
107
100
  return { closed: tab.id };
108
101
  }
109
102
  case "page.snapshot":
110
- return executeInTab(params, snapshotPage, [params.maxElements || 80]);
103
+ return executeInTab(params, snapshotPage, [
104
+ params.maxElements || 80,
105
+ params.containingText ?? null,
106
+ params.roleFilter ?? null,
107
+ params.nearUid ?? null,
108
+ ]);
111
109
  case "page.evaluate":
112
- return executeInTab(params, evaluateExpression, [params.expression, params.awaitPromise !== false]);
110
+ return evaluateInTab(params);
113
111
  case "page.click":
114
- return executeInTab(params, clickPage, [params.selector ?? null, params.x ?? null, params.y ?? null]);
112
+ return executeActionInTab(params, clickPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
113
+ case "page.hover":
114
+ return executeActionInTab(params, hoverPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
115
+ case "page.drag":
116
+ return executeActionInTab(params, dragPage, [params.fromUid ?? null, params.fromSelector ?? null, params.fromX ?? null, params.fromY ?? null, params.toUid ?? null, params.toSelector ?? null, params.toX ?? null, params.toY ?? null, params.steps ?? 12]);
117
+ case "page.upload":
118
+ return executeActionInTab(params, uploadFiles, [params.selector ?? null, params.uid ?? null, params.files || []]);
115
119
  case "page.type":
116
- return executeInTab(params, typeIntoPage, [params.selector ?? null, params.text || "", Boolean(params.pressEnter)]);
120
+ return executeActionInTab(params, typeIntoPage, [params.selector ?? null, params.uid ?? null, params.text || "", Boolean(params.pressEnter)]);
121
+ case "page.fill":
122
+ return executeActionInTab(params, fillPage, [params.selector ?? null, params.uid ?? null, params.text || "", params.submit === true]);
117
123
  case "page.key":
118
- return executeInTab(params, pressKeyInPage, [params.key]);
124
+ return executeActionInTab(params, pressKeyInPage, [params.key]);
125
+ case "page.console.list":
126
+ return executeInTab(params, listConsoleMessages, [params.clear === true]);
127
+ case "page.network.list":
128
+ return executeInTab(params, listNetworkRequests, [params.includePreservedRequests === true, params.clear === true]);
129
+ case "page.network.get":
130
+ return executeInTab(params, getNetworkRequest, [params.requestId]);
119
131
  case "page.waitFor":
120
132
  return executeInTab(params, waitForPage, [params.kind, params.value, params.timeoutMs || 10000, params.intervalMs || 250]);
133
+ case "page.probe":
134
+ // Lightweight capability probe for /chrome-doctor. Runs in MAIN world.
135
+ return executeInTab(params, probePage, []);
121
136
  case "page.navigate": {
122
137
  const tab = await getTabByParams(params);
123
138
  if (params.foreground) await bringToFront(tab);
139
+ if (params.initScript) {
140
+ // Register a one-shot document_start content script. We register, navigate, wait, then unregister.
141
+ await registerInitScript(tab.id, params.initScript);
142
+ }
124
143
  const wait = params.waitUntilLoad !== false ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined);
125
144
  const updated = await chrome.tabs.update(tab.id, { url: params.url });
126
- await wait;
127
- return formatTab(await chrome.tabs.get(updated.id));
128
- }
129
- case "page.screenshot": {
130
- const tab = await getTabByParams(params);
131
- if (params.foreground) await bringToFront(tab);
132
- // captureVisibleTab requires the target tab to be the active tab in its window. Activate it
133
- // without focusing the window so other apps don't get pushed behind Chrome, and restore the
134
- // previous active tab afterwards to minimize disruption.
135
- let previousActiveId;
136
- if (!tab.active) {
137
- const activeBefore = await chrome.tabs.query({ active: true, windowId: tab.windowId });
138
- previousActiveId = activeBefore[0]?.id;
139
- await chrome.tabs.update(tab.id, { active: true });
140
- }
141
145
  try {
142
- const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
143
- format: params.format || "png",
144
- quality: params.format === "jpeg" ? params.quality : undefined,
145
- });
146
- return { dataUrl, tab: formatTab(tab) };
146
+ await wait;
147
147
  } finally {
148
- if (previousActiveId !== undefined && previousActiveId !== tab.id) {
149
- await chrome.tabs.update(previousActiveId, { active: true }).catch(() => undefined);
150
- }
148
+ if (params.initScript) await unregisterInitScript(tab.id).catch(() => undefined);
151
149
  }
150
+ return formatTab(await chrome.tabs.get(updated.id));
152
151
  }
152
+ case "page.screenshot":
153
+ return takeScreenshot(params);
153
154
  default:
154
155
  throw new Error(`Unknown action: ${action}`);
155
156
  }
@@ -190,21 +191,46 @@ async function getTabByParams(params) {
190
191
  return tab;
191
192
  }
192
193
 
194
+ // Helper sources that get concatenated into the injected MAIN-world script. Kept as separate
195
+ // functions so callers below can reference them by `.toString()`. The helpers do not perform any
196
+ // eval themselves — they're plain function declarations.
197
+ const HELPER_FUNCS = [
198
+ getPiChromeState,
199
+ rememberElement,
200
+ elementBySelectorOrUid,
201
+ installPiChromeInstrumentation,
202
+ resolvePoint,
203
+ dispatchInputEvents,
204
+ setNativeValue,
205
+ normalizeKey,
206
+ isElementVisible,
207
+ occluderAt,
208
+ pageHash,
209
+ pointerEventSequence,
210
+ ];
211
+
193
212
  async function executeInTab(params, func, args) {
194
213
  const tab = await getTabByParams(params);
195
214
  if (params.foreground) await bringToFront(tab);
215
+ const helperSource = HELPER_FUNCS.map((helper) => helper.toString()).join("\n");
196
216
  const results = await chrome.scripting.executeScript({
197
217
  target: { tabId: tab.id },
198
218
  world: "MAIN",
199
- func: async (source, invocationArgs) => {
219
+ func: async (helperSource, source, invocationArgs) => {
200
220
  try {
201
- const injected = (0, eval)(`(${source})`);
221
+ // Helpers are plain function declarations; injecting them via Function constructor avoids
222
+ // running through `eval` (which is restricted under strict CSP) and keeps them isolated.
223
+ new Function(helperSource).call(globalThis);
224
+ // The action itself is reconstructed from its source text. We use `new Function` rather
225
+ // than `eval` because the latter is blocked by `script-src 'self'` (no `'unsafe-eval'`)
226
+ // CSPs that are common on production sites.
227
+ const injected = new Function(helperSource + "\nreturn (" + source + ");").call(globalThis);
202
228
  return { ok: true, value: await injected(...invocationArgs) };
203
229
  } catch (error) {
204
230
  return { ok: false, error: error?.stack || error?.message || String(error) };
205
231
  }
206
232
  },
207
- args: [func.toString(), args],
233
+ args: [helperSource, func.toString(), args],
208
234
  });
209
235
  const first = results?.[0];
210
236
  if (first?.error) {
@@ -218,6 +244,108 @@ async function executeInTab(params, func, args) {
218
244
  return envelope?.value;
219
245
  }
220
246
 
247
+ // Dedicated executor for page.evaluate. Doesn't go through the helper-source injection chain;
248
+ // that chain was the root cause of `chrome_evaluate` silently returning null on pages with strict
249
+ // CSP. We build a single Function in MAIN world and invoke it directly.
250
+ async function evaluateInTab(params) {
251
+ const tab = await getTabByParams(params);
252
+ if (params.foreground) await bringToFront(tab);
253
+ const expression = String(params.expression ?? "");
254
+ const awaitPromise = params.awaitPromise !== false;
255
+ const results = await chrome.scripting.executeScript({
256
+ target: { tabId: tab.id },
257
+ world: "MAIN",
258
+ func: async (expression, awaitPromise) => {
259
+ const stringify = (v) => {
260
+ if (v === undefined) return { kind: "undefined" };
261
+ if (typeof v === "function") return { kind: "function", source: v.toString().slice(0, 500) };
262
+ if (typeof v === "symbol") return { kind: "symbol", description: v.description };
263
+ if (typeof v === "bigint") return { kind: "bigint", value: v.toString() };
264
+ if (v instanceof Error) return { kind: "error", name: v.name, message: v.message, stack: v.stack };
265
+ return v;
266
+ };
267
+ // Compile via the Function constructor. We try expression form first so callers can pass
268
+ // `1+1` or `document.title` without a `return`; if that's a SyntaxError we retry with the
269
+ // statement form so callers can use multi-statement bodies (loops, var decls, etc).
270
+ const compile = (src) => {
271
+ try {
272
+ return { fn: new Function(`return (async () => (${src}))();`), mode: "expression" };
273
+ } catch (e1) {
274
+ if (e1 && e1.name === "SyntaxError") {
275
+ try {
276
+ return { fn: new Function(`return (async () => { ${src} })();`), mode: "statement" };
277
+ } catch (e2) {
278
+ throw e2;
279
+ }
280
+ }
281
+ throw e1;
282
+ }
283
+ };
284
+ try {
285
+ const { fn } = compile(expression);
286
+ const value = await fn.call(globalThis);
287
+ const resolved = awaitPromise && value && typeof value.then === "function" ? await value : value;
288
+ return { ok: true, value: stringify(resolved) };
289
+ } catch (error) {
290
+ return { ok: false, error: error?.stack || error?.message || String(error) };
291
+ }
292
+ },
293
+ args: [expression, awaitPromise],
294
+ });
295
+ const first = results?.[0];
296
+ if (first?.error) {
297
+ const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
298
+ throw new Error(`chrome_evaluate failed: ${message}`);
299
+ }
300
+ const envelope = first?.result;
301
+ if (!envelope) throw new Error("chrome_evaluate returned no envelope from MAIN world");
302
+ if (envelope.ok === false) throw new Error(envelope.error || "chrome_evaluate failed");
303
+ const v = envelope.value;
304
+ // Unwrap special markers from MAIN world
305
+ if (v && typeof v === "object" && !Array.isArray(v)) {
306
+ if (v.kind === "undefined") return undefined;
307
+ if (v.kind === "function") return `[Function: ${v.source}]`;
308
+ if (v.kind === "symbol") return `[Symbol: ${v.description}]`;
309
+ if (v.kind === "bigint") return v.value;
310
+ if (v.kind === "error") throw new Error(`${v.name}: ${v.message}\n${v.stack || ""}`);
311
+ }
312
+ return v;
313
+ }
314
+
315
+ async function executeActionInTab(params, func, args) {
316
+ const result = await executeInTab(params, func, args);
317
+ if (params.includeSnapshot) {
318
+ const snapshot = await executeInTab({ ...params, foreground: false }, snapshotPage, [params.maxElements || 80, null, null, null]);
319
+ return { result, snapshot };
320
+ }
321
+ return result;
322
+ }
323
+
324
+ // One-shot init script registry, scoped per tab. The script source is injected at
325
+ // document_start of the next committed navigation in that tab, in MAIN world, then cleared.
326
+ const initScriptIds = new Map();
327
+ async function registerInitScript(tabId, source) {
328
+ initScriptIds.set(tabId, source);
329
+ }
330
+ async function unregisterInitScript(tabId) {
331
+ initScriptIds.delete(tabId);
332
+ }
333
+
334
+ if (chrome.webNavigation && chrome.webNavigation.onCommitted) {
335
+ chrome.webNavigation.onCommitted.addListener((details) => {
336
+ if (details.frameId !== 0) return;
337
+ const source = initScriptIds.get(details.tabId);
338
+ if (!source) return;
339
+ chrome.scripting.executeScript({
340
+ target: { tabId: details.tabId, frameIds: [0] },
341
+ world: "MAIN",
342
+ injectImmediately: true,
343
+ func: (code) => { try { new Function(code).call(globalThis); } catch (e) { console.error("[pi-chrome init script]", e); } },
344
+ args: [source],
345
+ }).catch(() => undefined);
346
+ });
347
+ }
348
+
221
349
  async function bringToFront(tab) {
222
350
  await chrome.windows.update(tab.windowId, { focused: true });
223
351
  await chrome.tabs.update(tab.id, { active: true });
@@ -240,7 +368,268 @@ function waitForTabComplete(tabId, timeoutMs) {
240
368
  });
241
369
  }
242
370
 
243
- function snapshotPage(maxElements) {
371
+ async function takeScreenshot(params) {
372
+ const tab = await getTabByParams(params);
373
+ if (params.foreground) await bringToFront(tab);
374
+ let previousActiveId;
375
+ if (!tab.active) {
376
+ const activeBefore = await chrome.tabs.query({ active: true, windowId: tab.windowId });
377
+ previousActiveId = activeBefore[0]?.id;
378
+ await chrome.tabs.update(tab.id, { active: true });
379
+ }
380
+ try {
381
+ if (params.fullPage) {
382
+ // Tile-stitched full page capture: scroll, capture, paste, repeat.
383
+ const tiles = await executeInTab({ ...params, foreground: false }, captureFullPageTiles, []);
384
+ // captureFullPageTiles only computes scroll positions / metrics; we capture per scroll here
385
+ // (chrome.tabs.captureVisibleTab can't be called from MAIN world).
386
+ const captured = [];
387
+ for (const tile of tiles.tiles) {
388
+ await executeInTab({ ...params, foreground: false }, scrollToY, [tile.scrollY]);
389
+ // Small settle delay; many sites have on-scroll animations / lazy-load.
390
+ await sleep(120);
391
+ const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
392
+ format: params.format || "png",
393
+ quality: params.format === "jpeg" ? params.quality : undefined,
394
+ });
395
+ captured.push({ y: tile.y, dataUrl });
396
+ }
397
+ await executeInTab({ ...params, foreground: false }, scrollToY, [tiles.originalScrollY]);
398
+ return {
399
+ fullPage: true,
400
+ tab: formatTab(tab),
401
+ dimensions: { width: tiles.width, height: tiles.height, viewportHeight: tiles.viewportHeight, dpr: tiles.dpr },
402
+ tiles: captured,
403
+ };
404
+ }
405
+ const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
406
+ format: params.format || "png",
407
+ quality: params.format === "jpeg" ? params.quality : undefined,
408
+ });
409
+ return { dataUrl, tab: formatTab(tab) };
410
+ } finally {
411
+ if (previousActiveId !== undefined && previousActiveId !== tab.id) {
412
+ await chrome.tabs.update(previousActiveId, { active: true }).catch(() => undefined);
413
+ }
414
+ }
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // MAIN-world helpers (function declarations injected into the page).
419
+ // ---------------------------------------------------------------------------
420
+
421
+ function getPiChromeState() {
422
+ const state = window.__PI_CHROME_STATE__ || {
423
+ nextElementUid: 1,
424
+ elements: {},
425
+ console: [],
426
+ network: [],
427
+ nextRequestId: 1,
428
+ instrumentationInstalled: false,
429
+ };
430
+ window.__PI_CHROME_STATE__ = state;
431
+ return state;
432
+ }
433
+
434
+ function rememberElement(element) {
435
+ const state = getPiChromeState();
436
+ if (!element.__piChromeUid) element.__piChromeUid = "el-" + state.nextElementUid++;
437
+ state.elements[element.__piChromeUid] = element;
438
+ return element.__piChromeUid;
439
+ }
440
+
441
+ function elementBySelectorOrUid(selector, uid) {
442
+ if (uid) {
443
+ const element = getPiChromeState().elements[uid];
444
+ if (!element || !element.isConnected) throw new Error(`No live element for uid: ${uid}. Take a fresh chrome_snapshot.`);
445
+ return element;
446
+ }
447
+ if (selector) {
448
+ const element = document.querySelector(selector);
449
+ if (!element) throw new Error(`No element matches selector: ${selector}`);
450
+ return element;
451
+ }
452
+ return null;
453
+ }
454
+
455
+ function isElementVisible(element) {
456
+ if (!element || !element.getBoundingClientRect) return false;
457
+ const style = getComputedStyle(element);
458
+ if (style.visibility === "hidden" || style.display === "none") return false;
459
+ const rect = element.getBoundingClientRect();
460
+ if (rect.width === 0 || rect.height === 0) return false;
461
+ if (rect.bottom < 0 || rect.right < 0) return false;
462
+ if (rect.top > innerHeight || rect.left > innerWidth) return false;
463
+ return true;
464
+ }
465
+
466
+ function occluderAt(x, y, expected) {
467
+ const top = document.elementFromPoint(x, y);
468
+ if (!top || top === expected) return null;
469
+ if (expected && expected.contains(top)) return null;
470
+ if (top.contains(expected)) return null;
471
+ return {
472
+ tag: top.tagName.toLowerCase(),
473
+ id: top.id || undefined,
474
+ className: typeof top.className === "string" ? top.className : undefined,
475
+ };
476
+ }
477
+
478
+ function pageHash() {
479
+ // Cheap rolling hash used for `pageMutated`. Combines first 4kb of body innerText with the
480
+ // current values of inputs/textareas (which are not part of innerText) and the count of
481
+ // descendants of <body>. This catches: text changes, input value edits, and DOM structure
482
+ // changes — the three things a click/type/fill might cause.
483
+ const body = document.body;
484
+ const text = (body ? body.innerText : "").slice(0, 4000);
485
+ let h = 0;
486
+ for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
487
+ if (body) {
488
+ const inputs = body.querySelectorAll("input,textarea,select");
489
+ let valueBlob = "";
490
+ for (let i = 0; i < inputs.length && valueBlob.length < 4000; i++) {
491
+ const v = inputs[i].value;
492
+ if (typeof v === "string") valueBlob += v + "\x00";
493
+ }
494
+ for (let i = 0; i < valueBlob.length; i++) h = (h * 31 + valueBlob.charCodeAt(i)) | 0;
495
+ h = (h * 31 + body.getElementsByTagName("*").length) | 0;
496
+ }
497
+ return h;
498
+ }
499
+
500
+ function pointerEventSequence(element, x, y, sequence) {
501
+ let defaultPrevented = false;
502
+ for (const type of sequence) {
503
+ const isPointer = type.startsWith("pointer");
504
+ const Ctor = isPointer ? PointerEvent : MouseEvent;
505
+ const init = {
506
+ bubbles: true,
507
+ cancelable: true,
508
+ view: window,
509
+ clientX: x,
510
+ clientY: y,
511
+ button: 0,
512
+ buttons: type === "pointermove" || type === "mousemove" ? 0 : 1,
513
+ };
514
+ if (isPointer) {
515
+ init.pointerType = "mouse";
516
+ init.pointerId = 1;
517
+ init.isPrimary = true;
518
+ }
519
+ const ev = new Ctor(type, init);
520
+ element.dispatchEvent(ev);
521
+ if (ev.defaultPrevented) defaultPrevented = true;
522
+ }
523
+ return defaultPrevented;
524
+ }
525
+
526
+ function installPiChromeInstrumentation() {
527
+ const state = getPiChromeState();
528
+ if (state.instrumentationInstalled) return;
529
+ state.instrumentationInstalled = true;
530
+ const pushConsole = (level, args) => {
531
+ state.console.push({
532
+ id: state.console.length + 1,
533
+ level,
534
+ timestamp: Date.now(),
535
+ url: location.href,
536
+ args: Array.from(args).map((arg) => {
537
+ try {
538
+ if (typeof arg === "string") return arg;
539
+ if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack };
540
+ return JSON.parse(JSON.stringify(arg));
541
+ } catch {
542
+ return String(arg);
543
+ }
544
+ }),
545
+ });
546
+ if (state.console.length > 500) state.console.splice(0, state.console.length - 500);
547
+ };
548
+ for (const level of ["debug", "log", "info", "warn", "error"]){
549
+ const original = console[level];
550
+ if (typeof original !== "function" || original.__piChromeWrapped) continue;
551
+ const wrapped = function(...args) {
552
+ pushConsole(level, args);
553
+ return original.apply(this, args);
554
+ };
555
+ wrapped.__piChromeWrapped = true;
556
+ console[level] = wrapped;
557
+ }
558
+ window.addEventListener("error", (event) => pushConsole("pageerror", [event.message, event.filename + ":" + event.lineno + ":" + event.colno]));
559
+ window.addEventListener("unhandledrejection", (event) => pushConsole("unhandledrejection", [event.reason]));
560
+
561
+ const trimBody = (text) => typeof text === "string" && text.length > 200000 ? text.slice(0, 200000) + `\n[truncated ${text.length - 200000} chars]` : text;
562
+ const record = (entry) => {
563
+ state.network.push(entry);
564
+ if (state.network.length > 1000) state.network.splice(0, state.network.length - 1000);
565
+ return entry;
566
+ };
567
+ if (window.fetch && !window.fetch.__piChromeWrapped) {
568
+ const originalFetch = window.fetch.bind(window);
569
+ const wrappedFetch = async (...args) => {
570
+ const id = "req-" + state.nextRequestId++;
571
+ const startedAt = Date.now();
572
+ const input = args[0];
573
+ const init = args[1] || {};
574
+ const url = typeof input === "string" ? input : input?.url;
575
+ const method = (init.method || input?.method || "GET").toUpperCase();
576
+ const entry = record({ id, type: "fetch", method, url: String(url || ""), startedAt, pageUrl: location.href, status: "pending" });
577
+ try {
578
+ const response = await originalFetch(...args);
579
+ entry.status = response.status;
580
+ entry.statusText = response.statusText;
581
+ entry.ok = response.ok;
582
+ entry.responseUrl = response.url;
583
+ entry.durationMs = Date.now() - startedAt;
584
+ entry.responseHeaders = Array.from(response.headers.entries());
585
+ response.clone().text().then((text) => {
586
+ entry.responseBody = trimBody(text);
587
+ entry.responseBodyTruncated = typeof text === "string" && text.length > 200000;
588
+ }).catch((error) => { entry.responseBodyError = error?.message || String(error); });
589
+ return response;
590
+ } catch (error) {
591
+ entry.error = error?.message || String(error);
592
+ entry.durationMs = Date.now() - startedAt;
593
+ throw error;
594
+ }
595
+ };
596
+ wrappedFetch.__piChromeWrapped = true;
597
+ window.fetch = wrappedFetch;
598
+ }
599
+ if (window.XMLHttpRequest && !XMLHttpRequest.prototype.open.__piChromeWrapped) {
600
+ const originalOpen = XMLHttpRequest.prototype.open;
601
+ const originalSend = XMLHttpRequest.prototype.send;
602
+ XMLHttpRequest.prototype.open = function(method, url, ...rest) {
603
+ this.__piChromeRequest = { method: String(method || "GET").toUpperCase(), url: String(url || "") };
604
+ return originalOpen.call(this, method, url, ...rest);
605
+ };
606
+ XMLHttpRequest.prototype.open.__piChromeWrapped = true;
607
+ XMLHttpRequest.prototype.send = function(body) {
608
+ const id = "req-" + state.nextRequestId++;
609
+ const startedAt = Date.now();
610
+ const info = this.__piChromeRequest || {};
611
+ const entry = record({ id, type: "xhr", method: info.method || "GET", url: info.url || "", startedAt, pageUrl: location.href, status: "pending" });
612
+ this.addEventListener("loadend", () => {
613
+ entry.status = this.status;
614
+ entry.statusText = this.statusText;
615
+ entry.responseUrl = this.responseURL;
616
+ entry.durationMs = Date.now() - startedAt;
617
+ try { entry.responseHeadersText = this.getAllResponseHeaders(); } catch {}
618
+ try {
619
+ if (typeof this.responseText === "string") {
620
+ entry.responseBody = trimBody(this.responseText);
621
+ entry.responseBodyTruncated = this.responseText.length > 200000;
622
+ }
623
+ } catch (error) { entry.responseBodyError = error?.message || String(error); }
624
+ });
625
+ this.addEventListener("error", () => { entry.error = "XMLHttpRequest error"; entry.durationMs = Date.now() - startedAt; });
626
+ return originalSend.call(this, body);
627
+ };
628
+ }
629
+ }
630
+
631
+ function snapshotPage(maxElements, containingText, roleFilter, nearUid) {
632
+ installPiChromeInstrumentation();
244
633
  const unique = (selector) => {
245
634
  try { return document.querySelectorAll(selector).length === 1; } catch { return false; }
246
635
  };
@@ -266,11 +655,7 @@ function snapshotPage(maxElements) {
266
655
  }
267
656
  return parts.join(" > ");
268
657
  };
269
- const visible = (element) => {
270
- const style = getComputedStyle(element);
271
- const rect = element.getBoundingClientRect();
272
- return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
273
- };
658
+ const visible = (element) => isElementVisible(element);
274
659
  const labelFor = (element) => (
275
660
  element.getAttribute("aria-label") ||
276
661
  element.getAttribute("title") ||
@@ -280,11 +665,44 @@ function snapshotPage(maxElements) {
280
665
  element.textContent ||
281
666
  ""
282
667
  ).trim().replace(/\s+/g, " ").slice(0, 160);
283
- const candidates = Array.from(document.querySelectorAll('a, button, input, textarea, select, summary, [role="button"], [role="link"], [contenteditable="true"], [tabindex]:not([tabindex="-1"])'));
668
+ let candidates = Array.from(document.querySelectorAll('a, button, input, textarea, select, summary, [role="button"], [role="link"], [role="menuitem"], [role="tab"], [role="checkbox"], [contenteditable="true"], [tabindex]:not([tabindex="-1"])'));
669
+ if (containingText) {
670
+ const needle = String(containingText).toLowerCase();
671
+ candidates = candidates.filter((element) => labelFor(element).toLowerCase().includes(needle));
672
+ }
673
+ if (roleFilter) {
674
+ const wanted = String(roleFilter).toLowerCase();
675
+ candidates = candidates.filter((element) => {
676
+ const role = (element.getAttribute("role") || element.tagName).toLowerCase();
677
+ return role === wanted;
678
+ });
679
+ }
680
+ let near;
681
+ if (nearUid) {
682
+ const state = getPiChromeState();
683
+ near = state.elements[nearUid];
684
+ }
685
+ if (near) {
686
+ const nearRect = near.getBoundingClientRect();
687
+ const cx = nearRect.left + nearRect.width / 2;
688
+ const cy = nearRect.top + nearRect.height / 2;
689
+ candidates.sort((a, b) => {
690
+ const ra = a.getBoundingClientRect();
691
+ const rb = b.getBoundingClientRect();
692
+ const da = Math.hypot(ra.left + ra.width / 2 - cx, ra.top + ra.height / 2 - cy);
693
+ const db = Math.hypot(rb.left + rb.width / 2 - cx, rb.top + rb.height / 2 - cy);
694
+ return da - db;
695
+ });
696
+ }
284
697
  const elements = candidates.filter(visible).slice(0, maxElements).map((element, index) => {
285
698
  const rect = element.getBoundingClientRect();
699
+ const style = getComputedStyle(element);
700
+ const cx = rect.left + rect.width / 2;
701
+ const cy = rect.top + rect.height / 2;
702
+ const occluded = occluderAt(cx, cy, element);
286
703
  return {
287
704
  index,
705
+ uid: rememberElement(element),
288
706
  tag: element.tagName.toLowerCase(),
289
707
  selector: selectorFor(element),
290
708
  label: labelFor(element),
@@ -292,6 +710,9 @@ function snapshotPage(maxElements) {
292
710
  type: element.getAttribute("type") || undefined,
293
711
  role: element.getAttribute("role") || undefined,
294
712
  disabled: Boolean(element.disabled || element.getAttribute("aria-disabled") === "true"),
713
+ inert: Boolean(element.closest?.("[inert]")),
714
+ pointerEvents: style.pointerEvents,
715
+ occluded: occluded || undefined,
295
716
  rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
296
717
  };
297
718
  });
@@ -301,110 +722,251 @@ function snapshotPage(maxElements) {
301
722
  viewport: { width: innerWidth, height: innerHeight, scrollX, scrollY },
302
723
  text: document.body ? document.body.innerText.replace(/\s+\n/g, "\n").trim().slice(0, 30000) : "",
303
724
  elements,
725
+ filter: { containingText: containingText || undefined, roleFilter: roleFilter || undefined, nearUid: nearUid || undefined },
304
726
  };
305
727
  }
306
728
 
307
- async function evaluateExpression(expression, awaitPromise) {
308
- const indirectEval = (0, eval);
309
- const value = indirectEval(expression);
310
- return awaitPromise && value && typeof value.then === "function" ? await value : value;
729
+ function probePage() {
730
+ // Sanity probe used by /chrome-doctor. Returns evidence that MAIN-world execution works.
731
+ return {
732
+ arithmetic: 1 + 1,
733
+ location: location.href,
734
+ title: document.title,
735
+ documentReady: document.readyState,
736
+ userAgent: navigator.userAgent.slice(0, 200),
737
+ webdriver: !!navigator.webdriver,
738
+ };
311
739
  }
312
740
 
313
- function resolvePoint(selector, x, y) {
314
- if (selector) {
315
- const element = document.querySelector(selector);
316
- if (!element) throw new Error(`No element matches selector: ${selector}`);
741
+ function captureFullPageTiles() {
742
+ // Returns the *plan* for tile capture; the actual chrome.tabs.captureVisibleTab calls happen
743
+ // in the SW. We just report the scroll positions and metrics.
744
+ const html = document.documentElement;
745
+ const body = document.body;
746
+ const width = Math.max(html.scrollWidth, body ? body.scrollWidth : 0, innerWidth);
747
+ const height = Math.max(html.scrollHeight, body ? body.scrollHeight : 0, innerHeight);
748
+ const viewportHeight = innerHeight;
749
+ const dpr = window.devicePixelRatio || 1;
750
+ const originalScrollY = scrollY;
751
+ const tiles = [];
752
+ let y = 0;
753
+ while (y < height) {
754
+ tiles.push({ y, scrollY: y });
755
+ y += viewportHeight;
756
+ }
757
+ return { width, height, viewportHeight, dpr, originalScrollY, tiles };
758
+ }
759
+
760
+ function scrollToY(y) {
761
+ window.scrollTo({ top: y, left: 0, behavior: "instant" });
762
+ return { scrollY };
763
+ }
764
+
765
+ function resolvePoint(selector, uid, x, y) {
766
+ const element = elementBySelectorOrUid(selector, uid);
767
+ if (element) {
317
768
  element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
318
769
  const rect = element.getBoundingClientRect();
319
770
  return { element, x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, rect };
320
771
  }
321
- if (typeof x !== "number" || typeof y !== "number") throw new Error("Provide selector or x/y");
772
+ if (typeof x !== "number" || typeof y !== "number") throw new Error("Provide selector, uid, or x/y");
322
773
  return { element: document.elementFromPoint(x, y), x, y, rect: undefined };
323
774
  }
324
775
 
325
- function clickPage(selector, x, y) {
326
- const resolvePoint = (selector, x, y) => {
327
- if (selector) {
328
- const element = document.querySelector(selector);
329
- if (!element) throw new Error(`No element matches selector: ${selector}`);
330
- element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
331
- const rect = element.getBoundingClientRect();
332
- return { element, x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, rect };
333
- }
334
- if (typeof x !== "number" || typeof y !== "number") throw new Error("Provide selector or x/y");
335
- return { element: document.elementFromPoint(x, y), x, y, rect: undefined };
336
- };
337
- const point = resolvePoint(selector, x, y);
776
+ function clickPage(selector, uid, x, y) {
777
+ installPiChromeInstrumentation();
778
+ const before = pageHash();
779
+ const point = resolvePoint(selector, uid, x, y);
338
780
  if (!point.element) throw new Error("No element at click point");
339
- for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
340
- point.element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: point.x, clientY: point.y, button: 0 }));
341
- }
342
- return { x: point.x, y: point.y, selector, tag: point.element.tagName };
343
- }
344
-
345
- function typeIntoPage(selector, text, pressEnter) {
346
- const normalizeKey = (key) => {
347
- const table = {
348
- enter: "Enter",
349
- escape: "Escape",
350
- tab: "Tab",
351
- backspace: "Backspace",
352
- delete: "Delete",
353
- arrowup: "ArrowUp",
354
- arrowdown: "ArrowDown",
355
- arrowleft: "ArrowLeft",
356
- arrowright: "ArrowRight",
357
- };
358
- return table[String(key).toLowerCase()] || key;
781
+ const visible = isElementVisible(point.element);
782
+ const occluded = occluderAt(point.x, point.y, point.element);
783
+ const defaultPrevented = pointerEventSequence(point.element, point.x, point.y, [
784
+ "pointerdown", "mousedown", "pointerup", "mouseup", "click",
785
+ ]);
786
+ // Heuristic: if the clicked thing looks like a media play affordance and the page has paused
787
+ // audio/video, the synthetic click may not unlock autoplay. Surface a warning.
788
+ let autoplayHint;
789
+ const label = (point.element.getAttribute("aria-label") || point.element.textContent || "").toLowerCase();
790
+ if (/^(play|start|begin|next|continue|unmute)/.test(label.trim())) {
791
+ const idleMedia = Array.from(document.querySelectorAll("audio,video")).some((m) => m.paused);
792
+ if (idleMedia) autoplayHint = "This element looks like a media affordance and the page has paused media. Synthetic clicks do not satisfy user-activation gates; audio/video may not start.";
793
+ }
794
+ return {
795
+ x: point.x,
796
+ y: point.y,
797
+ selector,
798
+ uid,
799
+ tag: point.element.tagName,
800
+ isTrusted: false,
801
+ defaultPrevented,
802
+ elementVisible: visible,
803
+ occludedBy: occluded || undefined,
804
+ pageMutated: pageHash() !== before,
805
+ autoplayHint,
359
806
  };
360
- const pressKey = (key) => {
361
- const target = document.activeElement || document.body;
362
- const normalized = normalizeKey(key);
363
- target.dispatchEvent(new KeyboardEvent("keydown", { key: normalized, bubbles: true, cancelable: true }));
364
- target.dispatchEvent(new KeyboardEvent("keyup", { key: normalized, bubbles: true, cancelable: true }));
365
- if (normalized === "Enter" && target instanceof HTMLFormElement) target.requestSubmit();
366
- return { key: normalized };
807
+ }
808
+
809
+ function hoverPage(selector, uid, x, y) {
810
+ installPiChromeInstrumentation();
811
+ const point = resolvePoint(selector, uid, x, y);
812
+ if (!point.element) throw new Error("No element to hover");
813
+ const defaultPrevented = pointerEventSequence(point.element, point.x, point.y, [
814
+ "pointerover", "mouseover", "pointerenter", "mouseenter", "pointermove", "mousemove",
815
+ ]);
816
+ return { x: point.x, y: point.y, selector, uid, tag: point.element.tagName, defaultPrevented, isTrusted: false };
817
+ }
818
+
819
+ function dragPage(fromUid, fromSelector, fromX, fromY, toUid, toSelector, toX, toY, steps) {
820
+ installPiChromeInstrumentation();
821
+ const before = pageHash();
822
+ const from = resolvePoint(fromSelector, fromUid, fromX, fromY);
823
+ const to = resolvePoint(toSelector, toUid, toX, toY);
824
+ if (!from.element) throw new Error("Drag source element not found");
825
+ if (!to.element) throw new Error("Drag target element not found");
826
+ pointerEventSequence(from.element, from.x, from.y, ["pointerover", "pointerdown", "mousedown"]);
827
+ for (let i = 1; i <= steps; i++) {
828
+ const t = i / steps;
829
+ const x = from.x + (to.x - from.x) * t;
830
+ const y = from.y + (to.y - from.y) * t;
831
+ const overEl = document.elementFromPoint(x, y) || to.element;
832
+ pointerEventSequence(overEl, x, y, ["pointermove", "mousemove"]);
833
+ }
834
+ pointerEventSequence(to.element, to.x, to.y, ["pointerover", "mouseover", "pointerup", "mouseup"]);
835
+ return {
836
+ from: { x: from.x, y: from.y },
837
+ to: { x: to.x, y: to.y },
838
+ steps,
839
+ pageMutated: pageHash() !== before,
840
+ note: "Synthetic pointer drag. HTML5 DataTransfer is not synthesized; native drag-and-drop targets may not respond.",
367
841
  };
368
- let element = selector ? document.querySelector(selector) : document.activeElement;
369
- if (!element) throw new Error(selector ? `No element matches selector: ${selector}` : "No active element");
842
+ }
843
+
844
+ function uploadFiles(selector, uid, files) {
845
+ installPiChromeInstrumentation();
846
+ const element = elementBySelectorOrUid(selector, uid);
847
+ if (!element || element.tagName !== "INPUT" || element.type !== "file") {
848
+ throw new Error("Target must be <input type=file>");
849
+ }
850
+ const dt = new DataTransfer();
851
+ for (const f of files) {
852
+ const bytes = Uint8Array.from(atob(f.base64 || ""), (c) => c.charCodeAt(0));
853
+ dt.items.add(new File([bytes], f.name, { type: f.type || "application/octet-stream" }));
854
+ }
855
+ element.files = dt.files;
856
+ element.dispatchEvent(new Event("input", { bubbles: true }));
857
+ element.dispatchEvent(new Event("change", { bubbles: true }));
858
+ return { uploaded: files.map((f) => ({ name: f.name, type: f.type, size: (f.base64 || "").length })) };
859
+ }
860
+
861
+ function dispatchInputEvents(element, data, inputType = "insertText") {
862
+ element.dispatchEvent(new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType, data }));
863
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType, data }));
864
+ element.dispatchEvent(new Event("change", { bubbles: true }));
865
+ }
866
+
867
+ function setNativeValue(element, value) {
868
+ const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
869
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
870
+ if (descriptor?.set) descriptor.set.call(element, value);
871
+ else element.value = value;
872
+ }
873
+
874
+ function typeIntoPage(selector, uid, text, pressEnter) {
875
+ installPiChromeInstrumentation();
876
+ const before = pageHash();
877
+ let element = elementBySelectorOrUid(selector, uid) || document.activeElement;
878
+ if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element");
370
879
  element.focus();
371
880
  if (element.isContentEditable) {
372
881
  document.execCommand("insertText", false, text);
373
882
  } else if ("value" in element) {
374
883
  const start = element.selectionStart ?? element.value.length;
375
884
  const end = element.selectionEnd ?? element.value.length;
376
- element.value = element.value.slice(0, start) + text + element.value.slice(end);
885
+ setNativeValue(element, element.value.slice(0, start) + text + element.value.slice(end));
377
886
  element.selectionStart = element.selectionEnd = start + text.length;
378
- element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }));
379
- element.dispatchEvent(new Event("change", { bubbles: true }));
887
+ dispatchInputEvents(element, text, "insertText");
380
888
  } else {
381
889
  throw new Error("Focused element is not text-editable");
382
890
  }
383
- if (pressEnter) pressKey("Enter");
384
- return { selector, length: text.length, pressEnter };
891
+ if (pressEnter) pressKeyInPage("Enter");
892
+ return {
893
+ selector, uid, length: text.length, pressEnter,
894
+ isTrusted: false,
895
+ valueMatches: "value" in element ? element.value.includes(text) : undefined,
896
+ pageMutated: pageHash() !== before,
897
+ };
385
898
  }
386
899
 
387
- function pressKeyInPage(key) {
388
- const normalizeKey = (key) => {
389
- const table = {
390
- enter: "Enter",
391
- escape: "Escape",
392
- tab: "Tab",
393
- backspace: "Backspace",
394
- delete: "Delete",
395
- arrowup: "ArrowUp",
396
- arrowdown: "ArrowDown",
397
- arrowleft: "ArrowLeft",
398
- arrowright: "ArrowRight",
399
- };
400
- return table[String(key).toLowerCase()] || key;
900
+ function fillPage(selector, uid, text, submit) {
901
+ installPiChromeInstrumentation();
902
+ const before = pageHash();
903
+ let element = elementBySelectorOrUid(selector, uid) || document.activeElement;
904
+ if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element");
905
+ element.focus();
906
+ if (element.isContentEditable) {
907
+ element.textContent = "";
908
+ document.execCommand("insertText", false, text);
909
+ } else if ("value" in element) {
910
+ setNativeValue(element, text);
911
+ const length = String(text).length;
912
+ try { element.selectionStart = element.selectionEnd = length; } catch {}
913
+ dispatchInputEvents(element, text, "insertReplacementText");
914
+ } else {
915
+ throw new Error("Focused element is not text-editable");
916
+ }
917
+ if (submit) pressKeyInPage("Enter");
918
+ return {
919
+ selector, uid, length: String(text).length, submit,
920
+ isTrusted: false,
921
+ valueMatches: "value" in element ? element.value === String(text) : undefined,
922
+ pageMutated: pageHash() !== before,
401
923
  };
402
- const target = document.activeElement || document.body;
924
+ }
925
+
926
+ function pressKeyInPage(key) {
403
927
  const normalized = normalizeKey(key);
404
- target.dispatchEvent(new KeyboardEvent("keydown", { key: normalized, bubbles: true, cancelable: true }));
405
- target.dispatchEvent(new KeyboardEvent("keyup", { key: normalized, bubbles: true, cancelable: true }));
406
- if (normalized === "Enter" && target instanceof HTMLFormElement) target.requestSubmit();
407
- return { key: normalized };
928
+ const target = document.activeElement || document.body;
929
+ const before = (typeof pageHash === "function") ? pageHash() : 0;
930
+ const down = new KeyboardEvent("keydown", { key: normalized, bubbles: true, cancelable: true });
931
+ target.dispatchEvent(down);
932
+ const up = new KeyboardEvent("keyup", { key: normalized, bubbles: true, cancelable: true });
933
+ target.dispatchEvent(up);
934
+ if (normalized === "Enter") {
935
+ const form = target.closest?.("form");
936
+ if (form) form.requestSubmit?.();
937
+ }
938
+ return {
939
+ key: normalized,
940
+ isTrusted: false,
941
+ defaultPrevented: down.defaultPrevented || up.defaultPrevented,
942
+ pageMutated: (typeof pageHash === "function") ? pageHash() !== before : undefined,
943
+ };
944
+ }
945
+
946
+ function listConsoleMessages(clear) {
947
+ installPiChromeInstrumentation();
948
+ const state = getPiChromeState();
949
+ const messages = state.console.slice();
950
+ if (clear) state.console = [];
951
+ return { messages, count: messages.length };
952
+ }
953
+
954
+ function listNetworkRequests(includePreservedRequests, clear) {
955
+ installPiChromeInstrumentation();
956
+ const state = getPiChromeState();
957
+ const currentUrl = location.href;
958
+ const requests = state.network
959
+ .filter((request) => includePreservedRequests || request.pageUrl === currentUrl)
960
+ .map(({ responseBody, ...summary }) => ({ ...summary, hasResponseBody: responseBody !== undefined }));
961
+ if (clear) state.network = [];
962
+ return { requests, count: requests.length, note: "Captures fetch/XHR after instrumentation is installed. Browser-initiated document/static asset requests are not captured." };
963
+ }
964
+
965
+ function getNetworkRequest(requestId) {
966
+ installPiChromeInstrumentation();
967
+ const request = getPiChromeState().network.find((entry) => entry.id === requestId);
968
+ if (!request) throw new Error(`No network request with id ${requestId}`);
969
+ return request;
408
970
  }
409
971
 
410
972
  async function waitForPage(kind, value, timeoutMs, intervalMs) {
@@ -412,7 +974,9 @@ async function waitForPage(kind, value, timeoutMs, intervalMs) {
412
974
  while (Date.now() - started < timeoutMs) {
413
975
  let ok = false;
414
976
  if (kind === "selector") ok = Boolean(document.querySelector(value));
415
- else ok = Boolean((0, eval)(value));
977
+ else {
978
+ try { ok = Boolean(new Function("return (" + value + ");").call(globalThis)); } catch { ok = false; }
979
+ }
416
980
  if (ok) return { elapsedMs: Date.now() - started };
417
981
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
418
982
  }