cdp-toolkit 1.9.0 → 1.9.4
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 +507 -0
- package/CONTRACT.md +62 -5
- package/README.md +74 -10
- package/dist/cli.js +2940 -138
- package/dist/index.js +994 -90
- package/dist/mcp.js +1221 -137
- package/package.json +2 -1
- package/src/backend.ts +156 -21
- package/src/bidi/client.ts +48 -10
- package/src/bidi/driver.ts +228 -33
- package/src/bidi/launch.ts +23 -13
- package/src/bidi-tools.ts +21 -0
- package/src/cdp/driver.ts +594 -9
- package/src/cli.ts +111 -12
- package/src/driver.ts +254 -3
- package/src/manifest.ts +42 -14
- package/src/mcp.ts +21 -12
- package/src/png.ts +809 -0
- package/src/shared-tools.ts +313 -11
- package/src/tools/console.ts +24 -7
- package/src/tools/network.ts +21 -7
- package/src/tools/recorder.ts +110 -10
- package/src/types.ts +8 -6
- package/src/workers.ts +69 -7
package/dist/mcp.js
CHANGED
|
@@ -406,6 +406,12 @@ async function resolveLiveLabel(backend, label, liveIds) {
|
|
|
406
406
|
|
|
407
407
|
// src/workers.ts
|
|
408
408
|
var WORKER_SELECTOR_PREFIX = "worker:";
|
|
409
|
+
var WORKER_CAPABLE_TOOLS = [
|
|
410
|
+
"evaluate_script",
|
|
411
|
+
"list_network_requests",
|
|
412
|
+
"get_network_request",
|
|
413
|
+
"list_console_messages"
|
|
414
|
+
];
|
|
409
415
|
var WORKER_TARGET_TYPES = new Set(["service_worker", "shared_worker"]);
|
|
410
416
|
function isWorkerSelector(selector) {
|
|
411
417
|
return typeof selector === "string" && selector.startsWith(WORKER_SELECTOR_PREFIX);
|
|
@@ -424,8 +430,18 @@ function resolveWorkerTargets(targets, needle) {
|
|
|
424
430
|
};
|
|
425
431
|
}
|
|
426
432
|
var WORKER_EMPTY_NEEDLE_MESSAGE = "'worker:' needs a substring of the worker's url, e.g. worker:<extension-id> or worker:background.js";
|
|
427
|
-
var WORKER_SELECTOR_UNSUPPORTED_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, which this backend cannot address " + "(WebDriver BiDi has no extension service-worker target; Capability 'worker.targets' is Chrome-only). " + "Use --browser chrome to evaluate in an extension's background worker.";
|
|
428
|
-
var WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless).
|
|
433
|
+
var WORKER_SELECTOR_UNSUPPORTED_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, which this backend cannot address " + "(WebDriver BiDi has no extension service-worker target; Capability 'worker.targets' is Chrome-only). " + "Use --browser chrome to evaluate in, or record console/network from, an extension's background worker.";
|
|
434
|
+
var WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless). The tools that accept a worker: target are " + `${WORKER_CAPABLE_TOOLS.join(", ")}.`;
|
|
435
|
+
function workerWakeMisuseMessage(kind) {
|
|
436
|
+
if (kind === "not-a-worker-selector") {
|
|
437
|
+
return "'wake' only applies to a target of the form 'worker:<substring>'. A page is never asleep, and a bare " + "worker target id cannot be woken either: Chrome destroys the target when an MV3 worker is evicted and " + "mints a NEW id when it restarts, so only the url-substring form can name a worker that is not running.";
|
|
438
|
+
}
|
|
439
|
+
return "'wake' only applies when a capture is being started (reload:true, or includeBody with url). Reading an " + "existing buffer cannot use a woken worker: a restarted MV3 worker gets a NEW target id, and the buffer is " + "keyed by id, so the wake would hand back an empty capture. Re-run with reload:true to record a fresh one.";
|
|
440
|
+
}
|
|
441
|
+
function workerBufferReadMissMessage(needle, liveWorkers) {
|
|
442
|
+
const running = liveWorkers.length ? ` (running workers: ${urlList(liveWorkers)})` : " (no workers are running)";
|
|
443
|
+
return `no running worker whose url contains '${needle}'${running}. ` + "An MV3 extension service worker is idle-evicted after seconds of inactivity and then appears in NO target " + "listing, so this is usually a sleeping worker rather than a wrong substring. This call only READS a buffer " + "recorded earlier, and that buffer is keyed by the worker's target id, which Chrome re-mints on restart — so " + "re-run with reload:true, which starts the worker and records a fresh capture window.";
|
|
444
|
+
}
|
|
429
445
|
function urlList(workers) {
|
|
430
446
|
return workers.map((w) => w.url).join(", ");
|
|
431
447
|
}
|
|
@@ -1105,6 +1121,8 @@ async function clearMocks(args = {}) {
|
|
|
1105
1121
|
}
|
|
1106
1122
|
|
|
1107
1123
|
// src/driver.ts
|
|
1124
|
+
import { mkdir as mkdir3, readFile as readFile3, unlink as unlink3, writeFile as writeFile3 } from "node:fs/promises";
|
|
1125
|
+
import { join as join3 } from "node:path";
|
|
1108
1126
|
var UID_STAMP_ATTR = "data-cdp-uid";
|
|
1109
1127
|
var LEGACY_NUMERIC_UID = /^[0-9]+$/;
|
|
1110
1128
|
var REQUIRED_CAPABILITIES = {
|
|
@@ -1134,6 +1152,41 @@ function interpolatePoints(from, to, steps) {
|
|
|
1134
1152
|
out.push({ x: to.x, y: to.y });
|
|
1135
1153
|
return out;
|
|
1136
1154
|
}
|
|
1155
|
+
function isTiledCapture(delivery) {
|
|
1156
|
+
return delivery.tiled === true;
|
|
1157
|
+
}
|
|
1158
|
+
function viewportRecordFile(scheme, targetId) {
|
|
1159
|
+
return join3(leaseDir(), `viewport-${scheme}-${targetId.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
|
|
1160
|
+
}
|
|
1161
|
+
async function readViewportRecord(scheme, targetId) {
|
|
1162
|
+
try {
|
|
1163
|
+
const raw = JSON.parse(await readFile3(viewportRecordFile(scheme, targetId), "utf8"));
|
|
1164
|
+
if (typeof raw?.width !== "number" || typeof raw?.height !== "number")
|
|
1165
|
+
return;
|
|
1166
|
+
return {
|
|
1167
|
+
width: raw.width,
|
|
1168
|
+
height: raw.height,
|
|
1169
|
+
...typeof raw.deviceScaleFactor === "number" ? { deviceScaleFactor: raw.deviceScaleFactor } : {},
|
|
1170
|
+
...typeof raw.mobile === "boolean" ? { mobile: raw.mobile } : {}
|
|
1171
|
+
};
|
|
1172
|
+
} catch {
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
async function writeViewportRecord(scheme, targetId, rec) {
|
|
1177
|
+
try {
|
|
1178
|
+
await mkdir3(leaseDir(), { recursive: true });
|
|
1179
|
+
await writeFile3(viewportRecordFile(scheme, targetId), JSON.stringify({ scheme, targetId, ...rec }), "utf8");
|
|
1180
|
+
} catch {}
|
|
1181
|
+
}
|
|
1182
|
+
function renderRestoreFailureNote(size, why) {
|
|
1183
|
+
return `— AND the ${size.width}x${size.height} render-size override could NOT be restored (${why}), so this page is STILL ` + `emulated at ${size.width}x${size.height}: put it back with emulate{width,height} or emulate{clearOverrides:true}.`;
|
|
1184
|
+
}
|
|
1185
|
+
async function clearViewportRecord(scheme, targetId) {
|
|
1186
|
+
try {
|
|
1187
|
+
await unlink3(viewportRecordFile(scheme, targetId));
|
|
1188
|
+
} catch {}
|
|
1189
|
+
}
|
|
1137
1190
|
|
|
1138
1191
|
// src/cdp/driver.ts
|
|
1139
1192
|
function driverError(code, message, data) {
|
|
@@ -1365,14 +1418,73 @@ async function backendNodeIdOf(conn, loc) {
|
|
|
1365
1418
|
}
|
|
1366
1419
|
return searchLocate(conn, "xpath" in loc ? loc.xpath : loc.text);
|
|
1367
1420
|
}
|
|
1368
|
-
async function
|
|
1369
|
-
|
|
1421
|
+
async function layoutMetrics(conn) {
|
|
1422
|
+
return conn.send("Page.getLayoutMetrics");
|
|
1423
|
+
}
|
|
1424
|
+
function contentClip(m) {
|
|
1370
1425
|
const size = m.cssContentSize ?? m.contentSize;
|
|
1371
1426
|
if (!size)
|
|
1372
1427
|
throw driverError("page-error", "Page.getLayoutMetrics returned no content size");
|
|
1373
1428
|
return { x: 0, y: 0, width: Math.ceil(size.width), height: Math.ceil(size.height) };
|
|
1374
1429
|
}
|
|
1375
|
-
|
|
1430
|
+
function devicePixelsPerCssPx(m) {
|
|
1431
|
+
const css = m.cssVisualViewport?.clientWidth ?? m.cssLayoutViewport?.clientWidth;
|
|
1432
|
+
const dev = m.visualViewport?.clientWidth ?? m.layoutViewport?.clientWidth;
|
|
1433
|
+
if (!css || !dev)
|
|
1434
|
+
return 1;
|
|
1435
|
+
const ratio = dev / css;
|
|
1436
|
+
return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
|
|
1437
|
+
}
|
|
1438
|
+
function scrollOffsetCssPx(m, devicePx) {
|
|
1439
|
+
const css = m.cssVisualViewport ?? m.cssLayoutViewport;
|
|
1440
|
+
if (css)
|
|
1441
|
+
return { x: css.pageX ?? 0, y: css.pageY ?? 0 };
|
|
1442
|
+
const legacy = m.layoutViewport;
|
|
1443
|
+
if (legacy)
|
|
1444
|
+
return { x: (legacy.pageX ?? 0) / devicePx, y: (legacy.pageY ?? 0) / devicePx };
|
|
1445
|
+
return { x: 0, y: 0 };
|
|
1446
|
+
}
|
|
1447
|
+
var MAX_ENCODED_SIDE_PX = 16384;
|
|
1448
|
+
function assertClipEncodable(clip, scale, devicePx, renderSize) {
|
|
1449
|
+
const outWidth = Math.ceil(clip.width * scale * devicePx);
|
|
1450
|
+
const outHeight = Math.ceil(clip.height * scale * devicePx);
|
|
1451
|
+
if (outWidth <= MAX_ENCODED_SIDE_PX && outHeight <= MAX_ENCODED_SIDE_PX)
|
|
1452
|
+
return;
|
|
1453
|
+
const at = renderSize ? ` at render size ${renderSize.width}x${renderSize.height}` : "";
|
|
1454
|
+
const projection = `(clip ${clip.width}x${clip.height} css px${at} x scale ${scale} x device pixel ratio ${devicePx}), ` + `past Chrome's ${MAX_ENCODED_SIDE_PX} px per-side limit; `;
|
|
1455
|
+
if (scale !== 1) {
|
|
1456
|
+
const fits = Math.floor(MAX_ENCODED_SIDE_PX / (Math.max(clip.width, clip.height) * devicePx) * 100) / 100;
|
|
1457
|
+
throw driverError("page-error", `screenshot scale ${scale} would encode ${outWidth}x${outHeight} px ${projection}` + (fits >= 0.01 ? `the largest scale that fits this clip is ${fits}.` : `this clip is already past the limit at scale 1 — capture a smaller region.`));
|
|
1458
|
+
}
|
|
1459
|
+
const maxCssSide = Math.floor(MAX_ENCODED_SIDE_PX / devicePx);
|
|
1460
|
+
throw driverError("page-error", `this capture would encode ${outWidth}x${outHeight} px ${projection}` + `at device pixel ratio ${devicePx} the clip may be at most ${maxCssSide} css px per side` + (renderSize ? ` — a narrow renderWidth reflows the document TALLER, so widen it, shorten the page, or drop fullPage.` : ` — capture a smaller region or drop fullPage.`));
|
|
1461
|
+
}
|
|
1462
|
+
function projectedSide(cssLength, scale, devicePx) {
|
|
1463
|
+
return Math.ceil(cssLength * scale * devicePx);
|
|
1464
|
+
}
|
|
1465
|
+
function planTiledCapture(clip, scale, devicePx, tile) {
|
|
1466
|
+
const untiled = { tiled: false, bandCount: 1, bandHeightCss: clip.height };
|
|
1467
|
+
if (tile === false)
|
|
1468
|
+
return untiled;
|
|
1469
|
+
const fits = projectedSide(clip.width, scale, devicePx) <= MAX_ENCODED_SIDE_PX && projectedSide(clip.height, scale, devicePx) <= MAX_ENCODED_SIDE_PX;
|
|
1470
|
+
if (fits && tile !== true)
|
|
1471
|
+
return untiled;
|
|
1472
|
+
const outWidth = projectedSide(clip.width, scale, devicePx);
|
|
1473
|
+
if (outWidth > MAX_ENCODED_SIDE_PX) {
|
|
1474
|
+
const maxCssWidth = Math.floor(MAX_ENCODED_SIDE_PX / (scale * devicePx));
|
|
1475
|
+
throw driverError("page-error", `this capture is ${outWidth} px WIDE (clip ${clip.width} css px x scale ${scale} x device pixel ratio ${devicePx}), ` + `past Chrome's ${MAX_ENCODED_SIDE_PX} px per-side limit, and width cannot be tiled: bands are stitched ` + `vertically only. Lower scale (at most ${Math.floor(MAX_ENCODED_SIDE_PX / (clip.width * devicePx) * 100) / 100}), ` + `or set renderWidth at most ${maxCssWidth} css px, or clip to a narrower element.`);
|
|
1476
|
+
}
|
|
1477
|
+
let bandHeightCss = Math.floor(MAX_ENCODED_SIDE_PX / (scale * devicePx));
|
|
1478
|
+
while (bandHeightCss > 1 && projectedSide(bandHeightCss, scale, devicePx) > MAX_ENCODED_SIDE_PX)
|
|
1479
|
+
bandHeightCss -= 1;
|
|
1480
|
+
if (bandHeightCss < 1) {
|
|
1481
|
+
throw driverError("page-error", `scale ${scale} at device pixel ratio ${devicePx} projects a single css pixel to ${scale * devicePx} device px, ` + `past the ${MAX_ENCODED_SIDE_PX} px limit — no band height can fit. Lower scale.`);
|
|
1482
|
+
}
|
|
1483
|
+
if (tile === true && clip.height <= bandHeightCss)
|
|
1484
|
+
bandHeightCss = Math.max(1, Math.ceil(clip.height / 2));
|
|
1485
|
+
return { tiled: true, bandCount: Math.max(1, Math.ceil(clip.height / bandHeightCss)), bandHeightCss };
|
|
1486
|
+
}
|
|
1487
|
+
async function elementBox(conn, loc) {
|
|
1376
1488
|
const backendNodeId = await backendNodeIdOf(conn, loc);
|
|
1377
1489
|
await conn.send("DOM.scrollIntoViewIfNeeded", { backendNodeId }).catch(() => {
|
|
1378
1490
|
return;
|
|
@@ -1389,6 +1501,10 @@ async function elementClip(conn, loc) {
|
|
|
1389
1501
|
throw driverError("page-error", "resolved element has zero area");
|
|
1390
1502
|
return { x: minX, y: minY, width, height };
|
|
1391
1503
|
}
|
|
1504
|
+
function documentClip(box, m, devicePx) {
|
|
1505
|
+
const scroll = scrollOffsetCssPx(m, devicePx);
|
|
1506
|
+
return { x: box.x + scroll.x, y: box.y + scroll.y, width: box.width, height: box.height };
|
|
1507
|
+
}
|
|
1392
1508
|
var CDP_CAPABILITIES = new Set([
|
|
1393
1509
|
"trace.performance",
|
|
1394
1510
|
"heap.snapshot",
|
|
@@ -1399,6 +1515,9 @@ var CDP_CAPABILITIES = new Set([
|
|
|
1399
1515
|
"emulate.networkConditions",
|
|
1400
1516
|
"screenshot.fullPage",
|
|
1401
1517
|
"screenshot.element",
|
|
1518
|
+
"screenshot.scale",
|
|
1519
|
+
"screenshot.renderSize",
|
|
1520
|
+
"screenshot.tile",
|
|
1402
1521
|
"network.intercept",
|
|
1403
1522
|
"snapshot.accessibilityTree",
|
|
1404
1523
|
"input.insertTextAtomic",
|
|
@@ -1689,31 +1808,128 @@ class CdpPageDriver {
|
|
|
1689
1808
|
const { objectId } = await resolveElementLocator(this.conn, loc);
|
|
1690
1809
|
await this.conn.send("DOM.setFileInputFiles", { files: [...files], objectId });
|
|
1691
1810
|
}
|
|
1692
|
-
async screenshot(opts) {
|
|
1811
|
+
async screenshot(opts, consumeBands) {
|
|
1812
|
+
const renderSize = opts?.renderWidth !== undefined && opts?.renderHeight !== undefined ? { width: opts.renderWidth, height: opts.renderHeight } : undefined;
|
|
1813
|
+
if (!renderSize)
|
|
1814
|
+
return this.capture(opts, consumeBands);
|
|
1815
|
+
const previous = await readViewportRecord(this.browser.scheme, this.info.id);
|
|
1816
|
+
await this.conn.send("Emulation.setDeviceMetricsOverride", {
|
|
1817
|
+
width: renderSize.width,
|
|
1818
|
+
height: renderSize.height,
|
|
1819
|
+
deviceScaleFactor: previous?.deviceScaleFactor ?? 0,
|
|
1820
|
+
mobile: previous?.mobile ?? false
|
|
1821
|
+
});
|
|
1822
|
+
let shot;
|
|
1823
|
+
let captureError;
|
|
1824
|
+
try {
|
|
1825
|
+
shot = await this.capture(opts, consumeBands);
|
|
1826
|
+
} catch (e) {
|
|
1827
|
+
captureError = e;
|
|
1828
|
+
}
|
|
1829
|
+
let restoreError;
|
|
1830
|
+
try {
|
|
1831
|
+
if (previous) {
|
|
1832
|
+
await this.conn.send("Emulation.setDeviceMetricsOverride", {
|
|
1833
|
+
width: previous.width,
|
|
1834
|
+
height: previous.height,
|
|
1835
|
+
deviceScaleFactor: previous.deviceScaleFactor ?? 0,
|
|
1836
|
+
mobile: previous.mobile ?? false
|
|
1837
|
+
});
|
|
1838
|
+
} else {
|
|
1839
|
+
await this.conn.send("Emulation.clearDeviceMetricsOverride");
|
|
1840
|
+
}
|
|
1841
|
+
} catch (e) {
|
|
1842
|
+
restoreError = e instanceof Error ? e.message : String(e);
|
|
1843
|
+
}
|
|
1844
|
+
if (!shot) {
|
|
1845
|
+
if (restoreError !== undefined && captureError instanceof Error) {
|
|
1846
|
+
captureError.message = `${captureError.message} ${renderRestoreFailureNote(renderSize, restoreError)}`;
|
|
1847
|
+
}
|
|
1848
|
+
throw captureError;
|
|
1849
|
+
}
|
|
1850
|
+
const result = Object.assign(shot, { renderSize, renderRestored: restoreError === undefined });
|
|
1851
|
+
if (restoreError !== undefined)
|
|
1852
|
+
result.renderRestoreError = restoreError;
|
|
1853
|
+
return result;
|
|
1854
|
+
}
|
|
1855
|
+
async capture(opts, consumeBands) {
|
|
1693
1856
|
await this.ensureDomain("Page");
|
|
1694
1857
|
const format = opts?.format ?? "png";
|
|
1695
1858
|
const quality = format === "jpeg" ? opts?.quality ?? 80 : undefined;
|
|
1859
|
+
const scale = opts?.scale ?? 1;
|
|
1860
|
+
const renderSize = opts?.renderWidth !== undefined && opts?.renderHeight !== undefined ? { width: opts.renderWidth, height: opts.renderHeight } : undefined;
|
|
1861
|
+
const box = opts?.clip ? await elementBox(this.conn, opts.clip) : undefined;
|
|
1862
|
+
const metrics = await layoutMetrics(this.conn);
|
|
1863
|
+
const devicePx = devicePixelsPerCssPx(metrics);
|
|
1864
|
+
const clip = box ? documentClip(box, metrics, devicePx) : contentClip(metrics);
|
|
1865
|
+
const sendsClip = opts?.clip !== undefined || opts?.fullPage === true || scale !== 1 || renderSize !== undefined;
|
|
1866
|
+
if (opts?.tile === true && !consumeBands) {
|
|
1867
|
+
throw driverError("unsupported", "screenshot tile:true needs a caller that can consume bands; this one passed no band consumer");
|
|
1868
|
+
}
|
|
1869
|
+
const plan = planTiledCapture(clip, scale, devicePx, consumeBands ? opts?.tile : false);
|
|
1870
|
+
if (plan.tiled)
|
|
1871
|
+
return this.captureTiled(clip, plan, scale, format, consumeBands);
|
|
1872
|
+
assertClipEncodable(clip, scale, devicePx, renderSize);
|
|
1696
1873
|
const params = { format, captureBeyondViewport: true };
|
|
1697
1874
|
if (quality != null)
|
|
1698
1875
|
params.quality = quality;
|
|
1699
|
-
if (
|
|
1700
|
-
params.clip = { ...
|
|
1701
|
-
else if (opts?.fullPage)
|
|
1702
|
-
params.clip = { ...await fullPageClip(this.conn), scale: 1 };
|
|
1876
|
+
if (sendsClip)
|
|
1877
|
+
params.clip = { ...clip, scale };
|
|
1703
1878
|
const { data } = await this.conn.send("Page.captureScreenshot", params);
|
|
1704
1879
|
return { data: new Uint8Array(Buffer.from(data, "base64")), format };
|
|
1705
1880
|
}
|
|
1881
|
+
async captureTiled(clip, plan, scale, format, consumeBands) {
|
|
1882
|
+
const capture = {
|
|
1883
|
+
bands: this.bands(clip, plan, scale, format),
|
|
1884
|
+
bandCount: plan.bandCount,
|
|
1885
|
+
bandHeightCss: plan.bandHeightCss,
|
|
1886
|
+
clip,
|
|
1887
|
+
format: "png"
|
|
1888
|
+
};
|
|
1889
|
+
const consumed = await consumeBands(capture);
|
|
1890
|
+
return { tiled: true, format: "png", bandCount: plan.bandCount, bandHeightCss: plan.bandHeightCss, consumed };
|
|
1891
|
+
}
|
|
1892
|
+
async* bands(clip, plan, scale, format) {
|
|
1893
|
+
if (format !== "png") {
|
|
1894
|
+
throw driverError("unsupported", `a tiled capture is PNG-only (bands are stitched as PNG scanlines); got format "${format}"`);
|
|
1895
|
+
}
|
|
1896
|
+
const bottom = clip.y + clip.height;
|
|
1897
|
+
for (let i = 0;i < plan.bandCount; i++) {
|
|
1898
|
+
const y = clip.y + i * plan.bandHeightCss;
|
|
1899
|
+
const height = Math.min(plan.bandHeightCss, bottom - y);
|
|
1900
|
+
const { data } = await this.conn.send("Page.captureScreenshot", {
|
|
1901
|
+
format: "png",
|
|
1902
|
+
captureBeyondViewport: true,
|
|
1903
|
+
clip: { x: clip.x, y, width: clip.width, height, scale }
|
|
1904
|
+
});
|
|
1905
|
+
yield new Uint8Array(Buffer.from(data, "base64"));
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1706
1908
|
async emulate(opts) {
|
|
1707
1909
|
if (opts.clearOverrides) {
|
|
1708
1910
|
const c = () => {
|
|
1709
1911
|
return;
|
|
1710
1912
|
};
|
|
1711
|
-
|
|
1913
|
+
let metricsClearError;
|
|
1914
|
+
try {
|
|
1915
|
+
await this.ensureDomain("Page");
|
|
1916
|
+
const metrics = await layoutMetrics(this.conn);
|
|
1917
|
+
const width = metrics.cssVisualViewport?.clientWidth ?? metrics.cssLayoutViewport?.clientWidth ?? metrics.visualViewport?.clientWidth ?? metrics.layoutViewport?.clientWidth ?? 1;
|
|
1918
|
+
const height = metrics.cssVisualViewport?.clientHeight ?? metrics.cssLayoutViewport?.clientHeight ?? metrics.visualViewport?.clientHeight ?? metrics.layoutViewport?.clientHeight ?? 1;
|
|
1919
|
+
await this.conn.send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 0, mobile: false });
|
|
1920
|
+
await this.conn.send("Emulation.clearDeviceMetricsOverride");
|
|
1921
|
+
} catch (e) {
|
|
1922
|
+
metricsClearError = e instanceof Error ? e.message : String(e);
|
|
1923
|
+
}
|
|
1712
1924
|
await this.conn.send("Emulation.setUserAgentOverride", { userAgent: "" }).catch(c);
|
|
1713
1925
|
await this.conn.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(c);
|
|
1714
1926
|
await this.conn.send("Emulation.setEmulatedMedia", { media: "", features: [] }).catch(c);
|
|
1715
1927
|
await this.ensureDomainSoft("Network");
|
|
1716
1928
|
await this.conn.send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }).catch(c);
|
|
1929
|
+
if (metricsClearError !== undefined) {
|
|
1930
|
+
throw driverError("page-error", `emulate: could not verify the device-metrics override was cleared (${metricsClearError})`);
|
|
1931
|
+
}
|
|
1932
|
+
await clearViewportRecord(this.browser.scheme, this.info.id);
|
|
1717
1933
|
return { applied: [] };
|
|
1718
1934
|
}
|
|
1719
1935
|
const applied = [];
|
|
@@ -1727,6 +1943,12 @@ class CdpPageDriver {
|
|
|
1727
1943
|
deviceScaleFactor: opts.deviceScaleFactor ?? 0,
|
|
1728
1944
|
mobile: opts.mobile ?? false
|
|
1729
1945
|
});
|
|
1946
|
+
await writeViewportRecord(this.browser.scheme, this.info.id, {
|
|
1947
|
+
width: opts.width,
|
|
1948
|
+
height: opts.height,
|
|
1949
|
+
deviceScaleFactor: opts.deviceScaleFactor ?? 0,
|
|
1950
|
+
mobile: opts.mobile ?? false
|
|
1951
|
+
});
|
|
1730
1952
|
applied.push("deviceMetrics");
|
|
1731
1953
|
}
|
|
1732
1954
|
if (opts.userAgent != null) {
|
|
@@ -2114,8 +2336,522 @@ async function reapStaleAgentTabs(driver, backend) {
|
|
|
2114
2336
|
}
|
|
2115
2337
|
|
|
2116
2338
|
// src/shared-tools.ts
|
|
2117
|
-
import { mkdir as
|
|
2118
|
-
import { dirname, join as
|
|
2339
|
+
import { mkdir as mkdir5, writeFile as writeFile4 } from "node:fs/promises";
|
|
2340
|
+
import { dirname as dirname2, join as join4 } from "node:path";
|
|
2341
|
+
|
|
2342
|
+
// src/png.ts
|
|
2343
|
+
import { once } from "node:events";
|
|
2344
|
+
import { mkdir as mkdir4, open, rename, rm } from "node:fs/promises";
|
|
2345
|
+
import { dirname } from "node:path";
|
|
2346
|
+
import { createDeflate, createInflate, deflateSync } from "node:zlib";
|
|
2347
|
+
var PNG_SIGNATURE = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
|
|
2348
|
+
var ZLIB_CHUNK = 1 << 18;
|
|
2349
|
+
var BLOCK_RAW_BYTES = 1 << 22;
|
|
2350
|
+
var MAX_BLOCK_ROWS = 4096;
|
|
2351
|
+
var DECIDE_LEVEL = 6;
|
|
2352
|
+
var CARRIED_CHUNKS = ["gAMA", "cHRM", "sRGB", "iCCP", "pHYs"];
|
|
2353
|
+
var CHANNELS = { 0: 1, 2: 3, 4: 2, 6: 4 };
|
|
2354
|
+
var LEGAL_DEPTHS = {
|
|
2355
|
+
0: [1, 2, 4, 8, 16],
|
|
2356
|
+
2: [8, 16],
|
|
2357
|
+
4: [8, 16],
|
|
2358
|
+
6: [8, 16]
|
|
2359
|
+
};
|
|
2360
|
+
var COLOUR_TYPE_NAMES = {
|
|
2361
|
+
0: "greyscale",
|
|
2362
|
+
2: "truecolour RGB",
|
|
2363
|
+
3: "palette",
|
|
2364
|
+
4: "greyscale+alpha",
|
|
2365
|
+
6: "RGBA"
|
|
2366
|
+
};
|
|
2367
|
+
|
|
2368
|
+
class PngStitchError extends Error {
|
|
2369
|
+
constructor(message) {
|
|
2370
|
+
super(message);
|
|
2371
|
+
this.name = "PngStitchError";
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
var CRC_TABLE = (() => {
|
|
2375
|
+
const table = new Uint32Array(256);
|
|
2376
|
+
for (let n = 0;n < 256; n++) {
|
|
2377
|
+
let c = n;
|
|
2378
|
+
for (let k = 0;k < 8; k++)
|
|
2379
|
+
c = (c & 1) !== 0 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
2380
|
+
table[n] = c >>> 0;
|
|
2381
|
+
}
|
|
2382
|
+
return table;
|
|
2383
|
+
})();
|
|
2384
|
+
function crcUpdate(crc, bytes) {
|
|
2385
|
+
let c = crc;
|
|
2386
|
+
for (let i = 0;i < bytes.length; i++)
|
|
2387
|
+
c = CRC_TABLE[(c ^ bytes[i]) & 255] ^ c >>> 8;
|
|
2388
|
+
return c >>> 0;
|
|
2389
|
+
}
|
|
2390
|
+
function crc32(bytes) {
|
|
2391
|
+
return (crcUpdate(4294967295, bytes) ^ 4294967295) >>> 0;
|
|
2392
|
+
}
|
|
2393
|
+
function readU32(b, off) {
|
|
2394
|
+
return (b[off] << 24 | b[off + 1] << 16 | b[off + 2] << 8 | b[off + 3]) >>> 0;
|
|
2395
|
+
}
|
|
2396
|
+
function chunkTypeAt(b, off) {
|
|
2397
|
+
return String.fromCharCode(b[off], b[off + 1], b[off + 2], b[off + 3]);
|
|
2398
|
+
}
|
|
2399
|
+
function sameBytes(a, b) {
|
|
2400
|
+
if (a.length !== b.length)
|
|
2401
|
+
return false;
|
|
2402
|
+
for (let i = 0;i < a.length; i++)
|
|
2403
|
+
if (a[i] !== b[i])
|
|
2404
|
+
return false;
|
|
2405
|
+
return true;
|
|
2406
|
+
}
|
|
2407
|
+
function paeth(a, b, c) {
|
|
2408
|
+
const p = a + b - c;
|
|
2409
|
+
const pa = p > a ? p - a : a - p;
|
|
2410
|
+
const pb = p > b ? p - b : b - p;
|
|
2411
|
+
const pc = p > c ? p - c : c - p;
|
|
2412
|
+
if (pa <= pb && pa <= pc)
|
|
2413
|
+
return a;
|
|
2414
|
+
return pb <= pc ? b : c;
|
|
2415
|
+
}
|
|
2416
|
+
function unfilterRow(type, row, prev, bpp, at) {
|
|
2417
|
+
const n = row.length;
|
|
2418
|
+
switch (type) {
|
|
2419
|
+
case 0:
|
|
2420
|
+
return;
|
|
2421
|
+
case 1:
|
|
2422
|
+
for (let i = bpp;i < n; i++)
|
|
2423
|
+
row[i] = row[i] + row[i - bpp] & 255;
|
|
2424
|
+
return;
|
|
2425
|
+
case 2:
|
|
2426
|
+
for (let i = 0;i < n; i++)
|
|
2427
|
+
row[i] = row[i] + prev[i] & 255;
|
|
2428
|
+
return;
|
|
2429
|
+
case 3:
|
|
2430
|
+
for (let i = 0;i < n; i++) {
|
|
2431
|
+
const a = i >= bpp ? row[i - bpp] : 0;
|
|
2432
|
+
row[i] = row[i] + (a + prev[i] >> 1) & 255;
|
|
2433
|
+
}
|
|
2434
|
+
return;
|
|
2435
|
+
case 4:
|
|
2436
|
+
for (let i = 0;i < n; i++) {
|
|
2437
|
+
const a = i >= bpp ? row[i - bpp] : 0;
|
|
2438
|
+
const c = i >= bpp ? prev[i - bpp] : 0;
|
|
2439
|
+
row[i] = row[i] + paeth(a, prev[i], c) & 255;
|
|
2440
|
+
}
|
|
2441
|
+
return;
|
|
2442
|
+
default:
|
|
2443
|
+
throw new PngStitchError(`${at}: unknown row filter type ${type} (PNG defines 0-4)`);
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
function absSigned(v) {
|
|
2447
|
+
const u = v & 255;
|
|
2448
|
+
return u < 128 ? u : 256 - u;
|
|
2449
|
+
}
|
|
2450
|
+
function chooseFilter(row, prev, bpp) {
|
|
2451
|
+
const n = row.length;
|
|
2452
|
+
let s0 = 0, s1 = 0, s2 = 0, s3 = 0, s4 = 0;
|
|
2453
|
+
for (let i = 0;i < n; i++) {
|
|
2454
|
+
const x = row[i];
|
|
2455
|
+
const b = prev[i];
|
|
2456
|
+
const a = i >= bpp ? row[i - bpp] : 0;
|
|
2457
|
+
const c = i >= bpp ? prev[i - bpp] : 0;
|
|
2458
|
+
s0 += absSigned(x);
|
|
2459
|
+
s1 += absSigned(x - a);
|
|
2460
|
+
s2 += absSigned(x - b);
|
|
2461
|
+
s3 += absSigned(x - (a + b >> 1));
|
|
2462
|
+
s4 += absSigned(x - paeth(a, b, c));
|
|
2463
|
+
}
|
|
2464
|
+
let best = 0, bestSum = s0;
|
|
2465
|
+
if (s1 < bestSum) {
|
|
2466
|
+
best = 1;
|
|
2467
|
+
bestSum = s1;
|
|
2468
|
+
}
|
|
2469
|
+
if (s2 < bestSum) {
|
|
2470
|
+
best = 2;
|
|
2471
|
+
bestSum = s2;
|
|
2472
|
+
}
|
|
2473
|
+
if (s3 < bestSum) {
|
|
2474
|
+
best = 3;
|
|
2475
|
+
bestSum = s3;
|
|
2476
|
+
}
|
|
2477
|
+
if (s4 < bestSum) {
|
|
2478
|
+
best = 4;
|
|
2479
|
+
}
|
|
2480
|
+
return best;
|
|
2481
|
+
}
|
|
2482
|
+
function writeFilteredRow(type, row, prev, bpp, out, off) {
|
|
2483
|
+
const n = row.length;
|
|
2484
|
+
switch (type) {
|
|
2485
|
+
case 0:
|
|
2486
|
+
out.set(row, off);
|
|
2487
|
+
return;
|
|
2488
|
+
case 1:
|
|
2489
|
+
for (let i = 0;i < n; i++)
|
|
2490
|
+
out[off + i] = row[i] - (i >= bpp ? row[i - bpp] : 0) & 255;
|
|
2491
|
+
return;
|
|
2492
|
+
case 2:
|
|
2493
|
+
for (let i = 0;i < n; i++)
|
|
2494
|
+
out[off + i] = row[i] - prev[i] & 255;
|
|
2495
|
+
return;
|
|
2496
|
+
case 3:
|
|
2497
|
+
for (let i = 0;i < n; i++) {
|
|
2498
|
+
const a = i >= bpp ? row[i - bpp] : 0;
|
|
2499
|
+
out[off + i] = row[i] - (a + prev[i] >> 1) & 255;
|
|
2500
|
+
}
|
|
2501
|
+
return;
|
|
2502
|
+
default:
|
|
2503
|
+
for (let i = 0;i < n; i++) {
|
|
2504
|
+
const a = i >= bpp ? row[i - bpp] : 0;
|
|
2505
|
+
const c = i >= bpp ? prev[i - bpp] : 0;
|
|
2506
|
+
out[off + i] = row[i] - paeth(a, prev[i], c) & 255;
|
|
2507
|
+
}
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
function filterUnitBytes(h) {
|
|
2512
|
+
return Math.max(1, Math.ceil(CHANNELS[h.colourType] * h.bitDepth / 8));
|
|
2513
|
+
}
|
|
2514
|
+
function bytesPerRow(h) {
|
|
2515
|
+
return Math.ceil(h.width * CHANNELS[h.colourType] * h.bitDepth / 8);
|
|
2516
|
+
}
|
|
2517
|
+
function readIhdr(band, dataStart, at) {
|
|
2518
|
+
const width = readU32(band, dataStart);
|
|
2519
|
+
const height = readU32(band, dataStart + 4);
|
|
2520
|
+
const bitDepth = band[dataStart + 8];
|
|
2521
|
+
const colourType = band[dataStart + 9];
|
|
2522
|
+
const compression = band[dataStart + 10];
|
|
2523
|
+
const filterMethod = band[dataStart + 11];
|
|
2524
|
+
const interlace = band[dataStart + 12];
|
|
2525
|
+
if (width < 1 || height < 1)
|
|
2526
|
+
throw new PngStitchError(`${at}: IHDR declares ${width}x${height}; both dimensions must be >= 1`);
|
|
2527
|
+
if (compression !== 0)
|
|
2528
|
+
throw new PngStitchError(`${at}: IHDR compression method ${compression}; PNG defines only 0 (deflate)`);
|
|
2529
|
+
if (filterMethod !== 0)
|
|
2530
|
+
throw new PngStitchError(`${at}: IHDR filter method ${filterMethod}; PNG defines only 0`);
|
|
2531
|
+
if (interlace === 1) {
|
|
2532
|
+
throw new PngStitchError(`${at}: interlaced (Adam7) PNG is not supported. Its seven reduced passes are not scanlines of the final image, ` + `so bands of them cannot be concatenated; re-capture without interlacing (Chrome's Page.captureScreenshot always does).`);
|
|
2533
|
+
}
|
|
2534
|
+
if (interlace !== 0)
|
|
2535
|
+
throw new PngStitchError(`${at}: IHDR interlace method ${interlace}; PNG defines only 0 (none) and 1 (Adam7)`);
|
|
2536
|
+
if (colourType === 3) {
|
|
2537
|
+
throw new PngStitchError(`${at}: colour type 3 (palette) is not supported. Concatenating palettized bands requires every band's PLTE/tRNS to ` + `be identical or a full palette remap; Chrome emits colour type 2, so this path is refused rather than guessed.`);
|
|
2538
|
+
}
|
|
2539
|
+
if (CHANNELS[colourType] === undefined)
|
|
2540
|
+
throw new PngStitchError(`${at}: IHDR colour type ${colourType}; PNG defines 0, 2, 3, 4 and 6`);
|
|
2541
|
+
if (!LEGAL_DEPTHS[colourType].includes(bitDepth)) {
|
|
2542
|
+
throw new PngStitchError(`${at}: bit depth ${bitDepth} is not legal for colour type ${colourType} (${COLOUR_TYPE_NAMES[colourType]}); ` + `allowed: ${LEGAL_DEPTHS[colourType].join(", ")}`);
|
|
2543
|
+
}
|
|
2544
|
+
return { width, height, bitDepth, colourType };
|
|
2545
|
+
}
|
|
2546
|
+
function parseBand(band, index) {
|
|
2547
|
+
const at = `band ${index}`;
|
|
2548
|
+
if (band.length < 8)
|
|
2549
|
+
throw new PngStitchError(`${at}: ${band.length} bytes is too short to be a PNG`);
|
|
2550
|
+
for (let i = 0;i < 8; i++) {
|
|
2551
|
+
if (band[i] !== PNG_SIGNATURE[i])
|
|
2552
|
+
throw new PngStitchError(`${at}: not a PNG (bad 8-byte signature)`);
|
|
2553
|
+
}
|
|
2554
|
+
let header;
|
|
2555
|
+
const idat = [];
|
|
2556
|
+
const carried = [];
|
|
2557
|
+
let sawIend = false;
|
|
2558
|
+
let off = 8;
|
|
2559
|
+
while (off + 8 <= band.length) {
|
|
2560
|
+
const len = readU32(band, off);
|
|
2561
|
+
const type = chunkTypeAt(band, off + 4);
|
|
2562
|
+
const dataStart = off + 8;
|
|
2563
|
+
const dataEnd = dataStart + len;
|
|
2564
|
+
if (dataEnd + 4 > band.length) {
|
|
2565
|
+
throw new PngStitchError(`${at}: chunk "${type}" at byte ${off} claims ${len} payload bytes but only ${band.length - dataStart} remain (truncated PNG)`);
|
|
2566
|
+
}
|
|
2567
|
+
if (type === "IHDR") {
|
|
2568
|
+
if (off !== 8)
|
|
2569
|
+
throw new PngStitchError(`${at}: IHDR must be the first chunk, found it at byte ${off}`);
|
|
2570
|
+
if (len !== 13)
|
|
2571
|
+
throw new PngStitchError(`${at}: IHDR payload is ${len} bytes, must be 13`);
|
|
2572
|
+
const want = readU32(band, dataEnd);
|
|
2573
|
+
const got = crc32(band.subarray(off + 4, dataEnd));
|
|
2574
|
+
if (want !== got)
|
|
2575
|
+
throw new PngStitchError(`${at}: IHDR CRC mismatch (file says 0x${want.toString(16)}, computed 0x${got.toString(16)})`);
|
|
2576
|
+
header = readIhdr(band, dataStart, at);
|
|
2577
|
+
} else if (!header) {
|
|
2578
|
+
throw new PngStitchError(`${at}: first chunk is "${type}", expected IHDR`);
|
|
2579
|
+
} else if (type === "IDAT") {
|
|
2580
|
+
idat.push([dataStart, dataEnd]);
|
|
2581
|
+
} else if (type === "IEND") {
|
|
2582
|
+
sawIend = true;
|
|
2583
|
+
break;
|
|
2584
|
+
} else if (CARRIED_CHUNKS.includes(type)) {
|
|
2585
|
+
carried.push({ type, data: band.subarray(dataStart, dataEnd) });
|
|
2586
|
+
}
|
|
2587
|
+
off = dataEnd + 4;
|
|
2588
|
+
}
|
|
2589
|
+
if (!header)
|
|
2590
|
+
throw new PngStitchError(`${at}: no IHDR chunk`);
|
|
2591
|
+
if (idat.length === 0)
|
|
2592
|
+
throw new PngStitchError(`${at}: no IDAT chunk`);
|
|
2593
|
+
if (!sawIend)
|
|
2594
|
+
throw new PngStitchError(`${at}: no IEND chunk (truncated PNG)`);
|
|
2595
|
+
return { header, idat, carried };
|
|
2596
|
+
}
|
|
2597
|
+
function assertCompatible(first, next, firstCarried, nextCarried, index) {
|
|
2598
|
+
const at = `band ${index}`;
|
|
2599
|
+
if (next.width !== first.width)
|
|
2600
|
+
throw new PngStitchError(`${at}: width ${next.width} does not match band 1's width ${first.width}`);
|
|
2601
|
+
if (next.bitDepth !== first.bitDepth)
|
|
2602
|
+
throw new PngStitchError(`${at}: bit depth ${next.bitDepth} does not match band 1's bit depth ${first.bitDepth}`);
|
|
2603
|
+
if (next.colourType !== first.colourType) {
|
|
2604
|
+
throw new PngStitchError(`${at}: colour type ${next.colourType} (${COLOUR_TYPE_NAMES[next.colourType] ?? "?"}) does not match band 1's ` + `colour type ${first.colourType} (${COLOUR_TYPE_NAMES[first.colourType] ?? "?"})`);
|
|
2605
|
+
}
|
|
2606
|
+
for (const type of CARRIED_CHUNKS) {
|
|
2607
|
+
const a = firstCarried.find((c) => c.type === type);
|
|
2608
|
+
const b = nextCarried.find((c) => c.type === type);
|
|
2609
|
+
if (!a && !b)
|
|
2610
|
+
continue;
|
|
2611
|
+
if (!a || !b)
|
|
2612
|
+
throw new PngStitchError(`${at}: ${b ? "has" : "is missing"} a "${type}" chunk that band 1 ${b ? "does not have" : "has"}; the bands disagree about how their samples are interpreted`);
|
|
2613
|
+
if (!sameBytes(a.data, b.data))
|
|
2614
|
+
throw new PngStitchError(`${at}: its "${type}" chunk differs from band 1's; the bands disagree about how their samples are interpreted`);
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
class ChunkWriter {
|
|
2619
|
+
fh;
|
|
2620
|
+
pos = 0;
|
|
2621
|
+
constructor(fh) {
|
|
2622
|
+
this.fh = fh;
|
|
2623
|
+
}
|
|
2624
|
+
get bytes() {
|
|
2625
|
+
return this.pos;
|
|
2626
|
+
}
|
|
2627
|
+
async writeRaw(b) {
|
|
2628
|
+
await this.fh.write(b, 0, b.length, this.pos);
|
|
2629
|
+
this.pos += b.length;
|
|
2630
|
+
}
|
|
2631
|
+
async writeChunk(type, payload) {
|
|
2632
|
+
const head = Buffer.allocUnsafe(8);
|
|
2633
|
+
head.writeUInt32BE(payload.length, 0);
|
|
2634
|
+
head.write(type, 4, "latin1");
|
|
2635
|
+
const crc = (crcUpdate(crcUpdate(4294967295, head.subarray(4)), payload) ^ 4294967295) >>> 0;
|
|
2636
|
+
const tail = Buffer.allocUnsafe(4);
|
|
2637
|
+
tail.writeUInt32BE(crc, 0);
|
|
2638
|
+
await this.fh.writev([head, payload, tail], this.pos);
|
|
2639
|
+
this.pos += 8 + payload.length + 4;
|
|
2640
|
+
}
|
|
2641
|
+
async patchAt(position, b) {
|
|
2642
|
+
await this.fh.write(b, 0, b.length, position);
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
function ihdrChunk(h, height) {
|
|
2646
|
+
const buf = Buffer.allocUnsafe(25);
|
|
2647
|
+
buf.writeUInt32BE(13, 0);
|
|
2648
|
+
buf.write("IHDR", 4, "latin1");
|
|
2649
|
+
buf.writeUInt32BE(h.width, 8);
|
|
2650
|
+
buf.writeUInt32BE(height, 12);
|
|
2651
|
+
buf[16] = h.bitDepth;
|
|
2652
|
+
buf[17] = h.colourType;
|
|
2653
|
+
buf[18] = 0;
|
|
2654
|
+
buf[19] = 0;
|
|
2655
|
+
buf[20] = 0;
|
|
2656
|
+
buf.writeUInt32BE(crc32(buf.subarray(4, 21)), 21);
|
|
2657
|
+
return buf;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
class RowEncoder {
|
|
2661
|
+
writer;
|
|
2662
|
+
bpr;
|
|
2663
|
+
bpp;
|
|
2664
|
+
deflate = createDeflate({ chunkSize: ZLIB_CHUNK });
|
|
2665
|
+
pump;
|
|
2666
|
+
rowsPerBlock;
|
|
2667
|
+
block;
|
|
2668
|
+
blockRows = 0;
|
|
2669
|
+
candNone;
|
|
2670
|
+
candHeuristic;
|
|
2671
|
+
prevRaw;
|
|
2672
|
+
constructor(writer, bpr, bpp) {
|
|
2673
|
+
this.writer = writer;
|
|
2674
|
+
this.bpr = bpr;
|
|
2675
|
+
this.bpp = bpp;
|
|
2676
|
+
this.rowsPerBlock = Math.min(MAX_BLOCK_ROWS, Math.max(1, Math.floor(BLOCK_RAW_BYTES / bpr)));
|
|
2677
|
+
this.block = Buffer.allocUnsafe(this.rowsPerBlock * bpr);
|
|
2678
|
+
this.candNone = Buffer.allocUnsafe(this.rowsPerBlock * (bpr + 1));
|
|
2679
|
+
this.candHeuristic = Buffer.allocUnsafe(this.rowsPerBlock * (bpr + 1));
|
|
2680
|
+
this.prevRaw = new Uint8Array(bpr);
|
|
2681
|
+
this.pump = (async () => {
|
|
2682
|
+
for await (const chunk of this.deflate)
|
|
2683
|
+
await this.writer.writeChunk("IDAT", chunk);
|
|
2684
|
+
})();
|
|
2685
|
+
this.pump.catch(() => {});
|
|
2686
|
+
}
|
|
2687
|
+
async addRow(raw) {
|
|
2688
|
+
this.block.set(raw, this.blockRows * this.bpr);
|
|
2689
|
+
this.blockRows += 1;
|
|
2690
|
+
if (this.blockRows === this.rowsPerBlock)
|
|
2691
|
+
await this.emitBlock();
|
|
2692
|
+
}
|
|
2693
|
+
async emitBlock() {
|
|
2694
|
+
const n = this.blockRows;
|
|
2695
|
+
if (n === 0)
|
|
2696
|
+
return;
|
|
2697
|
+
const { bpr, bpp } = this;
|
|
2698
|
+
const stride = bpr + 1;
|
|
2699
|
+
let prev = this.prevRaw;
|
|
2700
|
+
for (let r = 0;r < n; r++) {
|
|
2701
|
+
const row = this.block.subarray(r * bpr, (r + 1) * bpr);
|
|
2702
|
+
const at = r * stride;
|
|
2703
|
+
this.candNone[at] = 0;
|
|
2704
|
+
this.candNone.set(row, at + 1);
|
|
2705
|
+
const filter = chooseFilter(row, prev, bpp);
|
|
2706
|
+
this.candHeuristic[at] = filter;
|
|
2707
|
+
writeFilteredRow(filter, row, prev, bpp, this.candHeuristic, at + 1);
|
|
2708
|
+
prev = row;
|
|
2709
|
+
}
|
|
2710
|
+
const used = n * stride;
|
|
2711
|
+
const a = this.candNone.subarray(0, used);
|
|
2712
|
+
const b = this.candHeuristic.subarray(0, used);
|
|
2713
|
+
const heuristicWins = deflateSync(b, { level: DECIDE_LEVEL }).length < deflateSync(a, { level: DECIDE_LEVEL }).length;
|
|
2714
|
+
this.prevRaw.set(prev);
|
|
2715
|
+
this.blockRows = 0;
|
|
2716
|
+
const out = Buffer.from(heuristicWins ? b : a);
|
|
2717
|
+
if (!this.deflate.write(out))
|
|
2718
|
+
await once(this.deflate, "drain");
|
|
2719
|
+
}
|
|
2720
|
+
async finish() {
|
|
2721
|
+
await this.emitBlock();
|
|
2722
|
+
this.deflate.end();
|
|
2723
|
+
await this.pump;
|
|
2724
|
+
}
|
|
2725
|
+
async abort() {
|
|
2726
|
+
this.deflate.destroy();
|
|
2727
|
+
await this.pump.catch(() => {});
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
async function* inflateBandIdat(band, slices, at) {
|
|
2731
|
+
const inf = createInflate({ chunkSize: ZLIB_CHUNK });
|
|
2732
|
+
const feed = (async () => {
|
|
2733
|
+
for (const [start, end] of slices) {
|
|
2734
|
+
const view = Buffer.from(band.buffer, band.byteOffset + start, end - start);
|
|
2735
|
+
if (!inf.write(view))
|
|
2736
|
+
await once(inf, "drain");
|
|
2737
|
+
}
|
|
2738
|
+
inf.end();
|
|
2739
|
+
})();
|
|
2740
|
+
feed.catch(() => {});
|
|
2741
|
+
try {
|
|
2742
|
+
for await (const chunk of inf)
|
|
2743
|
+
yield chunk;
|
|
2744
|
+
} catch (err) {
|
|
2745
|
+
throw new PngStitchError(`${at}: IDAT stream would not inflate (${err.message})`);
|
|
2746
|
+
} finally {
|
|
2747
|
+
inf.destroy();
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
function isAsyncIterable(x) {
|
|
2751
|
+
return typeof x === "object" && x !== null && Symbol.asyncIterator in x;
|
|
2752
|
+
}
|
|
2753
|
+
async function* eachBand(bands) {
|
|
2754
|
+
if (isAsyncIterable(bands)) {
|
|
2755
|
+
for await (const b of bands)
|
|
2756
|
+
yield b;
|
|
2757
|
+
} else {
|
|
2758
|
+
for (const b of bands)
|
|
2759
|
+
yield b;
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
async function stitchPngBandsToFile(bands, outPath) {
|
|
2763
|
+
await mkdir4(dirname(outPath), { recursive: true });
|
|
2764
|
+
const partPath = `${outPath}.stitch-partial`;
|
|
2765
|
+
const fh = await open(partPath, "w");
|
|
2766
|
+
let done = false;
|
|
2767
|
+
try {
|
|
2768
|
+
const result = await stitchInto(fh, bands);
|
|
2769
|
+
await fh.close();
|
|
2770
|
+
await rename(partPath, outPath);
|
|
2771
|
+
done = true;
|
|
2772
|
+
return result;
|
|
2773
|
+
} finally {
|
|
2774
|
+
if (!done) {
|
|
2775
|
+
await fh.close().catch(() => {});
|
|
2776
|
+
await rm(partPath, { force: true }).catch(() => {});
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
async function stitchInto(fh, bands) {
|
|
2781
|
+
const writer = new ChunkWriter(fh);
|
|
2782
|
+
let first;
|
|
2783
|
+
let firstCarried = [];
|
|
2784
|
+
let encoder;
|
|
2785
|
+
let prevRow = new Uint8Array(0);
|
|
2786
|
+
let rowBuf = new Uint8Array(0);
|
|
2787
|
+
let bpp = 0;
|
|
2788
|
+
let bpr = 0;
|
|
2789
|
+
let totalRows = 0;
|
|
2790
|
+
let index = 0;
|
|
2791
|
+
try {
|
|
2792
|
+
for await (const band of eachBand(bands)) {
|
|
2793
|
+
index += 1;
|
|
2794
|
+
const parsed = parseBand(band, index);
|
|
2795
|
+
const at = `band ${index}`;
|
|
2796
|
+
if (!first) {
|
|
2797
|
+
first = parsed.header;
|
|
2798
|
+
firstCarried = parsed.carried;
|
|
2799
|
+
bpp = filterUnitBytes(first);
|
|
2800
|
+
bpr = bytesPerRow(first);
|
|
2801
|
+
prevRow = new Uint8Array(bpr);
|
|
2802
|
+
rowBuf = new Uint8Array(1 + bpr);
|
|
2803
|
+
await writer.writeRaw(PNG_SIGNATURE);
|
|
2804
|
+
await writer.writeRaw(ihdrChunk(first, 1));
|
|
2805
|
+
for (const c of parsed.carried)
|
|
2806
|
+
await writer.writeChunk(c.type, c.data);
|
|
2807
|
+
encoder = new RowEncoder(writer, bpr, bpp);
|
|
2808
|
+
} else {
|
|
2809
|
+
assertCompatible(first, parsed.header, firstCarried, parsed.carried, index);
|
|
2810
|
+
}
|
|
2811
|
+
const enc = encoder;
|
|
2812
|
+
const raw = rowBuf.subarray(1);
|
|
2813
|
+
prevRow.fill(0);
|
|
2814
|
+
let rows = 0;
|
|
2815
|
+
let filled = 0;
|
|
2816
|
+
for await (const chunk of inflateBandIdat(band, parsed.idat, at)) {
|
|
2817
|
+
let off = 0;
|
|
2818
|
+
while (off < chunk.length) {
|
|
2819
|
+
const take = Math.min(rowBuf.length - filled, chunk.length - off);
|
|
2820
|
+
rowBuf.set(chunk.subarray(off, off + take), filled);
|
|
2821
|
+
filled += take;
|
|
2822
|
+
off += take;
|
|
2823
|
+
if (filled !== rowBuf.length)
|
|
2824
|
+
continue;
|
|
2825
|
+
if (rows >= parsed.header.height) {
|
|
2826
|
+
throw new PngStitchError(`${at}: IDAT holds more scanlines than its IHDR height of ${parsed.header.height}`);
|
|
2827
|
+
}
|
|
2828
|
+
unfilterRow(rowBuf[0], raw, prevRow, bpp, at);
|
|
2829
|
+
await enc.addRow(raw);
|
|
2830
|
+
prevRow.set(raw);
|
|
2831
|
+
rows += 1;
|
|
2832
|
+
filled = 0;
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
if (filled !== 0 || rows !== parsed.header.height) {
|
|
2836
|
+
throw new PngStitchError(`${at}: IDAT decoded to ${rows} complete scanlines${filled > 0 ? ` plus ${filled} trailing bytes` : ""}, but its IHDR declares ${parsed.header.height}`);
|
|
2837
|
+
}
|
|
2838
|
+
totalRows += parsed.header.height;
|
|
2839
|
+
if (totalRows > 2147483647)
|
|
2840
|
+
throw new PngStitchError(`stitched height would be ${totalRows}, past PNG's 2^31-1 limit`);
|
|
2841
|
+
}
|
|
2842
|
+
if (!first || !encoder)
|
|
2843
|
+
throw new PngStitchError("no bands to stitch");
|
|
2844
|
+
await encoder.finish();
|
|
2845
|
+
await writer.writeChunk("IEND", new Uint8Array(0));
|
|
2846
|
+
await writer.patchAt(8, ihdrChunk(first, totalRows));
|
|
2847
|
+
return { width: first.width, height: totalRows, bytes: writer.bytes, bands: index };
|
|
2848
|
+
} catch (err) {
|
|
2849
|
+
await encoder?.abort();
|
|
2850
|
+
throw err;
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
// src/shared-tools.ts
|
|
2119
2855
|
var ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
2120
2856
|
var STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
2121
2857
|
|
|
@@ -2260,8 +2996,8 @@ async function closePage(driver, args) {
|
|
|
2260
2996
|
return { closed: p.id, success: true, ...released.released ? { leaseReleased: true } : {} };
|
|
2261
2997
|
}
|
|
2262
2998
|
async function writeSelectedFile(id) {
|
|
2263
|
-
await
|
|
2264
|
-
await
|
|
2999
|
+
await mkdir5(STATE_DIR, { recursive: true });
|
|
3000
|
+
await writeFile4(join4(STATE_DIR, "selected"), id, "utf8");
|
|
2265
3001
|
}
|
|
2266
3002
|
async function selectPage(driver, args) {
|
|
2267
3003
|
if (args.target === undefined || args.target === "")
|
|
@@ -2325,10 +3061,10 @@ function serializeForSink(value) {
|
|
|
2325
3061
|
return json ?? "null";
|
|
2326
3062
|
}
|
|
2327
3063
|
async function writeJsonSink(savePath, value) {
|
|
2328
|
-
const path = savePath.startsWith("/") ? savePath :
|
|
2329
|
-
await
|
|
3064
|
+
const path = savePath.startsWith("/") ? savePath : join4(ARTIFACT_DIR, savePath);
|
|
3065
|
+
await mkdir5(dirname2(path), { recursive: true });
|
|
2330
3066
|
const json = serializeForSink(value);
|
|
2331
|
-
await
|
|
3067
|
+
await writeFile4(path, json, "utf8");
|
|
2332
3068
|
return { path, bytes: Buffer.byteLength(json, "utf8") };
|
|
2333
3069
|
}
|
|
2334
3070
|
function sinkRequested(savePath) {
|
|
@@ -2632,30 +3368,166 @@ async function uploadFile(driver, args) {
|
|
|
2632
3368
|
function stamp() {
|
|
2633
3369
|
return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
|
|
2634
3370
|
}
|
|
3371
|
+
var MAX_SCREENSHOT_SCALE = 8;
|
|
3372
|
+
function resolveScreenshotScale(scale, capabilities) {
|
|
3373
|
+
if (scale === undefined)
|
|
3374
|
+
return 1;
|
|
3375
|
+
if (typeof scale !== "number" || !Number.isFinite(scale) || scale <= 0 || scale > MAX_SCREENSHOT_SCALE) {
|
|
3376
|
+
throw new SharedToolError(`take_screenshot: 'scale' must be a finite number greater than 0 and at most ${MAX_SCREENSHOT_SCALE} (default 1)`);
|
|
3377
|
+
}
|
|
3378
|
+
if (scale !== 1 && !capabilities.has("screenshot.scale")) {
|
|
3379
|
+
throw new SharedToolError("take_screenshot: 'scale' is not supported by this backend (WebDriver BiDi's browsingContext.captureScreenshot has no scale parameter). " + `Call emulate with deviceScaleFactor:${scale} first and capture at scale 1, or use --browser chrome for a per-capture scale.`);
|
|
3380
|
+
}
|
|
3381
|
+
return scale;
|
|
3382
|
+
}
|
|
3383
|
+
var MAX_RENDER_SIZE_PX = 16384;
|
|
3384
|
+
function resolveRenderSize(renderWidth, renderHeight, capabilities) {
|
|
3385
|
+
if (renderWidth === undefined && renderHeight === undefined)
|
|
3386
|
+
return;
|
|
3387
|
+
if (renderWidth === undefined || renderHeight === undefined) {
|
|
3388
|
+
throw new SharedToolError("take_screenshot: 'renderWidth' and 'renderHeight' are required together (a viewport is two numbers; " + `got ${renderWidth === undefined ? "only renderHeight" : "only renderWidth"})`);
|
|
3389
|
+
}
|
|
3390
|
+
for (const [name, value] of [["renderWidth", renderWidth], ["renderHeight", renderHeight]]) {
|
|
3391
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > MAX_RENDER_SIZE_PX) {
|
|
3392
|
+
throw new SharedToolError(`take_screenshot: '${name}' must be an integer between 1 and ${MAX_RENDER_SIZE_PX} css px (got ${JSON.stringify(value)})`);
|
|
3393
|
+
}
|
|
3394
|
+
}
|
|
3395
|
+
if (!capabilities.has("screenshot.renderSize")) {
|
|
3396
|
+
throw new SharedToolError("take_screenshot: 'renderWidth'/'renderHeight' are not supported by this backend (it cannot emulate a viewport for a capture). " + `Call emulate with width:${renderWidth}, height:${renderHeight} first, capture, then restore the viewport yourself.`);
|
|
3397
|
+
}
|
|
3398
|
+
return { width: renderWidth, height: renderHeight };
|
|
3399
|
+
}
|
|
3400
|
+
function resolveTileMode(tile, capabilities, format, returnBase64) {
|
|
3401
|
+
if (tile === undefined)
|
|
3402
|
+
return;
|
|
3403
|
+
if (typeof tile !== "boolean")
|
|
3404
|
+
throw new SharedToolError(`take_screenshot: 'tile' must be true, false, or omitted for automatic (got ${JSON.stringify(tile)})`);
|
|
3405
|
+
if (!tile)
|
|
3406
|
+
return false;
|
|
3407
|
+
if (!capabilities.has("screenshot.tile")) {
|
|
3408
|
+
throw new SharedToolError("take_screenshot: 'tile' is not supported by this backend (band tiling is declared only where the band arithmetic was measured against a real browser; " + "Firefox was not driven for it). Capture a smaller region or a single element, or use --browser chrome for a tiled capture.");
|
|
3409
|
+
}
|
|
3410
|
+
if (format !== "png")
|
|
3411
|
+
throw tiledCaptureRefusal("format");
|
|
3412
|
+
if (returnBase64)
|
|
3413
|
+
throw tiledCaptureRefusal("base64");
|
|
3414
|
+
return true;
|
|
3415
|
+
}
|
|
3416
|
+
function tiledCaptureRefusal(reason, banded) {
|
|
3417
|
+
const because = banded ? ` This capture needs ${banded.bandCount} bands (${banded.clip.width}x${banded.clip.height} css px is past what one image can encode).` : "";
|
|
3418
|
+
if (reason === "format") {
|
|
3419
|
+
return new SharedToolError("take_screenshot: a tiled capture is PNG-only — bands are stitched by concatenating PNG scanlines, and there is no lossless " + `JPEG equivalent to concatenate.${because} Pass format:"png", or lower scale / narrow the region until one JPEG fits.`);
|
|
3420
|
+
}
|
|
3421
|
+
return new SharedToolError("take_screenshot: 'returnBase64' cannot be combined with a tiled capture — a tiled image runs to hundreds of megabytes and base64 " + `inflates it by a third, into the caller's response.${because} Read the file at the returned path instead, or capture a region small enough for one shot.`);
|
|
3422
|
+
}
|
|
3423
|
+
function pngPixelSize(b, view) {
|
|
3424
|
+
if (b.length < 24)
|
|
3425
|
+
return;
|
|
3426
|
+
if (view.getUint32(0) !== 2303741511 || view.getUint32(4) !== 218765834)
|
|
3427
|
+
return;
|
|
3428
|
+
if (String.fromCharCode(b[12], b[13], b[14], b[15]) !== "IHDR")
|
|
3429
|
+
return;
|
|
3430
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
3431
|
+
}
|
|
3432
|
+
var JPEG_SOF = new Set([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207]);
|
|
3433
|
+
function jpegPixelSize(b, view) {
|
|
3434
|
+
if (b.length < 4 || b[0] !== 255 || b[1] !== 216)
|
|
3435
|
+
return;
|
|
3436
|
+
let i = 2;
|
|
3437
|
+
while (i + 3 < b.length) {
|
|
3438
|
+
if (b[i] !== 255) {
|
|
3439
|
+
i += 1;
|
|
3440
|
+
continue;
|
|
3441
|
+
}
|
|
3442
|
+
const marker = b[i + 1];
|
|
3443
|
+
if (marker === 255) {
|
|
3444
|
+
i += 1;
|
|
3445
|
+
continue;
|
|
3446
|
+
}
|
|
3447
|
+
if (marker === 216 || marker === 1 || marker >= 208 && marker <= 215) {
|
|
3448
|
+
i += 2;
|
|
3449
|
+
continue;
|
|
3450
|
+
}
|
|
3451
|
+
const segLen = view.getUint16(i + 2);
|
|
3452
|
+
if (JPEG_SOF.has(marker)) {
|
|
3453
|
+
if (i + 9 > b.length)
|
|
3454
|
+
return;
|
|
3455
|
+
return { width: view.getUint16(i + 7), height: view.getUint16(i + 5) };
|
|
3456
|
+
}
|
|
3457
|
+
if (segLen < 2)
|
|
3458
|
+
return;
|
|
3459
|
+
i += 2 + segLen;
|
|
3460
|
+
}
|
|
3461
|
+
return;
|
|
3462
|
+
}
|
|
3463
|
+
function imagePixelSize(bytes) {
|
|
3464
|
+
if (bytes.byteLength < 4)
|
|
3465
|
+
return;
|
|
3466
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
3467
|
+
return pngPixelSize(bytes, view) ?? jpegPixelSize(bytes, view);
|
|
3468
|
+
}
|
|
2635
3469
|
async function takeScreenshot(driver, args = {}) {
|
|
2636
3470
|
if (args.uid !== undefined && args.uid !== "" && args.selector)
|
|
2637
3471
|
throw new SharedToolError("provide exactly one of uid or selector, not both");
|
|
2638
3472
|
const format = args.format ?? "png";
|
|
3473
|
+
const scale = resolveScreenshotScale(args.scale, driver.capabilities);
|
|
3474
|
+
const renderSize = resolveRenderSize(args.renderWidth, args.renderHeight, driver.capabilities);
|
|
3475
|
+
const tile = resolveTileMode(args.tile, driver.capabilities, format, args.returnBase64);
|
|
2639
3476
|
return withPage2(driver, args.target, async (page) => {
|
|
2640
3477
|
const clip = args.uid !== undefined || args.selector ? locatorOf(args) : undefined;
|
|
2641
|
-
const
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
path,
|
|
2650
|
-
bytes: data.byteLength,
|
|
2651
|
-
format: outFormat,
|
|
2652
|
-
target: target3(page.info)
|
|
3478
|
+
const ext = format === "jpeg" ? "jpg" : "png";
|
|
3479
|
+
const path = args.savePath ?? join4(ARTIFACT_DIR, `screenshot-${page.info.id.slice(0, 8)}-${stamp()}.${ext}`);
|
|
3480
|
+
const stitch = async (banded) => {
|
|
3481
|
+
if (format !== "png")
|
|
3482
|
+
throw tiledCaptureRefusal("format", banded);
|
|
3483
|
+
if (args.returnBase64)
|
|
3484
|
+
throw tiledCaptureRefusal("base64", banded);
|
|
3485
|
+
return stitchPngBandsToFile(banded.bands, path);
|
|
2653
3486
|
};
|
|
2654
|
-
|
|
2655
|
-
|
|
3487
|
+
const shot = await page.screenshot({
|
|
3488
|
+
format,
|
|
3489
|
+
quality: args.quality,
|
|
3490
|
+
fullPage: args.fullPage,
|
|
3491
|
+
scale,
|
|
3492
|
+
...clip ? { clip } : {},
|
|
3493
|
+
...renderSize ? { renderWidth: renderSize.width, renderHeight: renderSize.height } : {},
|
|
3494
|
+
...tile !== undefined ? { tile } : {}
|
|
3495
|
+
}, stitch);
|
|
3496
|
+
const result = isTiledCapture(shot) ? {
|
|
3497
|
+
path,
|
|
3498
|
+
bytes: shot.consumed.bytes,
|
|
3499
|
+
format: "png",
|
|
3500
|
+
target: target3(page.info),
|
|
3501
|
+
scale,
|
|
3502
|
+
width: shot.consumed.width,
|
|
3503
|
+
height: shot.consumed.height,
|
|
3504
|
+
tiled: true,
|
|
3505
|
+
bands: shot.consumed.bands
|
|
3506
|
+
} : await writeOneShot(shot.data, shot.format, path, target3(page.info), scale, args.returnBase64);
|
|
3507
|
+
if (renderSize && shot.renderSize) {
|
|
3508
|
+
result.renderSize = shot.renderSize;
|
|
3509
|
+
result.renderRestored = shot.renderRestored ?? false;
|
|
3510
|
+
if (shot.renderRestoreError !== undefined)
|
|
3511
|
+
result.renderRestoreError = shot.renderRestoreError;
|
|
3512
|
+
}
|
|
2656
3513
|
return result;
|
|
2657
3514
|
});
|
|
2658
3515
|
}
|
|
3516
|
+
async function writeOneShot(data, outFormat, path, target, scale, returnBase64) {
|
|
3517
|
+
if (data.byteLength === 0)
|
|
3518
|
+
throw new SharedToolError("captureScreenshot returned empty data");
|
|
3519
|
+
await mkdir5(dirname2(path), { recursive: true });
|
|
3520
|
+
await writeFile4(path, Buffer.from(data));
|
|
3521
|
+
const size = imagePixelSize(data);
|
|
3522
|
+
const result = { path, bytes: data.byteLength, format: outFormat, target, scale };
|
|
3523
|
+
if (size) {
|
|
3524
|
+
result.width = size.width;
|
|
3525
|
+
result.height = size.height;
|
|
3526
|
+
}
|
|
3527
|
+
if (returnBase64)
|
|
3528
|
+
result.base64 = Buffer.from(data).toString("base64");
|
|
3529
|
+
return result;
|
|
3530
|
+
}
|
|
2659
3531
|
async function emulate(driver, args = {}) {
|
|
2660
3532
|
return withPage2(driver, args.target, async (page) => {
|
|
2661
3533
|
if (args.clearOverrides) {
|
|
@@ -2826,10 +3698,10 @@ var LEASE_TOOLS = {
|
|
|
2826
3698
|
};
|
|
2827
3699
|
|
|
2828
3700
|
// src/tools/console.ts
|
|
2829
|
-
import { readFile as
|
|
3701
|
+
import { readFile as readFile4 } from "node:fs/promises";
|
|
2830
3702
|
|
|
2831
3703
|
// src/tools/recorder.ts
|
|
2832
|
-
import { mkdir as
|
|
3704
|
+
import { mkdir as mkdir6, appendFile, writeFile as writeFile5, copyFile } from "node:fs/promises";
|
|
2833
3705
|
var ARTIFACT_DIR2 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
2834
3706
|
function recFile(targetId) {
|
|
2835
3707
|
return `${ARTIFACT_DIR2}/rec-${targetId}.jsonl`;
|
|
@@ -2840,6 +3712,31 @@ function captureFile(targetId, captureId) {
|
|
|
2840
3712
|
function delay2(ms) {
|
|
2841
3713
|
return new Promise((r) => setTimeout(r, ms));
|
|
2842
3714
|
}
|
|
3715
|
+
async function resolveRecorderTarget(target, opts) {
|
|
3716
|
+
if (!isWorkerSelector(target))
|
|
3717
|
+
return resolveTarget(target);
|
|
3718
|
+
if (opts.capture)
|
|
3719
|
+
return resolveWorkerSelector(target, { wake: opts.wake ?? true });
|
|
3720
|
+
const needle = workerNeedle(target);
|
|
3721
|
+
if (needle === "")
|
|
3722
|
+
throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
3723
|
+
const { matches, liveWorkers } = resolveWorkerTargets(await listTargets(), needle);
|
|
3724
|
+
if (matches.length > 1)
|
|
3725
|
+
throw new CdpError(workerAmbiguityMessage(needle, matches));
|
|
3726
|
+
if (matches.length === 1)
|
|
3727
|
+
return matches[0];
|
|
3728
|
+
throw new CdpError(workerBufferReadMissMessage(needle, liveWorkers));
|
|
3729
|
+
}
|
|
3730
|
+
function assertWakeApplies(target, wake, capture) {
|
|
3731
|
+
if (wake === undefined)
|
|
3732
|
+
return;
|
|
3733
|
+
if (typeof wake !== "boolean")
|
|
3734
|
+
throw new CdpError("'wake' must be a boolean");
|
|
3735
|
+
if (!isWorkerSelector(target))
|
|
3736
|
+
throw new CdpError(workerWakeMisuseMessage("not-a-worker-selector"));
|
|
3737
|
+
if (!capture)
|
|
3738
|
+
throw new CdpError(workerWakeMisuseMessage("read-only-call"));
|
|
3739
|
+
}
|
|
2843
3740
|
var NETWORK_EVENTS = [
|
|
2844
3741
|
"Network.requestWillBeSent",
|
|
2845
3742
|
"Network.responseReceived",
|
|
@@ -2850,11 +3747,11 @@ var CONSOLE_EVENTS = ["Runtime.consoleAPICalled", "Runtime.exceptionThrown", "Lo
|
|
|
2850
3747
|
async function startRecorder(target, opts = {}) {
|
|
2851
3748
|
const network = opts.network ?? true;
|
|
2852
3749
|
const consoleCap = opts.console ?? true;
|
|
2853
|
-
await
|
|
3750
|
+
await mkdir6(ARTIFACT_DIR2, { recursive: true });
|
|
2854
3751
|
const { conn, target: resolved } = await openPage(target, { timeoutMs: opts.timeoutMs });
|
|
2855
3752
|
const file = opts.file ?? recFile(resolved.id);
|
|
2856
3753
|
if (opts.truncate) {
|
|
2857
|
-
await
|
|
3754
|
+
await writeFile5(file, "");
|
|
2858
3755
|
}
|
|
2859
3756
|
let chain = Promise.resolve();
|
|
2860
3757
|
let dropped = 0;
|
|
@@ -2905,7 +3802,8 @@ async function startRecorder(target, opts = {}) {
|
|
|
2905
3802
|
};
|
|
2906
3803
|
}
|
|
2907
3804
|
async function captureWindow(target, durationMs, opts = {}) {
|
|
2908
|
-
const resolved = await
|
|
3805
|
+
const resolved = await resolveRecorderTarget(target, { capture: true, wake: opts.wake });
|
|
3806
|
+
const isWorker = isWorkerTargetType(resolved.type);
|
|
2909
3807
|
const captureId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2910
3808
|
const file = captureFile(resolved.id, captureId);
|
|
2911
3809
|
const rec = await startRecorder(resolved.id, {
|
|
@@ -2915,13 +3813,16 @@ async function captureWindow(target, durationMs, opts = {}) {
|
|
|
2915
3813
|
file,
|
|
2916
3814
|
timeoutMs: opts.timeoutMs
|
|
2917
3815
|
});
|
|
2918
|
-
|
|
2919
|
-
|
|
3816
|
+
if (!isWorker) {
|
|
3817
|
+
await rec.conn.send("Page.enable");
|
|
3818
|
+
await rec.conn.send("Page.reload", { ignoreCache: false });
|
|
3819
|
+
}
|
|
2920
3820
|
await delay2(durationMs);
|
|
2921
3821
|
return {
|
|
2922
3822
|
file: rec.file,
|
|
2923
3823
|
conn: rec.conn,
|
|
2924
3824
|
resolved: { id: rec.target.id, url: rec.target.url, title: rec.target.title },
|
|
3825
|
+
mode: isWorker ? "listen" : "reload",
|
|
2925
3826
|
droppedWrites: rec.droppedWrites,
|
|
2926
3827
|
flush: () => rec.flush(),
|
|
2927
3828
|
async stop() {
|
|
@@ -2996,7 +3897,7 @@ function lineToEntry(line, index) {
|
|
|
2996
3897
|
async function readConsoleEntries(file) {
|
|
2997
3898
|
let raw;
|
|
2998
3899
|
try {
|
|
2999
|
-
raw = await
|
|
3900
|
+
raw = await readFile4(file, "utf8");
|
|
3000
3901
|
} catch {
|
|
3001
3902
|
return [];
|
|
3002
3903
|
}
|
|
@@ -3021,8 +3922,9 @@ async function readConsoleEntries(file) {
|
|
|
3021
3922
|
return out;
|
|
3022
3923
|
}
|
|
3023
3924
|
async function listConsoleMessages(args = {}) {
|
|
3925
|
+
assertWakeApplies(args.target, args.wake, args.reload === true);
|
|
3024
3926
|
if (args.reload) {
|
|
3025
|
-
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
|
|
3927
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS, { wake: args.wake });
|
|
3026
3928
|
await cap.stop();
|
|
3027
3929
|
const messages2 = await readConsoleEntries(cap.file);
|
|
3028
3930
|
return {
|
|
@@ -3032,7 +3934,7 @@ async function listConsoleMessages(args = {}) {
|
|
|
3032
3934
|
droppedWrites: cap.droppedWrites()
|
|
3033
3935
|
};
|
|
3034
3936
|
}
|
|
3035
|
-
const target = await
|
|
3937
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
3036
3938
|
const messages = await readConsoleEntries(recFile(target.id));
|
|
3037
3939
|
return {
|
|
3038
3940
|
target: { id: target.id, url: target.url, title: target.title },
|
|
@@ -3041,7 +3943,7 @@ async function listConsoleMessages(args = {}) {
|
|
|
3041
3943
|
};
|
|
3042
3944
|
}
|
|
3043
3945
|
async function getConsoleMessage(args = {}) {
|
|
3044
|
-
const target = await
|
|
3946
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
3045
3947
|
const messages = await readConsoleEntries(recFile(target.id));
|
|
3046
3948
|
const index = args.index ?? 0;
|
|
3047
3949
|
const entry = messages[index];
|
|
@@ -3052,7 +3954,7 @@ async function getConsoleMessage(args = {}) {
|
|
|
3052
3954
|
}
|
|
3053
3955
|
|
|
3054
3956
|
// src/tools/network.ts
|
|
3055
|
-
import { readFile as
|
|
3957
|
+
import { readFile as readFile5 } from "node:fs/promises";
|
|
3056
3958
|
var DEFAULT_CAPTURE_MS2 = 2500;
|
|
3057
3959
|
function asHeaders(v) {
|
|
3058
3960
|
if (!v || typeof v !== "object")
|
|
@@ -3119,7 +4021,7 @@ function correlate(lines) {
|
|
|
3119
4021
|
async function readNetworkRequests(file) {
|
|
3120
4022
|
let raw;
|
|
3121
4023
|
try {
|
|
3122
|
-
raw = await
|
|
4024
|
+
raw = await readFile5(file, "utf8");
|
|
3123
4025
|
} catch {
|
|
3124
4026
|
return [];
|
|
3125
4027
|
}
|
|
@@ -3139,14 +4041,15 @@ async function listNetworkRequests(args = {}) {
|
|
|
3139
4041
|
let file;
|
|
3140
4042
|
let resolved;
|
|
3141
4043
|
let droppedWrites;
|
|
4044
|
+
assertWakeApplies(args.target, args.wake, args.reload === true);
|
|
3142
4045
|
if (args.reload) {
|
|
3143
|
-
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2);
|
|
4046
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2, { wake: args.wake });
|
|
3144
4047
|
await cap.stop();
|
|
3145
4048
|
file = cap.file;
|
|
3146
4049
|
resolved = cap.resolved;
|
|
3147
4050
|
droppedWrites = cap.droppedWrites();
|
|
3148
4051
|
} else {
|
|
3149
|
-
const target = await
|
|
4052
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
3150
4053
|
file = recFile(target.id);
|
|
3151
4054
|
resolved = { id: target.id, url: target.url, title: target.title };
|
|
3152
4055
|
}
|
|
@@ -3167,8 +4070,9 @@ async function getNetworkRequest(args = {}) {
|
|
|
3167
4070
|
return requests.find((r) => r.url.includes(args.url));
|
|
3168
4071
|
};
|
|
3169
4072
|
const describeSelector = () => args.requestId ? `requestId ${args.requestId}` : `url ~ '${args.url}'`;
|
|
4073
|
+
assertWakeApplies(args.target, args.wake, Boolean(args.includeBody && args.url));
|
|
3170
4074
|
if (args.includeBody && args.url) {
|
|
3171
|
-
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2);
|
|
4075
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2, { wake: args.wake });
|
|
3172
4076
|
try {
|
|
3173
4077
|
await cap.flush();
|
|
3174
4078
|
const match2 = (await readNetworkRequests(cap.file)).find((r) => r.url.includes(args.url));
|
|
@@ -3185,7 +4089,7 @@ async function getNetworkRequest(args = {}) {
|
|
|
3185
4089
|
await cap.stop();
|
|
3186
4090
|
}
|
|
3187
4091
|
}
|
|
3188
|
-
const target = await
|
|
4092
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
3189
4093
|
const match = pick(await readNetworkRequests(recFile(target.id)));
|
|
3190
4094
|
if (!match) {
|
|
3191
4095
|
throw new CdpError(`no network request matched ${describeSelector()}; run list_network_requests with reload:true first` + (args.includeBody ? `, then fetch the body with the url selector (reload re-mints requestIds)` : ``));
|
|
@@ -3201,8 +4105,8 @@ async function getNetworkRequest(args = {}) {
|
|
|
3201
4105
|
}
|
|
3202
4106
|
|
|
3203
4107
|
// src/tools/performance.ts
|
|
3204
|
-
import { mkdir as
|
|
3205
|
-
import { join as
|
|
4108
|
+
import { mkdir as mkdir7, readFile as readFile6, writeFile as writeFile6, stat, unlink as unlink4 } from "node:fs/promises";
|
|
4109
|
+
import { join as join5 } from "node:path";
|
|
3206
4110
|
var ARTIFACT_DIR3 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
3207
4111
|
var STATE_DIR2 = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
3208
4112
|
var DEFAULT_CATEGORIES = [
|
|
@@ -3220,7 +4124,7 @@ function shortId(id) {
|
|
|
3220
4124
|
return id.slice(0, 8);
|
|
3221
4125
|
}
|
|
3222
4126
|
function stateFilePath() {
|
|
3223
|
-
return
|
|
4127
|
+
return join5(STATE_DIR2, "trace-state.json");
|
|
3224
4128
|
}
|
|
3225
4129
|
function bufferTraceData(conn, sink) {
|
|
3226
4130
|
return conn.on("Tracing.dataCollected", (params) => {
|
|
@@ -3242,10 +4146,10 @@ async function endTrace(conn, sink, timeoutMs = 30000) {
|
|
|
3242
4146
|
return sink;
|
|
3243
4147
|
}
|
|
3244
4148
|
async function writeTrace(events, targetId) {
|
|
3245
|
-
await
|
|
3246
|
-
const path =
|
|
4149
|
+
await mkdir7(ARTIFACT_DIR3, { recursive: true });
|
|
4150
|
+
const path = join5(ARTIFACT_DIR3, `trace-${shortId(targetId)}-${stamp2()}.json`);
|
|
3247
4151
|
const body = JSON.stringify({ traceEvents: events, metadata: { source: "cdp-toolkit" } });
|
|
3248
|
-
await
|
|
4152
|
+
await writeFile6(path, body, "utf8");
|
|
3249
4153
|
const { size } = await stat(path);
|
|
3250
4154
|
return { path, bytes: size };
|
|
3251
4155
|
}
|
|
@@ -3265,7 +4169,7 @@ async function performanceStartTrace(args = {}) {
|
|
|
3265
4169
|
throw err;
|
|
3266
4170
|
}
|
|
3267
4171
|
liveTraces.set(target.id, { conn, target, events, startedAt: Date.now() });
|
|
3268
|
-
await
|
|
4172
|
+
await mkdir7(STATE_DIR2, { recursive: true });
|
|
3269
4173
|
const state = {
|
|
3270
4174
|
targetId: target.id,
|
|
3271
4175
|
url: target.url,
|
|
@@ -3273,7 +4177,7 @@ async function performanceStartTrace(args = {}) {
|
|
|
3273
4177
|
pid: process.pid,
|
|
3274
4178
|
categories
|
|
3275
4179
|
};
|
|
3276
|
-
await
|
|
4180
|
+
await writeFile6(stateFilePath(), JSON.stringify(state), "utf8");
|
|
3277
4181
|
return {
|
|
3278
4182
|
target: { id: target.id, url: target.url, title: target.title },
|
|
3279
4183
|
categories,
|
|
@@ -3286,7 +4190,7 @@ async function performanceStopTrace(args = {}) {
|
|
|
3286
4190
|
if (!entry) {
|
|
3287
4191
|
let hint = "";
|
|
3288
4192
|
try {
|
|
3289
|
-
const raw = await
|
|
4193
|
+
const raw = await readFile6(stateFilePath(), "utf8");
|
|
3290
4194
|
const state = JSON.parse(raw);
|
|
3291
4195
|
hint = state.pid === process.pid ? ` (state file lists target ${shortId(state.targetId)} but its live connection is gone)` : ` (trace was started by a DIFFERENT process pid=${state.pid}; live recordings cannot cross process boundaries)`;
|
|
3292
4196
|
} catch {}
|
|
@@ -3298,7 +4202,7 @@ async function performanceStopTrace(args = {}) {
|
|
|
3298
4202
|
} finally {
|
|
3299
4203
|
conn.close();
|
|
3300
4204
|
liveTraces.delete(target.id);
|
|
3301
|
-
await
|
|
4205
|
+
await unlink4(stateFilePath()).catch(() => {});
|
|
3302
4206
|
}
|
|
3303
4207
|
const written = await writeTrace(events, target.id);
|
|
3304
4208
|
const metrics = analyzeEvents(events);
|
|
@@ -3416,7 +4320,7 @@ async function performanceAnalyzeInsight(args = {}) {
|
|
|
3416
4320
|
}
|
|
3417
4321
|
let raw;
|
|
3418
4322
|
try {
|
|
3419
|
-
raw = await
|
|
4323
|
+
raw = await readFile6(args.tracePath, "utf8");
|
|
3420
4324
|
} catch (err) {
|
|
3421
4325
|
throw new CdpError(`cannot read trace file '${args.tracePath}': ${err.message}`);
|
|
3422
4326
|
}
|
|
@@ -3434,8 +4338,8 @@ async function performanceAnalyzeInsight(args = {}) {
|
|
|
3434
4338
|
|
|
3435
4339
|
// src/tools/screencast.ts
|
|
3436
4340
|
import { spawn } from "node:child_process";
|
|
3437
|
-
import { mkdir as
|
|
3438
|
-
import { join as
|
|
4341
|
+
import { mkdir as mkdir8, rm as rm2, stat as stat2, writeFile as writeFile7 } from "node:fs/promises";
|
|
4342
|
+
import { join as join6 } from "node:path";
|
|
3439
4343
|
var ARTIFACT_DIR4 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
3440
4344
|
var FFMPEG_BIN = process.env.CDP_FFMPEG ?? "ffmpeg";
|
|
3441
4345
|
function frameDurationsMs(ledger, stopWallclockMs) {
|
|
@@ -3491,7 +4395,7 @@ function pngSize(buf) {
|
|
|
3491
4395
|
return;
|
|
3492
4396
|
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
3493
4397
|
}
|
|
3494
|
-
var
|
|
4398
|
+
var JPEG_SOF2 = new Set([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207]);
|
|
3495
4399
|
function jpegSize(buf) {
|
|
3496
4400
|
if (buf.length < 4 || buf[0] !== 255 || buf[1] !== 216)
|
|
3497
4401
|
return;
|
|
@@ -3513,7 +4417,7 @@ function jpegSize(buf) {
|
|
|
3513
4417
|
if (i + 3 >= buf.length)
|
|
3514
4418
|
return;
|
|
3515
4419
|
const segLen = buf.readUInt16BE(i + 2);
|
|
3516
|
-
if (
|
|
4420
|
+
if (JPEG_SOF2.has(marker)) {
|
|
3517
4421
|
if (i + 9 > buf.length)
|
|
3518
4422
|
return;
|
|
3519
4423
|
return { width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) };
|
|
@@ -3656,9 +4560,9 @@ async function startScreenRecording(args = {}) {
|
|
|
3656
4560
|
conn.close();
|
|
3657
4561
|
throw err;
|
|
3658
4562
|
}
|
|
3659
|
-
const spoolDir =
|
|
4563
|
+
const spoolDir = join6(ARTIFACT_DIR4, `screencast-${shortId2(target.id)}-${stamp3()}`);
|
|
3660
4564
|
try {
|
|
3661
|
-
await
|
|
4565
|
+
await mkdir8(spoolDir, { recursive: true });
|
|
3662
4566
|
} catch (err) {
|
|
3663
4567
|
conn.close();
|
|
3664
4568
|
throw new CdpError(`cannot create the screencast spool directory '${spoolDir}': ${err.message}`);
|
|
@@ -3699,7 +4603,7 @@ async function startScreenRecording(args = {}) {
|
|
|
3699
4603
|
seq += 1;
|
|
3700
4604
|
const file = `frame-${String(seq).padStart(6, "0")}.${ext}`;
|
|
3701
4605
|
rec.ledger.push({ file, timestamp, width: size.width, height: size.height });
|
|
3702
|
-
rec.writes = rec.writes.then(() =>
|
|
4606
|
+
rec.writes = rec.writes.then(() => writeFile7(join6(spoolDir, file), buf)).catch(() => {
|
|
3703
4607
|
rec.dropped += 1;
|
|
3704
4608
|
rec.unwritten.add(file);
|
|
3705
4609
|
});
|
|
@@ -3721,7 +4625,7 @@ async function startScreenRecording(args = {}) {
|
|
|
3721
4625
|
} catch (err) {
|
|
3722
4626
|
rec.off();
|
|
3723
4627
|
conn.close();
|
|
3724
|
-
await
|
|
4628
|
+
await rm2(spoolDir, { recursive: true, force: true }).catch(() => {});
|
|
3725
4629
|
throw err;
|
|
3726
4630
|
}
|
|
3727
4631
|
liveRecordings.set(target.id, rec);
|
|
@@ -3759,14 +4663,14 @@ async function stopScreenRecording(args = {}) {
|
|
|
3759
4663
|
liveRecordings.delete(rec.target.id);
|
|
3760
4664
|
const ledger = rec.unwritten.size ? rec.ledger.filter((entry) => !rec.unwritten.has(entry.file)) : rec.ledger;
|
|
3761
4665
|
if (!ledger.length) {
|
|
3762
|
-
await
|
|
4666
|
+
await rm2(rec.spoolDir, { recursive: true, force: true }).catch(() => {});
|
|
3763
4667
|
throw new CdpError(`screen recording captured 0 frames for target ${shortId2(rec.target.id)}. Chrome emits a screencast frame only on repaint: a fully backgrounded or occluded tab may never paint. Retry with bringToFront:true, or drive the page while recording.`);
|
|
3764
4668
|
}
|
|
3765
|
-
await
|
|
3766
|
-
const outPath = args.savePath ??
|
|
4669
|
+
await mkdir8(ARTIFACT_DIR4, { recursive: true });
|
|
4670
|
+
const outPath = args.savePath ?? join6(ARTIFACT_DIR4, `screen-recording-${shortId2(rec.target.id)}-${stamp3()}.mp4`);
|
|
3767
4671
|
const frames = coalesceToConcatGrid(ledger, stoppedAt);
|
|
3768
|
-
const manifestPath =
|
|
3769
|
-
await
|
|
4672
|
+
const manifestPath = join6(rec.spoolDir, "frames.ffconcat");
|
|
4673
|
+
await writeFile7(manifestPath, buildConcatManifest(frames), "utf8");
|
|
3770
4674
|
const durationMs = frames.reduce((total, f) => total + f.durationMs, 0);
|
|
3771
4675
|
const { width, height } = evenDimensions(ledger[0].width, ledger[0].height);
|
|
3772
4676
|
let res;
|
|
@@ -3780,7 +4684,7 @@ async function stopScreenRecording(args = {}) {
|
|
|
3780
4684
|
ffmpeg said: ${res.stderr.trim().slice(0, 800)}`);
|
|
3781
4685
|
}
|
|
3782
4686
|
const { size } = await stat2(outPath);
|
|
3783
|
-
await
|
|
4687
|
+
await rm2(rec.spoolDir, { recursive: true, force: true }).catch(() => {});
|
|
3784
4688
|
return {
|
|
3785
4689
|
path: outPath,
|
|
3786
4690
|
bytes: size,
|
|
@@ -3797,8 +4701,8 @@ ffmpeg said: ${res.stderr.trim().slice(0, 800)}`);
|
|
|
3797
4701
|
}
|
|
3798
4702
|
|
|
3799
4703
|
// src/tools/heap.ts
|
|
3800
|
-
import { mkdir as
|
|
3801
|
-
import { join as
|
|
4704
|
+
import { mkdir as mkdir9, writeFile as writeFile8 } from "node:fs/promises";
|
|
4705
|
+
import { join as join7 } from "node:path";
|
|
3802
4706
|
var ARTIFACT_DIR5 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
3803
4707
|
function stamp4(targetId) {
|
|
3804
4708
|
const short = targetId.slice(0, 8);
|
|
@@ -3823,9 +4727,9 @@ async function takeHeapsnapshot(args = {}) {
|
|
|
3823
4727
|
if (snapshot.length === 0) {
|
|
3824
4728
|
throw new Error("HeapProfiler.takeHeapSnapshot produced no chunks");
|
|
3825
4729
|
}
|
|
3826
|
-
await
|
|
3827
|
-
const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath :
|
|
3828
|
-
await
|
|
4730
|
+
await mkdir9(ARTIFACT_DIR5, { recursive: true });
|
|
4731
|
+
const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath : join7(ARTIFACT_DIR5, args.savePath) : join7(ARTIFACT_DIR5, `take_heapsnapshot-${stamp4(target.id)}.heapsnapshot`);
|
|
4732
|
+
await writeFile8(path, snapshot, "utf8");
|
|
3829
4733
|
const bytes = Buffer.byteLength(snapshot, "utf8");
|
|
3830
4734
|
return {
|
|
3831
4735
|
path,
|
|
@@ -3838,7 +4742,7 @@ async function takeHeapsnapshot(args = {}) {
|
|
|
3838
4742
|
|
|
3839
4743
|
// src/tools/lighthouse.ts
|
|
3840
4744
|
import { spawn as spawn2 } from "node:child_process";
|
|
3841
|
-
import { mkdir as
|
|
4745
|
+
import { mkdir as mkdir10, readFile as readFile7, stat as stat3 } from "node:fs/promises";
|
|
3842
4746
|
var ARTIFACT_DIR6 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
3843
4747
|
function cdpPort() {
|
|
3844
4748
|
const base = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
@@ -3899,7 +4803,7 @@ async function lighthouseAudit(args) {
|
|
|
3899
4803
|
const url = args.url.trim();
|
|
3900
4804
|
const timeoutMs = args.timeoutMs ?? 120000;
|
|
3901
4805
|
const port = cdpPort();
|
|
3902
|
-
await
|
|
4806
|
+
await mkdir10(ARTIFACT_DIR6, { recursive: true });
|
|
3903
4807
|
const outPath = args.savePath ?? `${ARTIFACT_DIR6}/lighthouse-${shortHost(url)}-${stamp5()}.json`;
|
|
3904
4808
|
const formFactor = args.formFactor ?? "desktop";
|
|
3905
4809
|
const lhArgs = [
|
|
@@ -3928,7 +4832,7 @@ async function lighthouseAudit(args) {
|
|
|
3928
4832
|
}
|
|
3929
4833
|
let raw;
|
|
3930
4834
|
try {
|
|
3931
|
-
raw = await
|
|
4835
|
+
raw = await readFile7(outPath, "utf8");
|
|
3932
4836
|
} catch {
|
|
3933
4837
|
raw = undefined;
|
|
3934
4838
|
}
|
|
@@ -4015,14 +4919,14 @@ async function dispatchMouse(args) {
|
|
|
4015
4919
|
}
|
|
4016
4920
|
|
|
4017
4921
|
// src/tools/downloads.ts
|
|
4018
|
-
import { readdir as readdir3, rename, stat as stat4 } from "node:fs/promises";
|
|
4019
|
-
import { basename, extname, join as
|
|
4922
|
+
import { readdir as readdir3, rename as rename2, stat as stat4 } from "node:fs/promises";
|
|
4923
|
+
import { basename, extname, join as join9 } from "node:path";
|
|
4020
4924
|
|
|
4021
4925
|
// src/tools/browser-session.ts
|
|
4022
|
-
import { mkdir as
|
|
4023
|
-
import { join as
|
|
4926
|
+
import { mkdir as mkdir11 } from "node:fs/promises";
|
|
4927
|
+
import { join as join8 } from "node:path";
|
|
4024
4928
|
var ARTIFACT_DIR7 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
4025
|
-
var DOWNLOADS_DIR =
|
|
4929
|
+
var DOWNLOADS_DIR = join8(ARTIFACT_DIR7, "downloads");
|
|
4026
4930
|
var DOWNLOAD_BUFFER_LIMIT = 50;
|
|
4027
4931
|
var session;
|
|
4028
4932
|
function isDisconnect(e) {
|
|
@@ -4094,7 +4998,7 @@ async function browserSend(method, params = {}) {
|
|
|
4094
4998
|
}
|
|
4095
4999
|
}
|
|
4096
5000
|
async function armDownloadsAt(path) {
|
|
4097
|
-
await
|
|
5001
|
+
await mkdir11(path, { recursive: true });
|
|
4098
5002
|
const s = await getBrowserSession();
|
|
4099
5003
|
await s.conn.send("Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: path, eventsEnabled: true });
|
|
4100
5004
|
s.armedPath = path;
|
|
@@ -4184,8 +5088,8 @@ async function waitForDownload(args = {}) {
|
|
|
4184
5088
|
const taken = new Set(await readdir3(DOWNLOADS_DIR).catch(() => []));
|
|
4185
5089
|
taken.delete(basename(rec.filePath));
|
|
4186
5090
|
const finalName = nextFreeName(suggestedFilename, taken);
|
|
4187
|
-
const path =
|
|
4188
|
-
await
|
|
5091
|
+
const path = join9(DOWNLOADS_DIR, finalName);
|
|
5092
|
+
await rename2(rec.filePath, path);
|
|
4189
5093
|
const bytes = (await stat4(path)).size;
|
|
4190
5094
|
return { path, suggestedFilename, bytes, ...rec.url ? { url: rec.url } : {}, target };
|
|
4191
5095
|
}
|
|
@@ -4481,7 +5385,7 @@ var MANIFEST = [
|
|
|
4481
5385
|
},
|
|
4482
5386
|
{
|
|
4483
5387
|
name: "evaluate_script",
|
|
4484
|
-
description: "Run arbitrary JavaScript in the target page's main-world context over raw CDP and return the evaluated value (returnByValue). With no 'args' the 'expression' is evaluated as a raw expression; when 'args' is provided 'expression' must be a function literal (arrow or classic) invoked on globalThis with the args passed positionally. A thrown exception surfaces as an error; non-serializable returns (DOM nodes, functions) come back as their CDP description string. Pass 'savePath' to write the evaluated value to a JSON file instead: the response then carries only {path,bytes,type,target} and the value itself never appears in it, in any form. That is the way to read a credential (a JWT or session token out of localStorage, for example) without putting the secret into the caller's transcript. This is
|
|
5388
|
+
description: "Run arbitrary JavaScript in the target page's main-world context over raw CDP and return the evaluated value (returnByValue). With no 'args' the 'expression' is evaluated as a raw expression; when 'args' is provided 'expression' must be a function literal (arrow or classic) invoked on globalThis with the args passed positionally. A thrown exception surfaces as an error; non-serializable returns (DOM nodes, functions) come back as their CDP description string. Pass 'savePath' to write the evaluated value to a JSON file instead: the response then carries only {path,bytes,type,target} and the value itself never appears in it, in any form. That is the way to read a credential (a JWT or session token out of localStorage, for example) without putting the secret into the caller's transcript. This is the one tool that can EVALUATE inside an MV3 extension's background SERVICE WORKER rather than a page (list_network_requests/get_network_request/list_console_messages OBSERVE one): pass target 'worker:<extension-id>' (Chrome only) and read chrome.storage.local, chrome.runtime and the worker's own globals directly, with an idle-evicted worker started for you first (see 'wake').",
|
|
4485
5389
|
inputSchema: {
|
|
4486
5390
|
type: "object",
|
|
4487
5391
|
properties: {
|
|
@@ -4491,7 +5395,7 @@ var MANIFEST = [
|
|
|
4491
5395
|
},
|
|
4492
5396
|
target: {
|
|
4493
5397
|
type: "string",
|
|
4494
|
-
description: "Page selector: 'active' (or omit) -> first page-type target | '<32-hex targetId>' -> exact target by id | 'index:N' -> Nth page-type target (0-based) | 'url:<substring>' -> first page whose url contains substring | 'title:<substring>' -> first page whose title contains substring | 'label:<name>' -> the page-type target with exactly this label (checked against the origin ledger and live leases) | 'worker:<substring>' -> a service/shared worker whose url contains the substring, which is how you reach an MV3 extension's background service worker (worker:<extension-id> or worker:background.js) and read chrome.storage.local from it. The worker: arm is CHROME-ONLY (Capability 'worker.targets') and is accepted by
|
|
5398
|
+
description: "Page selector: 'active' (or omit) -> first page-type target | '<32-hex targetId>' -> exact target by id | 'index:N' -> Nth page-type target (0-based) | 'url:<substring>' -> first page whose url contains substring | 'title:<substring>' -> first page whose title contains substring | 'label:<name>' -> the page-type target with exactly this label (checked against the origin ledger and live leases) | 'worker:<substring>' -> a service/shared worker whose url contains the substring, which is how you reach an MV3 extension's background service worker (worker:<extension-id> or worker:background.js) and read chrome.storage.local from it. The worker: arm is CHROME-ONLY (Capability 'worker.targets') and is accepted by four tools only — evaluate_script, list_network_requests, get_network_request and list_console_messages — never as universal grammar; an MV3 worker that has been idle-evicted is started automatically first, see 'wake'."
|
|
4495
5399
|
},
|
|
4496
5400
|
wake: {
|
|
4497
5401
|
type: "boolean",
|
|
@@ -5186,7 +6090,7 @@ var MANIFEST = [
|
|
|
5186
6090
|
},
|
|
5187
6091
|
{
|
|
5188
6092
|
name: "take_screenshot",
|
|
5189
|
-
description: "Capture the viewport (default), the full scrollable page (fullPage), or a single element (uid or selector, exactly one and mutually exclusive) via raw CDP Page.captureScreenshot. Writes a PNG/JPEG under /tmp/cdp-toolkit (override with savePath) and returns {path,bytes,format,target};
|
|
6093
|
+
description: "Capture the viewport (default), the full scrollable page (fullPage), or a single element (uid or selector, exactly one and mutually exclusive) via raw CDP Page.captureScreenshot — as one image when it fits, or as vertical bands stitched losslessly into one PNG when it does not (see tile). Writes a PNG/JPEG under /tmp/cdp-toolkit (override with savePath; its directory is created for you) and returns {path,bytes,format,target,scale} plus width/height MEASURED from the encoded bytes, which are omitted rather than guessed when the bytes cannot be decoded; renderSize/renderRestored (+renderRestoreError) accompany a renderWidth/renderHeight capture, tiled/bands a banded one. Raw base64 is only included when returnBase64 is set, and is refused on a banded capture. quality applies to jpeg only. Every capture costs one Page.getLayoutMetrics round trip, because Chrome does not refuse an oversized capture politely: past 16384 device px per side Page.captureScreenshot can simply never answer, and captureBeyondViewport leaves the tab resized to the clip, so the size is checked before the capture rather than after.",
|
|
5190
6094
|
inputSchema: {
|
|
5191
6095
|
type: "object",
|
|
5192
6096
|
properties: {
|
|
@@ -5214,6 +6118,22 @@ var MANIFEST = [
|
|
|
5214
6118
|
type: "boolean",
|
|
5215
6119
|
description: "Capture the full scrollable content height computed from Page.getLayoutMetrics, not just the viewport."
|
|
5216
6120
|
},
|
|
6121
|
+
scale: {
|
|
6122
|
+
type: "number",
|
|
6123
|
+
description: "Output-pixel multiplier for this one capture: greater than 0, at most 8, default 1. Output px = ceil(css px x scale x devicePixelRatio), so on a ratio-2 display scale 3 is 6x the CSS size; the page is never told anything changed (devicePixelRatio and innerWidth/innerHeight read identically before and after). Chrome only (capability screenshot.scale) — WebDriver BiDi's browsingContext.captureScreenshot has no scale parameter, so under Firefox call emulate with deviceScaleFactor first and capture at scale 1. A projection past Chrome's 16384 device px per side is refused before the browser is dialled, naming the largest scale that fits."
|
|
6124
|
+
},
|
|
6125
|
+
renderWidth: {
|
|
6126
|
+
type: "number",
|
|
6127
|
+
description: "Emulate this viewport width, in CSS px (integer, 1-16384), for the length of ONE capture, then put the previous viewport back. REQUIRED together with renderHeight. Not the same knob as scale: scale re-renders the same layout at more pixels, this changes what the page BELIEVES, so media queries flip and the document re-lays-out — which is how you capture a responsive page at 1920x1080 from a tab that is not that size. Declared by BOTH backends (capability screenshot.renderSize). The restore can only put back an override this toolkit itself applied: one set by the DevTools UI, another client, or a pre-1.9.2 build is invisible and gets reset to the real device. The result reports renderSize and renderRestored; renderRestored:false means the tab is STILL emulated and you have to fix it with emulate."
|
|
6128
|
+
},
|
|
6129
|
+
renderHeight: {
|
|
6130
|
+
type: "number",
|
|
6131
|
+
description: "Emulate this viewport height, in CSS px (integer, 1-16384), for the length of one capture. Required together with renderWidth — a viewport is two numbers, and honouring one while inventing the other would render at a size you never named and never see in the result. Note a narrow renderWidth reflows most documents TALLER, which is the usual way a render-size capture trips the 16384-device-px encode limit."
|
|
6132
|
+
},
|
|
6133
|
+
tile: {
|
|
6134
|
+
type: "boolean",
|
|
6135
|
+
description: "Whether this capture may be taken as vertical bands and stitched into one lossless PNG. Three-valued: omitted = AUTO (one shot when the projected output fits under Chrome's 16384 device px per side, bands when it does not — this is why a whole-page capture of a very long page now returns a file instead of hanging and wedging the tab); false = never band, refuse instead (the pre-1.9.2 behaviour); true = always band, even when one shot would have fitted. Only true costs a capability (screenshot.tile, Chrome only; Firefox was never driven for the band arithmetic). A banded capture is PNG-only and cannot also returnBase64, and only HEIGHT is tiled — a projected WIDTH past the limit is refused, not split, because bands are stitched by concatenating scanlines. Bands are a faithful crop of one document render: fixed and sticky elements do not repeat per band and seams are pixel-exact, but content the page only loads on real scroll (lazy images, virtualized lists) renders blank in bands the real viewport never reached, which is a property of the page and not of the stitch."
|
|
6136
|
+
},
|
|
5217
6137
|
uid: {
|
|
5218
6138
|
type: "number",
|
|
5219
6139
|
description: "Element to clip to: a CDP backendDOMNodeId obtained from take_snapshot. Mutually exclusive with selector."
|
|
@@ -5224,11 +6144,11 @@ var MANIFEST = [
|
|
|
5224
6144
|
},
|
|
5225
6145
|
savePath: {
|
|
5226
6146
|
type: "string",
|
|
5227
|
-
description: "Override the output file path. Default: /tmp/cdp-toolkit/screenshot-<id>-<stamp>.<ext
|
|
6147
|
+
description: "Override the output file path. Default: /tmp/cdp-toolkit/screenshot-<id>-<stamp>.<ext>, where the stamp is the capture's START (a banded capture writes as it goes and has no finished image to name a file after). The destination directory is created from this path's own dirname, so a savePath outside /tmp/cdp-toolkit works."
|
|
5228
6148
|
},
|
|
5229
6149
|
returnBase64: {
|
|
5230
6150
|
type: "boolean",
|
|
5231
|
-
description: "Also return the raw base64 image bytes in the result, in addition to writing the file."
|
|
6151
|
+
description: "Also return the raw base64 image bytes in the result, in addition to writing the file. Refused on a banded capture (see tile): a stitched image runs to hundreds of megabytes and base64 inflates it by a third, into the caller's response — read the file at the returned path instead."
|
|
5232
6152
|
}
|
|
5233
6153
|
},
|
|
5234
6154
|
required: [],
|
|
@@ -5237,7 +6157,7 @@ var MANIFEST = [
|
|
|
5237
6157
|
},
|
|
5238
6158
|
{
|
|
5239
6159
|
name: "emulate",
|
|
5240
|
-
description: "Apply any subset of Chrome emulation overrides in one call: device metrics (width/height together, plus deviceScaleFactor/mobile), userAgent, cpuThrottlingRate (>=1), emulated media type + mediaFeatures, and networkConditions. Pass clearOverrides:true to reset every override to the browser default (ignores all other fields). Non-metrics overrides are session-scoped and reset when the per-call connection closes; device-metrics overrides persist on the target until cleared or the renderer navigates/reloads.",
|
|
6160
|
+
description: "Apply any subset of Chrome emulation overrides in one call: device metrics (width/height together, plus deviceScaleFactor/mobile), userAgent, cpuThrottlingRate (>=1), emulated media type + mediaFeatures, and networkConditions. Pass clearOverrides:true to reset every override to the browser default (ignores all other fields); as of 1.9.2 that genuinely clears a device-metrics override set by a DIFFERENT process (Emulation.clearDeviceMetricsOverride is a no-op unless the clearing connection also set the override, so the driver re-asserts the tab's current size on its own connection first, then clears — and throws rather than answering cleared:true if it cannot verify that). Non-metrics overrides are session-scoped and reset when the per-call connection closes; device-metrics overrides persist on the target until cleared or the renderer navigates/reloads.",
|
|
5241
6161
|
inputSchema: {
|
|
5242
6162
|
type: "object",
|
|
5243
6163
|
properties: {
|
|
@@ -5412,7 +6332,7 @@ var MANIFEST = [
|
|
|
5412
6332
|
},
|
|
5413
6333
|
{
|
|
5414
6334
|
name: "list_console_messages",
|
|
5415
|
-
description: "Read console output (logs, warnings, exceptions) captured for the target page. By default reads the target's existing shared buffer and returns parsed console entries (empty if no capture has run); with reload:true it reloads the page and records a fresh capture window (both console+network) so a network reload never wipes console history.",
|
|
6335
|
+
description: "Read console output (logs, warnings, exceptions) captured for the target page. By default reads the target's existing shared buffer and returns parsed console entries (empty if no capture has run); with reload:true it reloads the page and records a fresh capture window (both console+network) so a network reload never wipes console history. SERVICE WORKERS (Chrome only, Capability 'worker.targets'): pass target 'worker:<extension-id>' to watch an MV3 extension's BACKGROUND SERVICE WORKER. The outbound request that worker makes to your backend is recorded from CDP's Network domain on the worker's own session, which observes what the code ACTUALLY SENT and so needs neither of the two tricks a caller reaches for first. Both of those fail for ordinary JavaScript reasons rather than service-worker ones: a value bound at module top level is not visible from evaluate_script's global scope (plain lexical scoping, exactly the same in a page), and assigning self.fetch cannot rebind a fetch reference the module already captured. A worker's console.log arrives as Runtime.consoleAPICalled on its own session. A worker capture has no reload to drive it: reload:true means 'listen for durationMs while the worker runs', so trigger the worker's work (another tool call, an extension message, an alarm) during the window; a worker woken by the call has already run its top-level code by the time the recorder attaches, so a request it makes at startup can be missed. SIDE EFFECT, deliberate and documented: the recording holds a CDP session on the worker, and that KEEPS THE WORKER ALIVE for as long as the capture runs — an MV3 worker is otherwise idle-evicted within seconds — and Chrome resumes evicting it once the capture stops.",
|
|
5416
6336
|
inputSchema: {
|
|
5417
6337
|
type: "object",
|
|
5418
6338
|
properties: {
|
|
@@ -5422,15 +6342,19 @@ var MANIFEST = [
|
|
|
5422
6342
|
},
|
|
5423
6343
|
target: {
|
|
5424
6344
|
type: "string",
|
|
5425
|
-
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
|
|
6345
|
+
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>' | 'worker:<substring>' -> a service/shared worker whose url contains the substring, which is how you reach an MV3 extension's background service worker (worker:<extension-id> or worker:background.js). The worker: arm is CHROME-ONLY (Capability 'worker.targets'), is refused on Firefox, and a bare worker target id works too."
|
|
5426
6346
|
},
|
|
5427
6347
|
reload: {
|
|
5428
6348
|
type: "boolean",
|
|
5429
|
-
description: "Record fresh by reloading the page and capturing for a window. Default false (read the existing buffer)."
|
|
6349
|
+
description: "Record fresh by reloading the page and capturing for a window. Default false (read the existing buffer). With a worker: target there is nothing to reload: the window records for durationMs while the worker runs, and the session it holds keeps that worker from being idle-evicted until the capture stops."
|
|
5430
6350
|
},
|
|
5431
6351
|
durationMs: {
|
|
5432
6352
|
type: "number",
|
|
5433
6353
|
description: "Capture window for reload mode, in milliseconds. Default 2500."
|
|
6354
|
+
},
|
|
6355
|
+
wake: {
|
|
6356
|
+
type: "boolean",
|
|
6357
|
+
description: "Worker targets only, and only when a capture is being started (reload:true). Default true: an MV3 extension service worker is idle-evicted after seconds of inactivity and then exists in NO target listing, so the capture asks Chrome to start it (ServiceWorker.startWorker over a page session) and then re-reads the target list to confirm it actually came up. Pass false to fail fast instead of starting anything. Rejected rather than ignored on a page target, on a bare target id (Chrome re-mints a worker's target id when it restarts, so only the url-substring form can name a worker that is not running), and on a read-only call (the buffer is keyed by target id, so a woken worker's buffer would be empty)."
|
|
5434
6358
|
}
|
|
5435
6359
|
},
|
|
5436
6360
|
required: [],
|
|
@@ -5462,7 +6386,7 @@ var MANIFEST = [
|
|
|
5462
6386
|
},
|
|
5463
6387
|
{
|
|
5464
6388
|
name: "list_network_requests",
|
|
5465
|
-
description: "Return correlated network request rows (one per requestId, with status/headers/state) for the target page. By default reads the target's existing buffer; with reload:true it reloads and records a fresh both-domains capture window. Use filterUrl to keep only requests whose URL contains a substring.",
|
|
6389
|
+
description: "Return correlated network request rows (one per requestId, with status/headers/state) for the target page. By default reads the target's existing buffer; with reload:true it reloads and records a fresh both-domains capture window. Use filterUrl to keep only requests whose URL contains a substring. SERVICE WORKERS (Chrome only, Capability 'worker.targets'): pass target 'worker:<extension-id>' to watch an MV3 extension's BACKGROUND SERVICE WORKER. The outbound request that worker makes to your backend is recorded from CDP's Network domain on the worker's own session, which observes what the code ACTUALLY SENT and so needs neither of the two tricks a caller reaches for first. Both of those fail for ordinary JavaScript reasons rather than service-worker ones: a value bound at module top level is not visible from evaluate_script's global scope (plain lexical scoping, exactly the same in a page), and assigning self.fetch cannot rebind a fetch reference the module already captured. A worker capture has no reload to drive it: reload:true means 'listen for durationMs while the worker runs', so trigger the worker's work (another tool call, an extension message, an alarm) during the window; a worker woken by the call has already run its top-level code by the time the recorder attaches, so a request it makes at startup can be missed. SIDE EFFECT, deliberate and documented: the recording holds a CDP session on the worker, and that KEEPS THE WORKER ALIVE for as long as the capture runs — an MV3 worker is otherwise idle-evicted within seconds — and Chrome resumes evicting it once the capture stops.",
|
|
5466
6390
|
inputSchema: {
|
|
5467
6391
|
type: "object",
|
|
5468
6392
|
properties: {
|
|
@@ -5472,11 +6396,11 @@ var MANIFEST = [
|
|
|
5472
6396
|
},
|
|
5473
6397
|
target: {
|
|
5474
6398
|
type: "string",
|
|
5475
|
-
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
|
|
6399
|
+
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>' | 'worker:<substring>' -> a service/shared worker whose url contains the substring, which is how you reach an MV3 extension's background service worker (worker:<extension-id> or worker:background.js). The worker: arm is CHROME-ONLY (Capability 'worker.targets'), is refused on Firefox, and a bare worker target id works too."
|
|
5476
6400
|
},
|
|
5477
6401
|
reload: {
|
|
5478
6402
|
type: "boolean",
|
|
5479
|
-
description: "Record fresh by reloading the page and capturing for a window. Default false (read the existing buffer)."
|
|
6403
|
+
description: "Record fresh by reloading the page and capturing for a window. Default false (read the existing buffer). With a worker: target there is nothing to reload: the window records for durationMs while the worker runs, and the session it holds keeps that worker from being idle-evicted until the capture stops."
|
|
5480
6404
|
},
|
|
5481
6405
|
durationMs: {
|
|
5482
6406
|
type: "number",
|
|
@@ -5485,6 +6409,10 @@ var MANIFEST = [
|
|
|
5485
6409
|
filterUrl: {
|
|
5486
6410
|
type: "string",
|
|
5487
6411
|
description: "Only return requests whose URL contains this substring."
|
|
6412
|
+
},
|
|
6413
|
+
wake: {
|
|
6414
|
+
type: "boolean",
|
|
6415
|
+
description: "Worker targets only, and only when a capture is being started (reload:true). Default true: an MV3 extension service worker is idle-evicted after seconds of inactivity and then exists in NO target listing, so the capture asks Chrome to start it (ServiceWorker.startWorker over a page session) and then re-reads the target list to confirm it actually came up. Pass false to fail fast instead of starting anything. Rejected rather than ignored on a page target, on a bare target id (Chrome re-mints a worker's target id when it restarts, so only the url-substring form can name a worker that is not running), and on a read-only call (the buffer is keyed by target id, so a woken worker's buffer would be empty)."
|
|
5488
6416
|
}
|
|
5489
6417
|
},
|
|
5490
6418
|
required: [],
|
|
@@ -5493,7 +6421,7 @@ var MANIFEST = [
|
|
|
5493
6421
|
},
|
|
5494
6422
|
{
|
|
5495
6423
|
name: "get_network_request",
|
|
5496
|
-
description: "Return one network request (matched by exact requestId, else by url substring) including status/headers. Requires at least one of requestId or url (throws otherwise). With includeBody:true the body fetch drives a fresh reload
|
|
6424
|
+
description: "Return one network request (matched by exact requestId, else by url substring) including status/headers. Requires at least one of requestId or url (throws otherwise). With includeBody:true the body fetch drives a fresh capture (a reload for a page, a listen window for a worker) and is matched by url ONLY (a fresh capture re-mints requestIds, so a carried-over requestId cannot fetch a body, so it returns metadata plus bodyUnavailableReason). SERVICE WORKERS (Chrome only, Capability 'worker.targets'): pass target 'worker:<extension-id>' to watch an MV3 extension's BACKGROUND SERVICE WORKER. The outbound request that worker makes to your backend is recorded from CDP's Network domain on the worker's own session, which observes what the code ACTUALLY SENT and so needs neither of the two tricks a caller reaches for first. Both of those fail for ordinary JavaScript reasons rather than service-worker ones: a value bound at module top level is not visible from evaluate_script's global scope (plain lexical scoping, exactly the same in a page), and assigning self.fetch cannot rebind a fetch reference the module already captured. A worker capture has no reload to drive it: reload:true means 'listen for durationMs while the worker runs', so trigger the worker's work (another tool call, an extension message, an alarm) during the window; a worker woken by the call has already run its top-level code by the time the recorder attaches, so a request it makes at startup can be missed. SIDE EFFECT, deliberate and documented: the recording holds a CDP session on the worker, and that KEEPS THE WORKER ALIVE for as long as the capture runs — an MV3 worker is otherwise idle-evicted within seconds — and Chrome resumes evicting it once the capture stops.",
|
|
5497
6425
|
inputSchema: {
|
|
5498
6426
|
type: "object",
|
|
5499
6427
|
properties: {
|
|
@@ -5503,7 +6431,7 @@ var MANIFEST = [
|
|
|
5503
6431
|
},
|
|
5504
6432
|
target: {
|
|
5505
6433
|
type: "string",
|
|
5506
|
-
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
|
|
6434
|
+
description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>' | 'worker:<substring>' -> a service/shared worker whose url contains the substring, which is how you reach an MV3 extension's background service worker (worker:<extension-id> or worker:background.js). The worker: arm is CHROME-ONLY (Capability 'worker.targets'), is refused on Firefox, and a bare worker target id works too."
|
|
5507
6435
|
},
|
|
5508
6436
|
requestId: {
|
|
5509
6437
|
type: "string",
|
|
@@ -5520,6 +6448,10 @@ var MANIFEST = [
|
|
|
5520
6448
|
durationMs: {
|
|
5521
6449
|
type: "number",
|
|
5522
6450
|
description: "Capture window for the reload-driven body fetch, in milliseconds. Default 2500."
|
|
6451
|
+
},
|
|
6452
|
+
wake: {
|
|
6453
|
+
type: "boolean",
|
|
6454
|
+
description: "Worker targets only, and only when a capture is being started (includeBody together with url). Default true: an MV3 extension service worker is idle-evicted after seconds of inactivity and then exists in NO target listing, so the capture asks Chrome to start it (ServiceWorker.startWorker over a page session) and then re-reads the target list to confirm it actually came up. Pass false to fail fast instead of starting anything. Rejected rather than ignored on a page target, on a bare target id (Chrome re-mints a worker's target id when it restarts, so only the url-substring form can name a worker that is not running), and on a read-only call (the buffer is keyed by target id, so a woken worker's buffer would be empty)."
|
|
5523
6455
|
}
|
|
5524
6456
|
},
|
|
5525
6457
|
required: [],
|
|
@@ -6124,10 +7056,15 @@ class BidiConnection {
|
|
|
6124
7056
|
} catch {}
|
|
6125
7057
|
}
|
|
6126
7058
|
}
|
|
6127
|
-
async function
|
|
6128
|
-
const conn = new BidiConnection(
|
|
7059
|
+
async function connectBidiSessionUrl(wsUrl, opts = {}) {
|
|
7060
|
+
const conn = new BidiConnection(wsUrl, { timeoutMs: opts.timeoutMs });
|
|
6129
7061
|
await conn.connect();
|
|
6130
|
-
|
|
7062
|
+
try {
|
|
7063
|
+
await conn.newSession(opts.capabilities ?? {});
|
|
7064
|
+
} catch (e) {
|
|
7065
|
+
conn.dispose();
|
|
7066
|
+
throw e;
|
|
7067
|
+
}
|
|
6131
7068
|
conn.retain();
|
|
6132
7069
|
return conn;
|
|
6133
7070
|
}
|
|
@@ -6703,21 +7640,27 @@ var BIDI_CAPABILITIES = new Set([
|
|
|
6703
7640
|
"emulate.deviceMetrics",
|
|
6704
7641
|
"screenshot.fullPage",
|
|
6705
7642
|
"screenshot.element",
|
|
7643
|
+
"screenshot.renderSize",
|
|
6706
7644
|
"network.intercept",
|
|
6707
7645
|
"locate.xpath"
|
|
6708
7646
|
]);
|
|
6709
7647
|
var connections = new Map;
|
|
6710
|
-
async function getConnection(
|
|
6711
|
-
const existing = connections.get(
|
|
7648
|
+
async function getConnection(endpoint, timeoutMs) {
|
|
7649
|
+
const existing = connections.get(endpoint);
|
|
6712
7650
|
if (existing) {
|
|
6713
7651
|
existing.retain();
|
|
6714
7652
|
return existing;
|
|
6715
7653
|
}
|
|
6716
|
-
const conn = await
|
|
7654
|
+
const conn = await connectBidiSessionUrl(endpoint, {
|
|
6717
7655
|
...timeoutMs !== undefined ? { timeoutMs } : {},
|
|
6718
7656
|
capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } } }
|
|
7657
|
+
}).catch((e) => {
|
|
7658
|
+
if (e instanceof BidiError && /maximum number of active sessions/i.test(e.message)) {
|
|
7659
|
+
throw driverError2("disconnected", `Firefox already has an active WebDriver BiDi session on ${endpoint}; it allows only one. ` + `Close the other client, or restart Firefox, then retry. (A previously killed client can leave a ` + `session that Firefox does not reap.)`);
|
|
7660
|
+
}
|
|
7661
|
+
throw e;
|
|
6719
7662
|
});
|
|
6720
|
-
connections.set(
|
|
7663
|
+
connections.set(endpoint, conn);
|
|
6721
7664
|
return conn;
|
|
6722
7665
|
}
|
|
6723
7666
|
var bidiBeaconScripts = new Map;
|
|
@@ -7066,7 +8009,55 @@ class BidiPageDriver {
|
|
|
7066
8009
|
throw mapBidiError(e);
|
|
7067
8010
|
}
|
|
7068
8011
|
}
|
|
7069
|
-
async screenshot(opts) {
|
|
8012
|
+
async screenshot(opts, _consumeBands) {
|
|
8013
|
+
const renderSize = opts?.renderWidth !== undefined && opts?.renderHeight !== undefined ? { width: opts.renderWidth, height: opts.renderHeight } : undefined;
|
|
8014
|
+
if (!renderSize)
|
|
8015
|
+
return this.capture(opts);
|
|
8016
|
+
const previous = await readViewportRecord(this.browser.scheme, this.contextId);
|
|
8017
|
+
try {
|
|
8018
|
+
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: { width: renderSize.width, height: renderSize.height } });
|
|
8019
|
+
} catch (e) {
|
|
8020
|
+
throw mapBidiError(e);
|
|
8021
|
+
}
|
|
8022
|
+
let shot;
|
|
8023
|
+
let captureError;
|
|
8024
|
+
try {
|
|
8025
|
+
shot = await this.capture(opts);
|
|
8026
|
+
} catch (e) {
|
|
8027
|
+
captureError = e;
|
|
8028
|
+
}
|
|
8029
|
+
let restoreError;
|
|
8030
|
+
try {
|
|
8031
|
+
if (previous) {
|
|
8032
|
+
await this.conn.send("browsingContext.setViewport", {
|
|
8033
|
+
context: this.contextId,
|
|
8034
|
+
viewport: { width: previous.width, height: previous.height },
|
|
8035
|
+
...previous.deviceScaleFactor != null ? { devicePixelRatio: previous.deviceScaleFactor } : {}
|
|
8036
|
+
});
|
|
8037
|
+
} else {
|
|
8038
|
+
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: null });
|
|
8039
|
+
}
|
|
8040
|
+
} catch (e) {
|
|
8041
|
+
restoreError = e instanceof Error ? e.message : String(e);
|
|
8042
|
+
}
|
|
8043
|
+
if (!shot) {
|
|
8044
|
+
if (restoreError !== undefined && captureError instanceof Error) {
|
|
8045
|
+
captureError.message = `${captureError.message} ${renderRestoreFailureNote(renderSize, restoreError)}`;
|
|
8046
|
+
}
|
|
8047
|
+
throw captureError;
|
|
8048
|
+
}
|
|
8049
|
+
const result = { ...shot, renderSize, renderRestored: restoreError === undefined };
|
|
8050
|
+
if (restoreError !== undefined)
|
|
8051
|
+
result.renderRestoreError = restoreError;
|
|
8052
|
+
return result;
|
|
8053
|
+
}
|
|
8054
|
+
async capture(opts) {
|
|
8055
|
+
if (opts?.scale !== undefined && opts.scale !== 1) {
|
|
8056
|
+
throw driverError2("unsupported", `screenshot scale ${opts.scale} is not supported by this backend (WebDriver BiDi's ` + "browsingContext.captureScreenshot has no scale parameter). Call emulate with " + `deviceScaleFactor:${opts.scale} first and capture at scale 1, or use --browser chrome.`);
|
|
8057
|
+
}
|
|
8058
|
+
if (opts?.tile === true) {
|
|
8059
|
+
throw driverError2("unsupported", "screenshot tile:true is not supported by this backend: band tiling is declared only where the band " + "arithmetic has been measured against a real browser, and Firefox was not driven for it " + `(browsingContext.captureScreenshot's clip:{type:"box"} is the primitive it would ride, untested). ` + "Capture a smaller region or a single element, or use --browser chrome for a tiled capture.");
|
|
8060
|
+
}
|
|
7070
8061
|
const format = opts?.format ?? "png";
|
|
7071
8062
|
const params = {
|
|
7072
8063
|
context: this.contextId,
|
|
@@ -7091,12 +8082,18 @@ class BidiPageDriver {
|
|
|
7091
8082
|
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: null, devicePixelRatio: null }).catch(() => {
|
|
7092
8083
|
return;
|
|
7093
8084
|
});
|
|
8085
|
+
await clearViewportRecord(this.browser.scheme, this.contextId);
|
|
7094
8086
|
return { applied: [] };
|
|
7095
8087
|
}
|
|
7096
8088
|
const applied = [];
|
|
7097
8089
|
try {
|
|
7098
8090
|
if (opts.width != null && opts.height != null) {
|
|
7099
8091
|
await this.conn.send("browsingContext.setViewport", { context: this.contextId, viewport: { width: opts.width, height: opts.height }, ...opts.deviceScaleFactor != null ? { devicePixelRatio: opts.deviceScaleFactor } : {} });
|
|
8092
|
+
await writeViewportRecord(this.browser.scheme, this.contextId, {
|
|
8093
|
+
width: opts.width,
|
|
8094
|
+
height: opts.height,
|
|
8095
|
+
...opts.deviceScaleFactor != null ? { deviceScaleFactor: opts.deviceScaleFactor } : {}
|
|
8096
|
+
});
|
|
7100
8097
|
applied.push("deviceMetrics");
|
|
7101
8098
|
}
|
|
7102
8099
|
if (opts.userAgent != null) {
|
|
@@ -7304,19 +8301,19 @@ class BidiPageDriver {
|
|
|
7304
8301
|
}
|
|
7305
8302
|
|
|
7306
8303
|
class BidiBrowserDriver {
|
|
7307
|
-
|
|
8304
|
+
endpoint;
|
|
7308
8305
|
timeoutMs;
|
|
7309
8306
|
scheme = "bidi";
|
|
7310
8307
|
lifetime = "session";
|
|
7311
8308
|
capabilities = BIDI_CAPABILITIES;
|
|
7312
8309
|
uidStability = "document-stamp";
|
|
7313
8310
|
snapshotFidelity = "dom-heuristic";
|
|
7314
|
-
constructor(
|
|
7315
|
-
this.
|
|
8311
|
+
constructor(endpoint, timeoutMs) {
|
|
8312
|
+
this.endpoint = endpoint;
|
|
7316
8313
|
this.timeoutMs = timeoutMs;
|
|
7317
8314
|
}
|
|
7318
8315
|
async listPages() {
|
|
7319
|
-
const conn = await getConnection(this.
|
|
8316
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
7320
8317
|
try {
|
|
7321
8318
|
const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => {
|
|
7322
8319
|
throw mapBidiError(e);
|
|
@@ -7327,7 +8324,7 @@ class BidiBrowserDriver {
|
|
|
7327
8324
|
}
|
|
7328
8325
|
}
|
|
7329
8326
|
async newPage(url) {
|
|
7330
|
-
const conn = await getConnection(this.
|
|
8327
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
7331
8328
|
try {
|
|
7332
8329
|
const { context } = await conn.send("browsingContext.create", { type: "tab" }).catch((e) => {
|
|
7333
8330
|
throw mapBidiError(e);
|
|
@@ -7342,7 +8339,7 @@ class BidiBrowserDriver {
|
|
|
7342
8339
|
}
|
|
7343
8340
|
}
|
|
7344
8341
|
async closePage(id) {
|
|
7345
|
-
const conn = await getConnection(this.
|
|
8342
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
7346
8343
|
try {
|
|
7347
8344
|
await conn.send("browsingContext.close", { context: id }).catch((e) => {
|
|
7348
8345
|
throw mapBidiError(e);
|
|
@@ -7353,7 +8350,7 @@ class BidiBrowserDriver {
|
|
|
7353
8350
|
}
|
|
7354
8351
|
}
|
|
7355
8352
|
async activatePage(id) {
|
|
7356
|
-
const conn = await getConnection(this.
|
|
8353
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
7357
8354
|
try {
|
|
7358
8355
|
await conn.send("browsingContext.activate", { context: id }).catch((e) => {
|
|
7359
8356
|
throw mapBidiError(e);
|
|
@@ -7365,7 +8362,7 @@ class BidiBrowserDriver {
|
|
|
7365
8362
|
}
|
|
7366
8363
|
}
|
|
7367
8364
|
async page(selector) {
|
|
7368
|
-
const conn = await getConnection(this.
|
|
8365
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
7369
8366
|
let ctx;
|
|
7370
8367
|
try {
|
|
7371
8368
|
ctx = await resolveContext(conn, selector);
|
|
@@ -7377,13 +8374,13 @@ class BidiBrowserDriver {
|
|
|
7377
8374
|
return new BidiPageDriver(conn, ctx.context, info, this);
|
|
7378
8375
|
}
|
|
7379
8376
|
async installActivityBeacon(contextId) {
|
|
7380
|
-
const conn = await getConnection(this.
|
|
8377
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs).catch(() => {
|
|
7381
8378
|
return;
|
|
7382
8379
|
});
|
|
7383
8380
|
if (!conn)
|
|
7384
8381
|
return false;
|
|
7385
8382
|
try {
|
|
7386
|
-
const key = `${this.
|
|
8383
|
+
const key = `${this.endpoint}:${contextId}`;
|
|
7387
8384
|
if (!bidiBeaconScripts.has(key)) {
|
|
7388
8385
|
const registered = await conn.send("script.addPreloadScript", {
|
|
7389
8386
|
functionDeclaration: BEACON_FUNCTION_DECLARATION,
|
|
@@ -7404,7 +8401,7 @@ class BidiBrowserDriver {
|
|
|
7404
8401
|
}
|
|
7405
8402
|
}
|
|
7406
8403
|
async readActivityBeacon(contextId) {
|
|
7407
|
-
const conn = await getConnection(this.
|
|
8404
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs).catch(() => {
|
|
7408
8405
|
return;
|
|
7409
8406
|
});
|
|
7410
8407
|
if (!conn)
|
|
@@ -7427,18 +8424,24 @@ class BidiBrowserDriver {
|
|
|
7427
8424
|
}
|
|
7428
8425
|
async dispose() {
|
|
7429
8426
|
for (const key of [...bidiBeaconScripts.keys()]) {
|
|
7430
|
-
if (key.startsWith(`${this.
|
|
8427
|
+
if (key.startsWith(`${this.endpoint}:`))
|
|
7431
8428
|
bidiBeaconScripts.delete(key);
|
|
7432
8429
|
}
|
|
7433
|
-
const conn = connections.get(this.
|
|
8430
|
+
const conn = connections.get(this.endpoint);
|
|
7434
8431
|
if (!conn)
|
|
7435
8432
|
return;
|
|
7436
|
-
connections.delete(this.
|
|
8433
|
+
connections.delete(this.endpoint);
|
|
8434
|
+
await conn.send("session.end", {}).catch(() => {
|
|
8435
|
+
return;
|
|
8436
|
+
});
|
|
7437
8437
|
conn.dispose();
|
|
7438
8438
|
}
|
|
7439
8439
|
}
|
|
7440
8440
|
function createFirefoxDriver(port, opts) {
|
|
7441
|
-
return new BidiBrowserDriver(port
|
|
8441
|
+
return new BidiBrowserDriver(`ws://127.0.0.1:${port}/session`, opts?.timeoutMs);
|
|
8442
|
+
}
|
|
8443
|
+
function createFirefoxDriverForEndpoint(wsUrl, opts) {
|
|
8444
|
+
return new BidiBrowserDriver(wsUrl, opts?.timeoutMs);
|
|
7442
8445
|
}
|
|
7443
8446
|
|
|
7444
8447
|
// src/bidi/launch.ts
|
|
@@ -7446,7 +8449,7 @@ import { spawn as spawn3 } from "node:child_process";
|
|
|
7446
8449
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
7447
8450
|
import { createConnection, createServer } from "node:net";
|
|
7448
8451
|
import { tmpdir } from "node:os";
|
|
7449
|
-
import { join as
|
|
8452
|
+
import { join as join10 } from "node:path";
|
|
7450
8453
|
function resolveBinary() {
|
|
7451
8454
|
const candidates = [
|
|
7452
8455
|
process.env.FIREFOX_BIN,
|
|
@@ -7520,7 +8523,7 @@ async function launchFirefox(opts = {}) {
|
|
|
7520
8523
|
const binary = resolveBinary();
|
|
7521
8524
|
const port = await pickPort(opts.port);
|
|
7522
8525
|
const isThrowaway = opts.profilePath === undefined;
|
|
7523
|
-
const profilePath = opts.profilePath ?? mkdtempSync(
|
|
8526
|
+
const profilePath = opts.profilePath ?? mkdtempSync(join10(tmpdir(), "cdp-toolkit-ff-profile-"));
|
|
7524
8527
|
const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
|
|
7525
8528
|
if (opts.headless)
|
|
7526
8529
|
args.push("--headless");
|
|
@@ -7558,10 +8561,10 @@ async function launchFirefox(opts = {}) {
|
|
|
7558
8561
|
}
|
|
7559
8562
|
|
|
7560
8563
|
// src/backend.ts
|
|
7561
|
-
function resolveBrowserKind(argv) {
|
|
8564
|
+
function resolveBrowserKind(argv, env = process.env) {
|
|
7562
8565
|
const flagIdx = argv.indexOf("--browser");
|
|
7563
8566
|
const flagVal = flagIdx !== -1 ? argv[flagIdx + 1] : undefined;
|
|
7564
|
-
const raw = flagVal ??
|
|
8567
|
+
const raw = flagVal ?? env.CDP_BROWSER ?? "chrome";
|
|
7565
8568
|
if (raw !== "chrome" && raw !== "firefox") {
|
|
7566
8569
|
throw new Error(`unknown --browser '${raw}': expected 'chrome' or 'firefox' (or set CDP_BROWSER)`);
|
|
7567
8570
|
}
|
|
@@ -7578,7 +8581,79 @@ function stripBrowserFlag(argv) {
|
|
|
7578
8581
|
}
|
|
7579
8582
|
return out;
|
|
7580
8583
|
}
|
|
8584
|
+
function normalizeBidiEndpoint(raw) {
|
|
8585
|
+
const value = raw.trim();
|
|
8586
|
+
if (/^wss?:\/\//i.test(value)) {
|
|
8587
|
+
let url;
|
|
8588
|
+
try {
|
|
8589
|
+
url = new URL(value);
|
|
8590
|
+
} catch {
|
|
8591
|
+
throw new Error(`invalid Firefox endpoint '${raw}': not a parseable ws:// URL`);
|
|
8592
|
+
}
|
|
8593
|
+
if (url.pathname === "" || url.pathname === "/")
|
|
8594
|
+
url.pathname = "/session";
|
|
8595
|
+
return url.toString();
|
|
8596
|
+
}
|
|
8597
|
+
if (/^\d+$/.test(value))
|
|
8598
|
+
return `ws://127.0.0.1:${assertPort(value, raw)}/session`;
|
|
8599
|
+
const hostPort = /^([^:/]+):(\d+)$/.exec(value);
|
|
8600
|
+
if (hostPort)
|
|
8601
|
+
return `ws://${hostPort[1]}:${assertPort(hostPort[2], raw)}/session`;
|
|
8602
|
+
throw new Error(`invalid Firefox endpoint '${raw}': expected a port (9223), host:port, or a ws:// URL`);
|
|
8603
|
+
}
|
|
8604
|
+
function assertPort(digits, raw) {
|
|
8605
|
+
const port = Number(digits);
|
|
8606
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
8607
|
+
throw new Error(`invalid Firefox endpoint '${raw}': port must be 1-65535`);
|
|
8608
|
+
}
|
|
8609
|
+
return port;
|
|
8610
|
+
}
|
|
8611
|
+
function resolveFirefoxEndpoint(argv, env = process.env) {
|
|
8612
|
+
const flagIdx = argv.indexOf("--connect");
|
|
8613
|
+
const raw = (flagIdx !== -1 ? argv[flagIdx + 1] : undefined) ?? env.CDP_FIREFOX_ENDPOINT;
|
|
8614
|
+
if (raw === undefined || raw === "")
|
|
8615
|
+
return;
|
|
8616
|
+
return normalizeBidiEndpoint(raw);
|
|
8617
|
+
}
|
|
8618
|
+
function stripConnectFlag(argv) {
|
|
8619
|
+
const out = [];
|
|
8620
|
+
for (let i = 0;i < argv.length; i++) {
|
|
8621
|
+
if (argv[i] === "--connect") {
|
|
8622
|
+
i++;
|
|
8623
|
+
continue;
|
|
8624
|
+
}
|
|
8625
|
+
out.push(argv[i]);
|
|
8626
|
+
}
|
|
8627
|
+
return out;
|
|
8628
|
+
}
|
|
8629
|
+
function resolveBackend(argv, env = process.env) {
|
|
8630
|
+
const endpoint = resolveFirefoxEndpoint(argv, env);
|
|
8631
|
+
let kind = resolveBrowserKind(argv, env);
|
|
8632
|
+
const explicitBrowser = argv.includes("--browser") || env.CDP_BROWSER !== undefined;
|
|
8633
|
+
if (endpoint !== undefined) {
|
|
8634
|
+
if (explicitBrowser && kind === "chrome") {
|
|
8635
|
+
throw new Error("--connect / CDP_FIREFOX_ENDPOINT is a Firefox attach option and is not valid with the chrome backend");
|
|
8636
|
+
}
|
|
8637
|
+
kind = "firefox";
|
|
8638
|
+
}
|
|
8639
|
+
return endpoint !== undefined ? { kind, endpoint } : { kind };
|
|
8640
|
+
}
|
|
7581
8641
|
async function startFirefoxSession(opts) {
|
|
8642
|
+
if (opts?.endpoint !== undefined) {
|
|
8643
|
+
const driver2 = createFirefoxDriverForEndpoint(opts.endpoint);
|
|
8644
|
+
let disposed2 = false;
|
|
8645
|
+
return {
|
|
8646
|
+
driver: driver2,
|
|
8647
|
+
async dispose() {
|
|
8648
|
+
if (disposed2)
|
|
8649
|
+
return;
|
|
8650
|
+
disposed2 = true;
|
|
8651
|
+
await driver2.dispose().catch(() => {
|
|
8652
|
+
return;
|
|
8653
|
+
});
|
|
8654
|
+
}
|
|
8655
|
+
};
|
|
8656
|
+
}
|
|
7582
8657
|
const launched = await launchFirefox({ headless: opts?.headless });
|
|
7583
8658
|
const driver = createFirefoxDriver(launched.port);
|
|
7584
8659
|
let disposed = false;
|
|
@@ -7596,9 +8671,9 @@ async function startFirefoxSession(opts) {
|
|
|
7596
8671
|
};
|
|
7597
8672
|
}
|
|
7598
8673
|
var sessionPromise;
|
|
7599
|
-
function getOrCreateFirefoxSession() {
|
|
8674
|
+
function getOrCreateFirefoxSession(opts) {
|
|
7600
8675
|
if (!sessionPromise)
|
|
7601
|
-
sessionPromise = startFirefoxSession();
|
|
8676
|
+
sessionPromise = startFirefoxSession({ endpoint: opts?.endpoint });
|
|
7602
8677
|
return sessionPromise;
|
|
7603
8678
|
}
|
|
7604
8679
|
async function disposeFirefoxSession() {
|
|
@@ -7643,6 +8718,11 @@ async function withPage3(driver, target, fn) {
|
|
|
7643
8718
|
}
|
|
7644
8719
|
var consoleBuffers = new Map;
|
|
7645
8720
|
var networkBuffers = new Map;
|
|
8721
|
+
function refuseWorkerSelector(driver, target) {
|
|
8722
|
+
if (isWorkerSelector(target) && !driver.capabilities.has("worker.targets")) {
|
|
8723
|
+
throw new BidiToolError(WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
|
|
8724
|
+
}
|
|
8725
|
+
}
|
|
7646
8726
|
function bufferFor(map, id) {
|
|
7647
8727
|
let b = map.get(id);
|
|
7648
8728
|
if (!b) {
|
|
@@ -7671,6 +8751,7 @@ async function captureFresh(page, durationMs) {
|
|
|
7671
8751
|
}
|
|
7672
8752
|
}
|
|
7673
8753
|
async function listConsoleMessages2(driver, args = {}) {
|
|
8754
|
+
refuseWorkerSelector(driver, args.target);
|
|
7674
8755
|
return withPage3(driver, args.target, async (page) => {
|
|
7675
8756
|
if (args.reload)
|
|
7676
8757
|
await captureFresh(page, args.durationMs ?? 2500);
|
|
@@ -7679,6 +8760,7 @@ async function listConsoleMessages2(driver, args = {}) {
|
|
|
7679
8760
|
});
|
|
7680
8761
|
}
|
|
7681
8762
|
async function getConsoleMessage2(driver, args = {}) {
|
|
8763
|
+
refuseWorkerSelector(driver, args.target);
|
|
7682
8764
|
return withPage3(driver, args.target, async (page) => {
|
|
7683
8765
|
const buf = bufferFor(consoleBuffers, page.info.id);
|
|
7684
8766
|
const idx = args.index ?? 0;
|
|
@@ -7689,6 +8771,7 @@ async function getConsoleMessage2(driver, args = {}) {
|
|
|
7689
8771
|
});
|
|
7690
8772
|
}
|
|
7691
8773
|
async function listNetworkRequests2(driver, args = {}) {
|
|
8774
|
+
refuseWorkerSelector(driver, args.target);
|
|
7692
8775
|
return withPage3(driver, args.target, async (page) => {
|
|
7693
8776
|
if (args.reload)
|
|
7694
8777
|
await captureFresh(page, args.durationMs ?? 2500);
|
|
@@ -7699,6 +8782,7 @@ async function listNetworkRequests2(driver, args = {}) {
|
|
|
7699
8782
|
});
|
|
7700
8783
|
}
|
|
7701
8784
|
async function getNetworkRequest2(driver, args = {}) {
|
|
8785
|
+
refuseWorkerSelector(driver, args.target);
|
|
7702
8786
|
if (!args.requestId && !args.url)
|
|
7703
8787
|
throw new BidiToolError("get_network_request requires requestId or url");
|
|
7704
8788
|
return withPage3(driver, args.target, async (page) => {
|
|
@@ -7790,8 +8874,8 @@ var FIREFOX_TOOLS = {
|
|
|
7790
8874
|
};
|
|
7791
8875
|
|
|
7792
8876
|
// src/mcp.ts
|
|
7793
|
-
var VERSION = "1.9.
|
|
7794
|
-
var BROWSER =
|
|
8877
|
+
var VERSION = "1.9.4";
|
|
8878
|
+
var { kind: BROWSER, endpoint: FIREFOX_ENDPOINT } = resolveBackend(process.argv.slice(2));
|
|
7795
8879
|
var AVAILABILITY = toolAvailability(BROWSER);
|
|
7796
8880
|
var AVAILABLE_NAMES = new Set(AVAILABILITY.available);
|
|
7797
8881
|
var dispatch = TOOLS;
|
|
@@ -7837,7 +8921,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
7837
8921
|
if (!neutralFn)
|
|
7838
8922
|
return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
|
|
7839
8923
|
try {
|
|
7840
|
-
const session2 = await getOrCreateFirefoxSession();
|
|
8924
|
+
const session2 = await getOrCreateFirefoxSession({ endpoint: FIREFOX_ENDPOINT });
|
|
7841
8925
|
const result = await neutralFn(session2.driver, args);
|
|
7842
8926
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
7843
8927
|
} catch (err) {
|
|
@@ -7849,7 +8933,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
7849
8933
|
auditCoverage();
|
|
7850
8934
|
markLongLivedProcess();
|
|
7851
8935
|
await server.connect(new StdioServerTransport);
|
|
7852
|
-
console.error(`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}, ${AVAILABILITY.available.length} tools, CDP_BASE=${BASE}`);
|
|
8936
|
+
console.error(`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}${FIREFOX_ENDPOINT ? ` (attach ${FIREFOX_ENDPOINT})` : ""}, ${AVAILABILITY.available.length} tools, CDP_BASE=${BASE}`);
|
|
7853
8937
|
var shuttingDown = false;
|
|
7854
8938
|
async function shutdown() {
|
|
7855
8939
|
if (shuttingDown)
|