pi-chrome 0.7.0 → 0.9.1

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,19 @@ 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}`);
46
+ const expected = response.headers.get("x-pi-chrome-version");
47
+ const ours = chrome.runtime.getManifest().version;
48
+ if (expected && expected !== ours && isVersionOlder(ours, expected)) {
49
+ console.warn(`[pi-chrome] extension v${ours} behind pi-chrome v${expected}; reloading extension`);
50
+ try { chrome.runtime.reload(); } catch {}
51
+ return;
52
+ }
52
53
  const payload = await response.json();
53
54
  if (payload.type === "command") await handleCommand(payload.command);
54
- // Otherwise (type:"none"), loop and re-issue the long-poll.
55
55
  }
56
56
  } catch (error) {
57
57
  await sleep(POLL_ERROR_BACKOFF_MS);
@@ -81,6 +81,18 @@ function sleep(ms) {
81
81
  return new Promise((resolve) => setTimeout(resolve, ms));
82
82
  }
83
83
 
84
+ function isVersionOlder(a, b) {
85
+ const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
86
+ const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
87
+ const n = Math.max(pa.length, pb.length);
88
+ for (let i = 0; i < n; i++) {
89
+ const x = pa[i] ?? 0, y = pb[i] ?? 0;
90
+ if (x < y) return true;
91
+ if (x > y) return false;
92
+ }
93
+ return false;
94
+ }
95
+
84
96
  async function dispatch(action, params) {
85
97
  switch (action) {
86
98
  case "tab.version":
@@ -107,17 +119,30 @@ async function dispatch(action, params) {
107
119
  return { closed: tab.id };
108
120
  }
109
121
  case "page.snapshot":
110
- return executeInTab(params, snapshotPage, [params.maxElements || 80]);
122
+ return executeInTab(params, snapshotPage, [
123
+ params.maxElements || 80,
124
+ params.containingText ?? null,
125
+ params.roleFilter ?? null,
126
+ params.nearUid ?? null,
127
+ ]);
111
128
  case "page.evaluate":
112
- return executeInTab(params, evaluateExpression, [params.expression, params.awaitPromise !== false]);
129
+ return evaluateInTab(params);
113
130
  case "page.click":
114
131
  return executeActionInTab(params, clickPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
132
+ case "page.hover":
133
+ return executeActionInTab(params, hoverPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
134
+ case "page.drag":
135
+ 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]);
136
+ case "page.upload":
137
+ return executeActionInTab(params, uploadFiles, [params.selector ?? null, params.uid ?? null, params.files || []]);
115
138
  case "page.type":
116
139
  return executeActionInTab(params, typeIntoPage, [params.selector ?? null, params.uid ?? null, params.text || "", Boolean(params.pressEnter)]);
117
140
  case "page.fill":
118
141
  return executeActionInTab(params, fillPage, [params.selector ?? null, params.uid ?? null, params.text || "", params.submit === true]);
119
142
  case "page.key":
120
143
  return executeActionInTab(params, pressKeyInPage, [params.key]);
144
+ case "page.scroll":
145
+ return executeActionInTab(params, scrollPage, [params.selector ?? null, params.uid ?? null, params.deltaY ?? 0, params.deltaX ?? 0, params.steps ?? null]);
121
146
  case "page.console.list":
122
147
  return executeInTab(params, listConsoleMessages, [params.clear === true]);
123
148
  case "page.network.list":
@@ -126,38 +151,27 @@ async function dispatch(action, params) {
126
151
  return executeInTab(params, getNetworkRequest, [params.requestId]);
127
152
  case "page.waitFor":
128
153
  return executeInTab(params, waitForPage, [params.kind, params.value, params.timeoutMs || 10000, params.intervalMs || 250]);
154
+ case "page.probe":
155
+ // Lightweight capability probe for /chrome-doctor. Runs in MAIN world.
156
+ return executeInTab(params, probePage, []);
129
157
  case "page.navigate": {
130
158
  const tab = await getTabByParams(params);
131
159
  if (params.foreground) await bringToFront(tab);
160
+ if (params.initScript) {
161
+ // Register a one-shot document_start content script. We register, navigate, wait, then unregister.
162
+ await registerInitScript(tab.id, params.initScript);
163
+ }
132
164
  const wait = params.waitUntilLoad !== false ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined);
133
165
  const updated = await chrome.tabs.update(tab.id, { url: params.url });
134
- await wait;
135
- return formatTab(await chrome.tabs.get(updated.id));
136
- }
137
- case "page.screenshot": {
138
- const tab = await getTabByParams(params);
139
- if (params.foreground) await bringToFront(tab);
140
- // captureVisibleTab requires the target tab to be the active tab in its window. Activate it
141
- // without focusing the window so other apps don't get pushed behind Chrome, and restore the
142
- // previous active tab afterwards to minimize disruption.
143
- let previousActiveId;
144
- if (!tab.active) {
145
- const activeBefore = await chrome.tabs.query({ active: true, windowId: tab.windowId });
146
- previousActiveId = activeBefore[0]?.id;
147
- await chrome.tabs.update(tab.id, { active: true });
148
- }
149
166
  try {
150
- const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
151
- format: params.format || "png",
152
- quality: params.format === "jpeg" ? params.quality : undefined,
153
- });
154
- return { dataUrl, tab: formatTab(tab) };
167
+ await wait;
155
168
  } finally {
156
- if (previousActiveId !== undefined && previousActiveId !== tab.id) {
157
- await chrome.tabs.update(previousActiveId, { active: true }).catch(() => undefined);
158
- }
169
+ if (params.initScript) await unregisterInitScript(tab.id).catch(() => undefined);
159
170
  }
171
+ return formatTab(await chrome.tabs.get(updated.id));
160
172
  }
173
+ case "page.screenshot":
174
+ return takeScreenshot(params);
161
175
  default:
162
176
  throw new Error(`Unknown action: ${action}`);
163
177
  }
@@ -198,26 +212,49 @@ async function getTabByParams(params) {
198
212
  return tab;
199
213
  }
200
214
 
215
+ // Helper sources that get concatenated into the injected MAIN-world script. Kept as separate
216
+ // functions so callers below can reference them by `.toString()`. The helpers do not perform any
217
+ // eval themselves — they're plain function declarations.
218
+ const HELPER_FUNCS = [
219
+ getPiChromeState,
220
+ rememberElement,
221
+ elementBySelectorOrUid,
222
+ installPiChromeInstrumentation,
223
+ resolvePoint,
224
+ dispatchInputEvents,
225
+ setNativeValue,
226
+ normalizeKey,
227
+ isElementVisible,
228
+ occluderAt,
229
+ pageHash,
230
+ pointerEventSequence,
231
+ sleepPage,
232
+ rand,
233
+ dispatchPointerLikeEvent,
234
+ humanMoveTo,
235
+ humanClickPoint,
236
+ printableKeyCode,
237
+ dispatchKeyEvent,
238
+ typeCharacter,
239
+ scrollPage,
240
+ ];
241
+
201
242
  async function executeInTab(params, func, args) {
202
243
  const tab = await getTabByParams(params);
203
244
  if (params.foreground) await bringToFront(tab);
204
- const helperSource = [
205
- getPiChromeState,
206
- rememberElement,
207
- elementBySelectorOrUid,
208
- installPiChromeInstrumentation,
209
- resolvePoint,
210
- dispatchInputEvents,
211
- setNativeValue,
212
- normalizeKey,
213
- ].map((helper) => helper.toString()).join("\n");
245
+ const helperSource = HELPER_FUNCS.map((helper) => helper.toString()).join("\n");
214
246
  const results = await chrome.scripting.executeScript({
215
247
  target: { tabId: tab.id },
216
248
  world: "MAIN",
217
249
  func: async (helperSource, source, invocationArgs) => {
218
250
  try {
219
- (0, eval)(helperSource);
220
- const injected = (0, eval)(`(${source})`);
251
+ // Helpers are plain function declarations; injecting them via Function constructor avoids
252
+ // running through `eval` (which is restricted under strict CSP) and keeps them isolated.
253
+ new Function(helperSource).call(globalThis);
254
+ // The action itself is reconstructed from its source text. We use `new Function` rather
255
+ // than `eval` because the latter is blocked by `script-src 'self'` (no `'unsafe-eval'`)
256
+ // CSPs that are common on production sites.
257
+ const injected = new Function(helperSource + "\nreturn (" + source + ");").call(globalThis);
221
258
  return { ok: true, value: await injected(...invocationArgs) };
222
259
  } catch (error) {
223
260
  return { ok: false, error: error?.stack || error?.message || String(error) };
@@ -237,15 +274,108 @@ async function executeInTab(params, func, args) {
237
274
  return envelope?.value;
238
275
  }
239
276
 
277
+ // Dedicated executor for page.evaluate. Doesn't go through the helper-source injection chain;
278
+ // that chain was the root cause of `chrome_evaluate` silently returning null on pages with strict
279
+ // CSP. We build a single Function in MAIN world and invoke it directly.
280
+ async function evaluateInTab(params) {
281
+ const tab = await getTabByParams(params);
282
+ if (params.foreground) await bringToFront(tab);
283
+ const expression = String(params.expression ?? "");
284
+ const awaitPromise = params.awaitPromise !== false;
285
+ const results = await chrome.scripting.executeScript({
286
+ target: { tabId: tab.id },
287
+ world: "MAIN",
288
+ func: async (expression, awaitPromise) => {
289
+ const stringify = (v) => {
290
+ if (v === undefined) return { kind: "undefined" };
291
+ if (typeof v === "function") return { kind: "function", source: v.toString().slice(0, 500) };
292
+ if (typeof v === "symbol") return { kind: "symbol", description: v.description };
293
+ if (typeof v === "bigint") return { kind: "bigint", value: v.toString() };
294
+ if (v instanceof Error) return { kind: "error", name: v.name, message: v.message, stack: v.stack };
295
+ return v;
296
+ };
297
+ // Compile via the Function constructor. We try expression form first so callers can pass
298
+ // `1+1` or `document.title` without a `return`; if that's a SyntaxError we retry with the
299
+ // statement form so callers can use multi-statement bodies (loops, var decls, etc).
300
+ const compile = (src) => {
301
+ try {
302
+ return { fn: new Function(`return (async () => (${src}))();`), mode: "expression" };
303
+ } catch (e1) {
304
+ if (e1 && e1.name === "SyntaxError") {
305
+ try {
306
+ return { fn: new Function(`return (async () => { ${src} })();`), mode: "statement" };
307
+ } catch (e2) {
308
+ throw e2;
309
+ }
310
+ }
311
+ throw e1;
312
+ }
313
+ };
314
+ try {
315
+ const { fn } = compile(expression);
316
+ const value = await fn.call(globalThis);
317
+ const resolved = awaitPromise && value && typeof value.then === "function" ? await value : value;
318
+ return { ok: true, value: stringify(resolved) };
319
+ } catch (error) {
320
+ return { ok: false, error: error?.stack || error?.message || String(error) };
321
+ }
322
+ },
323
+ args: [expression, awaitPromise],
324
+ });
325
+ const first = results?.[0];
326
+ if (first?.error) {
327
+ const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
328
+ throw new Error(`chrome_evaluate failed: ${message}`);
329
+ }
330
+ const envelope = first?.result;
331
+ if (!envelope) throw new Error("chrome_evaluate returned no envelope from MAIN world");
332
+ if (envelope.ok === false) throw new Error(envelope.error || "chrome_evaluate failed");
333
+ const v = envelope.value;
334
+ // Unwrap special markers from MAIN world
335
+ if (v && typeof v === "object" && !Array.isArray(v)) {
336
+ if (v.kind === "undefined") return undefined;
337
+ if (v.kind === "function") return `[Function: ${v.source}]`;
338
+ if (v.kind === "symbol") return `[Symbol: ${v.description}]`;
339
+ if (v.kind === "bigint") return v.value;
340
+ if (v.kind === "error") throw new Error(`${v.name}: ${v.message}\n${v.stack || ""}`);
341
+ }
342
+ return v;
343
+ }
344
+
240
345
  async function executeActionInTab(params, func, args) {
241
346
  const result = await executeInTab(params, func, args);
242
347
  if (params.includeSnapshot) {
243
- const snapshot = await executeInTab({ ...params, foreground: false }, snapshotPage, [params.maxElements || 80]);
348
+ const snapshot = await executeInTab({ ...params, foreground: false }, snapshotPage, [params.maxElements || 80, null, null, null]);
244
349
  return { result, snapshot };
245
350
  }
246
351
  return result;
247
352
  }
248
353
 
354
+ // One-shot init script registry, scoped per tab. The script source is injected at
355
+ // document_start of the next committed navigation in that tab, in MAIN world, then cleared.
356
+ const initScriptIds = new Map();
357
+ async function registerInitScript(tabId, source) {
358
+ initScriptIds.set(tabId, source);
359
+ }
360
+ async function unregisterInitScript(tabId) {
361
+ initScriptIds.delete(tabId);
362
+ }
363
+
364
+ if (chrome.webNavigation && chrome.webNavigation.onCommitted) {
365
+ chrome.webNavigation.onCommitted.addListener((details) => {
366
+ if (details.frameId !== 0) return;
367
+ const source = initScriptIds.get(details.tabId);
368
+ if (!source) return;
369
+ chrome.scripting.executeScript({
370
+ target: { tabId: details.tabId, frameIds: [0] },
371
+ world: "MAIN",
372
+ injectImmediately: true,
373
+ func: (code) => { try { new Function(code).call(globalThis); } catch (e) { console.error("[pi-chrome init script]", e); } },
374
+ args: [source],
375
+ }).catch(() => undefined);
376
+ });
377
+ }
378
+
249
379
  async function bringToFront(tab) {
250
380
  await chrome.windows.update(tab.windowId, { focused: true });
251
381
  await chrome.tabs.update(tab.id, { active: true });
@@ -268,6 +398,56 @@ function waitForTabComplete(tabId, timeoutMs) {
268
398
  });
269
399
  }
270
400
 
401
+ async function takeScreenshot(params) {
402
+ const tab = await getTabByParams(params);
403
+ if (params.foreground) await bringToFront(tab);
404
+ let previousActiveId;
405
+ if (!tab.active) {
406
+ const activeBefore = await chrome.tabs.query({ active: true, windowId: tab.windowId });
407
+ previousActiveId = activeBefore[0]?.id;
408
+ await chrome.tabs.update(tab.id, { active: true });
409
+ }
410
+ try {
411
+ if (params.fullPage) {
412
+ // Tile-stitched full page capture: scroll, capture, paste, repeat.
413
+ const tiles = await executeInTab({ ...params, foreground: false }, captureFullPageTiles, []);
414
+ // captureFullPageTiles only computes scroll positions / metrics; we capture per scroll here
415
+ // (chrome.tabs.captureVisibleTab can't be called from MAIN world).
416
+ const captured = [];
417
+ for (const tile of tiles.tiles) {
418
+ await executeInTab({ ...params, foreground: false }, scrollToY, [tile.scrollY]);
419
+ // Small settle delay; many sites have on-scroll animations / lazy-load.
420
+ await sleep(120);
421
+ const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
422
+ format: params.format || "png",
423
+ quality: params.format === "jpeg" ? params.quality : undefined,
424
+ });
425
+ captured.push({ y: tile.y, dataUrl });
426
+ }
427
+ await executeInTab({ ...params, foreground: false }, scrollToY, [tiles.originalScrollY]);
428
+ return {
429
+ fullPage: true,
430
+ tab: formatTab(tab),
431
+ dimensions: { width: tiles.width, height: tiles.height, viewportHeight: tiles.viewportHeight, dpr: tiles.dpr },
432
+ tiles: captured,
433
+ };
434
+ }
435
+ const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
436
+ format: params.format || "png",
437
+ quality: params.format === "jpeg" ? params.quality : undefined,
438
+ });
439
+ return { dataUrl, tab: formatTab(tab) };
440
+ } finally {
441
+ if (previousActiveId !== undefined && previousActiveId !== tab.id) {
442
+ await chrome.tabs.update(previousActiveId, { active: true }).catch(() => undefined);
443
+ }
444
+ }
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // MAIN-world helpers (function declarations injected into the page).
449
+ // ---------------------------------------------------------------------------
450
+
271
451
  function getPiChromeState() {
272
452
  const state = window.__PI_CHROME_STATE__ || {
273
453
  nextElementUid: 1,
@@ -302,6 +482,139 @@ function elementBySelectorOrUid(selector, uid) {
302
482
  return null;
303
483
  }
304
484
 
485
+ function isElementVisible(element) {
486
+ if (!element || !element.getBoundingClientRect) return false;
487
+ const style = getComputedStyle(element);
488
+ if (style.visibility === "hidden" || style.display === "none") return false;
489
+ const rect = element.getBoundingClientRect();
490
+ if (rect.width === 0 || rect.height === 0) return false;
491
+ if (rect.bottom < 0 || rect.right < 0) return false;
492
+ if (rect.top > innerHeight || rect.left > innerWidth) return false;
493
+ return true;
494
+ }
495
+
496
+ function occluderAt(x, y, expected) {
497
+ const top = document.elementFromPoint(x, y);
498
+ if (!top || top === expected) return null;
499
+ if (expected && expected.contains(top)) return null;
500
+ if (top.contains(expected)) return null;
501
+ return {
502
+ tag: top.tagName.toLowerCase(),
503
+ id: top.id || undefined,
504
+ className: typeof top.className === "string" ? top.className : undefined,
505
+ };
506
+ }
507
+
508
+ function pageHash() {
509
+ // Cheap rolling hash used for `pageMutated`. Combines first 4kb of body innerText with the
510
+ // current values of inputs/textareas (which are not part of innerText) and the count of
511
+ // descendants of <body>. This catches: text changes, input value edits, and DOM structure
512
+ // changes — the three things a click/type/fill might cause.
513
+ const body = document.body;
514
+ const text = (body ? body.innerText : "").slice(0, 4000);
515
+ let h = 0;
516
+ for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
517
+ if (body) {
518
+ const inputs = body.querySelectorAll("input,textarea,select");
519
+ let valueBlob = "";
520
+ for (let i = 0; i < inputs.length && valueBlob.length < 4000; i++) {
521
+ const v = inputs[i].value;
522
+ if (typeof v === "string") valueBlob += v + "\x00";
523
+ }
524
+ for (let i = 0; i < valueBlob.length; i++) h = (h * 31 + valueBlob.charCodeAt(i)) | 0;
525
+ h = (h * 31 + body.getElementsByTagName("*").length) | 0;
526
+ }
527
+ return h;
528
+ }
529
+
530
+ function sleepPage(ms) {
531
+ return new Promise((resolve) => setTimeout(resolve, ms));
532
+ }
533
+
534
+ function rand(min, max) {
535
+ return min + Math.random() * (max - min);
536
+ }
537
+
538
+ function dispatchPointerLikeEvent(element, type, x, y, prevX, prevY, opts = {}) {
539
+ const isPointer = type.startsWith("pointer");
540
+ const Ctor = isPointer ? PointerEvent : MouseEvent;
541
+ const isMove = type === "pointermove" || type === "mousemove";
542
+ const isUpOrClick = type === "pointerup" || type === "mouseup" || type === "click";
543
+ const init = {
544
+ bubbles: true,
545
+ cancelable: true,
546
+ view: window,
547
+ clientX: x,
548
+ clientY: y,
549
+ screenX: x + (window.screenX || 0),
550
+ screenY: y + (window.screenY || 0),
551
+ movementX: Number.isFinite(prevX) ? x - prevX : 0,
552
+ movementY: Number.isFinite(prevY) ? y - prevY : 0,
553
+ button: 0,
554
+ buttons: isMove || isUpOrClick ? 0 : 1,
555
+ };
556
+ if (isPointer) {
557
+ init.pointerType = "mouse";
558
+ init.pointerId = 1;
559
+ init.isPrimary = true;
560
+ init.width = 1;
561
+ init.height = 1;
562
+ init.pressure = opts.pressure ?? (type === "pointerdown" ? 0.5 : 0);
563
+ init.tangentialPressure = 0;
564
+ init.tiltX = 0;
565
+ init.tiltY = 0;
566
+ }
567
+ const ev = new Ctor(type, init);
568
+ element.dispatchEvent(ev);
569
+ return ev.defaultPrevented;
570
+ }
571
+
572
+ function pointerEventSequence(element, x, y, sequence) {
573
+ let defaultPrevented = false;
574
+ const state = getPiChromeState();
575
+ const prevX = state.pointer?.x;
576
+ const prevY = state.pointer?.y;
577
+ for (const type of sequence) {
578
+ defaultPrevented = dispatchPointerLikeEvent(element, type, x, y, prevX, prevY) || defaultPrevented;
579
+ }
580
+ state.pointer = { x, y, t: performance.now() };
581
+ return defaultPrevented;
582
+ }
583
+
584
+ async function humanMoveTo(x, y, steps) {
585
+ const state = getPiChromeState();
586
+ const startX = Number.isFinite(state.pointer?.x) ? state.pointer.x : rand(12, Math.max(24, innerWidth - 12));
587
+ const startY = Number.isFinite(state.pointer?.y) ? state.pointer.y : rand(12, Math.max(24, innerHeight - 12));
588
+ const n = steps || Math.max(12, Math.min(42, Math.round(Math.hypot(x - startX, y - startY) / 18)));
589
+ let prevX = startX, prevY = startY;
590
+ let defaultPrevented = false;
591
+ for (let i = 1; i <= n; i++) {
592
+ const t = i / n;
593
+ const ease = t * t * (3 - 2 * t);
594
+ const wobble = Math.sin(t * Math.PI) * 8;
595
+ const px = startX + (x - startX) * ease + rand(-wobble, wobble);
596
+ const py = startY + (y - startY) * ease + rand(-wobble, wobble);
597
+ const el = document.elementFromPoint(px, py) || document.body || document.documentElement;
598
+ defaultPrevented = dispatchPointerLikeEvent(el, "pointermove", px, py, prevX, prevY) || defaultPrevented;
599
+ defaultPrevented = dispatchPointerLikeEvent(el, "mousemove", px, py, prevX, prevY) || defaultPrevented;
600
+ prevX = px; prevY = py;
601
+ await sleepPage(rand(4, 18));
602
+ }
603
+ state.pointer = { x, y, t: performance.now() };
604
+ return defaultPrevented;
605
+ }
606
+
607
+ function humanClickPoint(point) {
608
+ if (!point.rect) return { x: point.x, y: point.y };
609
+ const rect = point.rect;
610
+ const insetX = Math.min(rect.width * 0.35, Math.max(2, rect.width / 2 - 1));
611
+ const insetY = Math.min(rect.height * 0.35, Math.max(2, rect.height / 2 - 1));
612
+ return {
613
+ x: rect.left + rect.width / 2 + rand(-insetX, insetX),
614
+ y: rect.top + rect.height / 2 + rand(-insetY, insetY),
615
+ };
616
+ }
617
+
305
618
  function installPiChromeInstrumentation() {
306
619
  const state = getPiChromeState();
307
620
  if (state.instrumentationInstalled) return;
@@ -407,7 +720,7 @@ function installPiChromeInstrumentation() {
407
720
  }
408
721
  }
409
722
 
410
- function snapshotPage(maxElements) {
723
+ function snapshotPage(maxElements, containingText, roleFilter, nearUid) {
411
724
  installPiChromeInstrumentation();
412
725
  const unique = (selector) => {
413
726
  try { return document.querySelectorAll(selector).length === 1; } catch { return false; }
@@ -434,11 +747,7 @@ function snapshotPage(maxElements) {
434
747
  }
435
748
  return parts.join(" > ");
436
749
  };
437
- const visible = (element) => {
438
- const style = getComputedStyle(element);
439
- const rect = element.getBoundingClientRect();
440
- return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
441
- };
750
+ const visible = (element) => isElementVisible(element);
442
751
  const labelFor = (element) => (
443
752
  element.getAttribute("aria-label") ||
444
753
  element.getAttribute("title") ||
@@ -448,9 +757,41 @@ function snapshotPage(maxElements) {
448
757
  element.textContent ||
449
758
  ""
450
759
  ).trim().replace(/\s+/g, " ").slice(0, 160);
451
- const candidates = Array.from(document.querySelectorAll('a, button, input, textarea, select, summary, [role="button"], [role="link"], [contenteditable="true"], [tabindex]:not([tabindex="-1"])'));
760
+ 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"])'));
761
+ if (containingText) {
762
+ const needle = String(containingText).toLowerCase();
763
+ candidates = candidates.filter((element) => labelFor(element).toLowerCase().includes(needle));
764
+ }
765
+ if (roleFilter) {
766
+ const wanted = String(roleFilter).toLowerCase();
767
+ candidates = candidates.filter((element) => {
768
+ const role = (element.getAttribute("role") || element.tagName).toLowerCase();
769
+ return role === wanted;
770
+ });
771
+ }
772
+ let near;
773
+ if (nearUid) {
774
+ const state = getPiChromeState();
775
+ near = state.elements[nearUid];
776
+ }
777
+ if (near) {
778
+ const nearRect = near.getBoundingClientRect();
779
+ const cx = nearRect.left + nearRect.width / 2;
780
+ const cy = nearRect.top + nearRect.height / 2;
781
+ candidates.sort((a, b) => {
782
+ const ra = a.getBoundingClientRect();
783
+ const rb = b.getBoundingClientRect();
784
+ const da = Math.hypot(ra.left + ra.width / 2 - cx, ra.top + ra.height / 2 - cy);
785
+ const db = Math.hypot(rb.left + rb.width / 2 - cx, rb.top + rb.height / 2 - cy);
786
+ return da - db;
787
+ });
788
+ }
452
789
  const elements = candidates.filter(visible).slice(0, maxElements).map((element, index) => {
453
790
  const rect = element.getBoundingClientRect();
791
+ const style = getComputedStyle(element);
792
+ const cx = rect.left + rect.width / 2;
793
+ const cy = rect.top + rect.height / 2;
794
+ const occluded = occluderAt(cx, cy, element);
454
795
  return {
455
796
  index,
456
797
  uid: rememberElement(element),
@@ -461,6 +802,9 @@ function snapshotPage(maxElements) {
461
802
  type: element.getAttribute("type") || undefined,
462
803
  role: element.getAttribute("role") || undefined,
463
804
  disabled: Boolean(element.disabled || element.getAttribute("aria-disabled") === "true"),
805
+ inert: Boolean(element.closest?.("[inert]")),
806
+ pointerEvents: style.pointerEvents,
807
+ occluded: occluded || undefined,
464
808
  rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
465
809
  };
466
810
  });
@@ -470,14 +814,44 @@ function snapshotPage(maxElements) {
470
814
  viewport: { width: innerWidth, height: innerHeight, scrollX, scrollY },
471
815
  text: document.body ? document.body.innerText.replace(/\s+\n/g, "\n").trim().slice(0, 30000) : "",
472
816
  elements,
817
+ filter: { containingText: containingText || undefined, roleFilter: roleFilter || undefined, nearUid: nearUid || undefined },
473
818
  };
474
819
  }
475
820
 
476
- async function evaluateExpression(expression, awaitPromise) {
477
- installPiChromeInstrumentation();
478
- const indirectEval = (0, eval);
479
- const value = indirectEval(expression);
480
- return awaitPromise && value && typeof value.then === "function" ? await value : value;
821
+ function probePage() {
822
+ // Sanity probe used by /chrome-doctor. Returns evidence that MAIN-world execution works.
823
+ return {
824
+ arithmetic: 1 + 1,
825
+ location: location.href,
826
+ title: document.title,
827
+ documentReady: document.readyState,
828
+ userAgent: navigator.userAgent.slice(0, 200),
829
+ webdriver: !!navigator.webdriver,
830
+ };
831
+ }
832
+
833
+ function captureFullPageTiles() {
834
+ // Returns the *plan* for tile capture; the actual chrome.tabs.captureVisibleTab calls happen
835
+ // in the SW. We just report the scroll positions and metrics.
836
+ const html = document.documentElement;
837
+ const body = document.body;
838
+ const width = Math.max(html.scrollWidth, body ? body.scrollWidth : 0, innerWidth);
839
+ const height = Math.max(html.scrollHeight, body ? body.scrollHeight : 0, innerHeight);
840
+ const viewportHeight = innerHeight;
841
+ const dpr = window.devicePixelRatio || 1;
842
+ const originalScrollY = scrollY;
843
+ const tiles = [];
844
+ let y = 0;
845
+ while (y < height) {
846
+ tiles.push({ y, scrollY: y });
847
+ y += viewportHeight;
848
+ }
849
+ return { width, height, viewportHeight, dpr, originalScrollY, tiles };
850
+ }
851
+
852
+ function scrollToY(y) {
853
+ window.scrollTo({ top: y, left: 0, behavior: "instant" });
854
+ return { scrollY };
481
855
  }
482
856
 
483
857
  function resolvePoint(selector, uid, x, y) {
@@ -491,13 +865,202 @@ function resolvePoint(selector, uid, x, y) {
491
865
  return { element: document.elementFromPoint(x, y), x, y, rect: undefined };
492
866
  }
493
867
 
494
- function clickPage(selector, uid, x, y) {
868
+ async function clickPage(selector, uid, x, y) {
869
+ installPiChromeInstrumentation();
870
+ const before = pageHash();
495
871
  const point = resolvePoint(selector, uid, x, y);
496
872
  if (!point.element) throw new Error("No element at click point");
497
- for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]) {
498
- point.element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: point.x, clientY: point.y, button: 0 }));
873
+ const clickPoint = humanClickPoint(point);
874
+ point.x = clickPoint.x;
875
+ point.y = clickPoint.y;
876
+ point.element = document.elementFromPoint(point.x, point.y) || point.element;
877
+ const visible = isElementVisible(point.element);
878
+ const occluded = occluderAt(point.x, point.y, point.element);
879
+ let defaultPrevented = await humanMoveTo(point.x, point.y);
880
+ const state = getPiChromeState();
881
+ const prevX = state.pointer?.x;
882
+ const prevY = state.pointer?.y;
883
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "pointerdown", point.x, point.y, prevX, prevY, { pressure: 0.5 }) || defaultPrevented;
884
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "mousedown", point.x, point.y, prevX, prevY) || defaultPrevented;
885
+ if (typeof point.element.focus === "function" && /^(A|BUTTON|INPUT|TEXTAREA|SELECT|SUMMARY)$/.test(point.element.tagName)) {
886
+ try { point.element.focus({ preventScroll: true }); } catch { try { point.element.focus(); } catch {} }
887
+ }
888
+ await sleepPage(rand(45, 140));
889
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "pointerup", point.x, point.y, prevX, prevY) || defaultPrevented;
890
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "mouseup", point.x, point.y, prevX, prevY) || defaultPrevented;
891
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "click", point.x, point.y, prevX, prevY) || defaultPrevented;
892
+ state.pointer = { x: point.x, y: point.y, t: performance.now() };
893
+ // Heuristic: if the clicked thing looks like a media play affordance and the page has paused
894
+ // audio/video, the synthetic click may not unlock autoplay. Surface a warning.
895
+ let autoplayHint;
896
+ const label = (point.element.getAttribute("aria-label") || point.element.textContent || "").toLowerCase();
897
+ if (/^(play|start|begin|next|continue|unmute)/.test(label.trim())) {
898
+ const idleMedia = Array.from(document.querySelectorAll("audio,video")).some((m) => m.paused);
899
+ 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.";
499
900
  }
500
- return { x: point.x, y: point.y, selector, uid, tag: point.element.tagName };
901
+ return {
902
+ x: point.x,
903
+ y: point.y,
904
+ selector,
905
+ uid,
906
+ tag: point.element.tagName,
907
+ isTrusted: false,
908
+ defaultPrevented,
909
+ elementVisible: visible,
910
+ occludedBy: occluded || undefined,
911
+ pageMutated: pageHash() !== before,
912
+ autoplayHint,
913
+ };
914
+ }
915
+
916
+ async function hoverPage(selector, uid, x, y) {
917
+ installPiChromeInstrumentation();
918
+ const point = resolvePoint(selector, uid, x, y);
919
+ if (!point.element) throw new Error("No element to hover");
920
+ await humanMoveTo(point.x, point.y);
921
+ const state = getPiChromeState();
922
+ const prevX = state.pointer?.x, prevY = state.pointer?.y;
923
+ let defaultPrevented = false;
924
+ for (const type of ["pointerover", "mouseover", "pointerenter", "mouseenter"]) {
925
+ defaultPrevented = dispatchPointerLikeEvent(point.element, type, point.x, point.y, prevX, prevY) || defaultPrevented;
926
+ }
927
+ // Small dwell so hover-intent handlers fire.
928
+ await sleepPage(rand(80, 220));
929
+ return { x: point.x, y: point.y, selector, uid, tag: point.element.tagName, defaultPrevented, isTrusted: false };
930
+ }
931
+
932
+ async function dragPage(fromUid, fromSelector, fromX, fromY, toUid, toSelector, toX, toY, steps) {
933
+ installPiChromeInstrumentation();
934
+ const before = pageHash();
935
+ const from = resolvePoint(fromSelector, fromUid, fromX, fromY);
936
+ const to = resolvePoint(toSelector, toUid, toX, toY);
937
+ if (!from.element) throw new Error("Drag source element not found");
938
+ if (!to.element) throw new Error("Drag target element not found");
939
+ // Move to source.
940
+ await humanMoveTo(from.x, from.y);
941
+ const state = getPiChromeState();
942
+ let prevX = state.pointer?.x, prevY = state.pointer?.y;
943
+ // Build a shared DataTransfer so HTML5 drag-and-drop handlers can populate / read it.
944
+ const dt = new DataTransfer();
945
+ const dragInit = (type, target, x, y) => {
946
+ const ev = new DragEvent(type, {
947
+ bubbles: true, cancelable: true, composed: true,
948
+ clientX: x, clientY: y,
949
+ screenX: x + (window.screenX || 0), screenY: y + (window.screenY || 0),
950
+ button: 0, buttons: 1, view: window,
951
+ dataTransfer: dt,
952
+ });
953
+ target.dispatchEvent(ev);
954
+ return ev;
955
+ };
956
+ dispatchPointerLikeEvent(from.element, "pointerover", from.x, from.y, prevX, prevY);
957
+ dispatchPointerLikeEvent(from.element, "pointerdown", from.x, from.y, prevX, prevY, { pressure: 0.5 });
958
+ dispatchPointerLikeEvent(from.element, "mousedown", from.x, from.y, prevX, prevY);
959
+ await sleepPage(rand(40, 110));
960
+ dragInit("dragstart", from.element, from.x, from.y);
961
+ dragInit("drag", from.element, from.x, from.y);
962
+ let lastOver = from.element;
963
+ const n = steps || 18;
964
+ for (let i = 1; i <= n; i++) {
965
+ const t = i / n;
966
+ const ease = t * t * (3 - 2 * t);
967
+ const wobble = Math.sin(t * Math.PI) * 6;
968
+ const x = from.x + (to.x - from.x) * ease + rand(-wobble, wobble);
969
+ const y = from.y + (to.y - from.y) * ease + rand(-wobble, wobble);
970
+ const overEl = document.elementFromPoint(x, y) || to.element;
971
+ dispatchPointerLikeEvent(overEl, "pointermove", x, y, prevX, prevY);
972
+ dispatchPointerLikeEvent(overEl, "mousemove", x, y, prevX, prevY);
973
+ if (overEl !== lastOver) {
974
+ dragInit("dragleave", lastOver, x, y);
975
+ dragInit("dragenter", overEl, x, y);
976
+ lastOver = overEl;
977
+ }
978
+ dragInit("dragover", overEl, x, y);
979
+ dragInit("drag", from.element, x, y);
980
+ prevX = x; prevY = y;
981
+ await sleepPage(rand(8, 26));
982
+ }
983
+ dispatchPointerLikeEvent(to.element, "pointerover", to.x, to.y, prevX, prevY);
984
+ dispatchPointerLikeEvent(to.element, "mouseover", to.x, to.y, prevX, prevY);
985
+ dragInit("drop", to.element, to.x, to.y);
986
+ dragInit("dragend", from.element, to.x, to.y);
987
+ dispatchPointerLikeEvent(to.element, "pointerup", to.x, to.y, prevX, prevY);
988
+ dispatchPointerLikeEvent(to.element, "mouseup", to.x, to.y, prevX, prevY);
989
+ state.pointer = { x: to.x, y: to.y, t: performance.now() };
990
+ return {
991
+ from: { x: from.x, y: from.y },
992
+ to: { x: to.x, y: to.y },
993
+ steps: n,
994
+ pageMutated: pageHash() !== before,
995
+ note: "Synthetic drag with HTML5 DragEvent + shared DataTransfer. isTrusted is still false.",
996
+ };
997
+ }
998
+
999
+ async function scrollPage(selector, uid, deltaY, deltaX, steps) {
1000
+ installPiChromeInstrumentation();
1001
+ const before = pageHash();
1002
+ let target;
1003
+ if (selector || uid) {
1004
+ target = elementBySelectorOrUid(selector, uid);
1005
+ } else {
1006
+ target = document.scrollingElement || document.documentElement || document.body;
1007
+ }
1008
+ if (!target) throw new Error("No scroll target");
1009
+ const rect = target.getBoundingClientRect ? target.getBoundingClientRect() : { left: 0, top: 0, width: innerWidth, height: innerHeight };
1010
+ const cx = Math.max(0, Math.min(innerWidth - 1, rect.left + Math.min(rect.width, innerWidth) / 2));
1011
+ const cy = Math.max(0, Math.min(innerHeight - 1, rect.top + Math.min(rect.height, innerHeight) / 2));
1012
+ const n = Math.max(3, Math.min(40, steps || Math.max(3, Math.ceil(Math.abs(deltaY || 0) / 100))));
1013
+ // Front-loaded wheel deltas, momentum-style.
1014
+ const totalY = deltaY || 0;
1015
+ const totalX = deltaX || 0;
1016
+ const weights = [];
1017
+ for (let i = 1; i <= n; i++) weights.push(1 / i);
1018
+ const sumW = weights.reduce((a, b) => a + b, 0);
1019
+ let movedY = 0, movedX = 0;
1020
+ for (let i = 0; i < n; i++) {
1021
+ const dy = totalY * (weights[i] / sumW);
1022
+ const dx = totalX * (weights[i] / sumW);
1023
+ const ev = new WheelEvent("wheel", {
1024
+ bubbles: true, cancelable: true, composed: true, view: window,
1025
+ clientX: cx, clientY: cy,
1026
+ deltaX: dx, deltaY: dy, deltaMode: 0,
1027
+ });
1028
+ target.dispatchEvent(ev);
1029
+ if (!ev.defaultPrevented) {
1030
+ // Apply scroll ourselves; mirrors what the browser would do.
1031
+ if (target === document.scrollingElement || target === document.documentElement || target === document.body) {
1032
+ window.scrollBy({ left: dx, top: dy, behavior: "instant" });
1033
+ } else {
1034
+ target.scrollTop += dy;
1035
+ target.scrollLeft += dx;
1036
+ }
1037
+ }
1038
+ movedY += dy; movedX += dx;
1039
+ await sleepPage(rand(12, 28));
1040
+ }
1041
+ return {
1042
+ deltaX: movedX, deltaY: movedY, steps: n,
1043
+ scrollTop: target.scrollTop, scrollLeft: target.scrollLeft,
1044
+ pageMutated: pageHash() !== before,
1045
+ isTrusted: false,
1046
+ };
1047
+ }
1048
+
1049
+ function uploadFiles(selector, uid, files) {
1050
+ installPiChromeInstrumentation();
1051
+ const element = elementBySelectorOrUid(selector, uid);
1052
+ if (!element || element.tagName !== "INPUT" || element.type !== "file") {
1053
+ throw new Error("Target must be <input type=file>");
1054
+ }
1055
+ const dt = new DataTransfer();
1056
+ for (const f of files) {
1057
+ const bytes = Uint8Array.from(atob(f.base64 || ""), (c) => c.charCodeAt(0));
1058
+ dt.items.add(new File([bytes], f.name, { type: f.type || "application/octet-stream" }));
1059
+ }
1060
+ element.files = dt.files;
1061
+ element.dispatchEvent(new Event("input", { bubbles: true }));
1062
+ element.dispatchEvent(new Event("change", { bubbles: true }));
1063
+ return { uploaded: files.map((f) => ({ name: f.name, type: f.type, size: (f.base64 || "").length })) };
501
1064
  }
502
1065
 
503
1066
  function dispatchInputEvents(element, data, inputType = "insertText") {
@@ -513,28 +1076,102 @@ function setNativeValue(element, value) {
513
1076
  else element.value = value;
514
1077
  }
515
1078
 
516
- function typeIntoPage(selector, uid, text, pressEnter) {
517
- installPiChromeInstrumentation();
518
- let element = elementBySelectorOrUid(selector, uid) || document.activeElement;
519
- if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element");
520
- element.focus();
1079
+ function printableKeyCode(ch) {
1080
+ if (ch === " ") return 32;
1081
+ const upper = ch.toUpperCase();
1082
+ if (/^[A-Z]$/.test(upper)) return upper.charCodeAt(0);
1083
+ if (/^[0-9]$/.test(ch)) return ch.charCodeAt(0);
1084
+ return ch.charCodeAt(0) || 0;
1085
+ }
1086
+
1087
+ function dispatchKeyEvent(element, type, key, mods = {}) {
1088
+ const code = key.length === 1 && /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` :
1089
+ key.length === 1 && /^[0-9]$/.test(key) ? `Digit${key}` :
1090
+ key === " " ? "Space" : key;
1091
+ const SPECIAL = { Enter: 13, Tab: 9, Backspace: 8, Delete: 46, Escape: 27,
1092
+ ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, " ": 32, Shift: 16, Control: 17, Alt: 18, Meta: 91 };
1093
+ const keyCode = key.length === 1 ? printableKeyCode(key) : (SPECIAL[key] ?? 0);
1094
+ const ev = new KeyboardEvent(type, {
1095
+ key,
1096
+ code,
1097
+ keyCode,
1098
+ which: keyCode,
1099
+ charCode: type === "keypress" && key.length === 1 ? key.charCodeAt(0) : 0,
1100
+ shiftKey: !!mods.shiftKey,
1101
+ ctrlKey: !!mods.ctrlKey,
1102
+ altKey: !!mods.altKey,
1103
+ metaKey: !!mods.metaKey,
1104
+ bubbles: true,
1105
+ cancelable: true,
1106
+ composed: true,
1107
+ view: window,
1108
+ });
1109
+ element.dispatchEvent(ev);
1110
+ return ev;
1111
+ }
1112
+
1113
+ async function typeCharacter(element, ch) {
1114
+ const needShift = ch.length === 1 && (/^[A-Z]$/.test(ch) || "~!@#$%^&*()_+{}|:\"<>?".includes(ch));
1115
+ if (needShift) {
1116
+ dispatchKeyEvent(element, "keydown", "Shift", { shiftKey: true });
1117
+ await sleepPage(rand(8, 24));
1118
+ }
1119
+ const mods = { shiftKey: needShift };
1120
+ const down = dispatchKeyEvent(element, "keydown", ch, mods);
1121
+ if (down.defaultPrevented) {
1122
+ if (needShift) dispatchKeyEvent(element, "keyup", "Shift", { shiftKey: false });
1123
+ return { defaultPrevented: true };
1124
+ }
1125
+ if (ch.length === 1) dispatchKeyEvent(element, "keypress", ch, mods);
1126
+
521
1127
  if (element.isContentEditable) {
522
- document.execCommand("insertText", false, text);
1128
+ // execCommand("insertText") fires its own beforeinput + input. Don't double-dispatch.
1129
+ document.execCommand("insertText", false, ch);
523
1130
  } else if ("value" in element) {
524
1131
  const start = element.selectionStart ?? element.value.length;
525
1132
  const end = element.selectionEnd ?? element.value.length;
526
- setNativeValue(element, element.value.slice(0, start) + text + element.value.slice(end));
527
- element.selectionStart = element.selectionEnd = start + text.length;
528
- dispatchInputEvents(element, text, "insertText");
1133
+ const next = element.value.slice(0, start) + ch + element.value.slice(end);
1134
+ const before = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: ch });
1135
+ element.dispatchEvent(before);
1136
+ if (!before.defaultPrevented) {
1137
+ setNativeValue(element, next);
1138
+ try { element.selectionStart = element.selectionEnd = start + ch.length; } catch {}
1139
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ch }));
1140
+ }
529
1141
  } else {
530
1142
  throw new Error("Focused element is not text-editable");
531
1143
  }
