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