pi-chrome 0.15.53 → 0.15.55
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.
- package/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/docs/FAQ.md +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/manifest.json +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/service_worker.js +344 -54
- package/extensions/chrome-profile-bridge/browser-extension/snapshot_injected.js +1 -9
- package/extensions/chrome-profile-bridge/index.ts +115 -29
- package/package.json +6 -2
- package/test-suite/unit/background-policy.test.mjs +2 -1
- package/test-suite/unit/console-capture.test.mjs +122 -0
- package/test-suite/unit/hidden-input.test.mjs +279 -0
- package/test-suite/unit/input-reliability.test.mjs +39 -6
- package/test-suite/unit/prompt-section.test.mjs +52 -0
- package/test-suite/unit/type-evidence.test.mjs +23 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable user-facing changes to `pi-chrome`.
|
|
4
4
|
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
## 0.15.55 — 2026-09-26
|
|
8
|
+
|
|
9
|
+
- **Console capture no longer breaks sites with DevTools detection (#9).** Some sites (BOSS Zhipin) log an object with a getter or custom `toString` and close the tab if anything reads it, since only open DevTools would. The console capture serialized every logged argument immediately, so it tripped the check. Captured arguments are now stored as-is, and `chrome_list_console_messages` serializes them without running page code: data properties only, accessors shown as `[getter]`, and DOM nodes, regexes, dates and errors described through Chrome's native getters.
|
|
10
|
+
- **Early capture only in Pi's tabs.** The document-start console/network capture used to be injected into every page you opened. It now runs only in Pi's automation tab and in tabs tracked for a Pi session. Other tabs are not touched until a Pi tool acts on them.
|
|
11
|
+
- **Prompt-cache-friendly primer.** The Chrome primer is now contributed as a structured `chrome-profile-bridge` prompt section instead of a forced `systemPrompt` override. Previously the `before_agent_start` handler returned `systemPrompt` on every run (even while locked), which made pi collapse all system messages into a freshly rendered leading prompt each run. That disabled pi's append-only prompt/tool deltas for the whole session and busted the provider prompt cache when `/chrome authorize`, revoke, or expiry toggled the primer. Pi now appends a small section delta instead. Older pi versions without structured sections keep the previous append behavior.
|
|
12
|
+
- **Authorization log stays out of model context.** `pi-chrome-tool-change` records (authorize, reauthorize, revoke, expiry) are now session custom entries rendered in the transcript instead of model-facing custom messages. The model already sees the change through pi's tool and prompt-section delta, so the message was redundant context.
|
|
13
|
+
- **Validation.** New `prompt-section` unit suite.
|
|
14
|
+
|
|
15
|
+
## 0.15.54 — 2026-09-26
|
|
16
|
+
|
|
17
|
+
- **Hidden tabs report instead of silently dropping input.** Chrome ignores trusted input to hidden pages. A page is hidden when it is an inactive tab, its window is minimized, its window is fully covered by another window, or (on macOS) its window became a hidden window tab behind a full-screen window. In a hidden page, mouse presses and keys were dropped while the tool reported success, and mouse moves could hang until a timeout (the source of `Detached while handling command` in background runs). Input tools now check page visibility first. `chrome_type`, `chrome_key`, `chrome_hover`, `chrome_scroll`, `chrome_tap` and `chrome_drag` fail fast with the reason and the options (`background:false`, or `/chrome background off`). `chrome_click` and `chrome_fill` go straight to their existing DOM fallback and report the reason, or reject with `domFallback:false`. Hard background mode is unchanged: nothing is activated or focused.
|
|
18
|
+
- **No hidden automation windows in full screen.** While your Chrome window is full screen, a new automation window would become a hidden macOS window tab or land on another Space. The automation target is now an inactive tab in your window instead.
|
|
19
|
+
- **Targeted `chrome_type` appends.** With a uid/selector, `chrome_type` clicked a random point inside the field, which left the caret mid-text and spliced input into existing content. After the focus click it now moves the caret to the end with Chrome's `moveToEndOfDocument` editing command. Untargeted typing still uses the current caret; `replace:true` is unchanged.
|
|
20
|
+
- **Upload buttons, labels, wrappers, and iframes.** `chrome_upload_file` accepts a label for a file input, or a wrapper/dropzone containing exactly one, and searches same-origin iframes for selectors. For upload buttons that open the native picker (no persistent `<input type=file>`), it intercepts the file chooser (`Page.setInterceptFileChooserDialog`) so no OS dialog appears. The trigger click uses trusted Chrome input on visible pages. On hidden pages it uses `element.click()` with a CDP user gesture, unless `domFallback:false`. It rejects multiple paths for single-file inputs. Adapted from kkunkunya's fork.
|
|
21
|
+
- **No duplicate `change` on upload.** Chrome's `DOM.setFileInputFiles` already fires `input`/`change`, and pi-chrome dispatched a second pair, which could make apps upload twice. It now dispatches only when Chrome did not, and reports `events: native|dispatched`.
|
|
22
|
+
- **Bounded screenshot folder.** Default-path `chrome_screenshot` captures prune `.pi/chrome-screenshots` at capture time. Captures older than 7 days are removed, but the newest 20 are always kept. Only files matching the tool's own naming (including full-page tiles and manifests) are eligible, and explicit `path:` captures are never pruned. `retentionDays` overrides the window; `0` disables pruning. Adapted from ardhiqii's fork.
|
|
23
|
+
- **`input.debug` without a target.** It no longer fails with `Value must be at least 0`; it describes this session's automation tab if one exists.
|
|
24
|
+
- **Validation.** New `hidden-input` unit suite. Live-checked on Chrome/macOS: hidden-tab errors and DOM fallbacks, caret-end typing in inputs and contenteditables, all upload shapes in visible and hidden tabs, single `change` events, and pruning through a real Pi session.
|
|
25
|
+
|
|
5
26
|
## 0.15.53 — 2026-09-26
|
|
6
27
|
|
|
7
28
|
Changes adapted from community forks (ardhiqii, kkunkunya, nihar-oracle, steimerbyte).
|
package/README.md
CHANGED
|
@@ -85,7 +85,7 @@ Run `/chrome revoke` when finished. Use `/chrome authorize` again whenever you w
|
|
|
85
85
|
|
|
86
86
|
This is browser automation, not full OS control. Native Chrome/OS dialogs, password-manager prompts, passkeys/security keys/biometrics, CAPTCHA challenges, cross-origin iframe DOM access, rich multitouch/stylus gestures, and arbitrary desktop apps are outside its reliable tool surface. Some workflows need human assistance.
|
|
87
87
|
|
|
88
|
-
If page inspection or evaluation is blocked, use screenshots and coordinate input where possible. Background pages can throttle rendering or reject focus-gated actions. See the [FAQ](./docs/FAQ.md) for details.
|
|
88
|
+
If page inspection or evaluation is blocked, use screenshots and coordinate input where possible. Background pages can throttle rendering or reject focus-gated actions. Chrome ignores real input to hidden pages (inactive tabs, covered or minimized windows, windows behind a macOS full-screen window); input tools report this instead of pretending to succeed. See the [FAQ](./docs/FAQ.md) for details.
|
|
89
89
|
|
|
90
90
|
## Commands
|
|
91
91
|
|
package/docs/FAQ.md
CHANGED
|
@@ -75,7 +75,7 @@ If the page did not change, take a fresh snapshot or screenshot and check for ov
|
|
|
75
75
|
|
|
76
76
|
## How do I attach a file to a React file input?
|
|
77
77
|
|
|
78
|
-
`chrome_upload_file` — uses Chrome DevTools file-input control
|
|
78
|
+
`chrome_upload_file` — uses Chrome DevTools file-input control; the page gets one `input` + `change`. Target the file input, a label or wrapper containing one, or an upload button: for buttons, the native file chooser is intercepted, so no OS dialog appears. Selectors also search same-origin iframes. Works with React/Vue/Angular controlled inputs.
|
|
79
79
|
|
|
80
80
|
## Can it record videos?
|
|
81
81
|
|
|
@@ -174,6 +174,17 @@ function isPiChromeOwnedTarget(tabId, sessionKey) {
|
|
|
174
174
|
return false;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
// A new window created while the user's Chrome window is full screen does not become a visible
|
|
178
|
+
// standalone window: macOS either merges it into the full-screen window as a hidden native window
|
|
179
|
+
// tab ("Prefer tabs: in full screen") or places it on another Space. Its page is hidden, so trusted
|
|
180
|
+
// input cannot reach it. Use a grouped background tab in the user's window instead, which is at least
|
|
181
|
+
// visible in the tab strip and does not add a macOS tab bar to the user's full-screen window.
|
|
182
|
+
async function userWindowIsFullscreen() {
|
|
183
|
+
if (typeof chrome.windows?.getLastFocused !== "function") return false;
|
|
184
|
+
const win = await chrome.windows.getLastFocused({ windowTypes: ["normal"] }).catch(() => null);
|
|
185
|
+
return win?.state === "fullscreen";
|
|
186
|
+
}
|
|
187
|
+
|
|
177
188
|
// Create a fresh automation target for `sessionKey`. If this session already has a tab group,
|
|
178
189
|
// create the tab inside that group's window so one Pi session keeps one Chrome tab group (Chrome
|
|
179
190
|
// groups cannot span windows). If no group exists yet, prefer an isolated window; fall back to a
|
|
@@ -187,7 +198,7 @@ async function createAutomationTarget(sessionKey, groupTitle) {
|
|
|
187
198
|
await persistAutomationTargets();
|
|
188
199
|
return tab;
|
|
189
200
|
}
|
|
190
|
-
if (chrome.windows && typeof chrome.windows.create === "function") {
|
|
201
|
+
if (chrome.windows && typeof chrome.windows.create === "function" && !(await userWindowIsFullscreen())) {
|
|
191
202
|
try {
|
|
192
203
|
const win = await chrome.windows.create({ url: "about:blank", focused: false });
|
|
193
204
|
const created = win && Array.isArray(win.tabs) ? win.tabs[0] : undefined;
|
|
@@ -391,7 +402,8 @@ async function attachDebugger(tabId) {
|
|
|
391
402
|
}
|
|
392
403
|
|
|
393
404
|
async function describeInputTarget(tabId) {
|
|
394
|
-
const
|
|
405
|
+
const id = Number(tabId);
|
|
406
|
+
const tab = Number.isInteger(id) && id >= 0 ? await chrome.tabs.get(id).catch(() => null) : null;
|
|
395
407
|
const active = (await chrome.tabs.query({ active: true, lastFocusedWindow: true }).catch(() => []))[0] || null;
|
|
396
408
|
let targets = [];
|
|
397
409
|
try { targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))); } catch {}
|
|
@@ -408,7 +420,13 @@ function targetMetaSuffix(meta) {
|
|
|
408
420
|
}
|
|
409
421
|
|
|
410
422
|
async function inputDebug(params) {
|
|
411
|
-
|
|
423
|
+
// No explicit target: describe this session's automation tab if it exists, without creating one.
|
|
424
|
+
let tabId = params?.targetId !== undefined ? Number(params.targetId) : null;
|
|
425
|
+
if (tabId === null) {
|
|
426
|
+
const owned = await resolveOwnedAutomationTarget(sessionKeyOf(params || {})).catch(() => null);
|
|
427
|
+
tabId = owned?.id ?? null;
|
|
428
|
+
}
|
|
429
|
+
const requested = await describeInputTarget(tabId);
|
|
412
430
|
return {
|
|
413
431
|
extensionVersion: chrome.runtime.getManifest().version,
|
|
414
432
|
extensionId: chrome.runtime.id,
|
|
@@ -417,6 +435,57 @@ async function inputDebug(params) {
|
|
|
417
435
|
};
|
|
418
436
|
}
|
|
419
437
|
|
|
438
|
+
// Chrome drops trusted CDP input to hidden pages: mouse presses and keys are silently ignored and
|
|
439
|
+
// mouse moves can hang waiting for a frame. A page is hidden when its tab is inactive, its window is
|
|
440
|
+
// minimized, or (macOS) its window became a native window-tab behind a full-screen window. Check
|
|
441
|
+
// before sending input so the caller gets an actionable error instead of a false success.
|
|
442
|
+
async function pageVisibilityState(tabId) {
|
|
443
|
+
try {
|
|
444
|
+
const evaluated = await cdp(tabId, "Runtime.evaluate", { expression: "document.visibilityState", returnByValue: true });
|
|
445
|
+
const value = evaluated?.result?.value;
|
|
446
|
+
return typeof value === "string" ? value : null;
|
|
447
|
+
} catch {
|
|
448
|
+
return null; // Unknown: do not block input on a failed probe.
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function hiddenTabReason(tab, win) {
|
|
453
|
+
if (win?.state === "minimized") return "its window is minimized";
|
|
454
|
+
if (tab && tab.active === false) return "it is an inactive tab in its window";
|
|
455
|
+
if (win?.state === "fullscreen") return "its window is behind a full-screen window (on macOS, new windows can become hidden window tabs)";
|
|
456
|
+
return "its window is covered by another window or is on another Space (Chrome treats fully covered windows as hidden)";
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function assertTabVisibleForInput(tab, params, label) {
|
|
460
|
+
let state = await pageVisibilityState(tab.id);
|
|
461
|
+
if (state !== "hidden") return;
|
|
462
|
+
if (foregroundRequested(params)) {
|
|
463
|
+
// bringToFront just ran; give Chrome a moment to report the page visible.
|
|
464
|
+
for (let i = 0; i < 6 && state === "hidden"; i++) {
|
|
465
|
+
await sleep(100);
|
|
466
|
+
state = await pageVisibilityState(tab.id);
|
|
467
|
+
}
|
|
468
|
+
if (state !== "hidden") return;
|
|
469
|
+
}
|
|
470
|
+
const fresh = await chrome.tabs.get(tab.id).catch(() => tab);
|
|
471
|
+
const win = typeof chrome.windows?.get === "function" ? await chrome.windows.get(fresh?.windowId ?? tab.windowId).catch(() => null) : null;
|
|
472
|
+
const error = new Error(
|
|
473
|
+
`${label}: tab ${tab.id} is hidden because ${hiddenTabReason(fresh, win)}. Chrome ignores trusted input to hidden pages, so the events would be dropped. ` +
|
|
474
|
+
`Pass background:false for this call (or run /chrome background off) to bring the tab forward. chrome_click and chrome_fill can instead use their DOM fallback (untrusted page events).`,
|
|
475
|
+
);
|
|
476
|
+
error.hiddenTab = true;
|
|
477
|
+
error.windowState = win?.state;
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Put the caret at the end of the focused field with Chrome's editing command (platform-neutral,
|
|
482
|
+
// works for inputs, textareas, and contenteditables). Used after chrome_type clicks a field to focus
|
|
483
|
+
// it, so the random click point never splices text into the middle of existing content.
|
|
484
|
+
async function moveCaretToEnd(tabId) {
|
|
485
|
+
await cdp(tabId, "Input.dispatchKeyEvent", { type: "rawKeyDown", key: "End", code: "End", windowsVirtualKeyCode: 35, commands: ["moveToEndOfDocument"] });
|
|
486
|
+
await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "End", code: "End", windowsVirtualKeyCode: 35 });
|
|
487
|
+
}
|
|
488
|
+
|
|
420
489
|
async function detachDebugger(tabId) {
|
|
421
490
|
const entry = attachedTabs.get(tabId);
|
|
422
491
|
if (!entry) return;
|
|
@@ -825,6 +894,7 @@ async function chromeInputClick(params) {
|
|
|
825
894
|
await bringToFront(tab, params);
|
|
826
895
|
try {
|
|
827
896
|
await attachDebugger(tab.id);
|
|
897
|
+
await assertTabVisibleForInput(tab, params, "chrome.click");
|
|
828
898
|
const resolved = await resolveTargetInTab(tab.id, params);
|
|
829
899
|
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
830
900
|
await cdpMoveTo(tab.id, point.x, point.y);
|
|
@@ -861,6 +931,7 @@ async function chromeInputHover(params) {
|
|
|
861
931
|
const tab = await getTabByParams(params);
|
|
862
932
|
await bringToFront(tab, params);
|
|
863
933
|
await attachDebugger(tab.id);
|
|
934
|
+
await assertTabVisibleForInput(tab, params, "chrome.hover");
|
|
864
935
|
const resolved = await resolveTargetInTab(tab.id, params);
|
|
865
936
|
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
866
937
|
await cdpMoveTo(tab.id, point.x, point.y);
|
|
@@ -872,6 +943,7 @@ async function chromeInputKey(params) {
|
|
|
872
943
|
const tab = await getTabByParams(params);
|
|
873
944
|
await bringToFront(tab, params);
|
|
874
945
|
await attachDebugger(tab.id);
|
|
946
|
+
await assertTabVisibleForInput(tab, params, "chrome.key");
|
|
875
947
|
const key = String(params.key || "");
|
|
876
948
|
if (!key) throw new Error("chrome.key: missing key");
|
|
877
949
|
const mods = params.modifiers || {};
|
|
@@ -954,6 +1026,7 @@ async function chromeInputType(params) {
|
|
|
954
1026
|
const tab = await getTabByParams(params);
|
|
955
1027
|
await bringToFront(tab, params);
|
|
956
1028
|
await attachDebugger(tab.id);
|
|
1029
|
+
await assertTabVisibleForInput(tab, params, "chrome.type");
|
|
957
1030
|
if (params.selector || params.uid) {
|
|
958
1031
|
// Focus target by clicking it first.
|
|
959
1032
|
const resolved = await resolveTargetInTab(tab.id, params);
|
|
@@ -963,6 +1036,9 @@ async function chromeInputType(params) {
|
|
|
963
1036
|
await sleep(rng(45, 110));
|
|
964
1037
|
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
|
|
965
1038
|
await sleep(rng(50, 120));
|
|
1039
|
+
// The click lands at a random point inside the field, which leaves the caret mid-text. Move it to
|
|
1040
|
+
// the end so a targeted chrome_type appends; replace=true selects everything below anyway.
|
|
1041
|
+
if (params.replace !== true) await moveCaretToEnd(tab.id);
|
|
966
1042
|
}
|
|
967
1043
|
const text = String(params.text || "");
|
|
968
1044
|
// chrome_type inserts at the caret. Read the field before and after so a splice into existing
|
|
@@ -1144,6 +1220,7 @@ async function chromeInputFill(params) {
|
|
|
1144
1220
|
await bringToFront(tab, params);
|
|
1145
1221
|
try {
|
|
1146
1222
|
await attachDebugger(tab.id);
|
|
1223
|
+
await assertTabVisibleForInput(tab, params, "chrome.fill");
|
|
1147
1224
|
if (!(params.selector || params.uid)) throw new Error("chrome.fill: selector or uid required");
|
|
1148
1225
|
const resolved = await resolveTargetInTab(tab.id, params);
|
|
1149
1226
|
const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
|
|
@@ -1174,6 +1251,7 @@ async function chromeInputScroll(params) {
|
|
|
1174
1251
|
const tab = await getTabByParams(params);
|
|
1175
1252
|
await bringToFront(tab, params);
|
|
1176
1253
|
await attachDebugger(tab.id);
|
|
1254
|
+
await assertTabVisibleForInput(tab, params, "chrome.scroll");
|
|
1177
1255
|
const resolved = (params.selector || params.uid) ? await resolveTargetInTab(tab.id, params) : { x: 100, y: 100, rect: null };
|
|
1178
1256
|
const x = resolved.rect ? resolved.rect.left + Math.min(resolved.rect.width, 800) / 2 : resolved.x;
|
|
1179
1257
|
const y = resolved.rect ? resolved.rect.top + Math.min(resolved.rect.height, 600) / 2 : resolved.y;
|
|
@@ -1224,6 +1302,7 @@ async function chromeInputTap(params) {
|
|
|
1224
1302
|
const tab = await getTabByParams(params);
|
|
1225
1303
|
await bringToFront(tab, params);
|
|
1226
1304
|
await attachDebugger(tab.id);
|
|
1305
|
+
await assertTabVisibleForInput(tab, params, "chrome.tap");
|
|
1227
1306
|
const resolved = (params.selector || params.uid || (typeof params.x === "number" && typeof params.y === "number"))
|
|
1228
1307
|
? await resolveTargetInTab(tab.id, params)
|
|
1229
1308
|
: null;
|
|
@@ -1240,6 +1319,7 @@ async function chromeInputDrag(params) {
|
|
|
1240
1319
|
const tab = await getTabByParams(params);
|
|
1241
1320
|
await bringToFront(tab, params);
|
|
1242
1321
|
await attachDebugger(tab.id);
|
|
1322
|
+
await assertTabVisibleForInput(tab, params, "chrome.drag");
|
|
1243
1323
|
const from = await resolveTargetInTab(tab.id, { selector: params.fromSelector ?? null, uid: params.fromUid ?? null, x: params.fromX ?? null, y: params.fromY ?? null });
|
|
1244
1324
|
const to = await resolveTargetInTab(tab.id, { selector: params.toSelector ?? null, uid: params.toUid ?? null, x: params.toX ?? null, y: params.toY ?? null });
|
|
1245
1325
|
const fp = from.rect ? pickInsideRect(from.rect) : { x: from.x, y: from.y };
|
|
@@ -1261,6 +1341,100 @@ async function chromeInputDrag(params) {
|
|
|
1261
1341
|
return { input: "chrome", from: fp, to: tp, steps };
|
|
1262
1342
|
}
|
|
1263
1343
|
|
|
1344
|
+
// Pending native file-chooser intercepts, keyed by tabId. chrome_upload_file uses these when the
|
|
1345
|
+
// target is an upload trigger (button/dropzone) rather than a persistent <input type=file>:
|
|
1346
|
+
// Page.setInterceptFileChooserDialog -> click the trigger -> Page.fileChooserOpened ->
|
|
1347
|
+
// DOM.setFileInputFiles(backendNodeId). No OS dialog is shown.
|
|
1348
|
+
const pendingFileChoosers = new Map();
|
|
1349
|
+
const FILE_CHOOSER_TIMEOUT_MS = 8_000;
|
|
1350
|
+
|
|
1351
|
+
function fileChooserTabIdForSource(source) {
|
|
1352
|
+
if (typeof source?.tabId === "number") return source.tabId;
|
|
1353
|
+
if (source?.targetId) {
|
|
1354
|
+
for (const [tabId, entry] of attachedTabs) if (entry?.debuggee?.targetId === source.targetId) return tabId;
|
|
1355
|
+
}
|
|
1356
|
+
return undefined;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function handleDebuggerEvent(source, method, params) {
|
|
1360
|
+
if (method !== "Page.fileChooserOpened") return;
|
|
1361
|
+
const tabId = fileChooserTabIdForSource(source);
|
|
1362
|
+
const entry = tabId === undefined ? undefined : pendingFileChoosers.get(tabId);
|
|
1363
|
+
if (!entry) return;
|
|
1364
|
+
pendingFileChoosers.delete(tabId);
|
|
1365
|
+
entry.resolve({ backendNodeId: params?.backendNodeId, mode: params?.mode });
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
if (chrome.debugger && chrome.debugger.onEvent) {
|
|
1369
|
+
chrome.debugger.onEvent.addListener(handleDebuggerEvent);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function waitForFileChooser(tabId, timeoutMs) {
|
|
1373
|
+
let entry;
|
|
1374
|
+
const promise = new Promise((resolve, reject) => {
|
|
1375
|
+
entry = { resolve, reject };
|
|
1376
|
+
entry.timer = setTimeout(() => {
|
|
1377
|
+
if (pendingFileChoosers.get(tabId) === entry) pendingFileChoosers.delete(tabId);
|
|
1378
|
+
reject(new Error("chrome.upload: no file chooser opened after clicking the target. Pass the <input type=file> itself, or an element that opens the file picker when clicked."));
|
|
1379
|
+
}, timeoutMs);
|
|
1380
|
+
});
|
|
1381
|
+
pendingFileChoosers.set(tabId, entry);
|
|
1382
|
+
return { promise: promise.finally(() => clearTimeout(entry.timer)), cancel: () => { if (pendingFileChoosers.get(tabId) === entry) pendingFileChoosers.delete(tabId); clearTimeout(entry.timer); } };
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Resolve the upload target in the page. Selectors also search same-origin iframes (editors often
|
|
1386
|
+
// host their file input in one). A label for a file input, or a wrapper/dropzone containing exactly
|
|
1387
|
+
// one file input, resolves to that input so no click is needed.
|
|
1388
|
+
function uploadTargetExpression(selector, uid) {
|
|
1389
|
+
return `(() => {
|
|
1390
|
+
const selector = ${JSON.stringify(selector ?? null)};
|
|
1391
|
+
const uid = ${JSON.stringify(uid ?? null)};
|
|
1392
|
+
const state = window.__PI_CHROME_STATE__;
|
|
1393
|
+
const findIn = (doc, depth) => {
|
|
1394
|
+
let found = null;
|
|
1395
|
+
try { found = doc.querySelector(selector); } catch (error) { throw new Error("Invalid selector: " + selector); }
|
|
1396
|
+
if (found || depth >= 3) return found;
|
|
1397
|
+
for (const frame of doc.querySelectorAll("iframe,frame")) {
|
|
1398
|
+
let inner = null;
|
|
1399
|
+
try { inner = frame.contentDocument; } catch {}
|
|
1400
|
+
if (!inner) continue;
|
|
1401
|
+
found = findIn(inner, depth + 1);
|
|
1402
|
+
if (found) return found;
|
|
1403
|
+
}
|
|
1404
|
+
return null;
|
|
1405
|
+
};
|
|
1406
|
+
let el = uid ? state?.elements?.[uid] : (selector ? findIn(document, 0) : null);
|
|
1407
|
+
if (uid && (!el || !el.isConnected)) throw new Error("snapshot uid " + uid + " is stale; refresh chrome_snapshot");
|
|
1408
|
+
if (!el) throw new Error("chrome.upload: target not found: " + (uid || selector));
|
|
1409
|
+
const isFile = (node) => node && node.tagName === "INPUT" && String(node.type).toLowerCase() === "file";
|
|
1410
|
+
if (!isFile(el)) {
|
|
1411
|
+
const control = el.tagName === "LABEL" && isFile(el.control) ? el.control : null;
|
|
1412
|
+
const nested = control ? null : Array.from(el.querySelectorAll ? el.querySelectorAll("input[type=file]") : []);
|
|
1413
|
+
if (control) el = control;
|
|
1414
|
+
else if (nested && nested.length === 1) el = nested[0];
|
|
1415
|
+
}
|
|
1416
|
+
return el;
|
|
1417
|
+
})()`;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const UPLOAD_TARGET_INFO_FN = `function() {
|
|
1421
|
+
const isFile = this.tagName === "INPUT" && String(this.type).toLowerCase() === "file";
|
|
1422
|
+
if (!isFile) this.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
1423
|
+
const r = this.getBoundingClientRect();
|
|
1424
|
+
let x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
1425
|
+
let win = this.ownerDocument.defaultView;
|
|
1426
|
+
// Convert iframe-relative coordinates to top-level viewport coordinates (same-origin frames only).
|
|
1427
|
+
while (win && win.frameElement) {
|
|
1428
|
+
const frame = win.frameElement;
|
|
1429
|
+
const fr = frame.getBoundingClientRect();
|
|
1430
|
+
const cs = frame.ownerDocument.defaultView.getComputedStyle(frame);
|
|
1431
|
+
x += fr.left + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.paddingLeft) || 0);
|
|
1432
|
+
y += fr.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0);
|
|
1433
|
+
win = win.parent === win ? null : win.parent;
|
|
1434
|
+
}
|
|
1435
|
+
return { tag: this.tagName, isFile, multiple: isFile ? this.multiple === true : undefined, inFrame: this.ownerDocument !== document, x, y, width: r.width, height: r.height };
|
|
1436
|
+
}`;
|
|
1437
|
+
|
|
1264
1438
|
async function chromeInputUpload(params) {
|
|
1265
1439
|
const tab = await getTabByParams(params);
|
|
1266
1440
|
await bringToFront(tab, params);
|
|
@@ -1268,36 +1442,80 @@ async function chromeInputUpload(params) {
|
|
|
1268
1442
|
if (!(params.selector || params.uid)) throw new Error("chrome.upload: selector or uid required");
|
|
1269
1443
|
const paths = Array.isArray(params.paths) ? params.paths.map(String) : [];
|
|
1270
1444
|
if (!paths.length) throw new Error("chrome.upload: no file paths provided");
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1273
|
-
const uid = ${JSON.stringify(params.uid ?? null)};
|
|
1274
|
-
const state = window.__PI_CHROME_STATE__;
|
|
1275
|
-
const el = uid ? state?.elements?.[uid] : (selector ? document.querySelector(selector) : null);
|
|
1276
|
-
if (uid && (!el || !el.isConnected)) throw new Error("snapshot uid " + uid + " is stale; refresh chrome_snapshot");
|
|
1277
|
-
if (!el || el.tagName !== "INPUT" || el.type !== "file") throw new Error("Target must be <input type=file>");
|
|
1278
|
-
el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
1279
|
-
return el;
|
|
1280
|
-
})()`;
|
|
1281
|
-
const evaluated = await cdp(tab.id, "Runtime.evaluate", { expression, objectGroup: "pi-chrome-upload", includeCommandLineAPI: false, returnByValue: false });
|
|
1282
|
-
if (evaluated.exceptionDetails) throw new Error(cdpExceptionText(evaluated.exceptionDetails) || "Could not resolve file input");
|
|
1445
|
+
const evaluated = await cdp(tab.id, "Runtime.evaluate", { expression: uploadTargetExpression(params.selector, params.uid), objectGroup: "pi-chrome-upload", includeCommandLineAPI: false, returnByValue: false });
|
|
1446
|
+
if (evaluated.exceptionDetails) throw new Error(cdpExceptionText(evaluated.exceptionDetails) || "Could not resolve upload target");
|
|
1283
1447
|
const objectId = evaluated.result?.objectId;
|
|
1284
|
-
if (!objectId) throw new Error("Could not resolve
|
|
1448
|
+
if (!objectId) throw new Error("Could not resolve upload target object");
|
|
1285
1449
|
try {
|
|
1286
|
-
await cdp(tab.id, "
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
objectId
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1450
|
+
const infoResult = await cdp(tab.id, "Runtime.callFunctionOn", { objectId, functionDeclaration: UPLOAD_TARGET_INFO_FN, returnByValue: true });
|
|
1451
|
+
const info = infoResult?.result?.value || {};
|
|
1452
|
+
if (info.isFile) {
|
|
1453
|
+
if (paths.length > 1 && info.multiple === false) throw new Error(`chrome.upload: the file input accepts one file but ${paths.length} paths were given`);
|
|
1454
|
+
await cdp(tab.id, "DOM.enable", {}).catch(() => undefined);
|
|
1455
|
+
// Some DOM agents return nodeId:0 (or reject conversion) for a valid remote
|
|
1456
|
+
// element. CDP accepts that same objectId directly, before any file mutation.
|
|
1457
|
+
const requested = await cdp(tab.id, "DOM.requestNode", { objectId }).catch(() => null);
|
|
1458
|
+
const target = requested?.nodeId ? { nodeId: requested.nodeId } : { objectId };
|
|
1459
|
+
// Chrome's setFileInputFiles normally fires input/change itself. Watch for that and only
|
|
1460
|
+
// dispatch our own events when it did not, so apps never see a duplicate change (double upload).
|
|
1461
|
+
await cdp(tab.id, "Runtime.callFunctionOn", {
|
|
1462
|
+
objectId,
|
|
1463
|
+
functionDeclaration: `function() { const el = this; el.__piChromeChangeSeen = false; el.addEventListener("change", () => { el.__piChromeChangeSeen = true; }, { once: true, capture: true }); }`,
|
|
1464
|
+
}).catch(() => undefined);
|
|
1465
|
+
await cdp(tab.id, "DOM.setFileInputFiles", { ...target, files: paths });
|
|
1466
|
+
const notified = await cdp(tab.id, "Runtime.callFunctionOn", {
|
|
1467
|
+
objectId,
|
|
1468
|
+
functionDeclaration: `function() { const seen = this.__piChromeChangeSeen === true; delete this.__piChromeChangeSeen; if (seen) return "native"; this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); return "dispatched"; }`,
|
|
1469
|
+
returnByValue: true,
|
|
1470
|
+
}).catch(() => null);
|
|
1471
|
+
const events = notified?.result?.value;
|
|
1472
|
+
return { input: "chrome", mode: "file-input", events: typeof events === "string" ? events : undefined, inFrame: info.inFrame || undefined, uploaded: paths.map((path) => ({ path })) };
|
|
1473
|
+
}
|
|
1474
|
+
return await uploadViaFileChooser(tab, params, paths, objectId, info);
|
|
1297
1475
|
} finally {
|
|
1298
1476
|
await cdp(tab.id, "Runtime.releaseObject", { objectId }).catch(() => undefined);
|
|
1299
1477
|
}
|
|
1300
|
-
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// The target opens the native picker when clicked. Intercept the chooser so no OS dialog appears,
|
|
1481
|
+
// then attach the files to the input Chrome reports. The click is trusted Chrome input when the page
|
|
1482
|
+
// is visible; a hidden page drops trusted input, so there we call element.click() with a CDP user
|
|
1483
|
+
// gesture instead (an untrusted event, like chrome_click's DOM fallback) unless domFallback:false.
|
|
1484
|
+
async function uploadViaFileChooser(tab, params, paths, objectId, info) {
|
|
1485
|
+
const hidden = (await pageVisibilityState(tab.id)) === "hidden";
|
|
1486
|
+
if (hidden && params.domFallback === false) await assertTabVisibleForInput(tab, params, "chrome.upload");
|
|
1487
|
+
await cdp(tab.id, "Page.enable", {});
|
|
1488
|
+
await cdp(tab.id, "Page.setInterceptFileChooserDialog", { enabled: true });
|
|
1489
|
+
const chooser = waitForFileChooser(tab.id, FILE_CHOOSER_TIMEOUT_MS);
|
|
1490
|
+
let trigger;
|
|
1491
|
+
try {
|
|
1492
|
+
if (!hidden && typeof info.x === "number" && typeof info.y === "number" && info.width > 0 && info.height > 0) {
|
|
1493
|
+
trigger = "chrome";
|
|
1494
|
+
await cdpMoveTo(tab.id, info.x, info.y);
|
|
1495
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: info.x, y: info.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse" });
|
|
1496
|
+
await sleep(rng(40, 110));
|
|
1497
|
+
await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: info.x, y: info.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
|
|
1498
|
+
} else {
|
|
1499
|
+
trigger = "activation-click";
|
|
1500
|
+
await cdp(tab.id, "Runtime.callFunctionOn", { objectId, functionDeclaration: "function() { this.click(); }", userGesture: true, awaitPromise: false });
|
|
1501
|
+
}
|
|
1502
|
+
const opened = await chooser.promise;
|
|
1503
|
+
if (typeof opened.backendNodeId !== "number") throw new Error("chrome.upload: file chooser opened without an input node");
|
|
1504
|
+
if (paths.length > 1 && opened.mode === "selectSingle") throw new Error(`chrome.upload: the file chooser accepts one file but ${paths.length} paths were given`);
|
|
1505
|
+
await cdp(tab.id, "DOM.enable", {}).catch(() => undefined);
|
|
1506
|
+
await cdp(tab.id, "DOM.setFileInputFiles", { backendNodeId: opened.backendNodeId, files: paths });
|
|
1507
|
+
return {
|
|
1508
|
+
input: "chrome",
|
|
1509
|
+
mode: "file-chooser",
|
|
1510
|
+
trigger,
|
|
1511
|
+
reason: trigger === "activation-click" ? "target tab is hidden; Chrome drops trusted input to hidden pages" : undefined,
|
|
1512
|
+
inFrame: info.inFrame || undefined,
|
|
1513
|
+
uploaded: paths.map((path) => ({ path })),
|
|
1514
|
+
};
|
|
1515
|
+
} finally {
|
|
1516
|
+
chooser.cancel();
|
|
1517
|
+
await cdp(tab.id, "Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => undefined);
|
|
1518
|
+
}
|
|
1301
1519
|
}
|
|
1302
1520
|
// ===============================================================
|
|
1303
1521
|
|
|
@@ -2075,14 +2293,22 @@ async function unregisterInitScript(tabId) {
|
|
|
2075
2293
|
await cdp(tabId, "Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => undefined);
|
|
2076
2294
|
}
|
|
2077
2295
|
|
|
2078
|
-
//
|
|
2079
|
-
//
|
|
2080
|
-
//
|
|
2081
|
-
//
|
|
2082
|
-
// installPiChromeInstrumentation() call is idempotent.
|
|
2296
|
+
// Inject early console/network capture at document_start, but only into tabs Pi owns or has used
|
|
2297
|
+
// (its automation tab and tabs tracked for a session). Your other tabs are never patched. Catches
|
|
2298
|
+
// console messages, errors, and network requests that fire during page load, before chrome_snapshot
|
|
2299
|
+
// or chrome_evaluate install the instrumentation normally. installEarlyCapture sets __piChromeWrapped
|
|
2300
|
+
// flags so the post-hoc installPiChromeInstrumentation() call is idempotent.
|
|
2301
|
+
async function isPiTrackedTab(tabId) {
|
|
2302
|
+
await Promise.all([hydrateAutomationTargets(), hydrateSessionTabs()]);
|
|
2303
|
+
if (isPiChromeOwnedTarget(tabId)) return true;
|
|
2304
|
+
for (const tabs of sessionTabs.values()) if (tabs.has(tabId)) return true;
|
|
2305
|
+
return false;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2083
2308
|
if (chrome.webNavigation && chrome.webNavigation.onCommitted) {
|
|
2084
|
-
chrome.webNavigation.onCommitted.addListener((details) => {
|
|
2309
|
+
chrome.webNavigation.onCommitted.addListener(async (details) => {
|
|
2085
2310
|
if (details.frameId !== 0) return;
|
|
2311
|
+
if (!(await isPiTrackedTab(details.tabId).catch(() => false))) return;
|
|
2086
2312
|
chrome.scripting.executeScript({
|
|
2087
2313
|
target: { tabId: details.tabId, frameIds: [0] },
|
|
2088
2314
|
world: "MAIN",
|
|
@@ -2374,15 +2600,9 @@ function installPiChromeInstrumentation() {
|
|
|
2374
2600
|
level,
|
|
2375
2601
|
timestamp: Date.now(),
|
|
2376
2602
|
url: location.href,
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack };
|
|
2381
|
-
return JSON.parse(JSON.stringify(arg));
|
|
2382
|
-
} catch {
|
|
2383
|
-
return String(arg);
|
|
2384
|
-
}
|
|
2385
|
-
}),
|
|
2603
|
+
// Keep the raw values and serialize only when Pi lists messages. Serializing here reads every
|
|
2604
|
+
// logged object (getters, toString), which is exactly what anti-DevTools checks watch for.
|
|
2605
|
+
args: Array.from(args),
|
|
2386
2606
|
});
|
|
2387
2607
|
if (state.console.length > 500) state.console.splice(0, state.console.length - 500);
|
|
2388
2608
|
};
|
|
@@ -2497,15 +2717,7 @@ function installEarlyCapture() {
|
|
|
2497
2717
|
level: level,
|
|
2498
2718
|
timestamp: Date.now(),
|
|
2499
2719
|
url: location.href,
|
|
2500
|
-
args: Array.from(args)
|
|
2501
|
-
try {
|
|
2502
|
-
if (typeof arg === "string") return arg;
|
|
2503
|
-
if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack };
|
|
2504
|
-
return JSON.parse(JSON.stringify(arg));
|
|
2505
|
-
} catch (e) {
|
|
2506
|
-
return String(arg);
|
|
2507
|
-
}
|
|
2508
|
-
}),
|
|
2720
|
+
args: Array.from(args), // raw; serialized on read (see listConsoleMessages)
|
|
2509
2721
|
});
|
|
2510
2722
|
if (state.console.length > 500) state.console.splice(0, state.console.length - 500);
|
|
2511
2723
|
}
|
|
@@ -2699,7 +2911,7 @@ async function clickPage(selector, uid, x, y) {
|
|
|
2699
2911
|
// Inspect recent console errors for activation-gate rejections.
|
|
2700
2912
|
const recent = (state.console || []).slice(-8);
|
|
2701
2913
|
const hit = recent.find((e) => /NotAllowedError|Document is not focused|requires transient activation|gesture is required/.test(
|
|
2702
|
-
(e.args || []).map((a) => typeof a === "string" ? a :
|
|
2914
|
+
(e.args || []).map((a) => typeof a === "string" ? a : a instanceof Error ? a.message : "").join(" ")
|
|
2703
2915
|
));
|
|
2704
2916
|
if (hit) { suggestChromeInput = true; suggestReason = "recent console error indicates user-activation gate"; }
|
|
2705
2917
|
}
|
|
@@ -3067,7 +3279,85 @@ async function pressKeyInPage(key) {
|
|
|
3067
3279
|
function listConsoleMessages(clear) {
|
|
3068
3280
|
installPiChromeInstrumentation();
|
|
3069
3281
|
const state = getPiChromeState();
|
|
3070
|
-
|
|
3282
|
+
// Serialize without running page code. Anti-DevTools checks log objects with getters or a custom
|
|
3283
|
+
// toString and treat any call as "DevTools is open" (issue #9). Copy data properties only, report
|
|
3284
|
+
// accessors as "[getter]" without calling them, and describe built-ins through native getters.
|
|
3285
|
+
const nativeSource = Function.prototype.toString;
|
|
3286
|
+
const isNative = (fn) => { try { return typeof fn === "function" && nativeSource.call(fn).includes("[native code]"); } catch { return false; } };
|
|
3287
|
+
const protoGetter = (proto, key) => { try { return Object.getOwnPropertyDescriptor(proto, key)?.get; } catch { return undefined; } };
|
|
3288
|
+
const callGetter = (proto, key, value) => { const get = protoGetter(proto, key); return get ? get.call(value) : undefined; };
|
|
3289
|
+
const dataProp = (value, key) => {
|
|
3290
|
+
for (let obj = value, hops = 0; obj && hops < 20; obj = Object.getPrototypeOf(obj), hops++) {
|
|
3291
|
+
const desc = Object.getOwnPropertyDescriptor(obj, key);
|
|
3292
|
+
if (!desc) continue;
|
|
3293
|
+
if ("value" in desc) return { ok: true, value: desc.value };
|
|
3294
|
+
return isNative(desc.get) ? { ok: true, value: desc.get.call(value) } : { ok: false };
|
|
3295
|
+
}
|
|
3296
|
+
return { ok: true, value: undefined };
|
|
3297
|
+
};
|
|
3298
|
+
const serialize = (value, depth = 0, seen = new Set()) => {
|
|
3299
|
+
try {
|
|
3300
|
+
const type = typeof value;
|
|
3301
|
+
if (value === null || type === "string" || type === "boolean") return value;
|
|
3302
|
+
if (type === "number") return Number.isFinite(value) ? value : String(value);
|
|
3303
|
+
if (type === "undefined") return "undefined";
|
|
3304
|
+
if (type === "bigint") return `${BigInt.prototype.toString.call(value)}n`;
|
|
3305
|
+
if (type === "symbol") return Symbol.prototype.toString.call(value);
|
|
3306
|
+
if (type === "function") {
|
|
3307
|
+
const name = dataProp(value, "name");
|
|
3308
|
+
return `[function ${name.ok && typeof name.value === "string" && name.value ? name.value : "anonymous"}]`;
|
|
3309
|
+
}
|
|
3310
|
+
if (seen.has(value)) return "[circular]";
|
|
3311
|
+
if (value instanceof Error) {
|
|
3312
|
+
const out = {};
|
|
3313
|
+
for (const key of ["name", "message"]) {
|
|
3314
|
+
const prop = dataProp(value, key);
|
|
3315
|
+
out[key] = prop.ok ? (typeof prop.value === "string" ? prop.value : serialize(prop.value, depth + 1, seen)) : "[getter]";
|
|
3316
|
+
}
|
|
3317
|
+
// V8 formats `stack` lazily from name/message, so only read it when both are plain data.
|
|
3318
|
+
const stack = out.name !== "[getter]" && out.message !== "[getter]" ? dataProp(value, "stack") : { ok: false };
|
|
3319
|
+
out.stack = stack.ok ? (typeof stack.value === "string" ? stack.value : undefined) : "[getter]";
|
|
3320
|
+
return out;
|
|
3321
|
+
}
|
|
3322
|
+
if (value instanceof Date) return Date.prototype.toISOString.call(value);
|
|
3323
|
+
if (value instanceof RegExp) return `/${callGetter(RegExp.prototype, "source", value)}/${callGetter(RegExp.prototype, "flags", value)}`;
|
|
3324
|
+
if (typeof Node !== "undefined" && value instanceof Node) {
|
|
3325
|
+
const name = String(callGetter(Node.prototype, "nodeName", value) || "node").toLowerCase();
|
|
3326
|
+
if (typeof Element === "undefined" || !(value instanceof Element)) return `<${name}>`;
|
|
3327
|
+
const id = callGetter(Element.prototype, "id", value);
|
|
3328
|
+
const cls = String(callGetter(Element.prototype, "className", value) || "").trim().split(/\s+/).filter(Boolean).slice(0, 3);
|
|
3329
|
+
return `<${name}${id ? `#${id}` : ""}${cls.map((c) => `.${c}`).join("")}>`;
|
|
3330
|
+
}
|
|
3331
|
+
if (value instanceof Map) return `[Map(${callGetter(Map.prototype, "size", value)})]`;
|
|
3332
|
+
if (value instanceof Set) return `[Set(${callGetter(Set.prototype, "size", value)})]`;
|
|
3333
|
+
if (depth >= 4) return Array.isArray(value) ? "[array]" : "[object]";
|
|
3334
|
+
seen.add(value);
|
|
3335
|
+
if (Array.isArray(value)) {
|
|
3336
|
+
const length = dataProp(value, "length");
|
|
3337
|
+
const n = length.ok ? Math.min(Number(length.value) || 0, 50) : 0;
|
|
3338
|
+
const out = [];
|
|
3339
|
+
for (let i = 0; i < n; i++) {
|
|
3340
|
+
const item = dataProp(value, String(i));
|
|
3341
|
+
out.push(item.ok ? serialize(item.value, depth + 1, seen) : "[getter]");
|
|
3342
|
+
}
|
|
3343
|
+
if (length.ok && length.value > 50) out.push(`[${length.value - 50} more]`);
|
|
3344
|
+
seen.delete(value);
|
|
3345
|
+
return out;
|
|
3346
|
+
}
|
|
3347
|
+
const out = {};
|
|
3348
|
+
const keys = Object.keys(value);
|
|
3349
|
+
for (const key of keys.slice(0, 50)) {
|
|
3350
|
+
const desc = Object.getOwnPropertyDescriptor(value, key);
|
|
3351
|
+
out[key] = desc && "value" in desc ? serialize(desc.value, depth + 1, seen) : "[getter]";
|
|
3352
|
+
}
|
|
3353
|
+
if (keys.length > 50) out["…"] = `${keys.length - 50} more keys`;
|
|
3354
|
+
seen.delete(value);
|
|
3355
|
+
return out;
|
|
3356
|
+
} catch {
|
|
3357
|
+
return "[unserializable]";
|
|
3358
|
+
}
|
|
3359
|
+
};
|
|
3360
|
+
const messages = state.console.map((entry) => ({ ...entry, args: Array.from(entry.args || [], (arg) => serialize(arg)) }));
|
|
3071
3361
|
if (clear) state.console = [];
|
|
3072
3362
|
return { messages, count: messages.length };
|
|
3073
3363
|
}
|
|
@@ -125,15 +125,7 @@
|
|
|
125
125
|
level,
|
|
126
126
|
timestamp: Date.now(),
|
|
127
127
|
url: location.href,
|
|
128
|
-
args: Array.from(args)
|
|
129
|
-
try {
|
|
130
|
-
if (typeof arg === "string") return arg;
|
|
131
|
-
if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack };
|
|
132
|
-
return JSON.parse(JSON.stringify(arg));
|
|
133
|
-
} catch {
|
|
134
|
-
return String(arg);
|
|
135
|
-
}
|
|
136
|
-
}),
|
|
128
|
+
args: Array.from(args), // raw; serialized on read by listConsoleMessages
|
|
137
129
|
});
|
|
138
130
|
if (state.console.length > 500) state.console.splice(0, state.console.length - 500);
|
|
139
131
|
};
|