1144
+
1145
+ await sleepPage(rand(25, 95));
1146
+ dispatchKeyEvent(element, "keyup", ch, mods);
1147
+ if (needShift) {
1148
+ await sleepPage(rand(5, 18));
1149
+ dispatchKeyEvent(element, "keyup", "Shift", { shiftKey: false });
1150
+ }
1151
+ await sleepPage(rand(35, 140));
1152
+ return { defaultPrevented: false };
1153
+ }
1154
+
1155
+ async function typeIntoPage(selector, uid, text, pressEnter) {
1156
+ installPiChromeInstrumentation();
1157
+ const before = pageHash();
1158
+ let element = elementBySelectorOrUid(selector, uid) || document.activeElement;
1159
+ if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element");
1160
+ element.focus();
1161
+ if (!(element.isContentEditable || "value" in element)) throw new Error("Focused element is not text-editable");
1162
+ for (const ch of Array.from(text)) await typeCharacter(element, ch);
532
1163
  if (pressEnter) pressKeyInPage("Enter");
533
- return { selector, uid, length: text.length, pressEnter };
1164
+ return {
1165
+ selector, uid, length: text.length, pressEnter,
1166
+ isTrusted: false,
1167
+ valueMatches: "value" in element ? element.value.includes(text) : undefined,
1168
+ pageMutated: pageHash() !== before,
1169
+ };
534
1170
  }
