@crouton-kit/capture 1.4.13 → 1.4.15
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/bin/capture +59 -27
- package/package.json +1 -1
- package/src/capture.ts +1 -1
- package/src/cdp/browser-process.ts +23 -3
- package/src/cdp/commands/heap/index.ts +2 -2
- package/src/cdp/commands/lighthouse.ts +3 -2
- package/src/cdp/commands/measure/index.ts +4 -4
- package/src/cdp/commands/measure/snap.ts +1 -1
- package/src/cdp/commands/motion/index.ts +2 -2
- package/src/cdp/commands/perf/index.ts +2 -2
- package/src/cdp/commands/tab/index.ts +1 -1
- package/src/cdp/commands/tab/launch.ts +4 -1
- package/src/cdp/detect.ts +2 -1
- package/src/cdp/loopback.ts +4 -0
- package/src/cdp/targets.ts +11 -6
- package/src/session/commands.ts +2 -1
package/bin/capture
CHANGED
|
@@ -3769,6 +3769,14 @@ var init_client = __esm({
|
|
|
3769
3769
|
}
|
|
3770
3770
|
});
|
|
3771
3771
|
|
|
3772
|
+
// src/cdp/loopback.ts
|
|
3773
|
+
var CDP_LOOPBACK_HOST;
|
|
3774
|
+
var init_loopback = __esm({
|
|
3775
|
+
"src/cdp/loopback.ts"() {
|
|
3776
|
+
CDP_LOOPBACK_HOST = "127.0.0.1";
|
|
3777
|
+
}
|
|
3778
|
+
});
|
|
3779
|
+
|
|
3772
3780
|
// src/cdp/detect.ts
|
|
3773
3781
|
var detect_exports = {};
|
|
3774
3782
|
__export(detect_exports, {
|
|
@@ -3784,7 +3792,7 @@ __export(detect_exports, {
|
|
|
3784
3792
|
function httpGetJson(port, path48, timeoutMs) {
|
|
3785
3793
|
return new Promise((resolve12) => {
|
|
3786
3794
|
const req = http.get(
|
|
3787
|
-
{ host:
|
|
3795
|
+
{ host: CDP_LOOPBACK_HOST, port, path: path48, timeout: timeoutMs },
|
|
3788
3796
|
(res) => {
|
|
3789
3797
|
const status = res.statusCode ?? 0;
|
|
3790
3798
|
if (status < 200 || status >= 300) {
|
|
@@ -3962,6 +3970,7 @@ var init_detect = __esm({
|
|
|
3962
3970
|
"src/cdp/detect.ts"() {
|
|
3963
3971
|
import_child_process = require("child_process");
|
|
3964
3972
|
http = __toESM(require("http"));
|
|
3973
|
+
init_loopback();
|
|
3965
3974
|
BROWSER_PATTERNS = {
|
|
3966
3975
|
"company.thebrowser.browser": ["arc"],
|
|
3967
3976
|
"com.google.chrome": ["chrome", "google chrome"],
|
|
@@ -5664,7 +5673,7 @@ async function reap(record5) {
|
|
|
5664
5673
|
}
|
|
5665
5674
|
async function endpointAnswers(port) {
|
|
5666
5675
|
try {
|
|
5667
|
-
const response = await fetch(`http
|
|
5676
|
+
const response = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`, {
|
|
5668
5677
|
signal: AbortSignal.timeout(ENDPOINT_PROBE_MS)
|
|
5669
5678
|
});
|
|
5670
5679
|
return response.ok;
|
|
@@ -5765,12 +5774,26 @@ function devToolsPort(profileDir) {
|
|
|
5765
5774
|
return null;
|
|
5766
5775
|
}
|
|
5767
5776
|
}
|
|
5777
|
+
function assertPortUnbound(port) {
|
|
5778
|
+
try {
|
|
5779
|
+
const output = (0, import_node_child_process2.execFileSync)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-F", "n"], { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
|
|
5780
|
+
if (output.trim()) {
|
|
5781
|
+
throw captureError("precondition", "browser_port_in_use", `received: port ${port}; expected: no TCP listener on that port. Capture refuses to launch because the endpoint would be ambiguous across loopback address families.`);
|
|
5782
|
+
}
|
|
5783
|
+
} catch (error4) {
|
|
5784
|
+
if (error4.status === 1) return;
|
|
5785
|
+
if (error4 instanceof CaptureError && error4.descriptor.code === "browser_port_in_use") throw error4;
|
|
5786
|
+
throw captureError("world", "browser_port_check_failed", `received: port ${port}; checking TCP listeners with lsof failed, so Capture cannot prove the port is unbound: ${error4 instanceof Error ? error4.message : String(error4)}`, error4);
|
|
5787
|
+
}
|
|
5788
|
+
}
|
|
5768
5789
|
async function launchOwnedBrowser(options) {
|
|
5790
|
+
if (options.port !== void 0) assertPortUnbound(options.port);
|
|
5769
5791
|
const executablePath = resolveBrowserExecutable(options.headless);
|
|
5770
5792
|
const token2 = crypto2.randomBytes(8).toString("hex");
|
|
5771
5793
|
const profileDir = ensurePrivateDir(profilePath(token2));
|
|
5772
5794
|
const args = [
|
|
5773
5795
|
`--remote-debugging-port=${options.port ?? 0}`,
|
|
5796
|
+
`--remote-debugging-address=${CDP_LOOPBACK_HOST}`,
|
|
5774
5797
|
`--user-data-dir=${profileDir}`,
|
|
5775
5798
|
"--no-first-run",
|
|
5776
5799
|
"--no-default-browser-check",
|
|
@@ -5841,6 +5864,7 @@ var init_browser_process = __esm({
|
|
|
5841
5864
|
os2 = __toESM(require("node:os"));
|
|
5842
5865
|
path2 = __toESM(require("node:path"));
|
|
5843
5866
|
init_errors();
|
|
5867
|
+
init_loopback();
|
|
5844
5868
|
init_artifacts();
|
|
5845
5869
|
IDLE_REAP_MS = 30 * 60 * 1e3;
|
|
5846
5870
|
READY_TIMEOUT_MS = 15e3;
|
|
@@ -5899,7 +5923,7 @@ __export(targets_exports, {
|
|
|
5899
5923
|
scoreTabUrlMatch: () => scoreTabUrlMatch
|
|
5900
5924
|
});
|
|
5901
5925
|
async function getBrowserClient(port) {
|
|
5902
|
-
const versionResp = await fetch(`http
|
|
5926
|
+
const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`);
|
|
5903
5927
|
if (!versionResp.ok) {
|
|
5904
5928
|
throw new Error(
|
|
5905
5929
|
`Failed to connect to CDP on port ${port}: ${versionResp.status}`
|
|
@@ -5911,10 +5935,12 @@ async function getBrowserClient(port) {
|
|
|
5911
5935
|
`Port ${port} answers CDP's /json/version but has no browser-level WebSocket debugger URL \u2014 it's not a real browser (e.g. a Node/workerd inspector), so it can't host tabs. Run "capture tab list" to find a real browser endpoint, or pass --port explicitly.`
|
|
5912
5936
|
);
|
|
5913
5937
|
}
|
|
5914
|
-
const
|
|
5938
|
+
const browserWsUrl = new URL(version.webSocketDebuggerUrl);
|
|
5939
|
+
browserWsUrl.hostname = CDP_LOOPBACK_HOST;
|
|
5940
|
+
const client = new CDPClient(browserWsUrl.toString());
|
|
5915
5941
|
await client.waitReady();
|
|
5916
5942
|
touchOwnedBrowser(port);
|
|
5917
|
-
return { client, browserWsUrl:
|
|
5943
|
+
return { client, browserWsUrl: browserWsUrl.toString() };
|
|
5918
5944
|
}
|
|
5919
5945
|
async function listTargets(port) {
|
|
5920
5946
|
const { client } = await getBrowserClient(port);
|
|
@@ -5926,7 +5952,7 @@ async function listTargets(port) {
|
|
|
5926
5952
|
title: t.title,
|
|
5927
5953
|
url: t.url,
|
|
5928
5954
|
type: t.type,
|
|
5929
|
-
webSocketDebuggerUrl: `ws
|
|
5955
|
+
webSocketDebuggerUrl: `ws://${CDP_LOOPBACK_HOST}:${port}/devtools/page/${t.targetId}`
|
|
5930
5956
|
}));
|
|
5931
5957
|
} finally {
|
|
5932
5958
|
client.close();
|
|
@@ -5985,7 +6011,9 @@ function normalizeUrlForMatch(value) {
|
|
|
5985
6011
|
const path48 = url.pathname.replace(/\/+$/, "") || "/";
|
|
5986
6012
|
return {
|
|
5987
6013
|
full: `${url.origin}${path48}${url.search}${url.hash}`.toLowerCase(),
|
|
5988
|
-
host
|
|
6014
|
+
// `host` (not `hostname`): two local dev servers on the same loopback
|
|
6015
|
+
// address but different ports must not register as a host match.
|
|
6016
|
+
host: url.host.toLowerCase(),
|
|
5989
6017
|
path: path48.toLowerCase()
|
|
5990
6018
|
};
|
|
5991
6019
|
} catch {
|
|
@@ -6071,7 +6099,7 @@ async function openTab(port, url) {
|
|
|
6071
6099
|
title: "",
|
|
6072
6100
|
url,
|
|
6073
6101
|
type: "page",
|
|
6074
|
-
webSocketDebuggerUrl: `ws
|
|
6102
|
+
webSocketDebuggerUrl: `ws://${CDP_LOOPBACK_HOST}:${port}/devtools/page/${targetId}`
|
|
6075
6103
|
};
|
|
6076
6104
|
} finally {
|
|
6077
6105
|
client.close();
|
|
@@ -6081,6 +6109,7 @@ var AmbiguousPageTargetError;
|
|
|
6081
6109
|
var init_targets = __esm({
|
|
6082
6110
|
"src/cdp/targets.ts"() {
|
|
6083
6111
|
init_client();
|
|
6112
|
+
init_loopback();
|
|
6084
6113
|
init_detect();
|
|
6085
6114
|
init_browser_process();
|
|
6086
6115
|
AmbiguousPageTargetError = class extends Error {
|
|
@@ -10512,7 +10541,7 @@ function validateHarAppendBatch(value, source) {
|
|
|
10512
10541
|
return { entries, incompleteLifecycles };
|
|
10513
10542
|
}
|
|
10514
10543
|
function captureVersion() {
|
|
10515
|
-
const version = "1.4.
|
|
10544
|
+
const version = "1.4.15";
|
|
10516
10545
|
return typeof version === "string" && version.length > 0 ? version : "development";
|
|
10517
10546
|
}
|
|
10518
10547
|
function token() {
|
|
@@ -12614,7 +12643,7 @@ function wallTime(value) {
|
|
|
12614
12643
|
}
|
|
12615
12644
|
}
|
|
12616
12645
|
function captureVersion2() {
|
|
12617
|
-
const version = "1.4.
|
|
12646
|
+
const version = "1.4.15";
|
|
12618
12647
|
return typeof version === "string" && version.length > 0 ? version : "development";
|
|
12619
12648
|
}
|
|
12620
12649
|
function deepFreeze(value) {
|
|
@@ -15409,6 +15438,7 @@ function responseEndDateTime(requestWallTime, requestMonotonic, terminalMonotoni
|
|
|
15409
15438
|
return Number.isNaN(end.getTime()) ? null : end.toISOString();
|
|
15410
15439
|
}
|
|
15411
15440
|
function completedTiming(e) {
|
|
15441
|
+
if ("_resourceType" in e) return fact`WebSocket: ${e._webSocketMessages.length} frame(s) recorded`;
|
|
15412
15442
|
const { clocks, terminal: terminal2 } = e._capture;
|
|
15413
15443
|
if (terminal2.kind === "failed") {
|
|
15414
15444
|
return fact`duration ${e.time} ms — response incomplete: failed`;
|
|
@@ -16091,7 +16121,7 @@ var init_commands = __esm({
|
|
|
16091
16121
|
};
|
|
16092
16122
|
SESSION_TAB_ATTACH_TIMEOUT_MS = 5e3;
|
|
16093
16123
|
COMMAND_BLOCK = `<command name="session">
|
|
16094
|
-
the
|
|
16124
|
+
the work's evidence bundle \u2014 \`start\` opens or adopts a tab, records its network traffic (HAR) and external logs, and collects every artifact into one directory; while running, every command auto-targets its tab
|
|
16095
16125
|
use when collecting or reading the traffic, logs, and artifacts of scoped work against one page
|
|
16096
16126
|
</command>`;
|
|
16097
16127
|
START_USAGE = `capture session start [--url <url> | --target <tab-id>] [--hold] [--port <n>] [--artifact-dir <path>] \u2014 open or adopt a tab, record HAR, and set the active capture context.
|
|
@@ -229755,6 +229785,7 @@ async function cmdTabLaunch(parsed, _args) {
|
|
|
229755
229785
|
console.log(USAGE9);
|
|
229756
229786
|
return;
|
|
229757
229787
|
}
|
|
229788
|
+
if (parsed.port !== void 0) assertPortUnbound(parsed.port);
|
|
229758
229789
|
const swept = await sweepOwnedBrowsers();
|
|
229759
229790
|
const browser = await launchOwnedBrowser({
|
|
229760
229791
|
url: parsed.url,
|
|
@@ -230686,7 +230717,7 @@ var init_tab = __esm({
|
|
|
230686
230717
|
init_network();
|
|
230687
230718
|
init_mock();
|
|
230688
230719
|
COMMAND_BLOCK3 = `<command name="tab">
|
|
230689
|
-
browser and tab plumbing \u2014
|
|
230720
|
+
browser and tab plumbing \u2014 launch or quit a browser capture owns, discover endpoints and tabs, open/close/reset tabs, and set the tab's network conditions (throttling, offline, mocked responses)
|
|
230690
230721
|
use when starting, finding, opening, closing, or resetting a browser tab, or changing its network conditions
|
|
230691
230722
|
</command>`;
|
|
230692
230723
|
TAB_USAGE = `<command name="tab" description="browser and tab lifecycle">
|
|
@@ -239412,7 +239443,7 @@ function artifactEntries(dir, artifacts, listCrops) {
|
|
|
239412
239443
|
if (!listCrops && name.startsWith("crops/")) continue;
|
|
239413
239444
|
entries.push({
|
|
239414
239445
|
name,
|
|
239415
|
-
...name === "geometry.json" && geometryCount !== void 0 ? { note: `${geometryCount} elements` } : {}
|
|
239446
|
+
...name === "geometry.json" && geometryCount !== void 0 ? { note: `${geometryCount} elements at .elements[]` } : {}
|
|
239416
239447
|
});
|
|
239417
239448
|
}
|
|
239418
239449
|
if (!listCrops && crops.length) entries.push({ name: "crops/", note: `${crops.length} crops; pass --list-crops for paths` });
|
|
@@ -243961,11 +243992,11 @@ var init_measure = __esm({
|
|
|
243961
243992
|
init_map_ax2();
|
|
243962
243993
|
init_map_paint();
|
|
243963
243994
|
COMMAND_BLOCK6 = `<command name="measure">
|
|
243964
|
-
|
|
243965
|
-
use
|
|
243995
|
+
static page measurement \u2014 \`snap\` waits for the page to settle, then records its layout, styles, text, and accessibility into one artifact; every other leaf answers questions from that artifact without re-driving the browser
|
|
243996
|
+
use when a claim depends on what is rendered right now: element geometry, overflow, clipping, stacking, computed style and where it came from, text and contrast, focus order, scroll containers, paint coverage, or the difference between two snapshots
|
|
243966
243997
|
</command>`;
|
|
243967
243998
|
MEASURE_USAGE = `<command name="measure" description="enriched snapshot substrate and read-only queries">
|
|
243968
|
-
<model>\`snap\` drives the page (or a base snapshot) and writes one settled artifact directory; every other leaf below reads that artifact and never re-drives the browser unless its target is a URL, which first creates a snapshot. Findings exit 0 \u2014 a report, not a failure. \`--gate\` (exit 2 on findings or changes) is accepted only by check and diff.</model>
|
|
243999
|
+
<model>\`snap\` drives the page (or a base snapshot) and writes one settled artifact directory; every other leaf below reads that artifact and never re-drives the browser unless its target is a URL, which first creates a snapshot. Findings exit 0 \u2014 a report, not a failure. \`--gate\` (exit 2 on findings or changes) is accepted only by check and diff. \`explain\` (and \`map paint\`) take the snapshot as \`<snap>\` positionally and name the element with a required \`--selector\`, unlike every other leaf's plain \`[url|snap]\` target.</model>
|
|
243969
244000
|
<subcommand name="snap" description="settled snapshot artifact" whenToUse="Use to capture the static rendered-page substrate that measure queries read."/>
|
|
243970
244001
|
<subcommand name="check" description="snapshot threshold measurements" whenToUse="Use to measure selected layout and content thresholds from one snapshot."/>
|
|
243971
244002
|
<subcommand name="diff" description="snapshot delta" whenToUse="Use to compare the measured facts from two snapshots."/>
|
|
@@ -243976,7 +244007,7 @@ use for static rendered-page facts from a settled snapshot: layout, content, cas
|
|
|
243976
244007
|
<subcommand name="map" description="one snapshot facet" whenToUse="Use to read focus, scroll, layer, accessibility, or paint facts from a snapshot."/>
|
|
243977
244008
|
</command>`;
|
|
243978
244009
|
MEASURE_MAP_USAGE = `<command name="map" description="one facet of a snapshot's substrate">
|
|
243979
|
-
<model>Focus, scroll, layers, and ax accept a URL target and create a snapshot first; paint requires an existing snapshot and reads it without browser driving.</model>
|
|
244010
|
+
<model>Focus, scroll, layers, and ax accept a URL target and create a snapshot first; paint requires an existing snapshot (\`<snap>\`, no URL) plus a required \`--selector\` naming the element, and reads it without browser driving.</model>
|
|
243980
244011
|
<subcommand name="focus" description="keyboard traversal order" whenToUse="Use to inspect focus order from a snapshot."/>
|
|
243981
244012
|
<subcommand name="scroll" description="scroll-container topology" whenToUse="Use to inspect scroll containers and their relationships."/>
|
|
243982
244013
|
<subcommand name="layers" description="paint and compositor layers" whenToUse="Use to inspect layer allocation and paint order."/>
|
|
@@ -245996,8 +246027,8 @@ var init_motion2 = __esm({
|
|
|
245996
246027
|
init_jank2();
|
|
245997
246028
|
init_response2();
|
|
245998
246029
|
COMMAND_BLOCK7 = `<command name="motion">
|
|
245999
|
-
|
|
246000
|
-
use
|
|
246030
|
+
screen recording \u2014 \`rec\` screencasts the tab through an interaction; every other leaf answers what moved, where, and how long it took from that recording's frames
|
|
246031
|
+
use when a claim depends on change over time: element motion across frames, which screen regions changed, layout shifts and long tasks, or the time from an input to a settled screen; for trace-event timing use \`perf\` instead
|
|
246001
246032
|
</command>`;
|
|
246002
246033
|
MOTION_USAGE = `<command name="motion" description="recording lifecycle and read-only queries over a finalized recording">
|
|
246003
246034
|
<model>\`rec\` drives and records the browser, one-shot or composed across intervening commands; every other leaf below reads the finalized recording artifact. Findings exit 0 \u2014 a report, not a failure; input and precondition errors exit 1. No leaf accepts --gate.</model>
|
|
@@ -265772,8 +265803,8 @@ var init_perf = __esm({
|
|
|
265772
265803
|
init_vitals();
|
|
265773
265804
|
init_insights2();
|
|
265774
265805
|
COMMAND_BLOCK8 = `<command name="perf">
|
|
265775
|
-
|
|
265776
|
-
use
|
|
265806
|
+
Chrome performance tracing \u2014 \`trace\` records a DevTools performance trace over a page load or an interaction; \`vitals\` and \`insights\` read it back without re-running anything
|
|
265807
|
+
use when a claim depends on runtime performance: LCP, INP, and CLS with attribution, or DevTools' computed insights such as render-blocking requests, forced reflow, and layout-shift culprits
|
|
265777
265808
|
</command>`;
|
|
265778
265809
|
HELP7 = `<command name="perf" description="the Chrome performance-trace substrate">
|
|
265779
265810
|
<model>\`trace\` drives (and records) the browser, one-shot or composed across intervening commands; the other leaves are cheap reads over the finalized trace artifact. Findings exit 0 \u2014 a report, not a failure; input/precondition errors exit 1. No leaf accepts --gate.</model>
|
|
@@ -266726,8 +266757,8 @@ var init_heap2 = __esm({
|
|
|
266726
266757
|
init_retainers();
|
|
266727
266758
|
init_diff4();
|
|
266728
266759
|
COMMAND_BLOCK9 = `<command name="heap">
|
|
266729
|
-
|
|
266730
|
-
use
|
|
266760
|
+
JavaScript memory \u2014 \`snapshot\` writes the tab's V8 heap snapshot; every other leaf queries that object graph without touching the browser
|
|
266761
|
+
use when a claim depends on memory: what the heap holds per constructor, which objects exist, what keeps one alive, duplicated strings, or what grew between two snapshots when hunting a leak
|
|
266731
266762
|
</command>`;
|
|
266732
266763
|
HELP13 = `<command name="heap" description="the V8 heap-snapshot substrate">
|
|
266733
266764
|
<model>\`snapshot\` drives the browser; every other leaf is a read of the finalized snapshot artifact and never touches Chrome. Retained sizes are dominator-subtree sums of Chrome's own self_size. Findings exit 0; input/precondition errors exit 1. No leaf accepts --gate.</model>
|
|
@@ -266843,7 +266874,7 @@ async function releaseTracing(reservation) {
|
|
|
266843
266874
|
async function pageForSessionTarget(port, targetId) {
|
|
266844
266875
|
const lighthouseRequire = (0, import_node_module.createRequire)(require.resolve("lighthouse"));
|
|
266845
266876
|
const puppeteer = lighthouseRequire("puppeteer-core");
|
|
266846
|
-
const browser = await puppeteer.connect({ browserURL: `http
|
|
266877
|
+
const browser = await puppeteer.connect({ browserURL: `http://${CDP_LOOPBACK_HOST}:${port}`, defaultViewport: null });
|
|
266847
266878
|
try {
|
|
266848
266879
|
for (const page of await browser.pages()) {
|
|
266849
266880
|
const session = await page.target().createCDPSession();
|
|
@@ -267050,6 +267081,7 @@ var init_lighthouse = __esm({
|
|
|
267050
267081
|
import_node_module = require("node:module");
|
|
267051
267082
|
init_detect();
|
|
267052
267083
|
init_errors();
|
|
267084
|
+
init_loopback();
|
|
267053
267085
|
init_selection();
|
|
267054
267086
|
init_artifacts();
|
|
267055
267087
|
init_session_context();
|
|
@@ -267060,7 +267092,7 @@ var init_lighthouse = __esm({
|
|
|
267060
267092
|
init_lifecycle();
|
|
267061
267093
|
init_render();
|
|
267062
267094
|
COMMAND_BLOCK10 = `<command name="lighthouse">
|
|
267063
|
-
|
|
267095
|
+
Google Lighthouse audits \u2014 capture runs Lighthouse against a URL and stores its scored report unmodified
|
|
267064
267096
|
use for one Lighthouse report's audited accessibility, SEO, performance, or best-practices conditions, categories, scores, and audits
|
|
267065
267097
|
</command>`;
|
|
267066
267098
|
HELP14 = `capture lighthouse <url> [--categories <list>] [--preset mobile|desktop] [--limit <N>] [--out <path>] \u2014 run Lighthouse's third-party scored diagnostic sweep against a URL and store its report
|
|
@@ -267289,14 +267321,14 @@ function rootHelp() {
|
|
|
267289
267321
|
|
|
267290
267322
|
${ROOT_BLOCKS.join("\n\n")}
|
|
267291
267323
|
|
|
267292
|
-
Selecting a noun: session, page, and tab address the live browser
|
|
267324
|
+
Selecting a noun: session, page, and tab address the live browser; measure, motion, perf, and heap each record once and then answer many questions from that recording \u2014 a settled snapshot, a screencast, a performance trace, a heap snapshot; lighthouse, cdp, and lib are third-party audit, raw protocol, and local-library surfaces. Record with the noun whose artifact holds the facts you need, then query it.
|
|
267293
267325
|
|
|
267294
267326
|
I/O contract: flags and positionals on input; one rendered prose block on stdout. --json mirrors the same result as JSON, but the rendered block is the contract. stderr carries in-flight diagnostics only. --artifact-dir <path> selects the root for a session bundle (\`session start\`) or a sessionless artifact-producing leaf's one-shot bundle; leaf help states when it writes one. --out always names one caller-owned file, never a bundle directory.
|
|
267295
267327
|
|
|
267296
267328
|
A CDP-enabled browser must be running; \`capture tab list\` is the probe, and \`capture tab launch\` starts one capture owns and reaps when nothing is (never hand-roll a detached browser \u2014 nothing reaps that). Capture never signals a browser it did not start, so attaching to your own with --port is unchanged. CDP_PORT / CDP_TARGET pin the browser + tab for target-selecting commands \u2014 precedence: explicit flag > active session > env. \`session start\` adopts only an explicit --target.`;
|
|
267297
267329
|
}
|
|
267298
267330
|
function printVersion() {
|
|
267299
|
-
const declared = "1.4.
|
|
267331
|
+
const declared = "1.4.15";
|
|
267300
267332
|
if (declared) {
|
|
267301
267333
|
console.log(declared);
|
|
267302
267334
|
return;
|
package/package.json
CHANGED
package/src/capture.ts
CHANGED
|
@@ -61,7 +61,7 @@ function rootHelp(): string {
|
|
|
61
61
|
|
|
62
62
|
${ROOT_BLOCKS.join('\n\n')}
|
|
63
63
|
|
|
64
|
-
Selecting a noun: session, page, and tab address the live browser
|
|
64
|
+
Selecting a noun: session, page, and tab address the live browser; measure, motion, perf, and heap each record once and then answer many questions from that recording — a settled snapshot, a screencast, a performance trace, a heap snapshot; lighthouse, cdp, and lib are third-party audit, raw protocol, and local-library surfaces. Record with the noun whose artifact holds the facts you need, then query it.
|
|
65
65
|
|
|
66
66
|
I/O contract: flags and positionals on input; one rendered prose block on stdout. --json mirrors the same result as JSON, but the rendered block is the contract. stderr carries in-flight diagnostics only. --artifact-dir <path> selects the root for a session bundle (\`session start\`) or a sessionless artifact-producing leaf's one-shot bundle; leaf help states when it writes one. --out always names one caller-owned file, never a bundle directory.
|
|
67
67
|
|
|
@@ -35,12 +35,13 @@
|
|
|
35
35
|
* rather than living until reboot.
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
import { spawn } from 'node:child_process';
|
|
38
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
39
39
|
import * as crypto from 'node:crypto';
|
|
40
40
|
import * as fs from 'node:fs';
|
|
41
41
|
import * as os from 'node:os';
|
|
42
42
|
import * as path from 'node:path';
|
|
43
|
-
import { captureError } from '../errors.js';
|
|
43
|
+
import { CaptureError, captureError } from '../errors.js';
|
|
44
|
+
import { CDP_LOOPBACK_HOST } from './loopback.js';
|
|
44
45
|
import {
|
|
45
46
|
CAPTURE_ROOT,
|
|
46
47
|
assertUnderCaptureRoot,
|
|
@@ -230,7 +231,7 @@ async function reap(record: OwnedBrowser): Promise<void> {
|
|
|
230
231
|
|
|
231
232
|
async function endpointAnswers(port: number): Promise<boolean> {
|
|
232
233
|
try {
|
|
233
|
-
const response = await fetch(`http
|
|
234
|
+
const response = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`, {
|
|
234
235
|
signal: AbortSignal.timeout(ENDPOINT_PROBE_MS),
|
|
235
236
|
});
|
|
236
237
|
return response.ok;
|
|
@@ -421,6 +422,23 @@ function devToolsPort(profileDir: string): number | null {
|
|
|
421
422
|
}
|
|
422
423
|
}
|
|
423
424
|
|
|
425
|
+
/** Refuses an explicit debugging port already listened on through either
|
|
426
|
+
* address family. `lsof` exits 1 when it found no listener; every other
|
|
427
|
+
* failure is a failed precondition check, never permission to launch into an
|
|
428
|
+
* endpoint whose identity capture cannot establish. */
|
|
429
|
+
export function assertPortUnbound(port: number): void {
|
|
430
|
+
try {
|
|
431
|
+
const output = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-F', 'n'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
432
|
+
if (output.trim()) {
|
|
433
|
+
throw captureError('precondition', 'browser_port_in_use', `received: port ${port}; expected: no TCP listener on that port. Capture refuses to launch because the endpoint would be ambiguous across loopback address families.`);
|
|
434
|
+
}
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if ((error as NodeJS.ErrnoException).status === 1) return;
|
|
437
|
+
if (error instanceof CaptureError && error.descriptor.code === 'browser_port_in_use') throw error;
|
|
438
|
+
throw captureError('world', 'browser_port_check_failed', `received: port ${port}; checking TCP listeners with lsof failed, so Capture cannot prove the port is unbound: ${error instanceof Error ? error.message : String(error)}`, error);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
424
442
|
export interface LaunchOptions {
|
|
425
443
|
/** First page to open; `about:blank` when unset. */
|
|
426
444
|
url?: string;
|
|
@@ -436,11 +454,13 @@ export interface LaunchOptions {
|
|
|
436
454
|
* `startBridge()` applies to the bridge process it spawns.
|
|
437
455
|
*/
|
|
438
456
|
export async function launchOwnedBrowser(options: LaunchOptions): Promise<OwnedBrowser> {
|
|
457
|
+
if (options.port !== undefined) assertPortUnbound(options.port);
|
|
439
458
|
const executablePath = resolveBrowserExecutable(options.headless);
|
|
440
459
|
const token = crypto.randomBytes(8).toString('hex');
|
|
441
460
|
const profileDir = ensurePrivateDir(profilePath(token));
|
|
442
461
|
const args = [
|
|
443
462
|
`--remote-debugging-port=${options.port ?? 0}`,
|
|
463
|
+
`--remote-debugging-address=${CDP_LOOPBACK_HOST}`,
|
|
444
464
|
`--user-data-dir=${profileDir}`,
|
|
445
465
|
'--no-first-run',
|
|
446
466
|
'--no-default-browser-check',
|
|
@@ -7,8 +7,8 @@ import { cmdHeapRetainers } from './retainers.js';
|
|
|
7
7
|
import { cmdHeapDiff } from './diff.js';
|
|
8
8
|
|
|
9
9
|
export const COMMAND_BLOCK = `<command name="heap">
|
|
10
|
-
|
|
11
|
-
use
|
|
10
|
+
JavaScript memory — \`snapshot\` writes the tab's V8 heap snapshot; every other leaf queries that object graph without touching the browser
|
|
11
|
+
use when a claim depends on memory: what the heap holds per constructor, which objects exist, what keeps one alive, duplicated strings, or what grew between two snapshots when hunting a leak
|
|
12
12
|
</command>`;
|
|
13
13
|
|
|
14
14
|
const HELP = `<command name="heap" description="the V8 heap-snapshot substrate">
|
|
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module';
|
|
|
5
5
|
import { detectCdpPort } from '../detect.js';
|
|
6
6
|
import { captureError } from '../../errors.js';
|
|
7
7
|
import { type ParsedArgs } from '../types.js';
|
|
8
|
+
import { CDP_LOOPBACK_HOST } from '../loopback.js';
|
|
8
9
|
import { selectRecords } from '../../output/selection.js';
|
|
9
10
|
import { CAPTURE_ROOT, ensurePrivateDir, processPidBirthProvider, registerArtifactRoot, writeJsonPrivate, writePrivateFile } from '../../session/artifacts.js';
|
|
10
11
|
import { getActiveSession } from '../../session-context.js';
|
|
@@ -16,7 +17,7 @@ import { stopAndReapCollectorHostAtSessionStop } from '../host/lifecycle.js';
|
|
|
16
17
|
import { capped, emitResult, fact, formatArtifactList, line, lineList, text, type FactLine, type JsonValue, type RenderableResult } from '../../output/render.js';
|
|
17
18
|
|
|
18
19
|
export const COMMAND_BLOCK = `<command name="lighthouse">
|
|
19
|
-
|
|
20
|
+
Google Lighthouse audits — capture runs Lighthouse against a URL and stores its scored report unmodified
|
|
20
21
|
use for one Lighthouse report's audited accessibility, SEO, performance, or best-practices conditions, categories, scores, and audits
|
|
21
22
|
</command>`;
|
|
22
23
|
|
|
@@ -191,7 +192,7 @@ async function releaseTracing(reservation: TracingReservation): Promise<void> {
|
|
|
191
192
|
async function pageForSessionTarget(port: number, targetId: string): Promise<{ browser: PuppeteerBrowser; page: LighthousePage }> {
|
|
192
193
|
const lighthouseRequire = createRequire(require.resolve('lighthouse'));
|
|
193
194
|
const puppeteer = lighthouseRequire('puppeteer-core') as PuppeteerModule;
|
|
194
|
-
const browser = await puppeteer.connect({ browserURL: `http
|
|
195
|
+
const browser = await puppeteer.connect({ browserURL: `http://${CDP_LOOPBACK_HOST}:${port}`, defaultViewport: null });
|
|
195
196
|
try {
|
|
196
197
|
for (const page of await browser.pages()) {
|
|
197
198
|
const session = await page.target().createCDPSession();
|
|
@@ -24,12 +24,12 @@ import { cmdMeasureMapPaint } from './map-paint.js';
|
|
|
24
24
|
|
|
25
25
|
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
26
26
|
export const COMMAND_BLOCK = `<command name="measure">
|
|
27
|
-
|
|
28
|
-
use
|
|
27
|
+
static page measurement — \`snap\` waits for the page to settle, then records its layout, styles, text, and accessibility into one artifact; every other leaf answers questions from that artifact without re-driving the browser
|
|
28
|
+
use when a claim depends on what is rendered right now: element geometry, overflow, clipping, stacking, computed style and where it came from, text and contrast, focus order, scroll containers, paint coverage, or the difference between two snapshots
|
|
29
29
|
</command>`;
|
|
30
30
|
|
|
31
31
|
export const MEASURE_USAGE = `<command name="measure" description="enriched snapshot substrate and read-only queries">
|
|
32
|
-
<model>\`snap\` drives the page (or a base snapshot) and writes one settled artifact directory; every other leaf below reads that artifact and never re-drives the browser unless its target is a URL, which first creates a snapshot. Findings exit 0 — a report, not a failure. \`--gate\` (exit 2 on findings or changes) is accepted only by check and diff.</model>
|
|
32
|
+
<model>\`snap\` drives the page (or a base snapshot) and writes one settled artifact directory; every other leaf below reads that artifact and never re-drives the browser unless its target is a URL, which first creates a snapshot. Findings exit 0 — a report, not a failure. \`--gate\` (exit 2 on findings or changes) is accepted only by check and diff. \`explain\` (and \`map paint\`) take the snapshot as \`<snap>\` positionally and name the element with a required \`--selector\`, unlike every other leaf's plain \`[url|snap]\` target.</model>
|
|
33
33
|
<subcommand name="snap" description="settled snapshot artifact" whenToUse="Use to capture the static rendered-page substrate that measure queries read."/>
|
|
34
34
|
<subcommand name="check" description="snapshot threshold measurements" whenToUse="Use to measure selected layout and content thresholds from one snapshot."/>
|
|
35
35
|
<subcommand name="diff" description="snapshot delta" whenToUse="Use to compare the measured facts from two snapshots."/>
|
|
@@ -41,7 +41,7 @@ export const MEASURE_USAGE = `<command name="measure" description="enriched snap
|
|
|
41
41
|
</command>`;
|
|
42
42
|
|
|
43
43
|
export const MEASURE_MAP_USAGE = `<command name="map" description="one facet of a snapshot's substrate">
|
|
44
|
-
<model>Focus, scroll, layers, and ax accept a URL target and create a snapshot first; paint requires an existing snapshot and reads it without browser driving.</model>
|
|
44
|
+
<model>Focus, scroll, layers, and ax accept a URL target and create a snapshot first; paint requires an existing snapshot (\`<snap>\`, no URL) plus a required \`--selector\` naming the element, and reads it without browser driving.</model>
|
|
45
45
|
<subcommand name="focus" description="keyboard traversal order" whenToUse="Use to inspect focus order from a snapshot."/>
|
|
46
46
|
<subcommand name="scroll" description="scroll-container topology" whenToUse="Use to inspect scroll containers and their relationships."/>
|
|
47
47
|
<subcommand name="layers" description="paint and compositor layers" whenToUse="Use to inspect layer allocation and paint order."/>
|
|
@@ -83,7 +83,7 @@ function artifactEntries(dir: string, artifacts: readonly string[], listCrops: b
|
|
|
83
83
|
if (!listCrops && name.startsWith('crops/')) continue;
|
|
84
84
|
entries.push({
|
|
85
85
|
name,
|
|
86
|
-
...(name === 'geometry.json' && geometryCount !== undefined ? { note: `${geometryCount} elements` } : {}),
|
|
86
|
+
...(name === 'geometry.json' && geometryCount !== undefined ? { note: `${geometryCount} elements at .elements[]` } : {}),
|
|
87
87
|
});
|
|
88
88
|
}
|
|
89
89
|
if (!listCrops && crops.length) entries.push({ name: 'crops/', note: `${crops.length} crops; pass --list-crops for paths` });
|
|
@@ -16,8 +16,8 @@ import { cmdMotionResponse } from './response.js';
|
|
|
16
16
|
|
|
17
17
|
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
18
18
|
export const COMMAND_BLOCK = `<command name="motion">
|
|
19
|
-
|
|
20
|
-
use
|
|
19
|
+
screen recording — \`rec\` screencasts the tab through an interaction; every other leaf answers what moved, where, and how long it took from that recording's frames
|
|
20
|
+
use when a claim depends on change over time: element motion across frames, which screen regions changed, layout shifts and long tasks, or the time from an input to a settled screen; for trace-event timing use \`perf\` instead
|
|
21
21
|
</command>`;
|
|
22
22
|
|
|
23
23
|
export const MOTION_USAGE = `<command name="motion" description="recording lifecycle and read-only queries over a finalized recording">
|
|
@@ -5,8 +5,8 @@ import { cmdPerfVitals } from './vitals.js';
|
|
|
5
5
|
import { cmdPerfInsights } from './insights.js';
|
|
6
6
|
|
|
7
7
|
export const COMMAND_BLOCK = `<command name="perf">
|
|
8
|
-
|
|
9
|
-
use
|
|
8
|
+
Chrome performance tracing — \`trace\` records a DevTools performance trace over a page load or an interaction; \`vitals\` and \`insights\` read it back without re-running anything
|
|
9
|
+
use when a claim depends on runtime performance: LCP, INP, and CLS with attribution, or DevTools' computed insights such as render-blocking requests, forced reflow, and layout-shift culprits
|
|
10
10
|
</command>`;
|
|
11
11
|
|
|
12
12
|
const HELP = `<command name="perf" description="the Chrome performance-trace substrate">
|
|
@@ -21,7 +21,7 @@ import { tabMockMain } from './mock/index.js';
|
|
|
21
21
|
|
|
22
22
|
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
23
23
|
export const COMMAND_BLOCK = `<command name="tab">
|
|
24
|
-
browser and tab plumbing —
|
|
24
|
+
browser and tab plumbing — launch or quit a browser capture owns, discover endpoints and tabs, open/close/reset tabs, and set the tab's network conditions (throttling, offline, mocked responses)
|
|
25
25
|
use when starting, finding, opening, closing, or resetting a browser tab, or changing its network conditions
|
|
26
26
|
</command>`;
|
|
27
27
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Ownership, registry, and reaping all live in `../../browser-process.ts`; this
|
|
10
10
|
* leaf is the surface over them.
|
|
11
11
|
*/
|
|
12
|
-
import { launchOwnedBrowser, sweepOwnedBrowsers, IDLE_REAP_MS } from '../../browser-process.js';
|
|
12
|
+
import { assertPortUnbound, launchOwnedBrowser, sweepOwnedBrowsers, IDLE_REAP_MS } from '../../browser-process.js';
|
|
13
13
|
import { type ParsedArgs } from '../../types.js';
|
|
14
14
|
import { data, emitResult, fact, line, lineList, text, type RenderableResult } from '../../../output/render.js';
|
|
15
15
|
|
|
@@ -54,6 +54,9 @@ export async function cmdTabLaunch(parsed: ParsedArgs, _args: string[]): Promise
|
|
|
54
54
|
return;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// An explicit port must be checked before a sweep can reap an owned listener
|
|
58
|
+
// on it: this invocation must reject ambiguity, never create it by replacement.
|
|
59
|
+
if (parsed.port !== undefined) assertPortUnbound(parsed.port);
|
|
57
60
|
const swept = await sweepOwnedBrowsers();
|
|
58
61
|
const browser = await launchOwnedBrowser({
|
|
59
62
|
url: parsed.url,
|
package/src/cdp/detect.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
2
|
import * as http from 'http';
|
|
3
|
+
import { CDP_LOOPBACK_HOST } from './loopback.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* A CDP-endpoint JSON probe that CANNOT leak a socket handle. Node's built-in
|
|
@@ -21,7 +22,7 @@ function httpGetJson(
|
|
|
21
22
|
): Promise<unknown | null> {
|
|
22
23
|
return new Promise((resolve) => {
|
|
23
24
|
const req = http.get(
|
|
24
|
-
{ host:
|
|
25
|
+
{ host: CDP_LOOPBACK_HOST, port, path, timeout: timeoutMs },
|
|
25
26
|
(res) => {
|
|
26
27
|
const status = res.statusCode ?? 0;
|
|
27
28
|
if (status < 200 || status >= 300) {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Capture addresses CDP endpoints on one literal loopback address, never the
|
|
2
|
+
* hostname `localhost`: the hostname can resolve to either address family,
|
|
3
|
+
* which makes two listeners sharing a port an ambiguous endpoint. */
|
|
4
|
+
export const CDP_LOOPBACK_HOST = '127.0.0.1';
|
package/src/cdp/targets.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CDPClient } from './client.js';
|
|
2
|
+
import { CDP_LOOPBACK_HOST } from './loopback.js';
|
|
2
3
|
import { detectCdpPortsAsync } from './detect.js';
|
|
3
4
|
import { touchOwnedBrowser } from './browser-process.js';
|
|
4
5
|
import { type CDPTarget } from './types.js';
|
|
@@ -13,7 +14,7 @@ import { type CDPTarget } from './types.js';
|
|
|
13
14
|
export async function getBrowserClient(
|
|
14
15
|
port: number,
|
|
15
16
|
): Promise<{ client: CDPClient; browserWsUrl: string }> {
|
|
16
|
-
const versionResp = await fetch(`http
|
|
17
|
+
const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`);
|
|
17
18
|
if (!versionResp.ok) {
|
|
18
19
|
throw new Error(
|
|
19
20
|
`Failed to connect to CDP on port ${port}: ${versionResp.status}`,
|
|
@@ -29,13 +30,15 @@ export async function getBrowserClient(
|
|
|
29
30
|
`Run "capture tab list" to find a real browser endpoint, or pass --port explicitly.`,
|
|
30
31
|
);
|
|
31
32
|
}
|
|
32
|
-
const
|
|
33
|
+
const browserWsUrl = new URL(version.webSocketDebuggerUrl);
|
|
34
|
+
browserWsUrl.hostname = CDP_LOOPBACK_HOST;
|
|
35
|
+
const client = new CDPClient(browserWsUrl.toString());
|
|
33
36
|
await client.waitReady();
|
|
34
37
|
// Every browser-level connection funnels through here, which makes this the
|
|
35
38
|
// one place that can tell capture's idle sweep the browser is still in use.
|
|
36
39
|
// A port capture did not launch has no registry record and this is a no-op.
|
|
37
40
|
touchOwnedBrowser(port);
|
|
38
|
-
return { client, browserWsUrl:
|
|
41
|
+
return { client, browserWsUrl: browserWsUrl.toString() };
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
export async function listTargets(port: number): Promise<CDPTarget[]> {
|
|
@@ -59,7 +62,7 @@ export async function listTargets(port: number): Promise<CDPTarget[]> {
|
|
|
59
62
|
title: t.title,
|
|
60
63
|
url: t.url,
|
|
61
64
|
type: t.type,
|
|
62
|
-
webSocketDebuggerUrl: `ws
|
|
65
|
+
webSocketDebuggerUrl: `ws://${CDP_LOOPBACK_HOST}:${port}/devtools/page/${t.targetId}`,
|
|
63
66
|
}));
|
|
64
67
|
} finally {
|
|
65
68
|
client.close();
|
|
@@ -152,7 +155,9 @@ function normalizeUrlForMatch(value: string): { full: string; host: string; path
|
|
|
152
155
|
const path = url.pathname.replace(/\/+$/, '') || '/';
|
|
153
156
|
return {
|
|
154
157
|
full: `${url.origin}${path}${url.search}${url.hash}`.toLowerCase(),
|
|
155
|
-
host
|
|
158
|
+
// `host` (not `hostname`): two local dev servers on the same loopback
|
|
159
|
+
// address but different ports must not register as a host match.
|
|
160
|
+
host: url.host.toLowerCase(),
|
|
156
161
|
path: path.toLowerCase(),
|
|
157
162
|
};
|
|
158
163
|
} catch {
|
|
@@ -276,7 +281,7 @@ export async function openTab(port: number, url: string): Promise<CDPTarget> {
|
|
|
276
281
|
title: '',
|
|
277
282
|
url,
|
|
278
283
|
type: 'page',
|
|
279
|
-
webSocketDebuggerUrl: `ws
|
|
284
|
+
webSocketDebuggerUrl: `ws://${CDP_LOOPBACK_HOST}:${port}/devtools/page/${targetId}`,
|
|
280
285
|
};
|
|
281
286
|
} finally {
|
|
282
287
|
client.close();
|
package/src/session/commands.ts
CHANGED
|
@@ -383,7 +383,7 @@ export async function waitForPageLoad(
|
|
|
383
383
|
|
|
384
384
|
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
385
385
|
export const COMMAND_BLOCK = `<command name="session">
|
|
386
|
-
the
|
|
386
|
+
the work's evidence bundle — \`start\` opens or adopts a tab, records its network traffic (HAR) and external logs, and collects every artifact into one directory; while running, every command auto-targets its tab
|
|
387
387
|
use when collecting or reading the traffic, logs, and artifacts of scoped work against one page
|
|
388
388
|
</command>`;
|
|
389
389
|
|
|
@@ -1103,6 +1103,7 @@ function responseEndDateTime(
|
|
|
1103
1103
|
}
|
|
1104
1104
|
|
|
1105
1105
|
function completedTiming(e: HAREntry): FactLine {
|
|
1106
|
+
if ('_resourceType' in e) return fact`WebSocket: ${e._webSocketMessages.length} frame(s) recorded`;
|
|
1106
1107
|
const { clocks, terminal } = e._capture;
|
|
1107
1108
|
if (terminal.kind === 'failed') {
|
|
1108
1109
|
return fact`duration ${e.time} ms — response incomplete: failed`;
|