cdp-toolkit 1.4.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +251 -0
- package/CONTRACT.md +10 -3
- package/README.md +78 -30
- package/dist/cli.js +583 -92
- package/dist/index.js +568 -77
- package/dist/mcp.js +712 -140
- package/package.json +3 -2
- package/src/bidi/driver.ts +4 -0
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +3 -0
- package/src/driver.ts +4 -1
- package/src/index.ts +15 -4
- package/src/leases-tools.ts +147 -26
- package/src/leases.ts +84 -4
- package/src/manifest.ts +127 -47
- package/src/mcp.ts +5 -2
- package/src/reap.ts +114 -0
- package/src/shared-tools.ts +41 -8
- package/src/tools/screencast.ts +741 -0
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,16 @@ function leaseTtlMs() {
|
|
|
13
13
|
const raw = Number(process.env.CDP_LEASE_TTL_MS);
|
|
14
14
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
|
|
15
15
|
}
|
|
16
|
+
var longLived = false;
|
|
17
|
+
function markLongLivedProcess(value = true) {
|
|
18
|
+
longLived = value;
|
|
19
|
+
}
|
|
20
|
+
function requireLease() {
|
|
21
|
+
if (!longLived)
|
|
22
|
+
return false;
|
|
23
|
+
const raw = (process.env.CDP_REQUIRE_LEASE ?? "").trim().toLowerCase();
|
|
24
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
25
|
+
}
|
|
16
26
|
function defaultLabel() {
|
|
17
27
|
return `pid-${process.pid}`;
|
|
18
28
|
}
|
|
@@ -99,7 +109,8 @@ async function claimLease(backend, targetId, opts) {
|
|
|
99
109
|
label: opts.label,
|
|
100
110
|
createdAt: now,
|
|
101
111
|
lastUsedAt: now,
|
|
102
|
-
ttlMs: opts.ttlMs ?? leaseTtlMs()
|
|
112
|
+
ttlMs: opts.ttlMs ?? leaseTtlMs(),
|
|
113
|
+
auto: opts.auto === true
|
|
103
114
|
};
|
|
104
115
|
try {
|
|
105
116
|
await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
|
|
@@ -175,8 +186,16 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
|
|
|
175
186
|
const held = rec && !staleReason(rec, { now, liveIds: ctx.liveIds }) ? rec : undefined;
|
|
176
187
|
const where = describeTarget(targetId, ctx);
|
|
177
188
|
if (token === undefined) {
|
|
178
|
-
if (!held)
|
|
189
|
+
if (!held) {
|
|
190
|
+
if (requireLease()) {
|
|
191
|
+
await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
|
|
192
|
+
}
|
|
179
193
|
return;
|
|
194
|
+
}
|
|
195
|
+
if (held.auto === true && held.pid === process.pid) {
|
|
196
|
+
await touchLease(held, now);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
180
199
|
throw new LeaseConflictError(`${where} is leased by '${held.label}' (pid ${held.pid}). Pass that lease's token as the 'lease' argument, or call release_page to free it. list_leases shows every active lease.`, targetId, held.label);
|
|
181
200
|
}
|
|
182
201
|
const parts = tokenParts(token);
|
|
@@ -184,8 +203,12 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
|
|
|
184
203
|
throw new LeaseConflictError(`malformed lease token for ${where}. Pass back the token claim_page returned, never a constructed one.`, targetId);
|
|
185
204
|
}
|
|
186
205
|
if (parts.backend !== backend || parts.targetId !== targetId) {
|
|
187
|
-
if (!held)
|
|
206
|
+
if (!held) {
|
|
207
|
+
if (requireLease()) {
|
|
208
|
+
await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
|
|
209
|
+
}
|
|
188
210
|
return;
|
|
211
|
+
}
|
|
189
212
|
throw new LeaseConflictError(`the lease token you passed is for ${parts.backend} target '${parts.targetId}', not ${where}, which is leased by '${held.label}'. Resolve the tab you actually hold, or claim this one.`, targetId, held.label);
|
|
190
213
|
}
|
|
191
214
|
if (!held) {
|
|
@@ -682,6 +705,8 @@ var REQUIRED_CAPABILITIES = {
|
|
|
682
705
|
performance_trace: ["trace.performance"],
|
|
683
706
|
take_heapsnapshot: ["heap.snapshot"],
|
|
684
707
|
lighthouse_audit: ["audit.lighthouse"],
|
|
708
|
+
start_screen_recording: ["capture.screencast"],
|
|
709
|
+
stop_screen_recording: ["capture.screencast"],
|
|
685
710
|
mock_request: ["network.intercept"],
|
|
686
711
|
list_mocks: ["network.intercept"],
|
|
687
712
|
clear_mocks: ["network.intercept"]
|
|
@@ -937,7 +962,8 @@ var CDP_CAPABILITIES = new Set([
|
|
|
937
962
|
"snapshot.accessibilityTree",
|
|
938
963
|
"input.insertTextAtomic",
|
|
939
964
|
"locate.text",
|
|
940
|
-
"locate.xpath"
|
|
965
|
+
"locate.xpath",
|
|
966
|
+
"capture.screencast"
|
|
941
967
|
]);
|
|
942
968
|
|
|
943
969
|
class CdpPageDriver {
|
|
@@ -1426,6 +1452,20 @@ async function recordOrigin(backend, targetId, opts = {}) {
|
|
|
1426
1452
|
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
1427
1453
|
} catch {}
|
|
1428
1454
|
}
|
|
1455
|
+
async function readOrigin(backend, targetId) {
|
|
1456
|
+
let raw;
|
|
1457
|
+
try {
|
|
1458
|
+
raw = await readFile2(originFile(backend, targetId), "utf8");
|
|
1459
|
+
} catch {
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
try {
|
|
1463
|
+
const rec = JSON.parse(raw);
|
|
1464
|
+
if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
|
|
1465
|
+
return rec;
|
|
1466
|
+
} catch {}
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1429
1469
|
async function listOrigins(opts = {}) {
|
|
1430
1470
|
let names = [];
|
|
1431
1471
|
try {
|
|
@@ -1483,59 +1523,43 @@ async function newTrackedPage(driver, backend, opts = {}) {
|
|
|
1483
1523
|
return page;
|
|
1484
1524
|
}
|
|
1485
1525
|
|
|
1486
|
-
// src/
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
const
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
const existing = pages.find((p) => p.id === targetId);
|
|
1504
|
-
if (!existing)
|
|
1505
|
-
throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
|
|
1506
|
-
url = existing.url;
|
|
1526
|
+
// src/reap.ts
|
|
1527
|
+
function staleAgentTabs(input) {
|
|
1528
|
+
const live = new Set(input.livePageIds);
|
|
1529
|
+
const out = [];
|
|
1530
|
+
for (const row of input.leases) {
|
|
1531
|
+
if (row.backend !== input.backend)
|
|
1532
|
+
continue;
|
|
1533
|
+
if (row.unreadable !== undefined)
|
|
1534
|
+
continue;
|
|
1535
|
+
if (row.stale !== "dead-pid" && row.stale !== "expired")
|
|
1536
|
+
continue;
|
|
1537
|
+
if (!live.has(row.targetId))
|
|
1538
|
+
continue;
|
|
1539
|
+
const origin = input.origins.get(row.targetId);
|
|
1540
|
+
if (origin === undefined || origin.unreadable !== undefined)
|
|
1541
|
+
continue;
|
|
1542
|
+
out.push({ targetId: row.targetId, label: row.label, reason: row.stale });
|
|
1507
1543
|
}
|
|
1508
|
-
|
|
1509
|
-
const { record, token } = await claimLease(backend, targetId, {
|
|
1510
|
-
label,
|
|
1511
|
-
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
1512
|
-
liveIds
|
|
1513
|
-
});
|
|
1514
|
-
return {
|
|
1515
|
-
lease: token,
|
|
1516
|
-
targetId,
|
|
1517
|
-
url,
|
|
1518
|
-
label: record.label,
|
|
1519
|
-
ttlMs: record.ttlMs,
|
|
1520
|
-
expiresAt: record.lastUsedAt + record.ttlMs
|
|
1521
|
-
};
|
|
1544
|
+
return out;
|
|
1522
1545
|
}
|
|
1523
|
-
async function
|
|
1524
|
-
|
|
1525
|
-
|
|
1546
|
+
async function reapStaleAgentTabs(driver, backend) {
|
|
1547
|
+
const livePageIds = (await driver.listPages()).map((p) => p.id);
|
|
1548
|
+
const [origins, leases] = await Promise.all([
|
|
1549
|
+
originIndex(backend, undefined),
|
|
1550
|
+
listLeases({ liveIds: livePageIds, liveBackend: backend }).catch(() => [])
|
|
1551
|
+
]);
|
|
1552
|
+
const candidates = staleAgentTabs({ backend, livePageIds, origins, leases });
|
|
1553
|
+
const closed = [];
|
|
1554
|
+
for (const c of candidates) {
|
|
1555
|
+
const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
|
|
1556
|
+
if (res.success !== true)
|
|
1557
|
+
continue;
|
|
1558
|
+
await releaseLeaseFor(backend, c.targetId);
|
|
1559
|
+
closed.push(c);
|
|
1526
1560
|
}
|
|
1527
|
-
return
|
|
1561
|
+
return closed;
|
|
1528
1562
|
}
|
|
1529
|
-
async function listLeasesTool(driver, _args = {}) {
|
|
1530
|
-
const liveIds = (await driver.listPages()).map((p) => p.id);
|
|
1531
|
-
const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
|
|
1532
|
-
return { leases, count: leases.length };
|
|
1533
|
-
}
|
|
1534
|
-
var LEASE_TOOLS = {
|
|
1535
|
-
claim_page: claimPage,
|
|
1536
|
-
release_page: releasePage,
|
|
1537
|
-
list_leases: listLeasesTool
|
|
1538
|
-
};
|
|
1539
1563
|
|
|
1540
1564
|
// src/shared-tools.ts
|
|
1541
1565
|
import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
|
|
@@ -1548,7 +1572,7 @@ class SharedToolError extends Error {
|
|
|
1548
1572
|
function target3(p) {
|
|
1549
1573
|
return { id: p.id, url: p.url, title: p.title };
|
|
1550
1574
|
}
|
|
1551
|
-
function
|
|
1575
|
+
function backendOf(driver) {
|
|
1552
1576
|
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
1553
1577
|
}
|
|
1554
1578
|
async function withPage2(driver, target, fn) {
|
|
@@ -1562,7 +1586,7 @@ async function withPage2(driver, target, fn) {
|
|
|
1562
1586
|
async function resolvePage(driver, selector) {
|
|
1563
1587
|
const pages = await driver.listPages();
|
|
1564
1588
|
const hit = await pickPage(driver, pages, selector);
|
|
1565
|
-
await assertLeaseOk(
|
|
1589
|
+
await assertLeaseOk(backendOf(driver), hit.id, { url: hit.url, title: hit.title });
|
|
1566
1590
|
return hit;
|
|
1567
1591
|
}
|
|
1568
1592
|
async function pickPage(driver, pages, selector) {
|
|
@@ -1617,8 +1641,10 @@ async function reapSet(driver, pages, all) {
|
|
|
1617
1641
|
}
|
|
1618
1642
|
}
|
|
1619
1643
|
async function listPages(driver, args = {}) {
|
|
1620
|
-
const
|
|
1621
|
-
const
|
|
1644
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backendOf(driver)) : [];
|
|
1645
|
+
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
1646
|
+
const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
|
|
1647
|
+
const ledger = await originIndex(backendOf(driver), await reapSet(driver, pages, args.all));
|
|
1622
1648
|
const annotated = pages.map((p) => {
|
|
1623
1649
|
const rec = ledger.get(p.id);
|
|
1624
1650
|
if (!rec)
|
|
@@ -1627,16 +1653,18 @@ async function listPages(driver, args = {}) {
|
|
|
1627
1653
|
return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
|
|
1628
1654
|
return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
|
|
1629
1655
|
});
|
|
1630
|
-
return { pages: annotated, count: annotated.length };
|
|
1656
|
+
return { pages: annotated, count: annotated.length, ...reaped.length ? { reaped } : {} };
|
|
1631
1657
|
}
|
|
1632
1658
|
async function newPage(driver, args = {}) {
|
|
1633
1659
|
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
1634
|
-
const p = await newTrackedPage(driver,
|
|
1635
|
-
|
|
1660
|
+
const p = await newTrackedPage(driver, backendOf(driver), { url: args.url, label });
|
|
1661
|
+
const explicit = args.claim === true;
|
|
1662
|
+
if (!explicit && !requireLease())
|
|
1636
1663
|
return { targetId: p.id, url: p.url };
|
|
1637
|
-
const { record, token } = await claimLease(
|
|
1664
|
+
const { record, token } = await claimLease(backendOf(driver), p.id, {
|
|
1638
1665
|
label,
|
|
1639
|
-
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs()
|
|
1666
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
1667
|
+
auto: !explicit
|
|
1640
1668
|
});
|
|
1641
1669
|
return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
|
|
1642
1670
|
}
|
|
@@ -1647,7 +1675,7 @@ async function closePage(driver, args) {
|
|
|
1647
1675
|
const res = await driver.closePage(p.id);
|
|
1648
1676
|
if (!res.success)
|
|
1649
1677
|
return { closed: p.id, success: false, leaseReleased: false };
|
|
1650
|
-
const released = await releaseLeaseFor(
|
|
1678
|
+
const released = await releaseLeaseFor(backendOf(driver), p.id);
|
|
1651
1679
|
return { closed: p.id, success: true, ...released.released ? { leaseReleased: true } : {} };
|
|
1652
1680
|
}
|
|
1653
1681
|
async function writeSelectedFile(id) {
|
|
@@ -1972,6 +2000,103 @@ var SHARED_TOOLS = {
|
|
|
1972
2000
|
handle_dialog: handleDialog
|
|
1973
2001
|
};
|
|
1974
2002
|
|
|
2003
|
+
// src/leases-tools.ts
|
|
2004
|
+
class LeaseToolError extends Error {
|
|
2005
|
+
}
|
|
2006
|
+
function backendOf2(driver) {
|
|
2007
|
+
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
2008
|
+
}
|
|
2009
|
+
async function claimPage(driver, args = {}) {
|
|
2010
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
2011
|
+
const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
|
|
2012
|
+
if (hasTarget && hasTargetId) {
|
|
2013
|
+
throw new LeaseToolError("claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | <targetId>) or 'targetId' (an exact target id, kept for back-compat). Pass 'target' alone to claim an open tab, or neither to open a fresh one.");
|
|
2014
|
+
}
|
|
2015
|
+
const backend = backendOf2(driver);
|
|
2016
|
+
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
2017
|
+
const pages = await driver.listPages();
|
|
2018
|
+
let targetId;
|
|
2019
|
+
let url;
|
|
2020
|
+
let opened;
|
|
2021
|
+
if (hasTarget) {
|
|
2022
|
+
const hit = await pickPage(driver, pages, args.target).catch((err) => {
|
|
2023
|
+
throw new LeaseToolError(`claim_page: no open tab matches target '${args.target}' (${err.message}). No tab was opened: claim_page never creates one when 'target' is given.`);
|
|
2024
|
+
});
|
|
2025
|
+
targetId = hit.id;
|
|
2026
|
+
url = hit.url;
|
|
2027
|
+
opened = false;
|
|
2028
|
+
} else if (hasTargetId) {
|
|
2029
|
+
const existing = pages.find((p) => p.id === args.targetId);
|
|
2030
|
+
if (!existing)
|
|
2031
|
+
throw new LeaseToolError(`claim_page: no page target with id '${args.targetId}'`);
|
|
2032
|
+
targetId = existing.id;
|
|
2033
|
+
url = existing.url;
|
|
2034
|
+
opened = false;
|
|
2035
|
+
} else {
|
|
2036
|
+
const page = await newTrackedPage(driver, backend, { url: args.url, label });
|
|
2037
|
+
targetId = page.id;
|
|
2038
|
+
url = page.url;
|
|
2039
|
+
opened = true;
|
|
2040
|
+
}
|
|
2041
|
+
const liveIds = [...pages.map((p) => p.id), targetId];
|
|
2042
|
+
const { record, token } = await claimLease(backend, targetId, {
|
|
2043
|
+
label,
|
|
2044
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
2045
|
+
liveIds
|
|
2046
|
+
});
|
|
2047
|
+
return {
|
|
2048
|
+
lease: token,
|
|
2049
|
+
targetId,
|
|
2050
|
+
url,
|
|
2051
|
+
label: record.label,
|
|
2052
|
+
ttlMs: record.ttlMs,
|
|
2053
|
+
expiresAt: record.lastUsedAt + record.ttlMs,
|
|
2054
|
+
opened
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
2057
|
+
async function releasePage(driver, args = {}) {
|
|
2058
|
+
const hasLease = typeof args.lease === "string" && args.lease.length > 0;
|
|
2059
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
2060
|
+
if (hasLease === hasTarget) {
|
|
2061
|
+
throw new LeaseToolError("release_page takes exactly one of 'lease' (the token claim_page returned) or 'target' (a selector for a tab this process holds)");
|
|
2062
|
+
}
|
|
2063
|
+
const backend = backendOf2(driver);
|
|
2064
|
+
let targetId;
|
|
2065
|
+
let released;
|
|
2066
|
+
if (hasLease) {
|
|
2067
|
+
const parts = tokenParts(args.lease);
|
|
2068
|
+
if (!parts)
|
|
2069
|
+
return { released: false, closed: false };
|
|
2070
|
+
targetId = parts.targetId;
|
|
2071
|
+
released = (await releaseLease(args.lease)).released;
|
|
2072
|
+
} else {
|
|
2073
|
+
const page = await resolvePage(driver, args.target);
|
|
2074
|
+
targetId = page.id;
|
|
2075
|
+
released = (await releaseLeaseFor(backend, targetId)).released;
|
|
2076
|
+
}
|
|
2077
|
+
if (!released)
|
|
2078
|
+
return { released: false, closed: false, ...hasTarget ? { targetId } : {} };
|
|
2079
|
+
const shouldClose = args.close === false ? false : args.close === true ? true : await readOrigin(backend, targetId) !== undefined;
|
|
2080
|
+
if (!shouldClose)
|
|
2081
|
+
return { released: true, closed: false, targetId };
|
|
2082
|
+
const res = await driver.closePage(targetId).catch(() => ({ success: false }));
|
|
2083
|
+
return { released: true, closed: res.success === true, targetId };
|
|
2084
|
+
}
|
|
2085
|
+
async function listLeasesTool(driver, _args = {}) {
|
|
2086
|
+
const backend = backendOf2(driver);
|
|
2087
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
|
|
2088
|
+
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
2089
|
+
const liveIds = (await driver.listPages()).map((p) => p.id);
|
|
2090
|
+
const all = await listLeases({ liveIds, liveBackend: backend });
|
|
2091
|
+
const leases = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
|
|
2092
|
+
return { leases, count: leases.length, ...reaped.length ? { reaped } : {} };
|
|
2093
|
+
}
|
|
2094
|
+
var LEASE_TOOLS = {
|
|
2095
|
+
claim_page: claimPage,
|
|
2096
|
+
release_page: releasePage,
|
|
2097
|
+
list_leases: listLeasesTool
|
|
2098
|
+
};
|
|
2099
|
+
|
|
1975
2100
|
// src/tools/console.ts
|
|
1976
2101
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
1977
2102
|
|
|
@@ -2579,11 +2704,375 @@ async function performanceAnalyzeInsight(args = {}) {
|
|
|
2579
2704
|
return { ...analyzeEvents(events), tracePath: args.tracePath };
|
|
2580
2705
|
}
|
|
2581
2706
|
|
|
2582
|
-
// src/tools/
|
|
2583
|
-
import {
|
|
2707
|
+
// src/tools/screencast.ts
|
|
2708
|
+
import { spawn } from "node:child_process";
|
|
2709
|
+
import { mkdir as mkdir6, rm, stat as stat2, writeFile as writeFile6 } from "node:fs/promises";
|
|
2584
2710
|
import { join as join5 } from "node:path";
|
|
2585
2711
|
var ARTIFACT_DIR4 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
2586
|
-
|
|
2712
|
+
var FFMPEG_BIN = process.env.CDP_FFMPEG ?? "ffmpeg";
|
|
2713
|
+
function frameDurationsMs(ledger, stopWallclockMs) {
|
|
2714
|
+
return ledger.map((entry, i) => {
|
|
2715
|
+
const next = i + 1 < ledger.length ? ledger[i + 1].timestamp : stopWallclockMs;
|
|
2716
|
+
return Math.max(1, Math.round(next - entry.timestamp));
|
|
2717
|
+
});
|
|
2718
|
+
}
|
|
2719
|
+
var CONCAT_GRID_MS = 40;
|
|
2720
|
+
function coalesceToConcatGrid(ledger, stopWallclockMs, gridMs = CONCAT_GRID_MS) {
|
|
2721
|
+
if (!ledger.length)
|
|
2722
|
+
return [];
|
|
2723
|
+
const raw = frameDurationsMs(ledger, stopWallclockMs);
|
|
2724
|
+
let elapsed = 0;
|
|
2725
|
+
const kept = [];
|
|
2726
|
+
let lastSlot = -1;
|
|
2727
|
+
for (let i = 0;i < ledger.length; i++) {
|
|
2728
|
+
const slot = Math.round(elapsed / gridMs);
|
|
2729
|
+
if (slot > lastSlot) {
|
|
2730
|
+
kept.push({ file: ledger[i].file, slot });
|
|
2731
|
+
lastSlot = slot;
|
|
2732
|
+
}
|
|
2733
|
+
elapsed += raw[i];
|
|
2734
|
+
}
|
|
2735
|
+
const endSlot = Math.max(lastSlot + 1, Math.round(elapsed / gridMs));
|
|
2736
|
+
return kept.map((frame, i) => ({
|
|
2737
|
+
file: frame.file,
|
|
2738
|
+
durationMs: ((i + 1 < kept.length ? kept[i + 1].slot : endSlot) - frame.slot) * gridMs
|
|
2739
|
+
}));
|
|
2740
|
+
}
|
|
2741
|
+
function buildConcatManifest(frames) {
|
|
2742
|
+
if (!frames.length)
|
|
2743
|
+
throw new CdpError("cannot build a concat manifest from an empty frame ledger");
|
|
2744
|
+
const lines = ["ffconcat version 1.0"];
|
|
2745
|
+
for (const frame of frames) {
|
|
2746
|
+
lines.push(`file ${frame.file}`);
|
|
2747
|
+
lines.push(`duration ${(frame.durationMs / 1000).toFixed(6)}`);
|
|
2748
|
+
}
|
|
2749
|
+
lines.push(`file ${frames[frames.length - 1].file}`);
|
|
2750
|
+
return `${lines.join(`
|
|
2751
|
+
`)}
|
|
2752
|
+
`;
|
|
2753
|
+
}
|
|
2754
|
+
function evenDimensions(width, height) {
|
|
2755
|
+
return { width: Math.max(0, Math.trunc(width / 2) * 2), height: Math.max(0, Math.trunc(height / 2) * 2) };
|
|
2756
|
+
}
|
|
2757
|
+
function pngSize(buf) {
|
|
2758
|
+
if (buf.length < 24)
|
|
2759
|
+
return;
|
|
2760
|
+
if (buf.readUInt32BE(0) !== 2303741511 || buf.readUInt32BE(4) !== 218765834)
|
|
2761
|
+
return;
|
|
2762
|
+
if (buf.toString("latin1", 12, 16) !== "IHDR")
|
|
2763
|
+
return;
|
|
2764
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
2765
|
+
}
|
|
2766
|
+
var JPEG_SOF = new Set([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207]);
|
|
2767
|
+
function jpegSize(buf) {
|
|
2768
|
+
if (buf.length < 4 || buf[0] !== 255 || buf[1] !== 216)
|
|
2769
|
+
return;
|
|
2770
|
+
let i = 2;
|
|
2771
|
+
while (i + 3 < buf.length) {
|
|
2772
|
+
if (buf[i] !== 255) {
|
|
2773
|
+
i += 1;
|
|
2774
|
+
continue;
|
|
2775
|
+
}
|
|
2776
|
+
const marker = buf[i + 1];
|
|
2777
|
+
if (marker === 255) {
|
|
2778
|
+
i += 1;
|
|
2779
|
+
continue;
|
|
2780
|
+
}
|
|
2781
|
+
if (marker === 216 || marker === 1 || marker >= 208 && marker <= 215) {
|
|
2782
|
+
i += 2;
|
|
2783
|
+
continue;
|
|
2784
|
+
}
|
|
2785
|
+
if (i + 3 >= buf.length)
|
|
2786
|
+
return;
|
|
2787
|
+
const segLen = buf.readUInt16BE(i + 2);
|
|
2788
|
+
if (JPEG_SOF.has(marker)) {
|
|
2789
|
+
if (i + 9 > buf.length)
|
|
2790
|
+
return;
|
|
2791
|
+
return { width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) };
|
|
2792
|
+
}
|
|
2793
|
+
if (segLen < 2)
|
|
2794
|
+
return;
|
|
2795
|
+
i += 2 + segLen;
|
|
2796
|
+
}
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
function imageSize(buf) {
|
|
2800
|
+
return pngSize(buf) ?? jpegSize(buf);
|
|
2801
|
+
}
|
|
2802
|
+
var ENCODER_LADDER = [
|
|
2803
|
+
{ encoder: "hevc_videotoolbox", codec: "hevc", tag: "hvc1" },
|
|
2804
|
+
{ encoder: "h264_videotoolbox", codec: "h264" },
|
|
2805
|
+
{ encoder: "libx265", codec: "hevc", tag: "hvc1" },
|
|
2806
|
+
{ encoder: "libx264", codec: "h264" }
|
|
2807
|
+
];
|
|
2808
|
+
function ffmpegEncoderNames(encodersOutput) {
|
|
2809
|
+
const names = new Set;
|
|
2810
|
+
for (const line of encodersOutput.split(`
|
|
2811
|
+
`)) {
|
|
2812
|
+
const m = /^\s*[VAS][A-Z.]{5}\s+(\S+)/.exec(line);
|
|
2813
|
+
if (m)
|
|
2814
|
+
names.add(m[1]);
|
|
2815
|
+
}
|
|
2816
|
+
return names;
|
|
2817
|
+
}
|
|
2818
|
+
function selectEncoder(encodersOutput) {
|
|
2819
|
+
const available = ffmpegEncoderNames(encodersOutput);
|
|
2820
|
+
for (const candidate of ENCODER_LADDER) {
|
|
2821
|
+
if (available.has(candidate.encoder))
|
|
2822
|
+
return candidate;
|
|
2823
|
+
}
|
|
2824
|
+
throw new CdpError(`ffmpeg reports none of the supported video encoders (${ENCODER_LADDER.map((c) => c.encoder).join(", ")}). ` + "Install a full ffmpeg build: 'brew install ffmpeg' (macOS) or 'apt install ffmpeg' (Debian/Ubuntu).");
|
|
2825
|
+
}
|
|
2826
|
+
function buildFfmpegArgs(manifestPath, outPath, choice) {
|
|
2827
|
+
return [
|
|
2828
|
+
"-hide_banner",
|
|
2829
|
+
"-loglevel",
|
|
2830
|
+
"error",
|
|
2831
|
+
"-y",
|
|
2832
|
+
"-f",
|
|
2833
|
+
"concat",
|
|
2834
|
+
"-safe",
|
|
2835
|
+
"0",
|
|
2836
|
+
"-i",
|
|
2837
|
+
manifestPath,
|
|
2838
|
+
"-vsync",
|
|
2839
|
+
"vfr",
|
|
2840
|
+
"-vf",
|
|
2841
|
+
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
|
|
2842
|
+
"-c:v",
|
|
2843
|
+
choice.encoder,
|
|
2844
|
+
...choice.tag ? ["-tag:v", choice.tag] : [],
|
|
2845
|
+
"-pix_fmt",
|
|
2846
|
+
"yuv420p",
|
|
2847
|
+
"-movflags",
|
|
2848
|
+
"+faststart",
|
|
2849
|
+
"-an",
|
|
2850
|
+
outPath
|
|
2851
|
+
];
|
|
2852
|
+
}
|
|
2853
|
+
function run(bin, args) {
|
|
2854
|
+
return new Promise((resolve, reject) => {
|
|
2855
|
+
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
2856
|
+
let stdout = "";
|
|
2857
|
+
let stderr = "";
|
|
2858
|
+
child.stdout?.on("data", (chunk) => {
|
|
2859
|
+
stdout += chunk.toString("utf8");
|
|
2860
|
+
});
|
|
2861
|
+
child.stderr?.on("data", (chunk) => {
|
|
2862
|
+
stderr += chunk.toString("utf8");
|
|
2863
|
+
});
|
|
2864
|
+
child.on("error", reject);
|
|
2865
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
var encoderProbe;
|
|
2869
|
+
var FFMPEG_MISSING = `ffmpeg is required to encode a screen recording but '${FFMPEG_BIN}' could not be executed. ` + "Install it with 'brew install ffmpeg' (macOS) or 'apt install ffmpeg' (Debian/Ubuntu), " + "or point CDP_FFMPEG at the binary.";
|
|
2870
|
+
async function probeEncoder() {
|
|
2871
|
+
if (!encoderProbe) {
|
|
2872
|
+
encoderProbe = (async () => {
|
|
2873
|
+
let res;
|
|
2874
|
+
try {
|
|
2875
|
+
res = await run(FFMPEG_BIN, ["-hide_banner", "-encoders"]);
|
|
2876
|
+
} catch (err) {
|
|
2877
|
+
throw new CdpError(`${FFMPEG_MISSING} (${err.message})`);
|
|
2878
|
+
}
|
|
2879
|
+
if (res.code !== 0)
|
|
2880
|
+
throw new CdpError(`${FFMPEG_MISSING} ('-encoders' exited ${res.code}: ${res.stderr.trim().slice(0, 300)})`);
|
|
2881
|
+
return selectEncoder(`${res.stdout}
|
|
2882
|
+
${res.stderr}`);
|
|
2883
|
+
})();
|
|
2884
|
+
encoderProbe.catch(() => {
|
|
2885
|
+
encoderProbe = undefined;
|
|
2886
|
+
});
|
|
2887
|
+
}
|
|
2888
|
+
return encoderProbe;
|
|
2889
|
+
}
|
|
2890
|
+
var liveRecordings = new Map;
|
|
2891
|
+
function shortId2(id) {
|
|
2892
|
+
return id.slice(0, 8);
|
|
2893
|
+
}
|
|
2894
|
+
function assertNotRecording(targetId, activeIds) {
|
|
2895
|
+
if (activeIds.includes(targetId)) {
|
|
2896
|
+
throw new CdpError(`a screen recording is already in progress for target ${shortId2(targetId)}; call stop_screen_recording first`);
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
function chooseRecordingId(activeIds, selector) {
|
|
2900
|
+
if (!activeIds.length) {
|
|
2901
|
+
throw new CdpError("no screen recording is in progress in this process. start_screen_recording parks a live CDP connection in memory, so a recording cannot be stopped from a different process than the one that started it (the CLI is one process per call: use the MCP server for the pair).");
|
|
2902
|
+
}
|
|
2903
|
+
if (selector === undefined || selector === "active") {
|
|
2904
|
+
if (activeIds.length === 1)
|
|
2905
|
+
return activeIds[0];
|
|
2906
|
+
throw new CdpError(`${activeIds.length} screen recordings are in progress (targets: ${activeIds.map(shortId2).join(", ")}); pass 'target' to say which one to stop`);
|
|
2907
|
+
}
|
|
2908
|
+
return activeIds.find((id) => id === selector);
|
|
2909
|
+
}
|
|
2910
|
+
function stamp3() {
|
|
2911
|
+
return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
|
|
2912
|
+
}
|
|
2913
|
+
function target32(t) {
|
|
2914
|
+
return { id: t.id, url: t.url, title: t.title };
|
|
2915
|
+
}
|
|
2916
|
+
async function startScreenRecording(args = {}) {
|
|
2917
|
+
const format = args.format ?? "jpeg";
|
|
2918
|
+
if (format !== "jpeg" && format !== "png")
|
|
2919
|
+
throw new CdpError("start_screen_recording: format must be 'jpeg' or 'png'");
|
|
2920
|
+
if (args.quality !== undefined && (!Number.isFinite(args.quality) || args.quality < 0 || args.quality > 100)) {
|
|
2921
|
+
throw new CdpError("start_screen_recording: quality must be a number between 0 and 100 (jpeg only)");
|
|
2922
|
+
}
|
|
2923
|
+
const encoder = await probeEncoder();
|
|
2924
|
+
const { conn, target } = await openPage(args.target);
|
|
2925
|
+
try {
|
|
2926
|
+
assertNotRecording(target.id, [...liveRecordings.keys()]);
|
|
2927
|
+
} catch (err) {
|
|
2928
|
+
conn.close();
|
|
2929
|
+
throw err;
|
|
2930
|
+
}
|
|
2931
|
+
const spoolDir = join5(ARTIFACT_DIR4, `screencast-${shortId2(target.id)}-${stamp3()}`);
|
|
2932
|
+
try {
|
|
2933
|
+
await mkdir6(spoolDir, { recursive: true });
|
|
2934
|
+
} catch (err) {
|
|
2935
|
+
conn.close();
|
|
2936
|
+
throw new CdpError(`cannot create the screencast spool directory '${spoolDir}': ${err.message}`);
|
|
2937
|
+
}
|
|
2938
|
+
const ext = format === "png" ? "png" : "jpg";
|
|
2939
|
+
const rec = {
|
|
2940
|
+
conn,
|
|
2941
|
+
target,
|
|
2942
|
+
spoolDir,
|
|
2943
|
+
ledger: [],
|
|
2944
|
+
startedAt: Date.now(),
|
|
2945
|
+
format,
|
|
2946
|
+
encoder,
|
|
2947
|
+
writes: Promise.resolve(),
|
|
2948
|
+
dropped: 0,
|
|
2949
|
+
unwritten: new Set,
|
|
2950
|
+
off: () => {}
|
|
2951
|
+
};
|
|
2952
|
+
let seq = 0;
|
|
2953
|
+
rec.off = conn.on("Page.screencastFrame", (params) => {
|
|
2954
|
+
const sessionId = params.sessionId;
|
|
2955
|
+
if (typeof sessionId === "number") {
|
|
2956
|
+
conn.send("Page.screencastFrameAck", { sessionId }).catch(() => {});
|
|
2957
|
+
}
|
|
2958
|
+
const data = params.data;
|
|
2959
|
+
if (typeof data !== "string" || !data.length) {
|
|
2960
|
+
rec.dropped += 1;
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
const meta = params.metadata;
|
|
2964
|
+
const timestamp = typeof meta?.timestamp === "number" ? Math.round(meta.timestamp * 1000) : Date.now();
|
|
2965
|
+
const buf = Buffer.from(data, "base64");
|
|
2966
|
+
const size = imageSize(buf);
|
|
2967
|
+
if (!size || size.width <= 0 || size.height <= 0) {
|
|
2968
|
+
rec.dropped += 1;
|
|
2969
|
+
return;
|
|
2970
|
+
}
|
|
2971
|
+
seq += 1;
|
|
2972
|
+
const file = `frame-${String(seq).padStart(6, "0")}.${ext}`;
|
|
2973
|
+
rec.ledger.push({ file, timestamp, width: size.width, height: size.height });
|
|
2974
|
+
rec.writes = rec.writes.then(() => writeFile6(join5(spoolDir, file), buf)).catch(() => {
|
|
2975
|
+
rec.dropped += 1;
|
|
2976
|
+
rec.unwritten.add(file);
|
|
2977
|
+
});
|
|
2978
|
+
});
|
|
2979
|
+
try {
|
|
2980
|
+
await conn.send("Page.enable");
|
|
2981
|
+
if (args.bringToFront)
|
|
2982
|
+
await conn.send("Page.bringToFront").catch(() => {});
|
|
2983
|
+
const params = { format };
|
|
2984
|
+
if (format === "jpeg" && args.quality !== undefined)
|
|
2985
|
+
params.quality = args.quality;
|
|
2986
|
+
if (args.maxWidth !== undefined)
|
|
2987
|
+
params.maxWidth = args.maxWidth;
|
|
2988
|
+
if (args.maxHeight !== undefined)
|
|
2989
|
+
params.maxHeight = args.maxHeight;
|
|
2990
|
+
if (args.everyNthFrame !== undefined)
|
|
2991
|
+
params.everyNthFrame = args.everyNthFrame;
|
|
2992
|
+
await conn.send("Page.startScreencast", params);
|
|
2993
|
+
} catch (err) {
|
|
2994
|
+
rec.off();
|
|
2995
|
+
conn.close();
|
|
2996
|
+
await rm(spoolDir, { recursive: true, force: true }).catch(() => {});
|
|
2997
|
+
throw err;
|
|
2998
|
+
}
|
|
2999
|
+
liveRecordings.set(target.id, rec);
|
|
3000
|
+
return {
|
|
3001
|
+
target: target32(target),
|
|
3002
|
+
format,
|
|
3003
|
+
spoolDir,
|
|
3004
|
+
encoder: encoder.encoder,
|
|
3005
|
+
codec: encoder.codec,
|
|
3006
|
+
startedAt: rec.startedAt,
|
|
3007
|
+
note: "Recording. Call stop_screen_recording in THIS process to encode the MP4; frames only arrive on repaint, so a still page yields one long-held frame."
|
|
3008
|
+
};
|
|
3009
|
+
}
|
|
3010
|
+
async function pickRecording(selector) {
|
|
3011
|
+
const chosen = chooseRecordingId([...liveRecordings.keys()], selector);
|
|
3012
|
+
if (chosen)
|
|
3013
|
+
return liveRecordings.get(chosen);
|
|
3014
|
+
const { conn, target } = await openPage(selector);
|
|
3015
|
+
conn.close();
|
|
3016
|
+
const hit = liveRecordings.get(target.id);
|
|
3017
|
+
if (!hit) {
|
|
3018
|
+
throw new CdpError(`no screen recording is in progress for target ${shortId2(target.id)} (recording: ${[...liveRecordings.keys()].map(shortId2).join(", ") || "none"})`);
|
|
3019
|
+
}
|
|
3020
|
+
return hit;
|
|
3021
|
+
}
|
|
3022
|
+
async function stopScreenRecording(args = {}) {
|
|
3023
|
+
const rec = await pickRecording(args.target);
|
|
3024
|
+
rec.off();
|
|
3025
|
+
try {
|
|
3026
|
+
await rec.conn.send("Page.stopScreencast");
|
|
3027
|
+
} catch {}
|
|
3028
|
+
const stoppedAt = Date.now();
|
|
3029
|
+
await rec.writes.catch(() => {});
|
|
3030
|
+
rec.conn.close();
|
|
3031
|
+
liveRecordings.delete(rec.target.id);
|
|
3032
|
+
const ledger = rec.unwritten.size ? rec.ledger.filter((entry) => !rec.unwritten.has(entry.file)) : rec.ledger;
|
|
3033
|
+
if (!ledger.length) {
|
|
3034
|
+
await rm(rec.spoolDir, { recursive: true, force: true }).catch(() => {});
|
|
3035
|
+
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.`);
|
|
3036
|
+
}
|
|
3037
|
+
await mkdir6(ARTIFACT_DIR4, { recursive: true });
|
|
3038
|
+
const outPath = args.savePath ?? join5(ARTIFACT_DIR4, `screen-recording-${shortId2(rec.target.id)}-${stamp3()}.mp4`);
|
|
3039
|
+
const frames = coalesceToConcatGrid(ledger, stoppedAt);
|
|
3040
|
+
const manifestPath = join5(rec.spoolDir, "frames.ffconcat");
|
|
3041
|
+
await writeFile6(manifestPath, buildConcatManifest(frames), "utf8");
|
|
3042
|
+
const durationMs = frames.reduce((total, f) => total + f.durationMs, 0);
|
|
3043
|
+
const { width, height } = evenDimensions(ledger[0].width, ledger[0].height);
|
|
3044
|
+
let res;
|
|
3045
|
+
try {
|
|
3046
|
+
res = await run(FFMPEG_BIN, buildFfmpegArgs(manifestPath, outPath, rec.encoder));
|
|
3047
|
+
} catch (err) {
|
|
3048
|
+
throw new CdpError(`${FFMPEG_MISSING} Spooled frames kept at ${rec.spoolDir}. (${err.message})`);
|
|
3049
|
+
}
|
|
3050
|
+
if (res.code !== 0) {
|
|
3051
|
+
throw new CdpError(`ffmpeg (${rec.encoder.encoder}) exited ${res.code} encoding ${frames.length} frames. Spooled frames kept at ${rec.spoolDir}, re-run by hand with: ` + `${FFMPEG_BIN} ${buildFfmpegArgs(manifestPath, outPath, rec.encoder).join(" ")}
|
|
3052
|
+
ffmpeg said: ${res.stderr.trim().slice(0, 800)}`);
|
|
3053
|
+
}
|
|
3054
|
+
const { size } = await stat2(outPath);
|
|
3055
|
+
await rm(rec.spoolDir, { recursive: true, force: true }).catch(() => {});
|
|
3056
|
+
return {
|
|
3057
|
+
path: outPath,
|
|
3058
|
+
bytes: size,
|
|
3059
|
+
durationMs,
|
|
3060
|
+
frameCount: ledger.length,
|
|
3061
|
+
encodedFrames: frames.length,
|
|
3062
|
+
codec: rec.encoder.codec,
|
|
3063
|
+
encoder: rec.encoder.encoder,
|
|
3064
|
+
width,
|
|
3065
|
+
height,
|
|
3066
|
+
droppedFrames: rec.dropped,
|
|
3067
|
+
target: target32(rec.target)
|
|
3068
|
+
};
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
// src/tools/heap.ts
|
|
3072
|
+
import { mkdir as mkdir7, writeFile as writeFile7 } from "node:fs/promises";
|
|
3073
|
+
import { join as join6 } from "node:path";
|
|
3074
|
+
var ARTIFACT_DIR5 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
3075
|
+
function stamp4(targetId) {
|
|
2587
3076
|
const short = targetId.slice(0, 8);
|
|
2588
3077
|
const iso = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2589
3078
|
return `${short}-${iso}`;
|
|
@@ -2606,9 +3095,9 @@ async function takeHeapsnapshot(args = {}) {
|
|
|
2606
3095
|
if (snapshot.length === 0) {
|
|
2607
3096
|
throw new Error("HeapProfiler.takeHeapSnapshot produced no chunks");
|
|
2608
3097
|
}
|
|
2609
|
-
await
|
|
2610
|
-
const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath :
|
|
2611
|
-
await
|
|
3098
|
+
await mkdir7(ARTIFACT_DIR5, { recursive: true });
|
|
3099
|
+
const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath : join6(ARTIFACT_DIR5, args.savePath) : join6(ARTIFACT_DIR5, `take_heapsnapshot-${stamp4(target.id)}.heapsnapshot`);
|
|
3100
|
+
await writeFile7(path, snapshot, "utf8");
|
|
2612
3101
|
const bytes = Buffer.byteLength(snapshot, "utf8");
|
|
2613
3102
|
return {
|
|
2614
3103
|
path,
|
|
@@ -2620,15 +3109,15 @@ async function takeHeapsnapshot(args = {}) {
|
|
|
2620
3109
|
}
|
|
2621
3110
|
|
|
2622
3111
|
// src/tools/lighthouse.ts
|
|
2623
|
-
import { spawn } from "node:child_process";
|
|
2624
|
-
import { mkdir as
|
|
2625
|
-
var
|
|
3112
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
3113
|
+
import { mkdir as mkdir8, readFile as readFile6, stat as stat3 } from "node:fs/promises";
|
|
3114
|
+
var ARTIFACT_DIR6 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
2626
3115
|
function cdpPort() {
|
|
2627
3116
|
const base = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
2628
3117
|
const m = base.match(/:(\d+)/);
|
|
2629
3118
|
return m?.[1] ? Number(m[1]) : 9222;
|
|
2630
3119
|
}
|
|
2631
|
-
function
|
|
3120
|
+
function stamp5() {
|
|
2632
3121
|
return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
|
|
2633
3122
|
}
|
|
2634
3123
|
function shortHost(url) {
|
|
@@ -2645,7 +3134,7 @@ function runProcess(cmd, args, opts) {
|
|
|
2645
3134
|
let stdout = "";
|
|
2646
3135
|
let stderr = "";
|
|
2647
3136
|
let settled = false;
|
|
2648
|
-
const child =
|
|
3137
|
+
const child = spawn2(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
2649
3138
|
const timer = setTimeout(() => {
|
|
2650
3139
|
if (settled)
|
|
2651
3140
|
return;
|
|
@@ -2682,8 +3171,8 @@ async function lighthouseAudit(args) {
|
|
|
2682
3171
|
const url = args.url.trim();
|
|
2683
3172
|
const timeoutMs = args.timeoutMs ?? 120000;
|
|
2684
3173
|
const port = cdpPort();
|
|
2685
|
-
await
|
|
2686
|
-
const outPath = args.savePath ?? `${
|
|
3174
|
+
await mkdir8(ARTIFACT_DIR6, { recursive: true });
|
|
3175
|
+
const outPath = args.savePath ?? `${ARTIFACT_DIR6}/lighthouse-${shortHost(url)}-${stamp5()}.json`;
|
|
2687
3176
|
const formFactor = args.formFactor ?? "desktop";
|
|
2688
3177
|
const lhArgs = [
|
|
2689
3178
|
"--yes",
|
|
@@ -2738,7 +3227,7 @@ async function lighthouseAudit(args) {
|
|
|
2738
3227
|
if (Object.keys(categories).length === 0) {
|
|
2739
3228
|
throw new CdpError(`lighthouse_audit report contained no category scores (exit code ${res.code}). stderr: ${truncate(res.stderr)}`);
|
|
2740
3229
|
}
|
|
2741
|
-
const { size } = await
|
|
3230
|
+
const { size } = await stat3(outPath);
|
|
2742
3231
|
return {
|
|
2743
3232
|
path: outPath,
|
|
2744
3233
|
bytes: size,
|
|
@@ -2792,6 +3281,8 @@ var TOOLS = {
|
|
|
2792
3281
|
performance_stop_trace: performanceStopTrace,
|
|
2793
3282
|
performance_analyze_insight: performanceAnalyzeInsight,
|
|
2794
3283
|
performance_trace: performanceTrace,
|
|
3284
|
+
start_screen_recording: startScreenRecording,
|
|
3285
|
+
stop_screen_recording: stopScreenRecording,
|
|
2795
3286
|
take_heapsnapshot: takeHeapsnapshot,
|
|
2796
3287
|
lighthouse_audit: lighthouseAudit,
|
|
2797
3288
|
mock_request: mockRequest,
|
|
@@ -3341,8 +3832,8 @@ function hostFromUrl(url, toolName) {
|
|
|
3341
3832
|
function driverError2(code, message, data) {
|
|
3342
3833
|
return Object.assign(new Error(message), { code, ...data !== undefined ? { data } : {} });
|
|
3343
3834
|
}
|
|
3344
|
-
function encodeUid2(
|
|
3345
|
-
return `bidi:${
|
|
3835
|
+
function encodeUid2(stamp6) {
|
|
3836
|
+
return `bidi:${stamp6}`;
|
|
3346
3837
|
}
|
|
3347
3838
|
function decodeStamp(uid) {
|
|
3348
3839
|
if (!uid.startsWith("bidi:"))
|
|
@@ -3449,7 +3940,7 @@ function parseSnapshotLine(line) {
|
|
|
3449
3940
|
const m = LINE_RE.exec(line);
|
|
3450
3941
|
if (!m)
|
|
3451
3942
|
return;
|
|
3452
|
-
const indent = m[1] ?? "",
|
|
3943
|
+
const indent = m[1] ?? "", stamp6 = m[2], role = m[3], nameLit = m[4], extrasRaw = m[5];
|
|
3453
3944
|
const depth = indent.length / 2;
|
|
3454
3945
|
let name;
|
|
3455
3946
|
if (nameLit) {
|
|
@@ -3477,7 +3968,7 @@ function parseSnapshotLine(line) {
|
|
|
3477
3968
|
extras[key] = val;
|
|
3478
3969
|
}
|
|
3479
3970
|
}
|
|
3480
|
-
return { uid: encodeUid2(
|
|
3971
|
+
return { uid: encodeUid2(stamp6), role, depth, ...name !== undefined ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} };
|
|
3481
3972
|
}
|
|
3482
3973
|
var INTERACTIVE_ROLES3 = new Set([
|
|
3483
3974
|
"button",
|
|
@@ -3510,8 +4001,8 @@ function sharedRefOf(node, describe) {
|
|
|
3510
4001
|
}
|
|
3511
4002
|
async function resolveElementLocator2(conn, contextId, loc) {
|
|
3512
4003
|
if ("uid" in loc) {
|
|
3513
|
-
const
|
|
3514
|
-
const nodes2 = await locateNodes(conn, contextId, { type: "css", value: `[${UID_STAMP_ATTR}="${cssEscape(
|
|
4004
|
+
const stamp6 = decodeStamp(loc.uid);
|
|
4005
|
+
const nodes2 = await locateNodes(conn, contextId, { type: "css", value: `[${UID_STAMP_ATTR}="${cssEscape(stamp6)}"]` });
|
|
3515
4006
|
if (!nodes2.length)
|
|
3516
4007
|
throw driverError2("stale-uid", `uid does not resolve: ${loc.uid}`);
|
|
3517
4008
|
return sharedRefOf(nodes2[0], loc.uid);
|
|
@@ -3723,14 +4214,14 @@ class BidiPageDriver {
|
|
|
3723
4214
|
return loc.uid;
|
|
3724
4215
|
}
|
|
3725
4216
|
const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
|
|
3726
|
-
const
|
|
3727
|
-
if (typeof
|
|
4217
|
+
const stamp6 = await this.callFunctionValue(STAMP_ONE_SOURCE, [], ref);
|
|
4218
|
+
if (typeof stamp6 !== "string")
|
|
3728
4219
|
throw driverError2("page-error", "stamping the located element did not return a string");
|
|
3729
|
-
return encodeUid2(
|
|
4220
|
+
return encodeUid2(stamp6);
|
|
3730
4221
|
}
|
|
3731
4222
|
async resolve(uid) {
|
|
3732
|
-
const
|
|
3733
|
-
const nodes = await locateNodes(this.conn, this.contextId, { type: "css", value: `[${UID_STAMP_ATTR}="${cssEscape(
|
|
4223
|
+
const stamp6 = decodeStamp(uid);
|
|
4224
|
+
const nodes = await locateNodes(this.conn, this.contextId, { type: "css", value: `[${UID_STAMP_ATTR}="${cssEscape(stamp6)}"]` });
|
|
3734
4225
|
if (!nodes.length)
|
|
3735
4226
|
throw driverError2("stale-uid", `uid does not resolve: ${uid}`);
|
|
3736
4227
|
}
|
|
@@ -4159,11 +4650,11 @@ function createFirefoxDriver(port, opts) {
|
|
|
4159
4650
|
}
|
|
4160
4651
|
|
|
4161
4652
|
// src/bidi/launch.ts
|
|
4162
|
-
import { spawn as
|
|
4653
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
4163
4654
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
4164
4655
|
import { createConnection, createServer } from "node:net";
|
|
4165
4656
|
import { tmpdir } from "node:os";
|
|
4166
|
-
import { join as
|
|
4657
|
+
import { join as join7 } from "node:path";
|
|
4167
4658
|
function resolveBinary() {
|
|
4168
4659
|
const candidates = [
|
|
4169
4660
|
process.env.FIREFOX_BIN,
|
|
@@ -4237,11 +4728,11 @@ async function launchFirefox(opts = {}) {
|
|
|
4237
4728
|
const binary = resolveBinary();
|
|
4238
4729
|
const port = await pickPort(opts.port);
|
|
4239
4730
|
const isThrowaway = opts.profilePath === undefined;
|
|
4240
|
-
const profilePath = opts.profilePath ?? mkdtempSync(
|
|
4731
|
+
const profilePath = opts.profilePath ?? mkdtempSync(join7(tmpdir(), "cdp-toolkit-ff-profile-"));
|
|
4241
4732
|
const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
|
|
4242
4733
|
if (opts.headless)
|
|
4243
4734
|
args.push("--headless");
|
|
4244
|
-
const proc =
|
|
4735
|
+
const proc = spawn3(binary, args, { stdio: "ignore", detached: false });
|
|
4245
4736
|
let closed = false;
|
|
4246
4737
|
const close = async () => {
|
|
4247
4738
|
if (closed)
|