535
1171
 
536
1172
  function fillPage(selector, uid, text, submit) {
537
1173
  installPiChromeInstrumentation();
1174
+ const before = pageHash();
538
1175
  let element = elementBySelectorOrUid(selector, uid) || document.activeElement;
539
1176
  if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element");
540
1177
  element.focus();
@@ -550,19 +1187,66 @@ function fillPage(selector, uid, text, submit) {
550
1187
  throw new Error("Focused element is not text-editable");
551
1188
  }
552
1189
  if (submit) pressKeyInPage("Enter");
553
- return { selector, uid, length: String(text).length, submit };
1190
+ return {
1191
+ selector, uid, length: String(text).length, submit,
1192
+ isTrusted: false,
1193
+ valueMatches: "value" in element ? element.value === String(text) : undefined,
1194
+ pageMutated: pageHash() !== before,
1195
+ };
554
1196
  }
555
1197
 
556
- function pressKeyInPage(key) {
1198
+ async function pressKeyInPage(key) {
557
1199
  const normalized = normalizeKey(key);
558
1200
  const target = document.activeElement || document.body;
559
- target.dispatchEvent(new KeyboardEvent("keydown", { key: normalized, bubbles: true, cancelable: true }));
560
- target.dispatchEvent(new KeyboardEvent("keyup", { key: normalized, bubbles: true, cancelable: true }));
1201
+ const before = pageHash();
1202
+ const down = dispatchKeyEvent(target, "keydown", normalized);
1203
+ if (normalized.length === 1) dispatchKeyEvent(target, "keypress", normalized);
1204
+ // Character insertion for printable keys when focus is in an editable.
1205
+ if (normalized.length === 1 && !down.defaultPrevented && (target.isContentEditable || ("value" in target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")))) {
1206
+ if (target.isContentEditable) {
1207
+ const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: normalized });
1208
+ target.dispatchEvent(bi);
1209
+ if (!bi.defaultPrevented) {
1210
+ document.execCommand("insertText", false, normalized);
1211
+ target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: normalized }));
1212
+ }
1213
+ } else {
1214
+ const start = target.selectionStart ?? target.value.length;
1215
+ const end = target.selectionEnd ?? target.value.length;
1216
+ const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: normalized });
1217
+ target.dispatchEvent(bi);
1218
+ if (!bi.defaultPrevented) {
1219
+ setNativeValue(target, target.value.slice(0, start) + normalized + target.value.slice(end));
1220
+ try { target.selectionStart = target.selectionEnd = start + 1; } catch {}
1221
+ target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: normalized }));
1222
+ }
1223
+ }
1224
+ } else if (normalized === "Backspace" && "value" in target) {
1225
+ const start = target.selectionStart ?? target.value.length;
1226
+ const end = target.selectionEnd ?? target.value.length;
1227
+ if (start > 0 || end > start) {
1228
+ const from = start === end ? start - 1 : start;
1229
+ const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "deleteContentBackward" });
1230
+ target.dispatchEvent(bi);
1231
+ if (!bi.defaultPrevented) {
1232
+ setNativeValue(target, target.value.slice(0, from) + target.value.slice(end));
1233
+ try { target.selectionStart = target.selectionEnd = from; } catch {}
1234
+ target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" }));
1235
+ }
1236
+ }
1237
+ }
1238
+ await sleepPage(rand(25, 95));
1239
+ const up = dispatchKeyEvent(target, "keyup", normalized);
561
1240
  if (normalized === "Enter") {
562
1241
  const form = target.closest?.("form");
563
1242
  if (form) form.requestSubmit?.();
564
1243
  }
