@crouton-kit/capture 1.4.14 → 1.4.16
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 +90 -27
- package/package.json +1 -1
- package/src/cdp/browser-process.ts +23 -3
- package/src/cdp/commands/lighthouse.ts +2 -1
- package/src/cdp/commands/measure/index.ts +2 -2
- package/src/cdp/commands/measure/snap.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 +33 -6
- package/src/session/commands.ts +25 -12
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;
|
|
@@ -5886,6 +5910,7 @@ var targets_exports = {};
|
|
|
5886
5910
|
__export(targets_exports, {
|
|
5887
5911
|
AmbiguousPageTargetError: () => AmbiguousPageTargetError,
|
|
5888
5912
|
closeTarget: () => closeTarget,
|
|
5913
|
+
findResponsivePageTabByUrl: () => findResponsivePageTabByUrl,
|
|
5889
5914
|
findTab: () => findTab,
|
|
5890
5915
|
findTabById: () => findTabById,
|
|
5891
5916
|
findTabByIdAcrossEndpoints: () => findTabByIdAcrossEndpoints,
|
|
@@ -5899,7 +5924,7 @@ __export(targets_exports, {
|
|
|
5899
5924
|
scoreTabUrlMatch: () => scoreTabUrlMatch
|
|
5900
5925
|
});
|
|
5901
5926
|
async function getBrowserClient(port) {
|
|
5902
|
-
const versionResp = await fetch(`http
|
|
5927
|
+
const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`);
|
|
5903
5928
|
if (!versionResp.ok) {
|
|
5904
5929
|
throw new Error(
|
|
5905
5930
|
`Failed to connect to CDP on port ${port}: ${versionResp.status}`
|
|
@@ -5911,10 +5936,12 @@ async function getBrowserClient(port) {
|
|
|
5911
5936
|
`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
5937
|
);
|
|
5913
5938
|
}
|
|
5914
|
-
const
|
|
5939
|
+
const browserWsUrl = new URL(version.webSocketDebuggerUrl);
|
|
5940
|
+
browserWsUrl.hostname = CDP_LOOPBACK_HOST;
|
|
5941
|
+
const client = new CDPClient(browserWsUrl.toString());
|
|
5915
5942
|
await client.waitReady();
|
|
5916
5943
|
touchOwnedBrowser(port);
|
|
5917
|
-
return { client, browserWsUrl:
|
|
5944
|
+
return { client, browserWsUrl: browserWsUrl.toString() };
|
|
5918
5945
|
}
|
|
5919
5946
|
async function listTargets(port) {
|
|
5920
5947
|
const { client } = await getBrowserClient(port);
|
|
@@ -5926,7 +5953,7 @@ async function listTargets(port) {
|
|
|
5926
5953
|
title: t.title,
|
|
5927
5954
|
url: t.url,
|
|
5928
5955
|
type: t.type,
|
|
5929
|
-
webSocketDebuggerUrl: `ws
|
|
5956
|
+
webSocketDebuggerUrl: `ws://${CDP_LOOPBACK_HOST}:${port}/devtools/page/${t.targetId}`
|
|
5930
5957
|
}));
|
|
5931
5958
|
} finally {
|
|
5932
5959
|
client.close();
|
|
@@ -5985,7 +6012,9 @@ function normalizeUrlForMatch(value) {
|
|
|
5985
6012
|
const path48 = url.pathname.replace(/\/+$/, "") || "/";
|
|
5986
6013
|
return {
|
|
5987
6014
|
full: `${url.origin}${path48}${url.search}${url.hash}`.toLowerCase(),
|
|
5988
|
-
host
|
|
6015
|
+
// `host` (not `hostname`): two local dev servers on the same loopback
|
|
6016
|
+
// address but different ports must not register as a host match.
|
|
6017
|
+
host: url.host.toLowerCase(),
|
|
5989
6018
|
path: path48.toLowerCase()
|
|
5990
6019
|
};
|
|
5991
6020
|
} catch {
|
|
@@ -6021,6 +6050,25 @@ async function findTabByIdAcrossEndpoints(targetId, preferredPort) {
|
|
|
6021
6050
|
const ports = resolveSearchPorts(await detectCdpPortsAsync(), preferredPort);
|
|
6022
6051
|
return findTabByIdInPorts(targetId, ports);
|
|
6023
6052
|
}
|
|
6053
|
+
async function findResponsivePageTabByUrl(port, url) {
|
|
6054
|
+
const pages = (await listTargets(port)).filter((target) => target.type === "page");
|
|
6055
|
+
const scores = pages.map((target) => scoreTabUrlMatch(target.url, url));
|
|
6056
|
+
const bestScore = Math.max(0, ...scores);
|
|
6057
|
+
if (bestScore === 0) return null;
|
|
6058
|
+
for (const target of pages.filter((_, index) => scores[index] === bestScore)) {
|
|
6059
|
+
if (!target.webSocketDebuggerUrl) continue;
|
|
6060
|
+
const client = new CDPClient(target.webSocketDebuggerUrl);
|
|
6061
|
+
try {
|
|
6062
|
+
await client.waitReady();
|
|
6063
|
+
await client.send("Page.captureScreenshot", { format: "png" }, 5e3);
|
|
6064
|
+
return target;
|
|
6065
|
+
} catch {
|
|
6066
|
+
} finally {
|
|
6067
|
+
client.close();
|
|
6068
|
+
}
|
|
6069
|
+
}
|
|
6070
|
+
return null;
|
|
6071
|
+
}
|
|
6024
6072
|
async function findTabByUrlAcrossEndpoints(url, preferredPort) {
|
|
6025
6073
|
const ports = resolveSearchPorts(await detectCdpPortsAsync(), preferredPort);
|
|
6026
6074
|
let best = null;
|
|
@@ -6071,7 +6119,7 @@ async function openTab(port, url) {
|
|
|
6071
6119
|
title: "",
|
|
6072
6120
|
url,
|
|
6073
6121
|
type: "page",
|
|
6074
|
-
webSocketDebuggerUrl: `ws
|
|
6122
|
+
webSocketDebuggerUrl: `ws://${CDP_LOOPBACK_HOST}:${port}/devtools/page/${targetId}`
|
|
6075
6123
|
};
|
|
6076
6124
|
} finally {
|
|
6077
6125
|
client.close();
|
|
@@ -6081,6 +6129,7 @@ var AmbiguousPageTargetError;
|
|
|
6081
6129
|
var init_targets = __esm({
|
|
6082
6130
|
"src/cdp/targets.ts"() {
|
|
6083
6131
|
init_client();
|
|
6132
|
+
init_loopback();
|
|
6084
6133
|
init_detect();
|
|
6085
6134
|
init_browser_process();
|
|
6086
6135
|
AmbiguousPageTargetError = class extends Error {
|
|
@@ -10512,7 +10561,7 @@ function validateHarAppendBatch(value, source) {
|
|
|
10512
10561
|
return { entries, incompleteLifecycles };
|
|
10513
10562
|
}
|
|
10514
10563
|
function captureVersion() {
|
|
10515
|
-
const version = "1.4.
|
|
10564
|
+
const version = "1.4.16";
|
|
10516
10565
|
return typeof version === "string" && version.length > 0 ? version : "development";
|
|
10517
10566
|
}
|
|
10518
10567
|
function token() {
|
|
@@ -12614,7 +12663,7 @@ function wallTime(value) {
|
|
|
12614
12663
|
}
|
|
12615
12664
|
}
|
|
12616
12665
|
function captureVersion2() {
|
|
12617
|
-
const version = "1.4.
|
|
12666
|
+
const version = "1.4.16";
|
|
12618
12667
|
return typeof version === "string" && version.length > 0 ? version : "development";
|
|
12619
12668
|
}
|
|
12620
12669
|
function deepFreeze(value) {
|
|
@@ -15111,6 +15160,7 @@ async function start(parsed) {
|
|
|
15111
15160
|
let bridgeSocket = null;
|
|
15112
15161
|
let bridgePid = null;
|
|
15113
15162
|
let cdpPort = null;
|
|
15163
|
+
let targetWasOpened = false;
|
|
15114
15164
|
let selectedBrowser = null;
|
|
15115
15165
|
let endpointSelectionReason = null;
|
|
15116
15166
|
try {
|
|
@@ -15137,22 +15187,28 @@ async function start(parsed) {
|
|
|
15137
15187
|
}
|
|
15138
15188
|
}
|
|
15139
15189
|
if (url) {
|
|
15190
|
+
let opened = false;
|
|
15140
15191
|
try {
|
|
15141
15192
|
target = await startWorld.openTab(cdpPort, url);
|
|
15193
|
+
opened = true;
|
|
15194
|
+
targetWasOpened = true;
|
|
15142
15195
|
} catch (error4) {
|
|
15143
15196
|
const message = error4 instanceof Error ? error4.message : String(error4);
|
|
15144
|
-
if (message.toLowerCase().includes("not supported"))
|
|
15197
|
+
if (!message.toLowerCase().includes("not supported")) throw error4;
|
|
15198
|
+
target = await startWorld.findResponsivePageTabByUrl?.(cdpPort, url) ?? null;
|
|
15199
|
+
if (!target) {
|
|
15145
15200
|
throw worldFailure(
|
|
15146
|
-
`Target.createTarget is unsupported on port ${cdpPort}
|
|
15201
|
+
`Target.createTarget is unsupported on port ${cdpPort}, and no matching existing page target answered a screenshot. Run \`capture tab list --port ${cdpPort}\` to inspect its targets.`,
|
|
15147
15202
|
error4
|
|
15148
15203
|
);
|
|
15149
15204
|
}
|
|
15150
|
-
throw error4;
|
|
15151
15205
|
}
|
|
15152
|
-
|
|
15153
|
-
|
|
15154
|
-
|
|
15155
|
-
|
|
15206
|
+
if (opened) {
|
|
15207
|
+
const openedPort = cdpPort;
|
|
15208
|
+
const openedTargetId = target.id;
|
|
15209
|
+
acquired.push({ label: "opened target", release: () => startWorld.closeTarget(openedPort, openedTargetId) });
|
|
15210
|
+
pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
|
|
15211
|
+
}
|
|
15156
15212
|
}
|
|
15157
15213
|
if (hold) {
|
|
15158
15214
|
const bridge = await startWorld.startBridge(dir, cdpPort);
|
|
@@ -15182,7 +15238,7 @@ async function start(parsed) {
|
|
|
15182
15238
|
return;
|
|
15183
15239
|
}
|
|
15184
15240
|
const rows = [fact`bundle dir: ${dir}`];
|
|
15185
|
-
if (target) rows.push(
|
|
15241
|
+
if (target) rows.push(targetWasOpened ? fact`tab ${target.id} opened at ${url}` : fact`tab ${target.id} adopted at ${target.url}`);
|
|
15186
15242
|
if (cdpPort !== null) rows.push(
|
|
15187
15243
|
parsed.portSource === "env" ? fact`CDP endpoint on port ${cdpPort} selected from ambient CDP_PORT; --port overrides endpoint selection and --target overrides tab selection.` : parsed.port !== void 0 ? fact`CDP endpoint on port ${cdpPort} selected by explicit --port; --port overrides endpoint selection and --target overrides tab selection.` : fact`${selectedBrowser ?? "CDP endpoint"} on port ${cdpPort} auto-selected because it ${endpointSelectionReason ?? "was selected by automatic endpoint detection"}; capture-launched browser recency does not affect selection. --port overrides endpoint selection and --target overrides tab selection.`
|
|
15188
15244
|
);
|
|
@@ -15409,6 +15465,7 @@ function responseEndDateTime(requestWallTime, requestMonotonic, terminalMonotoni
|
|
|
15409
15465
|
return Number.isNaN(end.getTime()) ? null : end.toISOString();
|
|
15410
15466
|
}
|
|
15411
15467
|
function completedTiming(e) {
|
|
15468
|
+
if ("_resourceType" in e) return fact`WebSocket: ${e._webSocketMessages.length} frame(s) recorded`;
|
|
15412
15469
|
const { clocks, terminal: terminal2 } = e._capture;
|
|
15413
15470
|
if (terminal2.kind === "failed") {
|
|
15414
15471
|
return fact`duration ${e.time} ms — response incomplete: failed`;
|
|
@@ -16098,10 +16155,10 @@ use when collecting or reading the traffic, logs, and artifacts of scoped work a
|
|
|
16098
16155
|
|
|
16099
16156
|
input:
|
|
16100
16157
|
--url <url> Absolute URL to open in a fresh tab through CDP's
|
|
16101
|
-
Target.createTarget.
|
|
16102
|
-
|
|
16103
|
-
|
|
16104
|
-
--target.
|
|
16158
|
+
Target.createTarget. If an endpoint, including Electron,
|
|
16159
|
+
rejects Target.createTarget, capture adopts the matching
|
|
16160
|
+
existing page target that answers a screenshot. Conflicts
|
|
16161
|
+
with --target.
|
|
16105
16162
|
--target <tab-id> Existing page tab to adopt. Resolves a full target id or
|
|
16106
16163
|
an unambiguous prefix of at least four characters at the
|
|
16107
16164
|
selected CDP endpoint; start fails if it does not resolve.
|
|
@@ -16247,6 +16304,10 @@ effects:
|
|
|
16247
16304
|
const { findTabById: findTabById2 } = await Promise.resolve().then(() => (init_targets(), targets_exports));
|
|
16248
16305
|
return findTabById2(port, targetId);
|
|
16249
16306
|
},
|
|
16307
|
+
async findResponsivePageTabByUrl(port, url) {
|
|
16308
|
+
const { findResponsivePageTabByUrl: findResponsivePageTabByUrl2 } = await Promise.resolve().then(() => (init_targets(), targets_exports));
|
|
16309
|
+
return findResponsivePageTabByUrl2(port, url);
|
|
16310
|
+
},
|
|
16250
16311
|
async closeTarget(port, targetId) {
|
|
16251
16312
|
const { closeTarget: closeTarget2 } = await Promise.resolve().then(() => (init_targets(), targets_exports));
|
|
16252
16313
|
return closeTarget2(port, targetId);
|
|
@@ -229755,6 +229816,7 @@ async function cmdTabLaunch(parsed, _args) {
|
|
|
229755
229816
|
console.log(USAGE9);
|
|
229756
229817
|
return;
|
|
229757
229818
|
}
|
|
229819
|
+
if (parsed.port !== void 0) assertPortUnbound(parsed.port);
|
|
229758
229820
|
const swept = await sweepOwnedBrowsers();
|
|
229759
229821
|
const browser = await launchOwnedBrowser({
|
|
229760
229822
|
url: parsed.url,
|
|
@@ -239412,7 +239474,7 @@ function artifactEntries(dir, artifacts, listCrops) {
|
|
|
239412
239474
|
if (!listCrops && name.startsWith("crops/")) continue;
|
|
239413
239475
|
entries.push({
|
|
239414
239476
|
name,
|
|
239415
|
-
...name === "geometry.json" && geometryCount !== void 0 ? { note: `${geometryCount} elements` } : {}
|
|
239477
|
+
...name === "geometry.json" && geometryCount !== void 0 ? { note: `${geometryCount} elements at .elements[]` } : {}
|
|
239416
239478
|
});
|
|
239417
239479
|
}
|
|
239418
239480
|
if (!listCrops && crops.length) entries.push({ name: "crops/", note: `${crops.length} crops; pass --list-crops for paths` });
|
|
@@ -243965,7 +244027,7 @@ static page measurement \u2014 \`snap\` waits for the page to settle, then recor
|
|
|
243965
244027
|
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
244028
|
</command>`;
|
|
243967
244029
|
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>
|
|
244030
|
+
<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
244031
|
<subcommand name="snap" description="settled snapshot artifact" whenToUse="Use to capture the static rendered-page substrate that measure queries read."/>
|
|
243970
244032
|
<subcommand name="check" description="snapshot threshold measurements" whenToUse="Use to measure selected layout and content thresholds from one snapshot."/>
|
|
243971
244033
|
<subcommand name="diff" description="snapshot delta" whenToUse="Use to compare the measured facts from two snapshots."/>
|
|
@@ -243976,7 +244038,7 @@ use when a claim depends on what is rendered right now: element geometry, overfl
|
|
|
243976
244038
|
<subcommand name="map" description="one snapshot facet" whenToUse="Use to read focus, scroll, layer, accessibility, or paint facts from a snapshot."/>
|
|
243977
244039
|
</command>`;
|
|
243978
244040
|
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>
|
|
244041
|
+
<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
244042
|
<subcommand name="focus" description="keyboard traversal order" whenToUse="Use to inspect focus order from a snapshot."/>
|
|
243981
244043
|
<subcommand name="scroll" description="scroll-container topology" whenToUse="Use to inspect scroll containers and their relationships."/>
|
|
243982
244044
|
<subcommand name="layers" description="paint and compositor layers" whenToUse="Use to inspect layer allocation and paint order."/>
|
|
@@ -266843,7 +266905,7 @@ async function releaseTracing(reservation) {
|
|
|
266843
266905
|
async function pageForSessionTarget(port, targetId) {
|
|
266844
266906
|
const lighthouseRequire = (0, import_node_module.createRequire)(require.resolve("lighthouse"));
|
|
266845
266907
|
const puppeteer = lighthouseRequire("puppeteer-core");
|
|
266846
|
-
const browser = await puppeteer.connect({ browserURL: `http
|
|
266908
|
+
const browser = await puppeteer.connect({ browserURL: `http://${CDP_LOOPBACK_HOST}:${port}`, defaultViewport: null });
|
|
266847
266909
|
try {
|
|
266848
266910
|
for (const page of await browser.pages()) {
|
|
266849
266911
|
const session = await page.target().createCDPSession();
|
|
@@ -267050,6 +267112,7 @@ var init_lighthouse = __esm({
|
|
|
267050
267112
|
import_node_module = require("node:module");
|
|
267051
267113
|
init_detect();
|
|
267052
267114
|
init_errors();
|
|
267115
|
+
init_loopback();
|
|
267053
267116
|
init_selection();
|
|
267054
267117
|
init_artifacts();
|
|
267055
267118
|
init_session_context();
|
|
@@ -267296,7 +267359,7 @@ I/O contract: flags and positionals on input; one rendered prose block on stdout
|
|
|
267296
267359
|
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
267360
|
}
|
|
267298
267361
|
function printVersion() {
|
|
267299
|
-
const declared = "1.4.
|
|
267362
|
+
const declared = "1.4.16";
|
|
267300
267363
|
if (declared) {
|
|
267301
267364
|
console.log(declared);
|
|
267302
267365
|
return;
|
package/package.json
CHANGED
|
@@ -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',
|
|
@@ -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';
|
|
@@ -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();
|
|
@@ -29,7 +29,7 @@ use when a claim depends on what is rendered right now: element geometry, overfl
|
|
|
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` });
|
|
@@ -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 {
|
|
@@ -200,6 +205,28 @@ export async function findTabByIdAcrossEndpoints(
|
|
|
200
205
|
return findTabByIdInPorts(targetId, ports);
|
|
201
206
|
}
|
|
202
207
|
|
|
208
|
+
export async function findResponsivePageTabByUrl(port: number, url: string): Promise<CDPTarget | null> {
|
|
209
|
+
const pages = (await listTargets(port)).filter((target) => target.type === 'page');
|
|
210
|
+
const scores = pages.map((target) => scoreTabUrlMatch(target.url, url));
|
|
211
|
+
const bestScore = Math.max(0, ...scores);
|
|
212
|
+
if (bestScore === 0) return null;
|
|
213
|
+
|
|
214
|
+
for (const target of pages.filter((_, index) => scores[index] === bestScore)) {
|
|
215
|
+
if (!target.webSocketDebuggerUrl) continue;
|
|
216
|
+
const client = new CDPClient(target.webSocketDebuggerUrl);
|
|
217
|
+
try {
|
|
218
|
+
await client.waitReady();
|
|
219
|
+
await client.send('Page.captureScreenshot', { format: 'png' }, 5_000);
|
|
220
|
+
return target;
|
|
221
|
+
} catch {
|
|
222
|
+
// Electron can expose a page target that never answers screenshot requests.
|
|
223
|
+
} finally {
|
|
224
|
+
client.close();
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
203
230
|
export async function findTabByUrlAcrossEndpoints(
|
|
204
231
|
url: string,
|
|
205
232
|
preferredPort?: number,
|
|
@@ -276,7 +303,7 @@ export async function openTab(port: number, url: string): Promise<CDPTarget> {
|
|
|
276
303
|
title: '',
|
|
277
304
|
url,
|
|
278
305
|
type: 'page',
|
|
279
|
-
webSocketDebuggerUrl: `ws
|
|
306
|
+
webSocketDebuggerUrl: `ws://${CDP_LOOPBACK_HOST}:${port}/devtools/page/${targetId}`,
|
|
280
307
|
};
|
|
281
308
|
} finally {
|
|
282
309
|
client.close();
|
package/src/session/commands.ts
CHANGED
|
@@ -391,10 +391,10 @@ const START_USAGE = `capture session start [--url <url> | --target <tab-id>] [--
|
|
|
391
391
|
|
|
392
392
|
input:
|
|
393
393
|
--url <url> Absolute URL to open in a fresh tab through CDP's
|
|
394
|
-
Target.createTarget.
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
--target.
|
|
394
|
+
Target.createTarget. If an endpoint, including Electron,
|
|
395
|
+
rejects Target.createTarget, capture adopts the matching
|
|
396
|
+
existing page target that answers a screenshot. Conflicts
|
|
397
|
+
with --target.
|
|
398
398
|
--target <tab-id> Existing page tab to adopt. Resolves a full target id or
|
|
399
399
|
an unambiguous prefix of at least four characters at the
|
|
400
400
|
selected CDP endpoint; start fails if it does not resolve.
|
|
@@ -563,6 +563,7 @@ export interface SessionStartWorld {
|
|
|
563
563
|
detectCdpPort(): Promise<number | { port: number; app: string; selectionReason: string }>;
|
|
564
564
|
openTab(port: number, url: string): Promise<CDPTarget>;
|
|
565
565
|
findTabById(port: number, targetId: string): Promise<CDPTarget | null>;
|
|
566
|
+
findResponsivePageTabByUrl?(port: number, url: string): Promise<CDPTarget | null>;
|
|
566
567
|
closeTarget(port: number, targetId: string): Promise<void>;
|
|
567
568
|
/** Attach to the freshly-opened tab and wait for load; returns page-load-timed-out. */
|
|
568
569
|
awaitTabReady(target: CDPTarget, url: string): Promise<boolean>;
|
|
@@ -588,6 +589,10 @@ const productionStartWorld: SessionStartWorld = {
|
|
|
588
589
|
const { findTabById } = await import('../cdp/targets.js');
|
|
589
590
|
return findTabById(port, targetId);
|
|
590
591
|
},
|
|
592
|
+
async findResponsivePageTabByUrl(port, url) {
|
|
593
|
+
const { findResponsivePageTabByUrl } = await import('../cdp/targets.js');
|
|
594
|
+
return findResponsivePageTabByUrl(port, url);
|
|
595
|
+
},
|
|
591
596
|
async closeTarget(port, targetId) {
|
|
592
597
|
const { closeTarget } = await import('../cdp/targets.js');
|
|
593
598
|
return closeTarget(port, targetId);
|
|
@@ -692,6 +697,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
692
697
|
let bridgeSocket: string | null = null;
|
|
693
698
|
let bridgePid: number | null = null;
|
|
694
699
|
let cdpPort: number | null = null;
|
|
700
|
+
let targetWasOpened = false;
|
|
695
701
|
let selectedBrowser: string | null = null;
|
|
696
702
|
let endpointSelectionReason: string | null = null;
|
|
697
703
|
|
|
@@ -723,22 +729,28 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
723
729
|
}
|
|
724
730
|
|
|
725
731
|
if (url) {
|
|
732
|
+
let opened = false;
|
|
726
733
|
try {
|
|
727
734
|
target = await startWorld.openTab(cdpPort!, url);
|
|
735
|
+
opened = true;
|
|
736
|
+
targetWasOpened = true;
|
|
728
737
|
} catch (error) {
|
|
729
738
|
const message = error instanceof Error ? error.message : String(error);
|
|
730
|
-
if (message.toLowerCase().includes('not supported'))
|
|
739
|
+
if (!message.toLowerCase().includes('not supported')) throw error;
|
|
740
|
+
target = await startWorld.findResponsivePageTabByUrl?.(cdpPort!, url) ?? null;
|
|
741
|
+
if (!target) {
|
|
731
742
|
throw worldFailure(
|
|
732
|
-
`Target.createTarget is unsupported on port ${cdpPort}
|
|
743
|
+
`Target.createTarget is unsupported on port ${cdpPort}, and no matching existing page target answered a screenshot. Run \`capture tab list --port ${cdpPort}\` to inspect its targets.`,
|
|
733
744
|
error,
|
|
734
745
|
);
|
|
735
746
|
}
|
|
736
|
-
throw error;
|
|
737
747
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
748
|
+
if (opened) {
|
|
749
|
+
const openedPort = cdpPort!;
|
|
750
|
+
const openedTargetId = target.id;
|
|
751
|
+
acquired.push({ label: 'opened target', release: () => startWorld.closeTarget(openedPort, openedTargetId) });
|
|
752
|
+
pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
|
|
753
|
+
}
|
|
742
754
|
}
|
|
743
755
|
|
|
744
756
|
// Hold a CDP browser connection open for the session's lifetime so
|
|
@@ -778,7 +790,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
778
790
|
}
|
|
779
791
|
|
|
780
792
|
const rows: FactLine[] = [fact`bundle dir: ${dir}`];
|
|
781
|
-
if (target) rows.push(
|
|
793
|
+
if (target) rows.push(targetWasOpened ? fact`tab ${target.id} opened at ${url}` : fact`tab ${target.id} adopted at ${target.url}`);
|
|
782
794
|
if (cdpPort !== null) rows.push(
|
|
783
795
|
parsed.portSource === 'env'
|
|
784
796
|
? fact`CDP endpoint on port ${cdpPort} selected from ambient CDP_PORT; --port overrides endpoint selection and --target overrides tab selection.`
|
|
@@ -1103,6 +1115,7 @@ function responseEndDateTime(
|
|
|
1103
1115
|
}
|
|
1104
1116
|
|
|
1105
1117
|
function completedTiming(e: HAREntry): FactLine {
|
|
1118
|
+
if ('_resourceType' in e) return fact`WebSocket: ${e._webSocketMessages.length} frame(s) recorded`;
|
|
1106
1119
|
const { clocks, terminal } = e._capture;
|
|
1107
1120
|
if (terminal.kind === 'failed') {
|
|
1108
1121
|
return fact`duration ${e.time} ms — response incomplete: failed`;
|