@crouton-kit/capture 1.4.26 → 1.4.28
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 +85 -29
- package/package.json +1 -1
- package/src/cdp/browser-process.ts +33 -7
- package/src/cdp/commands/page/exec.ts +2 -2
- package/src/cdp/commands/page/shot.ts +2 -1
- package/src/cdp/commands/tab/launch.ts +2 -2
- package/src/cdp/commands/tab/list.ts +1 -1
- package/src/cdp/connection.ts +39 -3
- package/src/cdp/record.ts +7 -1
- package/src/cdp/screenshot.ts +2 -2
- package/src/cdp/targets.ts +37 -10
package/bin/capture
CHANGED
|
@@ -5654,8 +5654,12 @@ function forgetRecordFile(file) {
|
|
|
5654
5654
|
}
|
|
5655
5655
|
}
|
|
5656
5656
|
function stillOurs(record5) {
|
|
5657
|
+
return processOwnership(record5) === "ours";
|
|
5658
|
+
}
|
|
5659
|
+
function processOwnership(record5) {
|
|
5657
5660
|
const observed = processPidBirthProvider.read(record5.pid);
|
|
5658
|
-
|
|
5661
|
+
if (observed.status === "unknown") return "unknown";
|
|
5662
|
+
return observed.status === "found" && sameBirth(observed.identity, record5.birth) ? "ours" : "gone";
|
|
5659
5663
|
}
|
|
5660
5664
|
function removeProfile(profileDir) {
|
|
5661
5665
|
fs2.rmSync(assertUnderCaptureRoot(profileDir), { recursive: true, force: true });
|
|
@@ -5677,26 +5681,33 @@ async function reap(record5) {
|
|
|
5677
5681
|
forgetRecordFile(recordPath(record5.token));
|
|
5678
5682
|
removeProfile(record5.profileDir);
|
|
5679
5683
|
}
|
|
5680
|
-
async function
|
|
5684
|
+
async function probeEndpoint(port) {
|
|
5681
5685
|
try {
|
|
5682
5686
|
const response = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`, {
|
|
5683
5687
|
signal: AbortSignal.timeout(ENDPOINT_PROBE_MS)
|
|
5684
5688
|
});
|
|
5685
|
-
return response.ok;
|
|
5686
|
-
} catch {
|
|
5687
|
-
|
|
5689
|
+
return response.ok ? "up" : "down";
|
|
5690
|
+
} catch (error4) {
|
|
5691
|
+
const code = error4.cause?.code;
|
|
5692
|
+
return code === "ECONNREFUSED" ? "down" : "unknown";
|
|
5688
5693
|
}
|
|
5689
5694
|
}
|
|
5695
|
+
async function endpointAnswers(port) {
|
|
5696
|
+
return await probeEndpoint(port) === "up";
|
|
5697
|
+
}
|
|
5690
5698
|
async function sweepOwnedBrowsers() {
|
|
5691
5699
|
const reaped = [];
|
|
5692
5700
|
for (const record5 of readOwnedBrowsers()) {
|
|
5693
|
-
|
|
5701
|
+
const ownership2 = processOwnership(record5);
|
|
5702
|
+
if (ownership2 === "unknown") continue;
|
|
5703
|
+
if (ownership2 === "gone") {
|
|
5694
5704
|
forgetRecordFile(recordPath(record5.token));
|
|
5695
5705
|
removeProfile(record5.profileDir);
|
|
5696
5706
|
reaped.push({ port: record5.port, pid: record5.pid, reason: "exited" });
|
|
5697
5707
|
continue;
|
|
5698
5708
|
}
|
|
5699
|
-
if (record5.port === null
|
|
5709
|
+
if (record5.port === null && Date.now() - Date.parse(record5.startedAt) <= READY_TIMEOUT_MS + ENDPOINT_PROBE_MS) continue;
|
|
5710
|
+
if (record5.port === null || await probeEndpoint(record5.port) === "down") {
|
|
5700
5711
|
await reap(record5);
|
|
5701
5712
|
reaped.push({ port: record5.port, pid: record5.pid, reason: "unreachable" });
|
|
5702
5713
|
continue;
|
|
@@ -5930,7 +5941,7 @@ __export(targets_exports, {
|
|
|
5930
5941
|
scoreTabUrlMatch: () => scoreTabUrlMatch
|
|
5931
5942
|
});
|
|
5932
5943
|
async function getBrowserClient(port) {
|
|
5933
|
-
const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version
|
|
5944
|
+
const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`, { signal: AbortSignal.timeout(BROWSER_PROBE_TIMEOUT_MS) });
|
|
5934
5945
|
if (!versionResp.ok) {
|
|
5935
5946
|
throw new Error(
|
|
5936
5947
|
`Failed to connect to CDP on port ${port}: ${versionResp.status}`
|
|
@@ -5952,8 +5963,8 @@ async function getBrowserClient(port) {
|
|
|
5952
5963
|
async function listTargets(port) {
|
|
5953
5964
|
const { client } = await getBrowserClient(port);
|
|
5954
5965
|
try {
|
|
5955
|
-
await client.send("Target.setDiscoverTargets", { discover: true });
|
|
5956
|
-
const result = await client.send("Target.getTargets");
|
|
5966
|
+
await client.send("Target.setDiscoverTargets", { discover: true }, BROWSER_PROBE_TIMEOUT_MS);
|
|
5967
|
+
const result = await client.send("Target.getTargets", {}, BROWSER_PROBE_TIMEOUT_MS);
|
|
5957
5968
|
return result.targetInfos.map((t) => ({
|
|
5958
5969
|
id: t.targetId,
|
|
5959
5970
|
title: t.title,
|
|
@@ -5991,25 +6002,40 @@ async function findTabById(port, targetId) {
|
|
|
5991
6002
|
}
|
|
5992
6003
|
return null;
|
|
5993
6004
|
}
|
|
6005
|
+
function unlistedTargetError(query, failures) {
|
|
6006
|
+
const detail = failures.map(({ port, error: error4 }) => `port ${port}: ${error4 instanceof Error ? error4.message : String(error4)}`).join("; ");
|
|
6007
|
+
return captureError(
|
|
6008
|
+
"precondition",
|
|
6009
|
+
"target_unavailable",
|
|
6010
|
+
`No tab found for "${query}", and ${failures.length} endpoint(s) could not list their tabs (${detail}). Run "capture tab list" to see which endpoints answer.`,
|
|
6011
|
+
failures[0]?.error
|
|
6012
|
+
);
|
|
6013
|
+
}
|
|
5994
6014
|
async function findTabByIdInPorts(targetId, ports, finder = findTabById) {
|
|
6015
|
+
const failures = [];
|
|
5995
6016
|
for (const port of ports) {
|
|
5996
6017
|
try {
|
|
5997
6018
|
const tab = await finder(port, targetId);
|
|
5998
6019
|
if (tab) return { port, tab };
|
|
5999
|
-
} catch {
|
|
6020
|
+
} catch (error4) {
|
|
6021
|
+
failures.push({ port, error: error4 });
|
|
6000
6022
|
}
|
|
6001
6023
|
}
|
|
6024
|
+
if (failures.length > 0) throw unlistedTargetError(targetId, failures);
|
|
6002
6025
|
return null;
|
|
6003
6026
|
}
|
|
6004
6027
|
async function findUnambiguousPageTabInPorts(ports, lister = listTargets) {
|
|
6005
6028
|
const candidates = [];
|
|
6029
|
+
const failures = [];
|
|
6006
6030
|
for (const port of ports) {
|
|
6007
6031
|
try {
|
|
6008
6032
|
for (const tab of await lister(port)) if (tab.type === "page") candidates.push({ port, tab });
|
|
6009
|
-
} catch {
|
|
6033
|
+
} catch (error4) {
|
|
6034
|
+
failures.push({ port, error: error4 });
|
|
6010
6035
|
}
|
|
6011
6036
|
}
|
|
6012
6037
|
if (candidates.length > 1) throw new AmbiguousPageTargetError(candidates);
|
|
6038
|
+
if (candidates.length === 0 && failures.length > 0) throw unlistedTargetError("(any page tab)", failures);
|
|
6013
6039
|
return candidates[0] ?? null;
|
|
6014
6040
|
}
|
|
6015
6041
|
function normalizeUrlForMatch(value) {
|
|
@@ -6076,6 +6102,7 @@ async function findResponsivePageTabByUrl(port, url) {
|
|
|
6076
6102
|
async function findTabByUrlAcrossEndpoints(url, preferredPort) {
|
|
6077
6103
|
const ports = resolveSearchPorts(await detectCdpPortsAsync(), preferredPort);
|
|
6078
6104
|
let best = null;
|
|
6105
|
+
const failures = [];
|
|
6079
6106
|
for (const port of ports) {
|
|
6080
6107
|
try {
|
|
6081
6108
|
const targets = await listTargets(port);
|
|
@@ -6086,10 +6113,13 @@ async function findTabByUrlAcrossEndpoints(url, preferredPort) {
|
|
|
6086
6113
|
best = { port, tab, score };
|
|
6087
6114
|
}
|
|
6088
6115
|
}
|
|
6089
|
-
} catch {
|
|
6116
|
+
} catch (error4) {
|
|
6117
|
+
failures.push({ port, error: error4 });
|
|
6090
6118
|
}
|
|
6091
6119
|
}
|
|
6092
|
-
|
|
6120
|
+
if (best) return { port: best.port, tab: best.tab };
|
|
6121
|
+
if (failures.length > 0) throw unlistedTargetError(url, failures);
|
|
6122
|
+
return null;
|
|
6093
6123
|
}
|
|
6094
6124
|
function requireTargetId(targetId, url) {
|
|
6095
6125
|
if (!targetId) {
|
|
@@ -6129,13 +6159,15 @@ async function openTab(port, url) {
|
|
|
6129
6159
|
client.close();
|
|
6130
6160
|
}
|
|
6131
6161
|
}
|
|
6132
|
-
var AmbiguousPageTargetError;
|
|
6162
|
+
var BROWSER_PROBE_TIMEOUT_MS, AmbiguousPageTargetError;
|
|
6133
6163
|
var init_targets = __esm({
|
|
6134
6164
|
"src/cdp/targets.ts"() {
|
|
6135
6165
|
init_client();
|
|
6136
6166
|
init_loopback();
|
|
6137
6167
|
init_detect();
|
|
6138
6168
|
init_browser_process();
|
|
6169
|
+
init_errors();
|
|
6170
|
+
BROWSER_PROBE_TIMEOUT_MS = 1e4;
|
|
6139
6171
|
AmbiguousPageTargetError = class extends Error {
|
|
6140
6172
|
constructor(candidates) {
|
|
6141
6173
|
super(`More than one page tab is available:
|
|
@@ -8671,7 +8703,7 @@ async function captureScreenshotWithCssViewport(client, viewport2, options) {
|
|
|
8671
8703
|
}
|
|
8672
8704
|
async function viewportScopedCapture(client, viewport2, options, includeCssViewport = false) {
|
|
8673
8705
|
const MAX_DIM = 1600;
|
|
8674
|
-
const SCREENSHOT_REQUEST_TIMEOUT_MS = 3e4;
|
|
8706
|
+
const SCREENSHOT_REQUEST_TIMEOUT_MS = options?.requestTimeoutMs ?? 3e4;
|
|
8675
8707
|
let ownsDeviceMetricsOverride = false;
|
|
8676
8708
|
let primaryFailed = false;
|
|
8677
8709
|
let primaryError;
|
|
@@ -9031,7 +9063,10 @@ async function navigateAndWait(port, url, options = {}) {
|
|
|
9031
9063
|
}
|
|
9032
9064
|
const targets = await listTargets(port);
|
|
9033
9065
|
const refreshed = targets.find((t) => t.id === tab.id);
|
|
9034
|
-
|
|
9066
|
+
if (!refreshed) {
|
|
9067
|
+
throw new Error(`the browser accepted the new tab ${tab.id} but no longer lists it \u2014 it dropped the tab. Open it again.`);
|
|
9068
|
+
}
|
|
9069
|
+
return refreshed;
|
|
9035
9070
|
}
|
|
9036
9071
|
var init_record = __esm({
|
|
9037
9072
|
"src/cdp/record.ts"() {
|
|
@@ -10945,7 +10980,7 @@ function validateHarAppendBatch(value, source) {
|
|
|
10945
10980
|
return { entries, incompleteLifecycles };
|
|
10946
10981
|
}
|
|
10947
10982
|
function captureVersion() {
|
|
10948
|
-
const version = "1.4.
|
|
10983
|
+
const version = "1.4.28";
|
|
10949
10984
|
return typeof version === "string" && version.length > 0 ? version : "development";
|
|
10950
10985
|
}
|
|
10951
10986
|
function token() {
|
|
@@ -13047,7 +13082,7 @@ function wallTime(value) {
|
|
|
13047
13082
|
}
|
|
13048
13083
|
}
|
|
13049
13084
|
function captureVersion2() {
|
|
13050
|
-
const version = "1.4.
|
|
13085
|
+
const version = "1.4.28";
|
|
13051
13086
|
return typeof version === "string" && version.length > 0 ? version : "development";
|
|
13052
13087
|
}
|
|
13053
13088
|
function deepFreeze(value) {
|
|
@@ -16777,6 +16812,24 @@ async function applyActiveSessionNetworkConditions(client, session, targetId) {
|
|
|
16777
16812
|
uploadThroughput: offline ? 0 : -1
|
|
16778
16813
|
});
|
|
16779
16814
|
}
|
|
16815
|
+
function setupDeadlineMs(parsed) {
|
|
16816
|
+
return parsed.timeoutMs ?? DEFAULT_SETUP_DEADLINE_MS;
|
|
16817
|
+
}
|
|
16818
|
+
async function withinDeadline(ms, phase, work) {
|
|
16819
|
+
let timer;
|
|
16820
|
+
const expired = new Promise((_, reject) => {
|
|
16821
|
+
timer = setTimeout(() => reject(captureError(
|
|
16822
|
+
"world",
|
|
16823
|
+
"command_deadline_exceeded",
|
|
16824
|
+
`${phase} did not finish within ${ms}ms \u2014 the tab is not answering CDP (its main thread may be blocked, or the tab throttled). Raise --timeout to wait longer.`
|
|
16825
|
+
)), ms);
|
|
16826
|
+
});
|
|
16827
|
+
try {
|
|
16828
|
+
return await Promise.race([work, expired]);
|
|
16829
|
+
} finally {
|
|
16830
|
+
clearTimeout(timer);
|
|
16831
|
+
}
|
|
16832
|
+
}
|
|
16780
16833
|
function deriveActionLabel(parsed) {
|
|
16781
16834
|
if (parsed.command === "type") {
|
|
16782
16835
|
return `type:${parsed.into ?? "focused element"}`;
|
|
@@ -16864,7 +16917,7 @@ async function connectForCommand(parsed) {
|
|
|
16864
16917
|
const client = seams.createClient(tab.webSocketDebuggerUrl);
|
|
16865
16918
|
try {
|
|
16866
16919
|
await client.waitReady();
|
|
16867
|
-
await applyActiveSessionNetworkConditions(client, seams.getActiveSession(), tab.id);
|
|
16920
|
+
await withinDeadline(setupDeadlineMs(parsed), "Restoring the session network state", applyActiveSessionNetworkConditions(client, seams.getActiveSession(), tab.id));
|
|
16868
16921
|
return { client, tab };
|
|
16869
16922
|
} catch (err) {
|
|
16870
16923
|
client.close();
|
|
@@ -16916,8 +16969,8 @@ function finishConsole(collectors) {
|
|
|
16916
16969
|
}
|
|
16917
16970
|
async function withConnection(parsed, fn, opts = {}) {
|
|
16918
16971
|
const { client, tab } = await connectForCommand(parsed);
|
|
16919
|
-
const collectors = await startCollectors(parsed, client);
|
|
16920
16972
|
try {
|
|
16973
|
+
const collectors = await withinDeadline(setupDeadlineMs(parsed), "Starting page collectors", startCollectors(parsed, client));
|
|
16921
16974
|
const result = await fn(client, tab);
|
|
16922
16975
|
if (collectors.harRecorder && parsed.har) {
|
|
16923
16976
|
const settle = opts.settle !== void 0 ? opts.settle : 3e3;
|
|
@@ -16934,8 +16987,8 @@ async function withConnection(parsed, fn, opts = {}) {
|
|
|
16934
16987
|
}
|
|
16935
16988
|
async function withPageAction(parsed, opts, fn) {
|
|
16936
16989
|
const { client, tab } = await connectForCommand(parsed);
|
|
16937
|
-
const collectors = await startCollectors(parsed, client);
|
|
16938
16990
|
try {
|
|
16991
|
+
const collectors = await withinDeadline(setupDeadlineMs(parsed), "Starting page collectors", startCollectors(parsed, client));
|
|
16939
16992
|
const result = await fn(client, tab);
|
|
16940
16993
|
const requestedMs = opts.settleMs;
|
|
16941
16994
|
const t0 = seams.now();
|
|
@@ -16963,7 +17016,7 @@ async function withPageAction(parsed, opts, fn) {
|
|
|
16963
17016
|
client.close();
|
|
16964
17017
|
}
|
|
16965
17018
|
}
|
|
16966
|
-
var fs14, import_node_perf_hooks2, seams;
|
|
17019
|
+
var fs14, import_node_perf_hooks2, seams, DEFAULT_SETUP_DEADLINE_MS;
|
|
16967
17020
|
var init_connection = __esm({
|
|
16968
17021
|
"src/cdp/connection.ts"() {
|
|
16969
17022
|
fs14 = __toESM(require("fs"));
|
|
@@ -16990,6 +17043,7 @@ var init_connection = __esm({
|
|
|
16990
17043
|
now: () => import_node_perf_hooks2.performance.now(),
|
|
16991
17044
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms))
|
|
16992
17045
|
};
|
|
17046
|
+
DEFAULT_SETUP_DEADLINE_MS = 3e4;
|
|
16993
17047
|
}
|
|
16994
17048
|
});
|
|
16995
17049
|
|
|
@@ -229200,7 +229254,7 @@ async function focusScopedEvaluate(client, expression, evaluationTimeoutMs) {
|
|
|
229200
229254
|
let primaryError;
|
|
229201
229255
|
try {
|
|
229202
229256
|
ownsFocusOverride = true;
|
|
229203
|
-
await client.send("Emulation.setFocusEmulationEnabled", { enabled: true });
|
|
229257
|
+
await client.send("Emulation.setFocusEmulationEnabled", { enabled: true }, evaluationTimeoutMs);
|
|
229204
229258
|
try {
|
|
229205
229259
|
return await client.send("Runtime.evaluate", {
|
|
229206
229260
|
expression,
|
|
@@ -229224,7 +229278,7 @@ async function focusScopedEvaluate(client, expression, evaluationTimeoutMs) {
|
|
|
229224
229278
|
} finally {
|
|
229225
229279
|
if (ownsFocusOverride) {
|
|
229226
229280
|
try {
|
|
229227
|
-
await client.send("Emulation.setFocusEmulationEnabled", { enabled: false });
|
|
229281
|
+
await client.send("Emulation.setFocusEmulationEnabled", { enabled: false }, evaluationTimeoutMs);
|
|
229228
229282
|
} catch (error4) {
|
|
229229
229283
|
const cleanupError = captureError(
|
|
229230
229284
|
"cleanup",
|
|
@@ -229706,6 +229760,7 @@ async function cmdPageShot(parsed, _args) {
|
|
|
229706
229760
|
fullPage: parsed.fullPage,
|
|
229707
229761
|
crop: requestedCrop,
|
|
229708
229762
|
zoom,
|
|
229763
|
+
requestTimeoutMs: parsed.timeoutMs,
|
|
229709
229764
|
...colorScheme ? { beforeCapture: () => finishColorTransitions(client) } : {}
|
|
229710
229765
|
});
|
|
229711
229766
|
const outPath = await resolveOutPath(parsed);
|
|
@@ -229746,6 +229801,7 @@ input:
|
|
|
229746
229801
|
--out <path> caller-owned destination PNG; default: the active session's shots/ sequence, or a fresh one-shot bundle when no session is active
|
|
229747
229802
|
--artifact-dir <path> root for that sessionless one-shot bundle (ignored with --out or an active session)
|
|
229748
229803
|
--target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
|
|
229804
|
+
--timeout <ms> deadline for connecting to the tab and for each screenshot request (default: 30000 for connecting and each screenshot request); a tab that does not answer in time fails with command_deadline_exceeded or a CDP request timeout instead of waiting
|
|
229749
229805
|
--json mirror the result as JSON
|
|
229750
229806
|
output:
|
|
229751
229807
|
<screenshot path=\u2026 width=\u2026 height=\u2026 css-width=\u2026 css-height=\u2026 css-x=\u2026 css-y=\u2026 css-to-image-x=\u2026 css-to-image-y=\u2026 effective-downscale-x=\u2026 effective-downscale-y=\u2026 scroll-x=\u2026 scroll-y=\u2026 emulation=none|viewport|full-page color-scheme=dark|light> \u2014 saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, visual-viewport scroll offset at capture, any effective downscale below 1 image px/CSS px, byte size, crop provenance, and any requested transient emulation
|
|
@@ -230361,7 +230417,7 @@ function buildBrowserLaunchedResult(browser, swept) {
|
|
|
230361
230417
|
line(text`mode `, data(browser.headless ? "headless" : "headed"))
|
|
230362
230418
|
])
|
|
230363
230419
|
];
|
|
230364
|
-
if (swept > 0) sections.push(fact`swept ${swept} previously-launched browser(s) that had exited,
|
|
230420
|
+
if (swept > 0) sections.push(fact`swept ${swept} previously-launched browser(s) that had exited, refused CDP connections, or gone idle.`);
|
|
230365
230421
|
return {
|
|
230366
230422
|
tag: "browser-launched",
|
|
230367
230423
|
attrs: { port: browser.port, pid: browser.pid, headless: browser.headless },
|
|
@@ -230409,7 +230465,7 @@ input:
|
|
|
230409
230465
|
--headed run with a visible window instead of headless
|
|
230410
230466
|
|
|
230411
230467
|
output: <browser-launched port=\u2026 pid=\u2026> \u2014 the port to pass as --port (or CDP_PORT), the pid capture owns, and the executable that answered with its provenance (CAPTURE_BROWSER, puppeteer-cache, or system).
|
|
230412
|
-
effects: spawns a detached browser with a private profile under CAPTURE_ROOT, and registers it as capture-owned. Capture stops it on \`capture tab quit\`, or on the first later capture invocation once it has been unused for ${IDLE_REAP_MS / 6e4} minutes; a browser capture did not start is never signalled. Also sweeps previously-launched browsers that have exited,
|
|
230468
|
+
effects: spawns a detached browser with a private profile under CAPTURE_ROOT, and registers it as capture-owned. Capture stops it on \`capture tab quit\`, or on the first later capture invocation once it has been unused for ${IDLE_REAP_MS / 6e4} minutes; a browser capture did not start is never signalled. Also sweeps previously-launched browsers that have exited, refused CDP connections, or gone idle.`;
|
|
230413
230469
|
}
|
|
230414
230470
|
});
|
|
230415
230471
|
|
|
@@ -230587,7 +230643,7 @@ input:
|
|
|
230587
230643
|
--port <port> probe one endpoint only, instead of discovering every localhost CDP endpoint; an active session's endpoint does not narrow it (CDP_PORT still pins one endpoint)
|
|
230588
230644
|
|
|
230589
230645
|
output: <tabs endpoints=\u2026 tabs=\u2026> \u2014 one section per reachable endpoint (port, app, page-tab count, preferred marker, and a capture-owned pid marker on any browser \`tab launch\` started) listing each page tab's target id (first 8 chars, enough for --target), title, and url; an endpoint that failed the tab fetch is an endpoints-unreachable fact, never a silent omission.
|
|
230590
|
-
effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited,
|
|
230646
|
+
effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited, refused CDP connections, or gone idle \u2014 browsers capture did not start are never signalled.`;
|
|
230591
230647
|
ARC_ENDPOINT_NOTE = text` note: Arc exposes only tabs opened over CDP as page targets; the tabs in its windows are not listed. Open one with "capture tab open <url> --port <port>", then "capture session start --port <port> --target <id>".`;
|
|
230592
230648
|
}
|
|
230593
230649
|
});
|
|
@@ -267596,7 +267652,7 @@ I/O contract: flags and positionals on input; one rendered prose block on stdout
|
|
|
267596
267652
|
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. When CRTR_NODE_ID is set (a crtr agent node), a browser command with no --port, no --target and no active session of that node is refused instead of falling back to CDP_PORT, CDP_TARGET or localhost discovery, because the browser found could belong to another node: run \`capture session start --url <url>\`, or pass --port or --target. \`capture tab list\` and commands that only read a recorded artifact are not refused.`;
|
|
267597
267653
|
}
|
|
267598
267654
|
function printVersion() {
|
|
267599
|
-
const declared = "1.4.
|
|
267655
|
+
const declared = "1.4.28";
|
|
267600
267656
|
if (declared) {
|
|
267601
267657
|
console.log(declared);
|
|
267602
267658
|
return;
|
package/package.json
CHANGED
|
@@ -191,8 +191,16 @@ function forgetRecordFile(file: string): void {
|
|
|
191
191
|
* ONLY gate that authorizes a signal; an `unknown` read (a provider that could
|
|
192
192
|
* not answer) is deliberately NOT a kill authorization. */
|
|
193
193
|
function stillOurs(record: OwnedBrowser): boolean {
|
|
194
|
+
return processOwnership(record) === 'ours';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** `gone` — the process exited, or its pid was recycled by a stranger; `unknown` —
|
|
198
|
+
* the provider could not answer (e.g. its `sysctl` read timed out on a loaded
|
|
199
|
+
* host), which says nothing about whether the browser is alive. */
|
|
200
|
+
function processOwnership(record: OwnedBrowser): 'ours' | 'gone' | 'unknown' {
|
|
194
201
|
const observed = processPidBirthProvider.read(record.pid);
|
|
195
|
-
|
|
202
|
+
if (observed.status === 'unknown') return 'unknown';
|
|
203
|
+
return observed.status === 'found' && sameBirth(observed.identity, record.birth) ? 'ours' : 'gone';
|
|
196
204
|
}
|
|
197
205
|
|
|
198
206
|
/**
|
|
@@ -229,17 +237,26 @@ async function reap(record: OwnedBrowser): Promise<void> {
|
|
|
229
237
|
removeProfile(record.profileDir);
|
|
230
238
|
}
|
|
231
239
|
|
|
232
|
-
|
|
240
|
+
/** `up` — answered; `down` — the connection was refused or the answer was an
|
|
241
|
+
* error, so nothing is serving CDP there; `unknown` — no answer within the probe
|
|
242
|
+
* window or the request failed some other way. A browser starved of CPU on a
|
|
243
|
+
* loaded host answers slowly, so `unknown` is never evidence it is unusable. */
|
|
244
|
+
async function probeEndpoint(port: number): Promise<'up' | 'down' | 'unknown'> {
|
|
233
245
|
try {
|
|
234
246
|
const response = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`, {
|
|
235
247
|
signal: AbortSignal.timeout(ENDPOINT_PROBE_MS),
|
|
236
248
|
});
|
|
237
|
-
return response.ok;
|
|
238
|
-
} catch {
|
|
239
|
-
|
|
249
|
+
return response.ok ? 'up' : 'down';
|
|
250
|
+
} catch (error) {
|
|
251
|
+
const code = (error as { cause?: { code?: unknown } }).cause?.code;
|
|
252
|
+
return code === 'ECONNREFUSED' ? 'down' : 'unknown';
|
|
240
253
|
}
|
|
241
254
|
}
|
|
242
255
|
|
|
256
|
+
async function endpointAnswers(port: number): Promise<boolean> {
|
|
257
|
+
return (await probeEndpoint(port)) === 'up';
|
|
258
|
+
}
|
|
259
|
+
|
|
243
260
|
/**
|
|
244
261
|
* Reconciles the registry with reality and reaps what no longer earns its
|
|
245
262
|
* memory. Run at the top of every `tab` leaf that touches owned browsers — this
|
|
@@ -249,7 +266,12 @@ async function endpointAnswers(port: number): Promise<boolean> {
|
|
|
249
266
|
export async function sweepOwnedBrowsers(): Promise<ReapedBrowser[]> {
|
|
250
267
|
const reaped: ReapedBrowser[] = [];
|
|
251
268
|
for (const record of readOwnedBrowsers()) {
|
|
252
|
-
|
|
269
|
+
const ownership = processOwnership(record);
|
|
270
|
+
// An unreadable process identity proves nothing: removing the profile of a
|
|
271
|
+
// browser that is in fact running would pull its user-data-dir out from
|
|
272
|
+
// under it. Leave the record for a sweep that can read it.
|
|
273
|
+
if (ownership === 'unknown') continue;
|
|
274
|
+
if (ownership === 'gone') {
|
|
253
275
|
// Crashed, or SIGKILLed by someone else. Nothing to signal — just drop
|
|
254
276
|
// the claim and the profile it left behind.
|
|
255
277
|
forgetRecordFile(recordPath(record.token));
|
|
@@ -257,7 +279,11 @@ export async function sweepOwnedBrowsers(): Promise<ReapedBrowser[]> {
|
|
|
257
279
|
reaped.push({ port: record.port, pid: record.pid, reason: 'exited' });
|
|
258
280
|
continue;
|
|
259
281
|
}
|
|
260
|
-
|
|
282
|
+
// A record with no port is a launch in flight in another capture process
|
|
283
|
+
// (the registry is shared by every agent on the host), not a dead browser —
|
|
284
|
+
// until the launch's own readiness deadline has passed.
|
|
285
|
+
if (record.port === null && Date.now() - Date.parse(record.startedAt) <= READY_TIMEOUT_MS + ENDPOINT_PROBE_MS) continue;
|
|
286
|
+
if (record.port === null || (await probeEndpoint(record.port)) === 'down') {
|
|
261
287
|
await reap(record);
|
|
262
288
|
reaped.push({ port: record.port, pid: record.pid, reason: 'unreachable' });
|
|
263
289
|
continue;
|
|
@@ -177,7 +177,7 @@ async function focusScopedEvaluate(client: ExecClient, expression: string, evalu
|
|
|
177
177
|
// Emulate focus so network requests the code triggers aren't deferred
|
|
178
178
|
// by the browser, without foregrounding the tab.
|
|
179
179
|
ownsFocusOverride = true;
|
|
180
|
-
await client.send('Emulation.setFocusEmulationEnabled', { enabled: true });
|
|
180
|
+
await client.send('Emulation.setFocusEmulationEnabled', { enabled: true }, evaluationTimeoutMs);
|
|
181
181
|
try {
|
|
182
182
|
return (await client.send('Runtime.evaluate', {
|
|
183
183
|
expression,
|
|
@@ -201,7 +201,7 @@ async function focusScopedEvaluate(client: ExecClient, expression: string, evalu
|
|
|
201
201
|
} finally {
|
|
202
202
|
if (ownsFocusOverride) {
|
|
203
203
|
try {
|
|
204
|
-
await client.send('Emulation.setFocusEmulationEnabled', { enabled: false });
|
|
204
|
+
await client.send('Emulation.setFocusEmulationEnabled', { enabled: false }, evaluationTimeoutMs);
|
|
205
205
|
} catch (error) {
|
|
206
206
|
const cleanupError = captureError(
|
|
207
207
|
'cleanup',
|
|
@@ -47,6 +47,7 @@ input:
|
|
|
47
47
|
--out <path> caller-owned destination PNG; default: the active session's shots/ sequence, or a fresh one-shot bundle when no session is active
|
|
48
48
|
--artifact-dir <path> root for that sessionless one-shot bundle (ignored with --out or an active session)
|
|
49
49
|
--target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
|
|
50
|
+
--timeout <ms> deadline for connecting to the tab and for each screenshot request (default: 30000 for connecting and each screenshot request); a tab that does not answer in time fails with command_deadline_exceeded or a CDP request timeout instead of waiting
|
|
50
51
|
--json mirror the result as JSON
|
|
51
52
|
output:
|
|
52
53
|
<screenshot path=… width=… height=… css-width=… css-height=… css-x=… css-y=… css-to-image-x=… css-to-image-y=… effective-downscale-x=… effective-downscale-y=… scroll-x=… scroll-y=… emulation=none|viewport|full-page color-scheme=dark|light> — saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, visual-viewport scroll offset at capture, any effective downscale below 1 image px/CSS px, byte size, crop provenance, and any requested transient emulation
|
|
@@ -410,7 +411,7 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
410
411
|
cropProvenance = { source: 'coordinates', requested: requestedCrop, zoom };
|
|
411
412
|
}
|
|
412
413
|
const screenshot = await captureScreenshotWithCssViewport(client, viewport, {
|
|
413
|
-
fullPage: parsed.fullPage, crop: requestedCrop, zoom,
|
|
414
|
+
fullPage: parsed.fullPage, crop: requestedCrop, zoom, requestTimeoutMs: parsed.timeoutMs,
|
|
414
415
|
...(colorScheme ? { beforeCapture: () => finishColorTransitions(client) } : {}),
|
|
415
416
|
});
|
|
416
417
|
const outPath = await resolveOutPath(parsed);
|
|
@@ -21,7 +21,7 @@ input:
|
|
|
21
21
|
--headed run with a visible window instead of headless
|
|
22
22
|
|
|
23
23
|
output: <browser-launched port=… pid=…> — the port to pass as --port (or CDP_PORT), the pid capture owns, and the executable that answered with its provenance (CAPTURE_BROWSER, puppeteer-cache, or system).
|
|
24
|
-
effects: spawns a detached browser with a private profile under CAPTURE_ROOT, and registers it as capture-owned. Capture stops it on \`capture tab quit\`, or on the first later capture invocation once it has been unused for ${IDLE_REAP_MS / 60000} minutes; a browser capture did not start is never signalled. Also sweeps previously-launched browsers that have exited,
|
|
24
|
+
effects: spawns a detached browser with a private profile under CAPTURE_ROOT, and registers it as capture-owned. Capture stops it on \`capture tab quit\`, or on the first later capture invocation once it has been unused for ${IDLE_REAP_MS / 60000} minutes; a browser capture did not start is never signalled. Also sweeps previously-launched browsers that have exited, refused CDP connections, or gone idle.`;
|
|
25
25
|
|
|
26
26
|
/** Pure `<browser-launched>` result builder — exported for tests. */
|
|
27
27
|
export function buildBrowserLaunchedResult(
|
|
@@ -34,7 +34,7 @@ export function buildBrowserLaunchedResult(
|
|
|
34
34
|
line(text`mode `, data(browser.headless ? 'headless' : 'headed')),
|
|
35
35
|
]),
|
|
36
36
|
];
|
|
37
|
-
if (swept > 0) sections.push(fact`swept ${swept} previously-launched browser(s) that had exited,
|
|
37
|
+
if (swept > 0) sections.push(fact`swept ${swept} previously-launched browser(s) that had exited, refused CDP connections, or gone idle.`);
|
|
38
38
|
return {
|
|
39
39
|
tag: 'browser-launched',
|
|
40
40
|
attrs: { port: browser.port, pid: browser.pid, headless: browser.headless },
|
|
@@ -33,7 +33,7 @@ input:
|
|
|
33
33
|
--port <port> probe one endpoint only, instead of discovering every localhost CDP endpoint; an active session's endpoint does not narrow it (CDP_PORT still pins one endpoint)
|
|
34
34
|
|
|
35
35
|
output: <tabs endpoints=… tabs=…> — one section per reachable endpoint (port, app, page-tab count, preferred marker, and a capture-owned pid marker on any browser \`tab launch\` started) listing each page tab's target id (first 8 chars, enough for --target), title, and url; an endpoint that failed the tab fetch is an endpoints-unreachable fact, never a silent omission.
|
|
36
|
-
effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited,
|
|
36
|
+
effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited, refused CDP connections, or gone idle — browsers capture did not start are never signalled.`;
|
|
37
37
|
|
|
38
38
|
export interface TabRow {
|
|
39
39
|
id: string;
|
package/src/cdp/connection.ts
CHANGED
|
@@ -97,6 +97,42 @@ export async function applyActiveSessionNetworkConditions(
|
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Command deadline — a page that stops answering CDP (blocked main thread,
|
|
103
|
+
// throttled background tab) never replies to anything, so every wait on it
|
|
104
|
+
// must be bounded by capture itself.
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/** Bound on connecting to a tab and starting its collectors when the caller declares no `--timeout`. */
|
|
108
|
+
export const DEFAULT_SETUP_DEADLINE_MS = 30_000;
|
|
109
|
+
|
|
110
|
+
/** The deadline for connecting a page command to its tab: the declared `--timeout`, else the default. */
|
|
111
|
+
function setupDeadlineMs(parsed: ParsedArgs): number {
|
|
112
|
+
return parsed.timeoutMs ?? DEFAULT_SETUP_DEADLINE_MS;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Races `work` against `ms`. On expiry it rejects with `command_deadline_exceeded`
|
|
117
|
+
* naming the phase; the caller's `finally` closes the client, which rejects the
|
|
118
|
+
* abandoned work's pending requests.
|
|
119
|
+
*/
|
|
120
|
+
async function withinDeadline<T>(ms: number, phase: string, work: Promise<T>): Promise<T> {
|
|
121
|
+
let timer: NodeJS.Timeout | undefined;
|
|
122
|
+
const expired = new Promise<never>((_, reject) => {
|
|
123
|
+
timer = setTimeout(() => reject(captureError(
|
|
124
|
+
'world',
|
|
125
|
+
'command_deadline_exceeded',
|
|
126
|
+
`${phase} did not finish within ${ms}ms — the tab is not answering CDP (its main thread may be blocked, or the tab throttled). Raise --timeout to wait longer.`,
|
|
127
|
+
)), ms);
|
|
128
|
+
});
|
|
129
|
+
try {
|
|
130
|
+
return await Promise.race([work, expired]);
|
|
131
|
+
} finally {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
100
136
|
/**
|
|
101
137
|
* Builds a short, human-readable label for this command invocation, used to
|
|
102
138
|
* tag every marked (`Input.dispatch*`) CDP call the recorder-held adapter
|
|
@@ -251,7 +287,7 @@ export async function connectForCommand(
|
|
|
251
287
|
await client.waitReady();
|
|
252
288
|
// Reissue the session's persisted offline/online state on this fresh
|
|
253
289
|
// connection — network emulation belongs to a connection, not the tab.
|
|
254
|
-
await applyActiveSessionNetworkConditions(client, seams.getActiveSession(), tab.id);
|
|
290
|
+
await withinDeadline(setupDeadlineMs(parsed), 'Restoring the session network state', applyActiveSessionNetworkConditions(client, seams.getActiveSession(), tab.id));
|
|
255
291
|
return { client, tab };
|
|
256
292
|
} catch (err) {
|
|
257
293
|
client.close();
|
|
@@ -339,9 +375,9 @@ export async function withConnection<T>(
|
|
|
339
375
|
opts: { settle?: number } = {},
|
|
340
376
|
): Promise<T> {
|
|
341
377
|
const { client, tab } = await connectForCommand(parsed);
|
|
342
|
-
const collectors = await startCollectors(parsed, client);
|
|
343
378
|
|
|
344
379
|
try {
|
|
380
|
+
const collectors = await withinDeadline(setupDeadlineMs(parsed), 'Starting page collectors', startCollectors(parsed, client));
|
|
345
381
|
const result = await fn(client, tab);
|
|
346
382
|
|
|
347
383
|
if (collectors.harRecorder && parsed.har) {
|
|
@@ -400,9 +436,9 @@ export async function withPageAction<T>(
|
|
|
400
436
|
fn: (client: CDPClient, tab: CDPTarget) => Promise<T>,
|
|
401
437
|
): Promise<{ result: T; settle: SettleFacts }> {
|
|
402
438
|
const { client, tab } = await connectForCommand(parsed);
|
|
403
|
-
const collectors = await startCollectors(parsed, client);
|
|
404
439
|
|
|
405
440
|
try {
|
|
441
|
+
const collectors = await withinDeadline(setupDeadlineMs(parsed), 'Starting page collectors', startCollectors(parsed, client));
|
|
406
442
|
// Action callback first — a rejection here claims no settle.
|
|
407
443
|
const result = await fn(client, tab);
|
|
408
444
|
|
package/src/cdp/record.ts
CHANGED
|
@@ -111,5 +111,11 @@ export async function navigateAndWait(
|
|
|
111
111
|
// Refresh target info — find by ID to avoid returning a different tab with same URL
|
|
112
112
|
const targets = await listTargets(port);
|
|
113
113
|
const refreshed = targets.find((t) => t.id === tab.id);
|
|
114
|
-
|
|
114
|
+
// Some browsers (Arc) acknowledge Target.createTarget and then drop the page.
|
|
115
|
+
// Reporting an id the browser no longer lists hands the caller a target every
|
|
116
|
+
// later command rejects, so the open itself fails instead.
|
|
117
|
+
if (!refreshed) {
|
|
118
|
+
throw new Error(`the browser accepted the new tab ${tab.id} but no longer lists it — it dropped the tab. Open it again.`);
|
|
119
|
+
}
|
|
120
|
+
return refreshed;
|
|
115
121
|
}
|
package/src/cdp/screenshot.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface ScreenshotCrop {
|
|
|
31
31
|
readonly height: number;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
type ScreenshotOptions = { fullPage?: boolean; crop?: ScreenshotCrop; zoom?: number; beforeCapture?: () => Promise<void
|
|
34
|
+
type ScreenshotOptions = { fullPage?: boolean; crop?: ScreenshotCrop; zoom?: number; beforeCapture?: () => Promise<void>; requestTimeoutMs?: number };
|
|
35
35
|
|
|
36
36
|
/** A region is reportable only if every number is finite and its size is positive; anything else would produce a fabricated scale. */
|
|
37
37
|
function validRegion(region: CapturedRegion): CapturedRegion | undefined {
|
|
@@ -151,7 +151,7 @@ async function viewportScopedCapture(
|
|
|
151
151
|
includeCssViewport = false,
|
|
152
152
|
): Promise<{ png: Buffer; cssViewport?: CapturedRegion; scrollOffset?: ScrollOffset }> {
|
|
153
153
|
const MAX_DIM = 1600; // headroom below Anthropic's 2000px many-image limit
|
|
154
|
-
const SCREENSHOT_REQUEST_TIMEOUT_MS = 30_000;
|
|
154
|
+
const SCREENSHOT_REQUEST_TIMEOUT_MS = options?.requestTimeoutMs ?? 30_000;
|
|
155
155
|
let ownsDeviceMetricsOverride = false;
|
|
156
156
|
let primaryFailed = false;
|
|
157
157
|
let primaryError: unknown;
|
package/src/cdp/targets.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { CDP_LOOPBACK_HOST } from './loopback.js';
|
|
|
3
3
|
import { detectCdpPortsAsync } from './detect.js';
|
|
4
4
|
import { touchOwnedBrowser } from './browser-process.js';
|
|
5
5
|
import { type CDPTarget } from './types.js';
|
|
6
|
+
import { captureError } from '../errors.js';
|
|
7
|
+
|
|
8
|
+
/** A browser that accepts the connection but never answers `/json/version` or `Target.getTargets` must fail the command, not stall it. */
|
|
9
|
+
const BROWSER_PROBE_TIMEOUT_MS = 10_000;
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* Connects to the BROWSER-level CDP websocket (not a page/tab target).
|
|
@@ -14,7 +18,7 @@ import { type CDPTarget } from './types.js';
|
|
|
14
18
|
export async function getBrowserClient(
|
|
15
19
|
port: number,
|
|
16
20
|
): Promise<{ client: CDPClient; browserWsUrl: string }> {
|
|
17
|
-
const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version
|
|
21
|
+
const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`, { signal: AbortSignal.timeout(BROWSER_PROBE_TIMEOUT_MS) });
|
|
18
22
|
if (!versionResp.ok) {
|
|
19
23
|
throw new Error(
|
|
20
24
|
`Failed to connect to CDP on port ${port}: ${versionResp.status}`,
|
|
@@ -46,8 +50,8 @@ export async function listTargets(port: number): Promise<CDPTarget[]> {
|
|
|
46
50
|
|
|
47
51
|
try {
|
|
48
52
|
// Enable target discovery to get all targets including pages
|
|
49
|
-
await client.send('Target.setDiscoverTargets', { discover: true });
|
|
50
|
-
const result = (await client.send('Target.getTargets')) as {
|
|
53
|
+
await client.send('Target.setDiscoverTargets', { discover: true }, BROWSER_PROBE_TIMEOUT_MS);
|
|
54
|
+
const result = (await client.send('Target.getTargets', {}, BROWSER_PROBE_TIMEOUT_MS)) as {
|
|
51
55
|
targetInfos: Array<{
|
|
52
56
|
targetId: string;
|
|
53
57
|
type: string;
|
|
@@ -109,19 +113,36 @@ export async function findTabById(
|
|
|
109
113
|
return null;
|
|
110
114
|
}
|
|
111
115
|
|
|
116
|
+
/**
|
|
117
|
+
* A selector that matched nothing while some endpoint failed to list its tabs
|
|
118
|
+
* is not "no such tab": the failure is the answer. `tab list` reports the same
|
|
119
|
+
* failure as an unreachable endpoint, so the two commands name the same fact.
|
|
120
|
+
*/
|
|
121
|
+
function unlistedTargetError(query: string, failures: ReadonlyArray<{ port: number; error: unknown }>): Error {
|
|
122
|
+
const detail = failures.map(({ port, error }) => `port ${port}: ${error instanceof Error ? error.message : String(error)}`).join('; ');
|
|
123
|
+
return captureError(
|
|
124
|
+
'precondition',
|
|
125
|
+
'target_unavailable',
|
|
126
|
+
`No tab found for "${query}", and ${failures.length} endpoint(s) could not list their tabs (${detail}). Run "capture tab list" to see which endpoints answer.`,
|
|
127
|
+
failures[0]?.error,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
112
131
|
export async function findTabByIdInPorts(
|
|
113
132
|
targetId: string,
|
|
114
133
|
ports: number[],
|
|
115
134
|
finder: (port: number, targetId: string) => Promise<CDPTarget | null> = findTabById,
|
|
116
135
|
): Promise<{ port: number; tab: CDPTarget } | null> {
|
|
136
|
+
const failures: Array<{ port: number; error: unknown }> = [];
|
|
117
137
|
for (const port of ports) {
|
|
118
138
|
try {
|
|
119
139
|
const tab = await finder(port, targetId);
|
|
120
140
|
if (tab) return { port, tab };
|
|
121
|
-
} catch {
|
|
122
|
-
|
|
141
|
+
} catch (error) {
|
|
142
|
+
failures.push({ port, error });
|
|
123
143
|
}
|
|
124
144
|
}
|
|
145
|
+
if (failures.length > 0) throw unlistedTargetError(targetId, failures);
|
|
125
146
|
return null;
|
|
126
147
|
}
|
|
127
148
|
|
|
@@ -138,14 +159,17 @@ export async function findUnambiguousPageTabInPorts(
|
|
|
138
159
|
lister: (port: number) => Promise<CDPTarget[]> = listTargets,
|
|
139
160
|
): Promise<{ port: number; tab: CDPTarget } | null> {
|
|
140
161
|
const candidates: Array<{ port: number; tab: CDPTarget }> = [];
|
|
162
|
+
const failures: Array<{ port: number; error: unknown }> = [];
|
|
141
163
|
for (const port of ports) {
|
|
142
164
|
try {
|
|
143
165
|
for (const tab of await lister(port)) if (tab.type === 'page') candidates.push({ port, tab });
|
|
144
|
-
} catch {
|
|
145
|
-
|
|
166
|
+
} catch (error) {
|
|
167
|
+
failures.push({ port, error });
|
|
146
168
|
}
|
|
147
169
|
}
|
|
148
170
|
if (candidates.length > 1) throw new AmbiguousPageTargetError(candidates);
|
|
171
|
+
// One page tab on a reachable endpoint is only "the" tab when no other endpoint hid a second one.
|
|
172
|
+
if (candidates.length === 0 && failures.length > 0) throw unlistedTargetError('(any page tab)', failures);
|
|
149
173
|
return candidates[0] ?? null;
|
|
150
174
|
}
|
|
151
175
|
|
|
@@ -232,6 +256,7 @@ export async function findTabByUrlAcrossEndpoints(
|
|
|
232
256
|
): Promise<{ port: number; tab: CDPTarget } | null> {
|
|
233
257
|
const ports = resolveSearchPorts(await detectCdpPortsAsync(), preferredPort);
|
|
234
258
|
let best: { port: number; tab: CDPTarget; score: number } | null = null;
|
|
259
|
+
const failures: Array<{ port: number; error: unknown }> = [];
|
|
235
260
|
|
|
236
261
|
for (const port of ports) {
|
|
237
262
|
try {
|
|
@@ -243,12 +268,14 @@ export async function findTabByUrlAcrossEndpoints(
|
|
|
243
268
|
best = { port, tab, score };
|
|
244
269
|
}
|
|
245
270
|
}
|
|
246
|
-
} catch {
|
|
247
|
-
|
|
271
|
+
} catch (error) {
|
|
272
|
+
failures.push({ port, error });
|
|
248
273
|
}
|
|
249
274
|
}
|
|
250
275
|
|
|
251
|
-
|
|
276
|
+
if (best) return { port: best.port, tab: best.tab };
|
|
277
|
+
if (failures.length > 0) throw unlistedTargetError(url, failures);
|
|
278
|
+
return null;
|
|
252
279
|
}
|
|
253
280
|
|
|
254
281
|
export function requireTargetId(
|