565
- return { key: normalized };
1244
+ return {
1245
+ key: normalized,
1246
+ isTrusted: false,
1247
+ defaultPrevented: down.defaultPrevented || up.defaultPrevented,
1248
+ pageMutated: pageHash() !== before,
1249
+ };
566
1250
  }
567
1251
 
568
1252
  function listConsoleMessages(clear) {
@@ -581,7 +1265,7 @@ function listNetworkRequests(includePreservedRequests, clear) {
581
1265
  .filter((request) => includePreservedRequests || request.pageUrl === currentUrl)
582
1266
  .map(({ responseBody, ...summary }) => ({ ...summary, hasResponseBody: responseBody !== undefined }));
583
1267
  if (clear) state.network = [];
584
- return { requests, count: requests.length, note: "Captures fetch/XHR after instrumentation is installed (snapshot/evaluate/network/console tools install it). Browser-initiated document/static asset requests are not captured." };
1268
+ return { requests, count: requests.length, note: "Captures fetch/XHR after instrumentation is installed. Browser-initiated document/static asset requests are not captured." };
585
1269
  }
586
1270
 
587
1271
  function getNetworkRequest(requestId) {
@@ -596,7 +1280,9 @@ async function waitForPage(kind, value, timeoutMs, intervalMs) {
596
1280
  while (Date.now() - started < timeoutMs) {
597
1281
  let ok = false;
598
1282
  if (kind === "selector") ok = Boolean(document.querySelector(value));
599
- else ok = Boolean((0, eval)(value));
1283
+ else {
1284
+ try { ok = Boolean(new Function("return (" + value + ");").call(globalThis)); } catch { ok = false; }
1285
+ }
600
1286
  if (ok) return { elapsedMs: Date.now() - started };
601
1287
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
602
1288
  }