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, [
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
498
|
-
|
|
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 {
|
|
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
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
if (
|
|
520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
560
|
-
target
|
|
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 {
|
|
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
|
|
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
|
|
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
|
}
|