@crouton-kit/capture 1.4.26 → 1.4.27
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 +64 -19
- package/package.json +1 -1
- package/src/cdp/commands/page/exec.ts +2 -2
- package/src/cdp/commands/page/shot.ts +2 -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
|
@@ -5930,7 +5930,7 @@ __export(targets_exports, {
|
|
|
5930
5930
|
scoreTabUrlMatch: () => scoreTabUrlMatch
|
|
5931
5931
|
});
|
|
5932
5932
|
async function getBrowserClient(port) {
|
|
5933
|
-
const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version
|
|
5933
|
+
const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`, { signal: AbortSignal.timeout(BROWSER_PROBE_TIMEOUT_MS) });
|
|
5934
5934
|
if (!versionResp.ok) {
|
|
5935
5935
|
throw new Error(
|
|
5936
5936
|
`Failed to connect to CDP on port ${port}: ${versionResp.status}`
|
|
@@ -5952,8 +5952,8 @@ async function getBrowserClient(port) {
|
|
|
5952
5952
|
async function listTargets(port) {
|
|
5953
5953
|
const { client } = await getBrowserClient(port);
|
|
5954
5954
|
try {
|
|
5955
|
-
await client.send("Target.setDiscoverTargets", { discover: true });
|
|
5956
|
-
const result = await client.send("Target.getTargets");
|
|
5955
|
+
await client.send("Target.setDiscoverTargets", { discover: true }, BROWSER_PROBE_TIMEOUT_MS);
|
|
5956
|
+
const result = await client.send("Target.getTargets", {}, BROWSER_PROBE_TIMEOUT_MS);
|
|
5957
5957
|
return result.targetInfos.map((t) => ({
|
|
5958
5958
|
id: t.targetId,
|
|
5959
5959
|
title: t.title,
|
|
@@ -5991,25 +5991,40 @@ async function findTabById(port, targetId) {
|
|
|
5991
5991
|
}
|
|
5992
5992
|
return null;
|
|
5993
5993
|
}
|
|
5994
|
+
function unlistedTargetError(query, failures) {
|
|
5995
|
+
const detail = failures.map(({ port, error: error4 }) => `port ${port}: ${error4 instanceof Error ? error4.message : String(error4)}`).join("; ");
|
|
5996
|
+
return captureError(
|
|
5997
|
+
"precondition",
|
|
5998
|
+
"target_unavailable",
|
|
5999
|
+
`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.`,
|
|
6000
|
+
failures[0]?.error
|
|
6001
|
+
);
|
|
6002
|
+
}
|
|
5994
6003
|
async function findTabByIdInPorts(targetId, ports, finder = findTabById) {
|
|
6004
|
+
const failures = [];
|
|
5995
6005
|
for (const port of ports) {
|
|
5996
6006
|
try {
|
|
5997
6007
|
const tab = await finder(port, targetId);
|
|
5998
6008
|
if (tab) return { port, tab };
|
|
5999
|
-
} catch {
|
|
6009
|
+
} catch (error4) {
|
|
6010
|
+
failures.push({ port, error: error4 });
|
|
6000
6011
|
}
|
|
6001
6012
|
}
|
|
6013
|
+
if (failures.length > 0) throw unlistedTargetError(targetId, failures);
|
|
6002
6014
|
return null;
|
|
6003
6015
|
}
|
|
6004
6016
|
async function findUnambiguousPageTabInPorts(ports, lister = listTargets) {
|
|
6005
6017
|
const candidates = [];
|
|
6018
|
+
const failures = [];
|
|
6006
6019
|
for (const port of ports) {
|
|
6007
6020
|
try {
|
|
6008
6021
|
for (const tab of await lister(port)) if (tab.type === "page") candidates.push({ port, tab });
|
|
6009
|
-
} catch {
|
|
6022
|
+
} catch (error4) {
|
|
6023
|
+
failures.push({ port, error: error4 });
|
|
6010
6024
|
}
|
|
6011
6025
|
}
|
|
6012
6026
|
if (candidates.length > 1) throw new AmbiguousPageTargetError(candidates);
|
|
6027
|
+
if (candidates.length === 0 && failures.length > 0) throw unlistedTargetError("(any page tab)", failures);
|
|
6013
6028
|
return candidates[0] ?? null;
|
|
6014
6029
|
}
|
|
6015
6030
|
function normalizeUrlForMatch(value) {
|
|
@@ -6076,6 +6091,7 @@ async function findResponsivePageTabByUrl(port, url) {
|
|
|
6076
6091
|
async function findTabByUrlAcrossEndpoints(url, preferredPort) {
|
|
6077
6092
|
const ports = resolveSearchPorts(await detectCdpPortsAsync(), preferredPort);
|
|
6078
6093
|
let best = null;
|
|
6094
|
+
const failures = [];
|
|
6079
6095
|
for (const port of ports) {
|
|
6080
6096
|
try {
|
|
6081
6097
|
const targets = await listTargets(port);
|
|
@@ -6086,10 +6102,13 @@ async function findTabByUrlAcrossEndpoints(url, preferredPort) {
|
|
|
6086
6102
|
best = { port, tab, score };
|
|
6087
6103
|
}
|
|
6088
6104
|
}
|
|
6089
|
-
} catch {
|
|
6105
|
+
} catch (error4) {
|
|
6106
|
+
failures.push({ port, error: error4 });
|
|
6090
6107
|
}
|
|
6091
6108
|
}
|
|
6092
|
-
|
|
6109
|
+
if (best) return { port: best.port, tab: best.tab };
|
|
6110
|
+
if (failures.length > 0) throw unlistedTargetError(url, failures);
|
|
6111
|
+
return null;
|
|
6093
6112
|
}
|
|
6094
6113
|
function requireTargetId(targetId, url) {
|
|
6095
6114
|
if (!targetId) {
|
|
@@ -6129,13 +6148,15 @@ async function openTab(port, url) {
|
|
|
6129
6148
|
client.close();
|
|
6130
6149
|
}
|
|
6131
6150
|
}
|
|
6132
|
-
var AmbiguousPageTargetError;
|
|
6151
|
+
var BROWSER_PROBE_TIMEOUT_MS, AmbiguousPageTargetError;
|
|
6133
6152
|
var init_targets = __esm({
|
|
6134
6153
|
"src/cdp/targets.ts"() {
|
|
6135
6154
|
init_client();
|
|
6136
6155
|
init_loopback();
|
|
6137
6156
|
init_detect();
|
|
6138
6157
|
init_browser_process();
|
|
6158
|
+
init_errors();
|
|
6159
|
+
BROWSER_PROBE_TIMEOUT_MS = 1e4;
|
|
6139
6160
|
AmbiguousPageTargetError = class extends Error {
|
|
6140
6161
|
constructor(candidates) {
|
|
6141
6162
|
super(`More than one page tab is available:
|
|
@@ -8671,7 +8692,7 @@ async function captureScreenshotWithCssViewport(client, viewport2, options) {
|
|
|
8671
8692
|
}
|
|
8672
8693
|
async function viewportScopedCapture(client, viewport2, options, includeCssViewport = false) {
|
|
8673
8694
|
const MAX_DIM = 1600;
|
|
8674
|
-
const SCREENSHOT_REQUEST_TIMEOUT_MS = 3e4;
|
|
8695
|
+
const SCREENSHOT_REQUEST_TIMEOUT_MS = options?.requestTimeoutMs ?? 3e4;
|
|
8675
8696
|
let ownsDeviceMetricsOverride = false;
|
|
8676
8697
|
let primaryFailed = false;
|
|
8677
8698
|
let primaryError;
|
|
@@ -9031,7 +9052,10 @@ async function navigateAndWait(port, url, options = {}) {
|
|
|
9031
9052
|
}
|
|
9032
9053
|
const targets = await listTargets(port);
|
|
9033
9054
|
const refreshed = targets.find((t) => t.id === tab.id);
|
|
9034
|
-
|
|
9055
|
+
if (!refreshed) {
|
|
9056
|
+
throw new Error(`the browser accepted the new tab ${tab.id} but no longer lists it \u2014 it dropped the tab. Open it again.`);
|
|
9057
|
+
}
|
|
9058
|
+
return refreshed;
|
|
9035
9059
|
}
|
|
9036
9060
|
var init_record = __esm({
|
|
9037
9061
|
"src/cdp/record.ts"() {
|
|
@@ -10945,7 +10969,7 @@ function validateHarAppendBatch(value, source) {
|
|
|
10945
10969
|
return { entries, incompleteLifecycles };
|
|
10946
10970
|
}
|
|
10947
10971
|
function captureVersion() {
|
|
10948
|
-
const version = "1.4.
|
|
10972
|
+
const version = "1.4.27";
|
|
10949
10973
|
return typeof version === "string" && version.length > 0 ? version : "development";
|
|
10950
10974
|
}
|
|
10951
10975
|
function token() {
|
|
@@ -13047,7 +13071,7 @@ function wallTime(value) {
|
|
|
13047
13071
|
}
|
|
13048
13072
|
}
|
|
13049
13073
|
function captureVersion2() {
|
|
13050
|
-
const version = "1.4.
|
|
13074
|
+
const version = "1.4.27";
|
|
13051
13075
|
return typeof version === "string" && version.length > 0 ? version : "development";
|
|
13052
13076
|
}
|
|
13053
13077
|
function deepFreeze(value) {
|
|
@@ -16777,6 +16801,24 @@ async function applyActiveSessionNetworkConditions(client, session, targetId) {
|
|
|
16777
16801
|
uploadThroughput: offline ? 0 : -1
|
|
16778
16802
|
});
|
|
16779
16803
|
}
|
|
16804
|
+
function setupDeadlineMs(parsed) {
|
|
16805
|
+
return parsed.timeoutMs ?? DEFAULT_SETUP_DEADLINE_MS;
|
|
16806
|
+
}
|
|
16807
|
+
async function withinDeadline(ms, phase, work) {
|
|
16808
|
+
let timer;
|
|
16809
|
+
const expired = new Promise((_, reject) => {
|
|
16810
|
+
timer = setTimeout(() => reject(captureError(
|
|
16811
|
+
"world",
|
|
16812
|
+
"command_deadline_exceeded",
|
|
16813
|
+
`${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.`
|
|
16814
|
+
)), ms);
|
|
16815
|
+
});
|
|
16816
|
+
try {
|
|
16817
|
+
return await Promise.race([work, expired]);
|
|
16818
|
+
} finally {
|
|
16819
|
+
clearTimeout(timer);
|
|
16820
|
+
}
|
|
16821
|
+
}
|
|
16780
16822
|
function deriveActionLabel(parsed) {
|
|
16781
16823
|
if (parsed.command === "type") {
|
|
16782
16824
|
return `type:${parsed.into ?? "focused element"}`;
|
|
@@ -16864,7 +16906,7 @@ async function connectForCommand(parsed) {
|
|
|
16864
16906
|
const client = seams.createClient(tab.webSocketDebuggerUrl);
|
|
16865
16907
|
try {
|
|
16866
16908
|
await client.waitReady();
|
|
16867
|
-
await applyActiveSessionNetworkConditions(client, seams.getActiveSession(), tab.id);
|
|
16909
|
+
await withinDeadline(setupDeadlineMs(parsed), "Restoring the session network state", applyActiveSessionNetworkConditions(client, seams.getActiveSession(), tab.id));
|
|
16868
16910
|
return { client, tab };
|
|
16869
16911
|
} catch (err) {
|
|
16870
16912
|
client.close();
|
|
@@ -16916,8 +16958,8 @@ function finishConsole(collectors) {
|
|
|
16916
16958
|
}
|
|
16917
16959
|
async function withConnection(parsed, fn, opts = {}) {
|
|
16918
16960
|
const { client, tab } = await connectForCommand(parsed);
|
|
16919
|
-
const collectors = await startCollectors(parsed, client);
|
|
16920
16961
|
try {
|
|
16962
|
+
const collectors = await withinDeadline(setupDeadlineMs(parsed), "Starting page collectors", startCollectors(parsed, client));
|
|
16921
16963
|
const result = await fn(client, tab);
|
|
16922
16964
|
if (collectors.harRecorder && parsed.har) {
|
|
16923
16965
|
const settle = opts.settle !== void 0 ? opts.settle : 3e3;
|
|
@@ -16934,8 +16976,8 @@ async function withConnection(parsed, fn, opts = {}) {
|
|
|
16934
16976
|
}
|
|
16935
16977
|
async function withPageAction(parsed, opts, fn) {
|
|
16936
16978
|
const { client, tab } = await connectForCommand(parsed);
|
|
16937
|
-
const collectors = await startCollectors(parsed, client);
|
|
16938
16979
|
try {
|
|
16980
|
+
const collectors = await withinDeadline(setupDeadlineMs(parsed), "Starting page collectors", startCollectors(parsed, client));
|
|
16939
16981
|
const result = await fn(client, tab);
|
|
16940
16982
|
const requestedMs = opts.settleMs;
|
|
16941
16983
|
const t0 = seams.now();
|
|
@@ -16963,7 +17005,7 @@ async function withPageAction(parsed, opts, fn) {
|
|
|
16963
17005
|
client.close();
|
|
16964
17006
|
}
|
|
16965
17007
|
}
|
|
16966
|
-
var fs14, import_node_perf_hooks2, seams;
|
|
17008
|
+
var fs14, import_node_perf_hooks2, seams, DEFAULT_SETUP_DEADLINE_MS;
|
|
16967
17009
|
var init_connection = __esm({
|
|
16968
17010
|
"src/cdp/connection.ts"() {
|
|
16969
17011
|
fs14 = __toESM(require("fs"));
|
|
@@ -16990,6 +17032,7 @@ var init_connection = __esm({
|
|
|
16990
17032
|
now: () => import_node_perf_hooks2.performance.now(),
|
|
16991
17033
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms))
|
|
16992
17034
|
};
|
|
17035
|
+
DEFAULT_SETUP_DEADLINE_MS = 3e4;
|
|
16993
17036
|
}
|
|
16994
17037
|
});
|
|
16995
17038
|
|
|
@@ -229200,7 +229243,7 @@ async function focusScopedEvaluate(client, expression, evaluationTimeoutMs) {
|
|
|
229200
229243
|
let primaryError;
|
|
229201
229244
|
try {
|
|
229202
229245
|
ownsFocusOverride = true;
|
|
229203
|
-
await client.send("Emulation.setFocusEmulationEnabled", { enabled: true });
|
|
229246
|
+
await client.send("Emulation.setFocusEmulationEnabled", { enabled: true }, evaluationTimeoutMs);
|
|
229204
229247
|
try {
|
|
229205
229248
|
return await client.send("Runtime.evaluate", {
|
|
229206
229249
|
expression,
|
|
@@ -229224,7 +229267,7 @@ async function focusScopedEvaluate(client, expression, evaluationTimeoutMs) {
|
|
|
229224
229267
|
} finally {
|
|
229225
229268
|
if (ownsFocusOverride) {
|
|
229226
229269
|
try {
|
|
229227
|
-
await client.send("Emulation.setFocusEmulationEnabled", { enabled: false });
|
|
229270
|
+
await client.send("Emulation.setFocusEmulationEnabled", { enabled: false }, evaluationTimeoutMs);
|
|
229228
229271
|
} catch (error4) {
|
|
229229
229272
|
const cleanupError = captureError(
|
|
229230
229273
|
"cleanup",
|
|
@@ -229706,6 +229749,7 @@ async function cmdPageShot(parsed, _args) {
|
|
|
229706
229749
|
fullPage: parsed.fullPage,
|
|
229707
229750
|
crop: requestedCrop,
|
|
229708
229751
|
zoom,
|
|
229752
|
+
requestTimeoutMs: parsed.timeoutMs,
|
|
229709
229753
|
...colorScheme ? { beforeCapture: () => finishColorTransitions(client) } : {}
|
|
229710
229754
|
});
|
|
229711
229755
|
const outPath = await resolveOutPath(parsed);
|
|
@@ -229746,6 +229790,7 @@ input:
|
|
|
229746
229790
|
--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
229791
|
--artifact-dir <path> root for that sessionless one-shot bundle (ignored with --out or an active session)
|
|
229748
229792
|
--target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
|
|
229793
|
+
--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
229794
|
--json mirror the result as JSON
|
|
229750
229795
|
output:
|
|
229751
229796
|
<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
|
|
@@ -267596,7 +267641,7 @@ I/O contract: flags and positionals on input; one rendered prose block on stdout
|
|
|
267596
267641
|
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
267642
|
}
|
|
267598
267643
|
function printVersion() {
|
|
267599
|
-
const declared = "1.4.
|
|
267644
|
+
const declared = "1.4.27";
|
|
267600
267645
|
if (declared) {
|
|
267601
267646
|
console.log(declared);
|
|
267602
267647
|
return;
|
package/package.json
CHANGED
|
@@ -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);
|
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(
|