@crouton-kit/capture 1.4.25 → 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 +67 -21
- package/package.json +1 -1
- package/src/cdp/args.ts +4 -1
- package/src/cdp/commands/page/exec.ts +2 -2
- package/src/cdp/commands/page/shot.ts +2 -1
- 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
|
@@ -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"() {
|
|
@@ -10174,7 +10198,8 @@ function resolveCliContext(parsed) {
|
|
|
10174
10198
|
parsed.target = session.targetId;
|
|
10175
10199
|
parsed.targetSource = "session";
|
|
10176
10200
|
}
|
|
10177
|
-
|
|
10201
|
+
const discoveringEndpoints = parsed.command === "tab" && parsed.positional[0] === "list";
|
|
10202
|
+
if (parsed.port === void 0 && !discoveringEndpoints && session.port !== void 0 && session.port !== null) {
|
|
10178
10203
|
parsed.port = session.port;
|
|
10179
10204
|
parsed.portSource = "session";
|
|
10180
10205
|
}
|
|
@@ -10944,7 +10969,7 @@ function validateHarAppendBatch(value, source) {
|
|
|
10944
10969
|
return { entries, incompleteLifecycles };
|
|
10945
10970
|
}
|
|
10946
10971
|
function captureVersion() {
|
|
10947
|
-
const version = "1.4.
|
|
10972
|
+
const version = "1.4.27";
|
|
10948
10973
|
return typeof version === "string" && version.length > 0 ? version : "development";
|
|
10949
10974
|
}
|
|
10950
10975
|
function token() {
|
|
@@ -13046,7 +13071,7 @@ function wallTime(value) {
|
|
|
13046
13071
|
}
|
|
13047
13072
|
}
|
|
13048
13073
|
function captureVersion2() {
|
|
13049
|
-
const version = "1.4.
|
|
13074
|
+
const version = "1.4.27";
|
|
13050
13075
|
return typeof version === "string" && version.length > 0 ? version : "development";
|
|
13051
13076
|
}
|
|
13052
13077
|
function deepFreeze(value) {
|
|
@@ -16776,6 +16801,24 @@ async function applyActiveSessionNetworkConditions(client, session, targetId) {
|
|
|
16776
16801
|
uploadThroughput: offline ? 0 : -1
|
|
16777
16802
|
});
|
|
16778
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
|
+
}
|
|
16779
16822
|
function deriveActionLabel(parsed) {
|
|
16780
16823
|
if (parsed.command === "type") {
|
|
16781
16824
|
return `type:${parsed.into ?? "focused element"}`;
|
|
@@ -16863,7 +16906,7 @@ async function connectForCommand(parsed) {
|
|
|
16863
16906
|
const client = seams.createClient(tab.webSocketDebuggerUrl);
|
|
16864
16907
|
try {
|
|
16865
16908
|
await client.waitReady();
|
|
16866
|
-
await applyActiveSessionNetworkConditions(client, seams.getActiveSession(), tab.id);
|
|
16909
|
+
await withinDeadline(setupDeadlineMs(parsed), "Restoring the session network state", applyActiveSessionNetworkConditions(client, seams.getActiveSession(), tab.id));
|
|
16867
16910
|
return { client, tab };
|
|
16868
16911
|
} catch (err) {
|
|
16869
16912
|
client.close();
|
|
@@ -16915,8 +16958,8 @@ function finishConsole(collectors) {
|
|
|
16915
16958
|
}
|
|
16916
16959
|
async function withConnection(parsed, fn, opts = {}) {
|
|
16917
16960
|
const { client, tab } = await connectForCommand(parsed);
|
|
16918
|
-
const collectors = await startCollectors(parsed, client);
|
|
16919
16961
|
try {
|
|
16962
|
+
const collectors = await withinDeadline(setupDeadlineMs(parsed), "Starting page collectors", startCollectors(parsed, client));
|
|
16920
16963
|
const result = await fn(client, tab);
|
|
16921
16964
|
if (collectors.harRecorder && parsed.har) {
|
|
16922
16965
|
const settle = opts.settle !== void 0 ? opts.settle : 3e3;
|
|
@@ -16933,8 +16976,8 @@ async function withConnection(parsed, fn, opts = {}) {
|
|
|
16933
16976
|
}
|
|
16934
16977
|
async function withPageAction(parsed, opts, fn) {
|
|
16935
16978
|
const { client, tab } = await connectForCommand(parsed);
|
|
16936
|
-
const collectors = await startCollectors(parsed, client);
|
|
16937
16979
|
try {
|
|
16980
|
+
const collectors = await withinDeadline(setupDeadlineMs(parsed), "Starting page collectors", startCollectors(parsed, client));
|
|
16938
16981
|
const result = await fn(client, tab);
|
|
16939
16982
|
const requestedMs = opts.settleMs;
|
|
16940
16983
|
const t0 = seams.now();
|
|
@@ -16962,7 +17005,7 @@ async function withPageAction(parsed, opts, fn) {
|
|
|
16962
17005
|
client.close();
|
|
16963
17006
|
}
|
|
16964
17007
|
}
|
|
16965
|
-
var fs14, import_node_perf_hooks2, seams;
|
|
17008
|
+
var fs14, import_node_perf_hooks2, seams, DEFAULT_SETUP_DEADLINE_MS;
|
|
16966
17009
|
var init_connection = __esm({
|
|
16967
17010
|
"src/cdp/connection.ts"() {
|
|
16968
17011
|
fs14 = __toESM(require("fs"));
|
|
@@ -16989,6 +17032,7 @@ var init_connection = __esm({
|
|
|
16989
17032
|
now: () => import_node_perf_hooks2.performance.now(),
|
|
16990
17033
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms))
|
|
16991
17034
|
};
|
|
17035
|
+
DEFAULT_SETUP_DEADLINE_MS = 3e4;
|
|
16992
17036
|
}
|
|
16993
17037
|
});
|
|
16994
17038
|
|
|
@@ -229199,7 +229243,7 @@ async function focusScopedEvaluate(client, expression, evaluationTimeoutMs) {
|
|
|
229199
229243
|
let primaryError;
|
|
229200
229244
|
try {
|
|
229201
229245
|
ownsFocusOverride = true;
|
|
229202
|
-
await client.send("Emulation.setFocusEmulationEnabled", { enabled: true });
|
|
229246
|
+
await client.send("Emulation.setFocusEmulationEnabled", { enabled: true }, evaluationTimeoutMs);
|
|
229203
229247
|
try {
|
|
229204
229248
|
return await client.send("Runtime.evaluate", {
|
|
229205
229249
|
expression,
|
|
@@ -229223,7 +229267,7 @@ async function focusScopedEvaluate(client, expression, evaluationTimeoutMs) {
|
|
|
229223
229267
|
} finally {
|
|
229224
229268
|
if (ownsFocusOverride) {
|
|
229225
229269
|
try {
|
|
229226
|
-
await client.send("Emulation.setFocusEmulationEnabled", { enabled: false });
|
|
229270
|
+
await client.send("Emulation.setFocusEmulationEnabled", { enabled: false }, evaluationTimeoutMs);
|
|
229227
229271
|
} catch (error4) {
|
|
229228
229272
|
const cleanupError = captureError(
|
|
229229
229273
|
"cleanup",
|
|
@@ -229705,6 +229749,7 @@ async function cmdPageShot(parsed, _args) {
|
|
|
229705
229749
|
fullPage: parsed.fullPage,
|
|
229706
229750
|
crop: requestedCrop,
|
|
229707
229751
|
zoom,
|
|
229752
|
+
requestTimeoutMs: parsed.timeoutMs,
|
|
229708
229753
|
...colorScheme ? { beforeCapture: () => finishColorTransitions(client) } : {}
|
|
229709
229754
|
});
|
|
229710
229755
|
const outPath = await resolveOutPath(parsed);
|
|
@@ -229745,6 +229790,7 @@ input:
|
|
|
229745
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
|
|
229746
229791
|
--artifact-dir <path> root for that sessionless one-shot bundle (ignored with --out or an active session)
|
|
229747
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
|
|
229748
229794
|
--json mirror the result as JSON
|
|
229749
229795
|
output:
|
|
229750
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
|
|
@@ -230583,7 +230629,7 @@ var init_list = __esm({
|
|
|
230583
230629
|
USAGE11 = `capture tab list \u2014 discover CDP endpoints and list the page tabs on each.
|
|
230584
230630
|
|
|
230585
230631
|
input:
|
|
230586
|
-
--port <port> probe one endpoint only, instead of discovering every localhost CDP endpoint
|
|
230632
|
+
--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)
|
|
230587
230633
|
|
|
230588
230634
|
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.
|
|
230589
230635
|
effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited, stopped answering CDP, or gone idle \u2014 browsers capture did not start are never signalled.`;
|
|
@@ -267595,7 +267641,7 @@ I/O contract: flags and positionals on input; one rendered prose block on stdout
|
|
|
267595
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.`;
|
|
267596
267642
|
}
|
|
267597
267643
|
function printVersion() {
|
|
267598
|
-
const declared = "1.4.
|
|
267644
|
+
const declared = "1.4.27";
|
|
267599
267645
|
if (declared) {
|
|
267600
267646
|
console.log(declared);
|
|
267601
267647
|
return;
|
package/package.json
CHANGED
package/src/cdp/args.ts
CHANGED
|
@@ -625,7 +625,10 @@ export function resolveCliContext(parsed: ParsedArgs): ParsedArgs {
|
|
|
625
625
|
// A session target is inseparable from the endpoint that created it.
|
|
626
626
|
// Prefer that endpoint over ambient CDP_PORT, but retain an explicit
|
|
627
627
|
// --port override for deliberate multi-browser work.
|
|
628
|
-
|
|
628
|
+
// `tab list` is the discovery probe: without --port it lists every
|
|
629
|
+
// localhost endpoint, so the session's endpoint must not narrow it.
|
|
630
|
+
const discoveringEndpoints = parsed.command === 'tab' && parsed.positional[0] === 'list';
|
|
631
|
+
if (parsed.port === undefined && !discoveringEndpoints && session.port !== undefined && session.port !== null) { parsed.port = session.port; parsed.portSource = 'session'; }
|
|
629
632
|
}
|
|
630
633
|
refuseUnownedEndpoint(parsed, sessionStart);
|
|
631
634
|
if (parsed.port === undefined && process.env.CDP_PORT !== undefined) {
|
|
@@ -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);
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
const USAGE = `capture tab list — discover CDP endpoints and list the page tabs on each.
|
|
31
31
|
|
|
32
32
|
input:
|
|
33
|
-
--port <port> probe one endpoint only, instead of discovering every localhost CDP endpoint
|
|
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
36
|
effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited, stopped answering CDP, or gone idle — browsers capture did not start are never signalled.`;
|
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(
|