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/index.js
CHANGED
|
@@ -11,6 +11,16 @@ function leaseTtlMs() {
|
|
|
11
11
|
const raw = Number(process.env.CDP_LEASE_TTL_MS);
|
|
12
12
|
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
|
|
13
13
|
}
|
|
14
|
+
var longLived = false;
|
|
15
|
+
function markLongLivedProcess(value = true) {
|
|
16
|
+
longLived = value;
|
|
17
|
+
}
|
|
18
|
+
function requireLease() {
|
|
19
|
+
if (!longLived)
|
|
20
|
+
return false;
|
|
21
|
+
const raw = (process.env.CDP_REQUIRE_LEASE ?? "").trim().toLowerCase();
|
|
22
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
23
|
+
}
|
|
14
24
|
function defaultLabel() {
|
|
15
25
|
return `pid-${process.pid}`;
|
|
16
26
|
}
|
|
@@ -97,7 +107,8 @@ async function claimLease(backend, targetId, opts) {
|
|
|
97
107
|
label: opts.label,
|
|
98
108
|
createdAt: now,
|
|
99
109
|
lastUsedAt: now,
|
|
100
|
-
ttlMs: opts.ttlMs ?? leaseTtlMs()
|
|
110
|
+
ttlMs: opts.ttlMs ?? leaseTtlMs(),
|
|
111
|
+
auto: opts.auto === true
|
|
101
112
|
};
|
|
102
113
|
try {
|
|
103
114
|
await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
|
|
@@ -173,8 +184,16 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
|
|
|
173
184
|
const held = rec && !staleReason(rec, { now, liveIds: ctx.liveIds }) ? rec : undefined;
|
|
174
185
|
const where = describeTarget(targetId, ctx);
|
|
175
186
|
if (token === undefined) {
|
|
176
|
-
if (!held)
|
|
187
|
+
if (!held) {
|
|
188
|
+
if (requireLease()) {
|
|
189
|
+
await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
|
|
190
|
+
}
|
|
177
191
|
return;
|
|
192
|
+
}
|
|
193
|
+
if (held.auto === true && held.pid === process.pid) {
|
|
194
|
+
await touchLease(held, now);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
178
197
|
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);
|
|
179
198
|
}
|
|
180
199
|
const parts = tokenParts(token);
|
|
@@ -182,8 +201,12 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
|
|
|
182
201
|
throw new LeaseConflictError(`malformed lease token for ${where}. Pass back the token claim_page returned, never a constructed one.`, targetId);
|
|
183
202
|
}
|
|
184
203
|
if (parts.backend !== backend || parts.targetId !== targetId) {
|
|
185
|
-
if (!held)
|
|
204
|
+
if (!held) {
|
|
205
|
+
if (requireLease()) {
|
|
206
|
+
await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
|
|
207
|
+
}
|
|
186
208
|
return;
|
|
209
|
+
}
|
|
187
210
|
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);
|
|
188
211
|
}
|
|
189
212
|
if (!held) {
|
|
@@ -680,6 +703,8 @@ var REQUIRED_CAPABILITIES = {
|
|
|
680
703
|
performance_trace: ["trace.performance"],
|
|
681
704
|
take_heapsnapshot: ["heap.snapshot"],
|
|
682
705
|
lighthouse_audit: ["audit.lighthouse"],
|
|
706
|
+
start_screen_recording: ["capture.screencast"],
|
|
707
|
+
stop_screen_recording: ["capture.screencast"],
|
|
683
708
|
mock_request: ["network.intercept"],
|
|
684
709
|
list_mocks: ["network.intercept"],
|
|
685
710
|
clear_mocks: ["network.intercept"]
|
|
@@ -935,7 +960,8 @@ var CDP_CAPABILITIES = new Set([
|
|
|
935
960
|
"snapshot.accessibilityTree",
|
|
936
961
|
"input.insertTextAtomic",
|
|
937
962
|
"locate.text",
|
|
938
|
-
"locate.xpath"
|
|
963
|
+
"locate.xpath",
|
|
964
|
+
"capture.screencast"
|
|
939
965
|
]);
|
|
940
966
|
|
|
941
967
|
class CdpPageDriver {
|
|
@@ -1424,6 +1450,20 @@ async function recordOrigin(backend, targetId, opts = {}) {
|
|
|
1424
1450
|
await writeFile2(file, JSON.stringify(record), { flag: "wx" });
|
|
1425
1451
|
} catch {}
|
|
1426
1452
|
}
|
|
1453
|
+
async function readOrigin(backend, targetId) {
|
|
1454
|
+
let raw;
|
|
1455
|
+
try {
|
|
1456
|
+
raw = await readFile2(originFile(backend, targetId), "utf8");
|
|
1457
|
+
} catch {
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
try {
|
|
1461
|
+
const rec = JSON.parse(raw);
|
|
1462
|
+
if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
|
|
1463
|
+
return rec;
|
|
1464
|
+
} catch {}
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1427
1467
|
async function listOrigins(opts = {}) {
|
|
1428
1468
|
let names = [];
|
|
1429
1469
|
try {
|
|
@@ -1481,59 +1521,43 @@ async function newTrackedPage(driver, backend, opts = {}) {
|
|
|
1481
1521
|
return page;
|
|
1482
1522
|
}
|
|
1483
1523
|
|
|
1484
|
-
// src/
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
const
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
const existing = pages.find((p) => p.id === targetId);
|
|
1502
|
-
if (!existing)
|
|
1503
|
-
throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
|
|
1504
|
-
url = existing.url;
|
|
1524
|
+
// src/reap.ts
|
|
1525
|
+
function staleAgentTabs(input) {
|
|
1526
|
+
const live = new Set(input.livePageIds);
|
|
1527
|
+
const out = [];
|
|
1528
|
+
for (const row of input.leases) {
|
|
1529
|
+
if (row.backend !== input.backend)
|
|
1530
|
+
continue;
|
|
1531
|
+
if (row.unreadable !== undefined)
|
|
1532
|
+
continue;
|
|
1533
|
+
if (row.stale !== "dead-pid" && row.stale !== "expired")
|
|
1534
|
+
continue;
|
|
1535
|
+
if (!live.has(row.targetId))
|
|
1536
|
+
continue;
|
|
1537
|
+
const origin = input.origins.get(row.targetId);
|
|
1538
|
+
if (origin === undefined || origin.unreadable !== undefined)
|
|
1539
|
+
continue;
|
|
1540
|
+
out.push({ targetId: row.targetId, label: row.label, reason: row.stale });
|
|
1505
1541
|
}
|
|
1506
|
-
|
|
1507
|
-
const { record, token } = await claimLease(backend, targetId, {
|
|
1508
|
-
label,
|
|
1509
|
-
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
1510
|
-
liveIds
|
|
1511
|
-
});
|
|
1512
|
-
return {
|
|
1513
|
-
lease: token,
|
|
1514
|
-
targetId,
|
|
1515
|
-
url,
|
|
1516
|
-
label: record.label,
|
|
1517
|
-
ttlMs: record.ttlMs,
|
|
1518
|
-
expiresAt: record.lastUsedAt + record.ttlMs
|
|
1519
|
-
};
|
|
1542
|
+
return out;
|
|
1520
1543
|
}
|
|
1521
|
-
async function
|
|
1522
|
-
|
|
1523
|
-
|
|
1544
|
+
async function reapStaleAgentTabs(driver, backend) {
|
|
1545
|
+
const livePageIds = (await driver.listPages()).map((p) => p.id);
|
|
1546
|
+
const [origins, leases] = await Promise.all([
|
|
1547
|
+
originIndex(backend, undefined),
|
|
1548
|
+
listLeases({ liveIds: livePageIds, liveBackend: backend }).catch(() => [])
|
|
1549
|
+
]);
|
|
1550
|
+
const candidates = staleAgentTabs({ backend, livePageIds, origins, leases });
|
|
1551
|
+
const closed = [];
|
|
1552
|
+
for (const c of candidates) {
|
|
1553
|
+
const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
|
|
1554
|
+
if (res.success !== true)
|
|
1555
|
+
continue;
|
|
1556
|
+
await releaseLeaseFor(backend, c.targetId);
|
|
1557
|
+
closed.push(c);
|
|
1524
1558
|
}
|
|
1525
|
-
return
|
|
1559
|
+
return closed;
|
|
1526
1560
|
}
|
|
1527
|
-
async function listLeasesTool(driver, _args = {}) {
|
|
1528
|
-
const liveIds = (await driver.listPages()).map((p) => p.id);
|
|
1529
|
-
const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
|
|
1530
|
-
return { leases, count: leases.length };
|
|
1531
|
-
}
|
|
1532
|
-
var LEASE_TOOLS = {
|
|
1533
|
-
claim_page: claimPage,
|
|
1534
|
-
release_page: releasePage,
|
|
1535
|
-
list_leases: listLeasesTool
|
|
1536
|
-
};
|
|
1537
1561
|
|
|
1538
1562
|
// src/shared-tools.ts
|
|
1539
1563
|
import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
|
|
@@ -1546,7 +1570,7 @@ class SharedToolError extends Error {
|
|
|
1546
1570
|
function target3(p) {
|
|
1547
1571
|
return { id: p.id, url: p.url, title: p.title };
|
|
1548
1572
|
}
|
|
1549
|
-
function
|
|
1573
|
+
function backendOf(driver) {
|
|
1550
1574
|
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
1551
1575
|
}
|
|
1552
1576
|
async function withPage2(driver, target, fn) {
|
|
@@ -1560,7 +1584,7 @@ async function withPage2(driver, target, fn) {
|
|
|
1560
1584
|
async function resolvePage(driver, selector) {
|
|
1561
1585
|
const pages = await driver.listPages();
|
|
1562
1586
|
const hit = await pickPage(driver, pages, selector);
|
|
1563
|
-
await assertLeaseOk(
|
|
1587
|
+
await assertLeaseOk(backendOf(driver), hit.id, { url: hit.url, title: hit.title });
|
|
1564
1588
|
return hit;
|
|
1565
1589
|
}
|
|
1566
1590
|
async function pickPage(driver, pages, selector) {
|
|
@@ -1615,8 +1639,10 @@ async function reapSet(driver, pages, all) {
|
|
|
1615
1639
|
}
|
|
1616
1640
|
}
|
|
1617
1641
|
async function listPages(driver, args = {}) {
|
|
1618
|
-
const
|
|
1619
|
-
const
|
|
1642
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backendOf(driver)) : [];
|
|
1643
|
+
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
1644
|
+
const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
|
|
1645
|
+
const ledger = await originIndex(backendOf(driver), await reapSet(driver, pages, args.all));
|
|
1620
1646
|
const annotated = pages.map((p) => {
|
|
1621
1647
|
const rec = ledger.get(p.id);
|
|
1622
1648
|
if (!rec)
|
|
@@ -1625,16 +1651,18 @@ async function listPages(driver, args = {}) {
|
|
|
1625
1651
|
return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
|
|
1626
1652
|
return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
|
|
1627
1653
|
});
|
|
1628
|
-
return { pages: annotated, count: annotated.length };
|
|
1654
|
+
return { pages: annotated, count: annotated.length, ...reaped.length ? { reaped } : {} };
|
|
1629
1655
|
}
|
|
1630
1656
|
async function newPage(driver, args = {}) {
|
|
1631
1657
|
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
1632
|
-
const p = await newTrackedPage(driver,
|
|
1633
|
-
|
|
1658
|
+
const p = await newTrackedPage(driver, backendOf(driver), { url: args.url, label });
|
|
1659
|
+
const explicit = args.claim === true;
|
|
1660
|
+
if (!explicit && !requireLease())
|
|
1634
1661
|
return { targetId: p.id, url: p.url };
|
|
1635
|
-
const { record, token } = await claimLease(
|
|
1662
|
+
const { record, token } = await claimLease(backendOf(driver), p.id, {
|
|
1636
1663
|
label,
|
|
1637
|
-
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs()
|
|
1664
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
1665
|
+
auto: !explicit
|
|
1638
1666
|
});
|
|
1639
1667
|
return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
|
|
1640
1668
|
}
|
|
@@ -1645,7 +1673,7 @@ async function closePage(driver, args) {
|
|
|
1645
1673
|
const res = await driver.closePage(p.id);
|
|
1646
1674
|
if (!res.success)
|
|
1647
1675
|
return { closed: p.id, success: false, leaseReleased: false };
|
|
1648
|
-
const released = await releaseLeaseFor(
|
|
1676
|
+
const released = await releaseLeaseFor(backendOf(driver), p.id);
|
|
1649
1677
|
return { closed: p.id, success: true, ...released.released ? { leaseReleased: true } : {} };
|
|
1650
1678
|
}
|
|
1651
1679
|
async function writeSelectedFile(id) {
|
|
@@ -1970,6 +1998,103 @@ var SHARED_TOOLS = {
|
|
|
1970
1998
|
handle_dialog: handleDialog
|
|
1971
1999
|
};
|
|
1972
2000
|
|
|
2001
|
+
// src/leases-tools.ts
|
|
2002
|
+
class LeaseToolError extends Error {
|
|
2003
|
+
}
|
|
2004
|
+
function backendOf2(driver) {
|
|
2005
|
+
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
2006
|
+
}
|
|
2007
|
+
async function claimPage(driver, args = {}) {
|
|
2008
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
2009
|
+
const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
|
|
2010
|
+
if (hasTarget && hasTargetId) {
|
|
2011
|
+
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.");
|
|
2012
|
+
}
|
|
2013
|
+
const backend = backendOf2(driver);
|
|
2014
|
+
const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
|
|
2015
|
+
const pages = await driver.listPages();
|
|
2016
|
+
let targetId;
|
|
2017
|
+
let url;
|
|
2018
|
+
let opened;
|
|
2019
|
+
if (hasTarget) {
|
|
2020
|
+
const hit = await pickPage(driver, pages, args.target).catch((err) => {
|
|
2021
|
+
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.`);
|
|
2022
|
+
});
|
|
2023
|
+
targetId = hit.id;
|
|
2024
|
+
url = hit.url;
|
|
2025
|
+
opened = false;
|
|
2026
|
+
} else if (hasTargetId) {
|
|
2027
|
+
const existing = pages.find((p) => p.id === args.targetId);
|
|
2028
|
+
if (!existing)
|
|
2029
|
+
throw new LeaseToolError(`claim_page: no page target with id '${args.targetId}'`);
|
|
2030
|
+
targetId = existing.id;
|
|
2031
|
+
url = existing.url;
|
|
2032
|
+
opened = false;
|
|
2033
|
+
} else {
|
|
2034
|
+
const page = await newTrackedPage(driver, backend, { url: args.url, label });
|
|
2035
|
+
targetId = page.id;
|
|
2036
|
+
url = page.url;
|
|
2037
|
+
opened = true;
|
|
2038
|
+
}
|
|
2039
|
+
const liveIds = [...pages.map((p) => p.id), targetId];
|
|
2040
|
+
const { record, token } = await claimLease(backend, targetId, {
|
|
2041
|
+
label,
|
|
2042
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
2043
|
+
liveIds
|
|
2044
|
+
});
|
|
2045
|
+
return {
|
|
2046
|
+
lease: token,
|
|
2047
|
+
targetId,
|
|
2048
|
+
url,
|
|
2049
|
+
label: record.label,
|
|
2050
|
+
ttlMs: record.ttlMs,
|
|
2051
|
+
expiresAt: record.lastUsedAt + record.ttlMs,
|
|
2052
|
+
opened
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
async function releasePage(driver, args = {}) {
|
|
2056
|
+
const hasLease = typeof args.lease === "string" && args.lease.length > 0;
|
|
2057
|
+
const hasTarget = typeof args.target === "string" && args.target.length > 0;
|
|
2058
|
+
if (hasLease === hasTarget) {
|
|
2059
|
+
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)");
|
|
2060
|
+
}
|
|
2061
|
+
const backend = backendOf2(driver);
|
|
2062
|
+
let targetId;
|
|
2063
|
+
let released;
|
|
2064
|
+
if (hasLease) {
|
|
2065
|
+
const parts = tokenParts(args.lease);
|
|
2066
|
+
if (!parts)
|
|
2067
|
+
return { released: false, closed: false };
|
|
2068
|
+
targetId = parts.targetId;
|
|
2069
|
+
released = (await releaseLease(args.lease)).released;
|
|
2070
|
+
} else {
|
|
2071
|
+
const page = await resolvePage(driver, args.target);
|
|
2072
|
+
targetId = page.id;
|
|
2073
|
+
released = (await releaseLeaseFor(backend, targetId)).released;
|
|
2074
|
+
}
|
|
2075
|
+
if (!released)
|
|
2076
|
+
return { released: false, closed: false, ...hasTarget ? { targetId } : {} };
|
|
2077
|
+
const shouldClose = args.close === false ? false : args.close === true ? true : await readOrigin(backend, targetId) !== undefined;
|
|
2078
|
+
if (!shouldClose)
|
|
2079
|
+
return { released: true, closed: false, targetId };
|
|
2080
|
+
const res = await driver.closePage(targetId).catch(() => ({ success: false }));
|
|
2081
|
+
return { released: true, closed: res.success === true, targetId };
|
|
2082
|
+
}
|
|
2083
|
+
async function listLeasesTool(driver, _args = {}) {
|
|
2084
|
+
const backend = backendOf2(driver);
|
|
2085
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
|
|
2086
|
+
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
2087
|
+
const liveIds = (await driver.listPages()).map((p) => p.id);
|
|
2088
|
+
const all = await listLeases({ liveIds, liveBackend: backend });
|
|
2089
|
+
const leases = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
|
|
2090
|
+
return { leases, count: leases.length, ...reaped.length ? { reaped } : {} };
|
|
2091
|
+
}
|
|
2092
|
+
var LEASE_TOOLS = {
|
|
2093
|
+
claim_page: claimPage,
|
|
2094
|
+
release_page: releasePage,
|
|
2095
|
+
list_leases: listLeasesTool
|
|
2096
|
+
};
|
|
2097
|
+
|
|
1973
2098
|
// src/tools/console.ts
|
|
1974
2099
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
1975
2100
|
|
|
@@ -2577,11 +2702,375 @@ async function performanceAnalyzeInsight(args = {}) {
|
|
|
2577
2702
|
return { ...analyzeEvents(events), tracePath: args.tracePath };
|
|
2578
2703
|
}
|
|
2579
2704
|
|
|
2580
|
-
// src/tools/
|
|
2581
|
-
import {
|
|
2705
|
+
// src/tools/screencast.ts
|
|
2706
|
+
import { spawn } from "node:child_process";
|
|
2707
|
+
import { mkdir as mkdir6, rm, stat as stat2, writeFile as writeFile6 } from "node:fs/promises";
|
|
2582
2708
|
import { join as join5 } from "node:path";
|
|
2583
2709
|
var ARTIFACT_DIR4 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
2584
|
-
|
|
2710
|
+
var FFMPEG_BIN = process.env.CDP_FFMPEG ?? "ffmpeg";
|
|
2711
|
+
function frameDurationsMs(ledger, stopWallclockMs) {
|
|
2712
|
+
return ledger.map((entry, i) => {
|
|
2713
|
+
const next = i + 1 < ledger.length ? ledger[i + 1].timestamp : stopWallclockMs;
|
|
2714
|
+
return Math.max(1, Math.round(next - entry.timestamp));
|
|
2715
|
+
});
|
|
2716
|
+
}
|
|
2717
|
+
var CONCAT_GRID_MS = 40;
|
|
2718
|
+
function coalesceToConcatGrid(ledger, stopWallclockMs, gridMs = CONCAT_GRID_MS) {
|
|
2719
|
+
if (!ledger.length)
|
|
2720
|
+
return [];
|
|
2721
|
+
const raw = frameDurationsMs(ledger, stopWallclockMs);
|
|
2722
|
+
let elapsed = 0;
|
|
2723
|
+
const kept = [];
|
|
2724
|
+
let lastSlot = -1;
|
|
2725
|
+
for (let i = 0;i < ledger.length; i++) {
|
|
2726
|
+
const slot = Math.round(elapsed / gridMs);
|
|
2727
|
+
if (slot > lastSlot) {
|
|
2728
|
+
kept.push({ file: ledger[i].file, slot });
|
|
2729
|
+
lastSlot = slot;
|
|
2730
|
+
}
|
|
2731
|
+
elapsed += raw[i];
|
|
2732
|
+
}
|
|
2733
|
+
const endSlot = Math.max(lastSlot + 1, Math.round(elapsed / gridMs));
|
|
2734
|
+
return kept.map((frame, i) => ({
|
|
2735
|
+
file: frame.file,
|
|
2736
|
+
durationMs: ((i + 1 < kept.length ? kept[i + 1].slot : endSlot) - frame.slot) * gridMs
|
|
2737
|
+
}));
|
|
2738
|
+
}
|
|
2739
|
+
function buildConcatManifest(frames) {
|
|
2740
|
+
if (!frames.length)
|
|
2741
|
+
throw new CdpError("cannot build a concat manifest from an empty frame ledger");
|
|
2742
|
+
const lines = ["ffconcat version 1.0"];
|
|
2743
|
+
for (const frame of frames) {
|
|
2744
|
+
lines.push(`file ${frame.file}`);
|
|
2745
|
+
lines.push(`duration ${(frame.durationMs / 1000).toFixed(6)}`);
|
|
2746
|
+
}
|
|
2747
|
+
lines.push(`file ${frames[frames.length - 1].file}`);
|
|
2748
|
+
return `${lines.join(`
|
|
2749
|
+
`)}
|
|
2750
|
+
`;
|
|
2751
|
+
}
|
|
2752
|
+
function evenDimensions(width, height) {
|
|
2753
|
+
return { width: Math.max(0, Math.trunc(width / 2) * 2), height: Math.max(0, Math.trunc(height / 2) * 2) };
|
|
2754
|
+
}
|
|
2755
|
+
function pngSize(buf) {
|
|
2756
|
+
if (buf.length < 24)
|
|
2757
|
+
return;
|
|
2758
|
+
if (buf.readUInt32BE(0) !== 2303741511 || buf.readUInt32BE(4) !== 218765834)
|
|
2759
|
+
return;
|
|
2760
|
+
if (buf.toString("latin1", 12, 16) !== "IHDR")
|
|
2761
|
+
return;
|
|
2762
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
2763
|
+
}
|
|
2764
|
+
var JPEG_SOF = new Set([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207]);
|
|
2765
|
+
function jpegSize(buf) {
|
|
2766
|
+
if (buf.length < 4 || buf[0] !== 255 || buf[1] !== 216)
|
|
2767
|
+
return;
|
|
2768
|
+
let i = 2;
|
|
2769
|
+
while (i + 3 < buf.length) {
|
|
2770
|
+
if (buf[i] !== 255) {
|
|
2771
|
+
i += 1;
|
|
2772
|
+
continue;
|
|
2773
|
+
}
|
|
2774
|
+
const marker = buf[i + 1];
|
|
2775
|
+
if (marker === 255) {
|
|
2776
|
+
i += 1;
|
|
2777
|
+
continue;
|
|
2778
|
+
}
|
|
2779
|
+
if (marker === 216 || marker === 1 || marker >= 208 && marker <= 215) {
|
|
2780
|
+
i += 2;
|
|
2781
|
+
continue;
|
|
2782
|
+
}
|
|
2783
|
+
if (i + 3 >= buf.length)
|
|
2784
|
+
return;
|
|
2785
|
+
const segLen = buf.readUInt16BE(i + 2);
|
|
2786
|
+
if (JPEG_SOF.has(marker)) {
|
|
2787
|
+
if (i + 9 > buf.length)
|
|
2788
|
+
return;
|
|
2789
|
+
return { width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) };
|
|
2790
|
+
}
|
|
2791
|
+
if (segLen < 2)
|
|
2792
|
+
return;
|
|
2793
|
+
i += 2 + segLen;
|
|
2794
|
+
}
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
function imageSize(buf) {
|
|
2798
|
+
return pngSize(buf) ?? jpegSize(buf);
|
|
2799
|
+
}
|
|
2800
|
+
var ENCODER_LADDER = [
|
|
2801
|
+
{ encoder: "hevc_videotoolbox", codec: "hevc", tag: "hvc1" },
|
|
2802
|
+
{ encoder: "h264_videotoolbox", codec: "h264" },
|
|
2803
|
+
{ encoder: "libx265", codec: "hevc", tag: "hvc1" },
|
|
2804
|
+
{ encoder: "libx264", codec: "h264" }
|
|
2805
|
+
];
|
|
2806
|
+
function ffmpegEncoderNames(encodersOutput) {
|
|
2807
|
+
const names = new Set;
|
|
2808
|
+
for (const line of encodersOutput.split(`
|
|
2809
|
+
`)) {
|
|
2810
|
+
const m = /^\s*[VAS][A-Z.]{5}\s+(\S+)/.exec(line);
|
|
2811
|
+
if (m)
|
|
2812
|
+
names.add(m[1]);
|
|
2813
|
+
}
|
|
2814
|
+
return names;
|
|
2815
|
+
}
|
|
2816
|
+
function selectEncoder(encodersOutput) {
|
|
2817
|
+
const available = ffmpegEncoderNames(encodersOutput);
|
|
2818
|
+
for (const candidate of ENCODER_LADDER) {
|
|
2819
|
+
if (available.has(candidate.encoder))
|
|
2820
|
+
return candidate;
|
|
2821
|
+
}
|
|
2822
|
+
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).");
|
|
2823
|
+
}
|
|
2824
|
+
function buildFfmpegArgs(manifestPath, outPath, choice) {
|
|
2825
|
+
return [
|
|
2826
|
+
"-hide_banner",
|
|
2827
|
+
"-loglevel",
|
|
2828
|
+
"error",
|
|
2829
|
+
"-y",
|
|
2830
|
+
"-f",
|
|
2831
|
+
"concat",
|
|
2832
|
+
"-safe",
|
|
2833
|
+
"0",
|
|
2834
|
+
"-i",
|
|
2835
|
+
manifestPath,
|
|
2836
|
+
"-vsync",
|
|
2837
|
+
"vfr",
|
|
2838
|
+
"-vf",
|
|
2839
|
+
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
|
|
2840
|
+
"-c:v",
|
|
2841
|
+
choice.encoder,
|
|
2842
|
+
...choice.tag ? ["-tag:v", choice.tag] : [],
|
|
2843
|
+
"-pix_fmt",
|
|
2844
|
+
"yuv420p",
|
|
2845
|
+
"-movflags",
|
|
2846
|
+
"+faststart",
|
|
2847
|
+
"-an",
|
|
2848
|
+
outPath
|
|
2849
|
+
];
|
|
2850
|
+
}
|
|
2851
|
+
function run(bin, args) {
|
|
2852
|
+
return new Promise((resolve, reject) => {
|
|
2853
|
+
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
2854
|
+
let stdout = "";
|
|
2855
|
+
let stderr = "";
|
|
2856
|
+
child.stdout?.on("data", (chunk) => {
|
|
2857
|
+
stdout += chunk.toString("utf8");
|
|
2858
|
+
});
|
|
2859
|
+
child.stderr?.on("data", (chunk) => {
|
|
2860
|
+
stderr += chunk.toString("utf8");
|
|
2861
|
+
});
|
|
2862
|
+
child.on("error", reject);
|
|
2863
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
2864
|
+
});
|
|
2865
|
+
}
|
|
2866
|
+
var encoderProbe;
|
|
2867
|
+
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.";
|
|
2868
|
+
async function probeEncoder() {
|
|
2869
|
+
if (!encoderProbe) {
|
|
2870
|
+
encoderProbe = (async () => {
|
|
2871
|
+
let res;
|
|
2872
|
+
try {
|
|
2873
|
+
res = await run(FFMPEG_BIN, ["-hide_banner", "-encoders"]);
|
|
2874
|
+
} catch (err) {
|
|
2875
|
+
throw new CdpError(`${FFMPEG_MISSING} (${err.message})`);
|
|
2876
|
+
}
|
|
2877
|
+
if (res.code !== 0)
|
|
2878
|
+
throw new CdpError(`${FFMPEG_MISSING} ('-encoders' exited ${res.code}: ${res.stderr.trim().slice(0, 300)})`);
|
|
2879
|
+
return selectEncoder(`${res.stdout}
|
|
2880
|
+
${res.stderr}`);
|
|
2881
|
+
})();
|
|
2882
|
+
encoderProbe.catch(() => {
|
|
2883
|
+
encoderProbe = undefined;
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
return encoderProbe;
|
|
2887
|
+
}
|
|
2888
|
+
var liveRecordings = new Map;
|
|
2889
|
+
function shortId2(id) {
|
|
2890
|
+
return id.slice(0, 8);
|
|
2891
|
+
}
|
|
2892
|
+
function assertNotRecording(targetId, activeIds) {
|
|
2893
|
+
if (activeIds.includes(targetId)) {
|
|
2894
|
+
throw new CdpError(`a screen recording is already in progress for target ${shortId2(targetId)}; call stop_screen_recording first`);
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
function chooseRecordingId(activeIds, selector) {
|
|
2898
|
+
if (!activeIds.length) {
|
|
2899
|
+
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).");
|
|
2900
|
+
}
|
|
2901
|
+
if (selector === undefined || selector === "active") {
|
|
2902
|
+
if (activeIds.length === 1)
|
|
2903
|
+
return activeIds[0];
|
|
2904
|
+
throw new CdpError(`${activeIds.length} screen recordings are in progress (targets: ${activeIds.map(shortId2).join(", ")}); pass 'target' to say which one to stop`);
|
|
2905
|
+
}
|
|
2906
|
+
return activeIds.find((id) => id === selector);
|
|
2907
|
+
}
|
|
2908
|
+
function stamp3() {
|
|
2909
|
+
return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
|
|
2910
|
+
}
|
|
2911
|
+
function target32(t) {
|
|
2912
|
+
return { id: t.id, url: t.url, title: t.title };
|
|
2913
|
+
}
|
|
2914
|
+
async function startScreenRecording(args = {}) {
|
|
2915
|
+
const format = args.format ?? "jpeg";
|
|
2916
|
+
if (format !== "jpeg" && format !== "png")
|
|
2917
|
+
throw new CdpError("start_screen_recording: format must be 'jpeg' or 'png'");
|
|
2918
|
+
if (args.quality !== undefined && (!Number.isFinite(args.quality) || args.quality < 0 || args.quality > 100)) {
|
|
2919
|
+
throw new CdpError("start_screen_recording: quality must be a number between 0 and 100 (jpeg only)");
|
|
2920
|
+
}
|
|
2921
|
+
const encoder = await probeEncoder();
|
|
2922
|
+
const { conn, target } = await openPage(args.target);
|
|
2923
|
+
try {
|
|
2924
|
+
assertNotRecording(target.id, [...liveRecordings.keys()]);
|
|
2925
|
+
} catch (err) {
|
|
2926
|
+
conn.close();
|
|
2927
|
+
throw err;
|
|
2928
|
+
}
|
|
2929
|
+
const spoolDir = join5(ARTIFACT_DIR4, `screencast-${shortId2(target.id)}-${stamp3()}`);
|
|
2930
|
+
try {
|
|
2931
|
+
await mkdir6(spoolDir, { recursive: true });
|
|
2932
|
+
} catch (err) {
|
|
2933
|
+
conn.close();
|
|
2934
|
+
throw new CdpError(`cannot create the screencast spool directory '${spoolDir}': ${err.message}`);
|
|
2935
|
+
}
|
|
2936
|
+
const ext = format === "png" ? "png" : "jpg";
|
|
2937
|
+
const rec = {
|
|
2938
|
+
conn,
|
|
2939
|
+
target,
|
|
2940
|
+
spoolDir,
|
|
2941
|
+
ledger: [],
|
|
2942
|
+
startedAt: Date.now(),
|
|
2943
|
+
format,
|
|
2944
|
+
encoder,
|
|
2945
|
+
writes: Promise.resolve(),
|
|
2946
|
+
dropped: 0,
|
|
2947
|
+
unwritten: new Set,
|
|
2948
|
+
off: () => {}
|
|
2949
|
+
};
|
|
2950
|
+
let seq = 0;
|
|
2951
|
+
rec.off = conn.on("Page.screencastFrame", (params) => {
|
|
2952
|
+
const sessionId = params.sessionId;
|
|
2953
|
+
if (typeof sessionId === "number") {
|
|
2954
|
+
conn.send("Page.screencastFrameAck", { sessionId }).catch(() => {});
|
|
2955
|
+
}
|
|
2956
|
+
const data = params.data;
|
|
2957
|
+
if (typeof data !== "string" || !data.length) {
|
|
2958
|
+
rec.dropped += 1;
|
|
2959
|
+
return;
|
|
2960
|
+
}
|
|
2961
|
+
const meta = params.metadata;
|
|
2962
|
+
const timestamp = typeof meta?.timestamp === "number" ? Math.round(meta.timestamp * 1000) : Date.now();
|
|
2963
|
+
const buf = Buffer.from(data, "base64");
|
|
2964
|
+
const size = imageSize(buf);
|
|
2965
|
+
if (!size || size.width <= 0 || size.height <= 0) {
|
|
2966
|
+
rec.dropped += 1;
|
|
2967
|
+
return;
|
|
2968
|
+
}
|
|
2969
|
+
seq += 1;
|
|
2970
|
+
const file = `frame-${String(seq).padStart(6, "0")}.${ext}`;
|
|
2971
|
+
rec.ledger.push({ file, timestamp, width: size.width, height: size.height });
|
|
2972
|
+
rec.writes = rec.writes.then(() => writeFile6(join5(spoolDir, file), buf)).catch(() => {
|
|
2973
|
+
rec.dropped += 1;
|
|
2974
|
+
rec.unwritten.add(file);
|
|
2975
|
+
});
|
|
2976
|
+
});
|
|
2977
|
+
try {
|
|
2978
|
+
await conn.send("Page.enable");
|
|
2979
|
+
if (args.bringToFront)
|
|
2980
|
+
await conn.send("Page.bringToFront").catch(() => {});
|
|
2981
|
+
const params = { format };
|
|
2982
|
+
if (format === "jpeg" && args.quality !== undefined)
|
|
2983
|
+
params.quality = args.quality;
|
|
2984
|
+
if (args.maxWidth !== undefined)
|
|
2985
|
+
params.maxWidth = args.maxWidth;
|
|
2986
|
+
if (args.maxHeight !== undefined)
|
|
2987
|
+
params.maxHeight = args.maxHeight;
|
|
2988
|
+
if (args.everyNthFrame !== undefined)
|
|
2989
|
+
params.everyNthFrame = args.everyNthFrame;
|
|
2990
|
+
await conn.send("Page.startScreencast", params);
|
|
2991
|
+
} catch (err) {
|
|
2992
|
+
rec.off();
|
|
2993
|
+
conn.close();
|
|
2994
|
+
await rm(spoolDir, { recursive: true, force: true }).catch(() => {});
|
|
2995
|
+
throw err;
|
|
2996
|
+
}
|
|
2997
|
+
liveRecordings.set(target.id, rec);
|
|
2998
|
+
return {
|
|
2999
|
+
target: target32(target),
|
|
3000
|
+
format,
|
|
3001
|
+
spoolDir,
|
|
3002
|
+
encoder: encoder.encoder,
|
|
3003
|
+
codec: encoder.codec,
|
|
3004
|
+
startedAt: rec.startedAt,
|
|
3005
|
+
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."
|
|
3006
|
+
};
|
|
3007
|
+
}
|
|
3008
|
+
async function pickRecording(selector) {
|
|
3009
|
+
const chosen = chooseRecordingId([...liveRecordings.keys()], selector);
|
|
3010
|
+
if (chosen)
|
|
3011
|
+
return liveRecordings.get(chosen);
|
|
3012
|
+
const { conn, target } = await openPage(selector);
|
|
3013
|
+
conn.close();
|
|
3014
|
+
const hit = liveRecordings.get(target.id);
|
|
3015
|
+
if (!hit) {
|
|
3016
|
+
throw new CdpError(`no screen recording is in progress for target ${shortId2(target.id)} (recording: ${[...liveRecordings.keys()].map(shortId2).join(", ") || "none"})`);
|
|
3017
|
+
}
|
|
3018
|
+
return hit;
|
|
3019
|
+
}
|
|
3020
|
+
async function stopScreenRecording(args = {}) {
|
|
3021
|
+
const rec = await pickRecording(args.target);
|
|
3022
|
+
rec.off();
|
|
3023
|
+
try {
|
|
3024
|
+
await rec.conn.send("Page.stopScreencast");
|
|
3025
|
+
} catch {}
|
|
3026
|
+
const stoppedAt = Date.now();
|
|
3027
|
+
await rec.writes.catch(() => {});
|
|
3028
|
+
rec.conn.close();
|
|
3029
|
+
liveRecordings.delete(rec.target.id);
|
|
3030
|
+
const ledger = rec.unwritten.size ? rec.ledger.filter((entry) => !rec.unwritten.has(entry.file)) : rec.ledger;
|
|
3031
|
+
if (!ledger.length) {
|
|
3032
|
+
await rm(rec.spoolDir, { recursive: true, force: true }).catch(() => {});
|
|
3033
|
+
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.`);
|
|
3034
|
+
}
|
|
3035
|
+
await mkdir6(ARTIFACT_DIR4, { recursive: true });
|
|
3036
|
+
const outPath = args.savePath ?? join5(ARTIFACT_DIR4, `screen-recording-${shortId2(rec.target.id)}-${stamp3()}.mp4`);
|
|
3037
|
+
const frames = coalesceToConcatGrid(ledger, stoppedAt);
|
|
3038
|
+
const manifestPath = join5(rec.spoolDir, "frames.ffconcat");
|
|
3039
|
+
await writeFile6(manifestPath, buildConcatManifest(frames), "utf8");
|
|
3040
|
+
const durationMs = frames.reduce((total, f) => total + f.durationMs, 0);
|
|
3041
|
+
const { width, height } = evenDimensions(ledger[0].width, ledger[0].height);
|
|
3042
|
+
let res;
|
|
3043
|
+
try {
|
|
3044
|
+
res = await run(FFMPEG_BIN, buildFfmpegArgs(manifestPath, outPath, rec.encoder));
|
|
3045
|
+
} catch (err) {
|
|
3046
|
+
throw new CdpError(`${FFMPEG_MISSING} Spooled frames kept at ${rec.spoolDir}. (${err.message})`);
|
|
3047
|
+
}
|
|
3048
|
+
if (res.code !== 0) {
|
|
3049
|
+
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(" ")}
|
|
3050
|
+
ffmpeg said: ${res.stderr.trim().slice(0, 800)}`);
|
|
3051
|
+
}
|
|
3052
|
+
const { size } = await stat2(outPath);
|
|
3053
|
+
await rm(rec.spoolDir, { recursive: true, force: true }).catch(() => {});
|
|
3054
|
+
return {
|
|
3055
|
+
path: outPath,
|
|
3056
|
+
bytes: size,
|
|
3057
|
+
durationMs,
|
|
3058
|
+
frameCount: ledger.length,
|
|
3059
|
+
encodedFrames: frames.length,
|
|
3060
|
+
codec: rec.encoder.codec,
|
|
3061
|
+
encoder: rec.encoder.encoder,
|
|
3062
|
+
width,
|
|
3063
|
+
height,
|
|
3064
|
+
droppedFrames: rec.dropped,
|
|
3065
|
+
target: target32(rec.target)
|
|
3066
|
+
};
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
// src/tools/heap.ts
|
|
3070
|
+
import { mkdir as mkdir7, writeFile as writeFile7 } from "node:fs/promises";
|
|
3071
|
+
import { join as join6 } from "node:path";
|
|
3072
|
+
var ARTIFACT_DIR5 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
3073
|
+
function stamp4(targetId) {
|
|
2585
3074
|
const short = targetId.slice(0, 8);
|
|
2586
3075
|
const iso = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2587
3076
|
return `${short}-${iso}`;
|
|
@@ -2604,9 +3093,9 @@ async function takeHeapsnapshot(args = {}) {
|
|
|
2604
3093
|
if (snapshot.length === 0) {
|
|
2605
3094
|
throw new Error("HeapProfiler.takeHeapSnapshot produced no chunks");
|
|
2606
3095
|
}
|
|
2607
|
-
await
|
|
2608
|
-
const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath :
|
|
2609
|
-
await
|
|
3096
|
+
await mkdir7(ARTIFACT_DIR5, { recursive: true });
|
|
3097
|
+
const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath : join6(ARTIFACT_DIR5, args.savePath) : join6(ARTIFACT_DIR5, `take_heapsnapshot-${stamp4(target.id)}.heapsnapshot`);
|
|
3098
|
+
await writeFile7(path, snapshot, "utf8");
|
|
2610
3099
|
const bytes = Buffer.byteLength(snapshot, "utf8");
|
|
2611
3100
|
return {
|
|
2612
3101
|
path,
|
|
@@ -2618,15 +3107,15 @@ async function takeHeapsnapshot(args = {}) {
|
|
|
2618
3107
|
}
|
|
2619
3108
|
|
|
2620
3109
|
// src/tools/lighthouse.ts
|
|
2621
|
-
import { spawn } from "node:child_process";
|
|
2622
|
-
import { mkdir as
|
|
2623
|
-
var
|
|
3110
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
3111
|
+
import { mkdir as mkdir8, readFile as readFile6, stat as stat3 } from "node:fs/promises";
|
|
3112
|
+
var ARTIFACT_DIR6 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
2624
3113
|
function cdpPort() {
|
|
2625
3114
|
const base = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
2626
3115
|
const m = base.match(/:(\d+)/);
|
|
2627
3116
|
return m?.[1] ? Number(m[1]) : 9222;
|
|
2628
3117
|
}
|
|
2629
|
-
function
|
|
3118
|
+
function stamp5() {
|
|
2630
3119
|
return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
|
|
2631
3120
|
}
|
|
2632
3121
|
function shortHost(url) {
|
|
@@ -2643,7 +3132,7 @@ function runProcess(cmd, args, opts) {
|
|
|
2643
3132
|
let stdout = "";
|
|
2644
3133
|
let stderr = "";
|
|
2645
3134
|
let settled = false;
|
|
2646
|
-
const child =
|
|
3135
|
+
const child = spawn2(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
2647
3136
|
const timer = setTimeout(() => {
|
|
2648
3137
|
if (settled)
|
|
2649
3138
|
return;
|
|
@@ -2680,8 +3169,8 @@ async function lighthouseAudit(args) {
|
|
|
2680
3169
|
const url = args.url.trim();
|
|
2681
3170
|
const timeoutMs = args.timeoutMs ?? 120000;
|
|
2682
3171
|
const port = cdpPort();
|
|
2683
|
-
await
|
|
2684
|
-
const outPath = args.savePath ?? `${
|
|
3172
|
+
await mkdir8(ARTIFACT_DIR6, { recursive: true });
|
|
3173
|
+
const outPath = args.savePath ?? `${ARTIFACT_DIR6}/lighthouse-${shortHost(url)}-${stamp5()}.json`;
|
|
2685
3174
|
const formFactor = args.formFactor ?? "desktop";
|
|
2686
3175
|
const lhArgs = [
|
|
2687
3176
|
"--yes",
|
|
@@ -2736,7 +3225,7 @@ async function lighthouseAudit(args) {
|
|
|
2736
3225
|
if (Object.keys(categories).length === 0) {
|
|
2737
3226
|
throw new CdpError(`lighthouse_audit report contained no category scores (exit code ${res.code}). stderr: ${truncate(res.stderr)}`);
|
|
2738
3227
|
}
|
|
2739
|
-
const { size } = await
|
|
3228
|
+
const { size } = await stat3(outPath);
|
|
2740
3229
|
return {
|
|
2741
3230
|
path: outPath,
|
|
2742
3231
|
bytes: size,
|
|
@@ -2790,6 +3279,8 @@ var TOOLS = {
|
|
|
2790
3279
|
performance_stop_trace: performanceStopTrace,
|
|
2791
3280
|
performance_analyze_insight: performanceAnalyzeInsight,
|
|
2792
3281
|
performance_trace: performanceTrace,
|
|
3282
|
+
start_screen_recording: startScreenRecording,
|
|
3283
|
+
stop_screen_recording: stopScreenRecording,
|
|
2793
3284
|
take_heapsnapshot: takeHeapsnapshot,
|
|
2794
3285
|
lighthouse_audit: lighthouseAudit,
|
|
2795
3286
|
mock_request: mockRequest,
|