@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 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
- return best ? { port: best.port, tab: best.tab } : null;
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
- return refreshed ?? tab;
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
- if (parsed.port === void 0 && session.port !== void 0 && session.port !== null) {
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.25";
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.25";
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.25";
267644
+ const declared = "1.4.27";
267599
267645
  if (declared) {
267600
267646
  console.log(declared);
267601
267647
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.4.25",
3
+ "version": "1.4.27",
4
4
  "description": "Browser automation and UI validation via CDP — session-based screenshots, HAR, a11y, click/type, and JS exec",
5
5
  "bin": {
6
6
  "capture": "./bin/capture"
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
- if (parsed.port === undefined && session.port !== undefined && session.port !== null) { parsed.port = session.port; parsed.portSource = 'session'; }
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.`;
@@ -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
- return refreshed ?? tab;
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
  }
@@ -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;
@@ -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
- // Skip endpoints that fail or are ambiguous on a different port.
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
- // An unavailable endpoint cannot supply a target.
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
- // Skip endpoints that fail to respond.
271
+ } catch (error) {
272
+ failures.push({ port, error });
248
273
  }
249
274
  }
250
275
 
251
- return best ? { port: best.port, tab: best.tab } : null;
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(