@crouton-kit/capture 1.3.20 → 1.3.21

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
@@ -10231,7 +10231,7 @@ function validateHarAppendBatch(value, source) {
10231
10231
  return { entries, incompleteLifecycles };
10232
10232
  }
10233
10233
  function captureVersion() {
10234
- const version = "1.3.20";
10234
+ const version = "1.3.21";
10235
10235
  return typeof version === "string" && version.length > 0 ? version : "development";
10236
10236
  }
10237
10237
  function token() {
@@ -11990,7 +11990,7 @@ function wallTime(value) {
11990
11990
  }
11991
11991
  }
11992
11992
  function captureVersion2() {
11993
- const version = "1.3.20";
11993
+ const version = "1.3.21";
11994
11994
  return typeof version === "string" && version.length > 0 ? version : "development";
11995
11995
  }
11996
11996
  function deepFreeze(value) {
@@ -15943,6 +15943,21 @@ var init_interact = __esm({
15943
15943
  function pageInputDeps() {
15944
15944
  return deps2;
15945
15945
  }
15946
+ async function capturePageInputScreenshot(client, action, label2, noScreenshot) {
15947
+ try {
15948
+ return {
15949
+ screenshot: await deps2.autoScreenshot(client, action, label2, noScreenshot),
15950
+ screenshotWarning: null
15951
+ };
15952
+ } catch (error) {
15953
+ const message = error instanceof Error ? error.message : String(error);
15954
+ if (!/^CDP request timeout \(\d+ms\): Page\.captureScreenshot$/.test(message)) throw error;
15955
+ return {
15956
+ screenshot: null,
15957
+ screenshotWarning: `automatic screenshot timed out after the page action completed; no screenshot was saved (${message})`
15958
+ };
15959
+ }
15960
+ }
15946
15961
  function effectiveSettle(parsed, defaults) {
15947
15962
  if (parsed.settle !== void 0) return parsed.settle;
15948
15963
  return deps2.getActiveSession() !== null ? defaults.session : defaults.standalone;
@@ -16024,19 +16039,20 @@ async function cmdPageClick(parsed, _args) {
16024
16039
  const resolved = await resolveLiveTarget(live, target);
16025
16040
  if (!resolved.ok) return { failure: resolved };
16026
16041
  const dispatch2 = await clickResolved(live, resolved);
16027
- const screenshot2 = await deps2.autoScreenshot(client, "click", target, parsed.noScreenshot);
16028
- return { dispatch: dispatch2, screenshot: screenshot2 };
16042
+ const screenshotResult = await capturePageInputScreenshot(client, "click", target, parsed.noScreenshot);
16043
+ return { dispatch: dispatch2, ...screenshotResult };
16029
16044
  }
16030
16045
  );
16031
16046
  if ("failure" in outcome) {
16032
16047
  return emitResolutionError(parsed, "page click", outcome.failure);
16033
16048
  }
16034
- const { dispatch, screenshot } = outcome;
16049
+ const { dispatch, screenshot, screenshotWarning } = outcome;
16035
16050
  const rows = [
16036
16051
  fact`clicked ${dispatch.role ?? "unknown"} "${dispatch.name ?? ""}" (backend:${dispatch.backendNodeId}) at x=${dispatch.x} y=${dispatch.y}`,
16037
16052
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`
16038
16053
  ];
16039
16054
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
16055
+ if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
16040
16056
  emitResult(
16041
16057
  {
16042
16058
  tag: "clicked",
@@ -16066,7 +16082,7 @@ input:
16066
16082
  --settle <ms> network-settle window applied after the click (default: 1000; 2500 with an active session; 0 disables)
16067
16083
  --no-screenshot skip the auto-screenshot
16068
16084
  output:
16069
- <clicked backend-node-id=\u2026 role=\u2026 name=\u2026> \u2014 resolved identity, dispatched coordinates, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
16085
+ <clicked backend-node-id=\u2026 role=\u2026 name=\u2026> \u2014 resolved identity, dispatched coordinates, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
16070
16086
  effects:
16071
16087
  scrolls the target into view, then dispatches a real mouse press/release at its center; writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
16072
16088
  }
@@ -16101,19 +16117,20 @@ async function cmdPageType(parsed, _args) {
16101
16117
  } else {
16102
16118
  await typeText(live, textArg);
16103
16119
  }
16104
- const screenshot2 = await deps7.autoScreenshot(client, "type", parsed.into ?? "focused element", parsed.noScreenshot);
16105
- return { dispatch: dispatch2, screenshot: screenshot2 };
16120
+ const screenshotResult = await capturePageInputScreenshot(client, "type", parsed.into ?? "focused element", parsed.noScreenshot);
16121
+ return { dispatch: dispatch2, ...screenshotResult };
16106
16122
  }
16107
16123
  );
16108
16124
  if ("failure" in outcome) {
16109
16125
  return emitResolutionError(parsed, "page type", outcome.failure);
16110
16126
  }
16111
- const { dispatch, screenshot } = outcome;
16127
+ const { dispatch, screenshot, screenshotWarning } = outcome;
16112
16128
  const rows = [
16113
16129
  dispatch ? fact`typed "${textArg}" into ${dispatch.role ?? "unknown"} "${dispatch.name ?? ""}" (backend:${dispatch.backendNodeId}), focus click at x=${dispatch.x} y=${dispatch.y}` : fact`typed "${textArg}" into the focused element`,
16114
16130
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`
16115
16131
  ];
16116
16132
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
16133
+ if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
16117
16134
  emitResult(
16118
16135
  {
16119
16136
  tag: "typed",
@@ -16141,7 +16158,7 @@ input:
16141
16158
  --settle <ms> network-settle window applied after typing (default: 500; 1500 with an active session; 0 disables)
16142
16159
  --no-screenshot skip the auto-screenshot
16143
16160
  output:
16144
- <typed> \u2014 the typed text, the resolved field identity (backend-node-id, role, name) and focus-click coordinates when --into was given, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
16161
+ <typed> \u2014 the typed text, the resolved field identity (backend-node-id, role, name) and focus-click coordinates when --into was given, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
16145
16162
  effects:
16146
16163
  dispatches a real focus click (when --into) followed by real text insertion; writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
16147
16164
  }
@@ -16186,19 +16203,20 @@ async function cmdPageScroll(parsed, _args) {
16186
16203
  const resolved = await resolveLiveTarget(live, target);
16187
16204
  if (!resolved.ok) return { failure: resolved };
16188
16205
  const dispatch2 = await scrollResolved(live, resolved, to, { mark: `scroll:${target},to=${to}` });
16189
- const screenshot2 = await deps7.autoScreenshot(client, "scroll", target, parsed.noScreenshot);
16190
- return { dispatch: dispatch2, screenshot: screenshot2 };
16206
+ const screenshotResult = await capturePageInputScreenshot(client, "scroll", target, parsed.noScreenshot);
16207
+ return { dispatch: dispatch2, ...screenshotResult };
16191
16208
  }
16192
16209
  );
16193
16210
  if ("failure" in outcome) {
16194
16211
  return emitResolutionError(parsed, "page scroll", outcome.failure);
16195
16212
  }
16196
- const { dispatch, screenshot } = outcome;
16213
+ const { dispatch, screenshot, screenshotWarning } = outcome;
16197
16214
  const rows = [
16198
16215
  fact`scrolled ${dispatch.role ?? "unknown"} "${dispatch.name ?? ""}" (backend:${dispatch.backendNodeId}) to ${dispatch.to} — scrollTop now ${dispatch.scrollTop}`,
16199
16216
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`
16200
16217
  ];
16201
16218
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
16219
+ if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
16202
16220
  emitResult(
16203
16221
  {
16204
16222
  tag: "scrolled",
@@ -16229,7 +16247,7 @@ input:
16229
16247
  --settle <ms> network-settle window applied after the scroll (default: 1000; 2500 with an active session; 0 disables)
16230
16248
  --no-screenshot skip the auto-screenshot
16231
16249
  output:
16232
- <scrolled backend-node-id=\u2026 role=\u2026 name=\u2026> \u2014 resolved identity, destination, the container's resulting scrollTop, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
16250
+ <scrolled backend-node-id=\u2026 role=\u2026 name=\u2026> \u2014 resolved identity, destination, the container's resulting scrollTop, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
16233
16251
  effects:
16234
16252
  assigns the container's scrollTop in-page (may trigger lazy-load network); writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
16235
16253
  }
@@ -230926,17 +230944,23 @@ function normalizeComputed(raw) {
230926
230944
  function resolvedIdentity3(backendNodeId2) {
230927
230945
  return backendNodeId2 === void 0 ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId: backendNodeId2 };
230928
230946
  }
230929
- async function mapWithConcurrency(values, limit, fn) {
230930
- const result = new Array(values.length);
230947
+ async function mapWithConcurrency(values, limit, budgetMs, fn) {
230948
+ const result = Array(values.length).fill(void 0);
230949
+ const deadline = Date.now() + budgetMs;
230931
230950
  let next = 0;
230951
+ let truncated = false;
230932
230952
  const worker = async () => {
230933
230953
  while (next < values.length) {
230954
+ if (Date.now() >= deadline) {
230955
+ truncated = true;
230956
+ return;
230957
+ }
230934
230958
  const index = next++;
230935
230959
  result[index] = await fn(values[index], index);
230936
230960
  }
230937
230961
  };
230938
230962
  await Promise.all(Array.from({ length: Math.min(limit, values.length) }, worker));
230939
- return result;
230963
+ return { values: result, truncated };
230940
230964
  }
230941
230965
  async function collectStylesInner(ctx, styleSheetUrls, styleSheetHeaders) {
230942
230966
  const { client } = ctx;
@@ -230986,7 +231010,7 @@ async function collectStylesInner(ctx, styleSheetUrls, styleSheetHeaders) {
230986
231010
  resolved = facts.map(() => void 0);
230987
231011
  }
230988
231012
  const sourceCache = /* @__PURE__ */ new Map();
230989
- const elements = await mapWithConcurrency(facts, PROVENANCE_CONCURRENCY, async (fact4, index) => {
231013
+ const provenance = await mapWithConcurrency(facts, PROVENANCE_CONCURRENCY, PROVENANCE_BUDGET_MS, async (fact4, index) => {
230990
231014
  const ref = resolved[index];
230991
231015
  const computed2 = normalizeComputed(fact4.computed);
230992
231016
  let winningDeclarations;
@@ -231021,6 +231045,7 @@ async function collectStylesInner(ctx, styleSheetUrls, styleSheetHeaders) {
231021
231045
  provenanceUnavailable
231022
231046
  };
231023
231047
  });
231048
+ const elements = provenance.values.filter((element) => element !== void 0);
231024
231049
  ctx.write.json("styles.json", {
231025
231050
  elements,
231026
231051
  coverage: {
@@ -231029,7 +231054,9 @@ async function collectStylesInner(ctx, styleSheetUrls, styleSheetHeaders) {
231029
231054
  shadowRootsNotWalked: inventory.shadowRootsNotWalked,
231030
231055
  totalCandidateElements: inventory.total,
231031
231056
  keptElements: elements.length,
231032
- elementsTruncated: inventory.total > elements.length
231057
+ elementsTruncated: inventory.total > facts.length,
231058
+ provenanceTruncated: provenance.truncated,
231059
+ ...provenance.truncated ? { provenanceTruncationReason: "collector-time-budget" } : {}
231033
231060
  },
231034
231061
  styleSheetHeaders,
231035
231062
  identity: identity3,
@@ -231037,13 +231064,14 @@ async function collectStylesInner(ctx, styleSheetUrls, styleSheetHeaders) {
231037
231064
  ...unavailableReason ? { unavailableReason } : {}
231038
231065
  });
231039
231066
  }
231040
- var STYLES_MAX_ELEMENTS, PROVENANCE_CONCURRENCY, COMPUTED_PROPERTIES, PROVENANCE_PROPERTIES, EXCLUDED_TAGS, STYLES_SCRIPT, collectStyles;
231067
+ var STYLES_MAX_ELEMENTS, PROVENANCE_CONCURRENCY, PROVENANCE_BUDGET_MS, COMPUTED_PROPERTIES, PROVENANCE_PROPERTIES, EXCLUDED_TAGS, STYLES_SCRIPT, collectStyles;
231041
231068
  var init_styles = __esm({
231042
231069
  "src/cdp/measure/collectors/styles.ts"() {
231043
231070
  init_redaction();
231044
231071
  init_style_provenance();
231045
231072
  STYLES_MAX_ELEMENTS = 150;
231046
231073
  PROVENANCE_CONCURRENCY = 8;
231074
+ PROVENANCE_BUDGET_MS = 5e3;
231047
231075
  COMPUTED_PROPERTIES = [
231048
231076
  "display",
231049
231077
  "position",
@@ -235936,22 +235964,24 @@ function boundedClient(client, phase, partialPath, onTimeout) {
235936
235964
  const timeoutMs = Math.min(timeout, SNAPSHOT_REQUEST_TIMEOUT_MS);
235937
235965
  let timer;
235938
235966
  let timedOut = false;
235939
- const markTimedOut = () => {
235967
+ const markTimedOut = (error) => {
235940
235968
  if (timedOut) return;
235941
235969
  timedOut = true;
235942
- onTimeout?.();
235970
+ onTimeout?.(error);
235943
235971
  };
235944
235972
  const deadline = new Promise((_, reject) => {
235945
235973
  timer = setTimeout(() => {
235946
- markTimedOut();
235947
- reject(new SnapshotCaptureTimeout(phase, method, timeoutMs, partialPath));
235974
+ const error = new SnapshotCaptureTimeout(phase, method, timeoutMs, partialPath);
235975
+ markTimedOut(error);
235976
+ reject(error);
235948
235977
  }, timeoutMs);
235949
235978
  });
235950
235979
  const request = Promise.resolve().then(() => client.send(method, params, timeoutMs, sessionId)).catch((error) => {
235951
235980
  if (error instanceof SnapshotCaptureTimeout) throw error;
235952
235981
  if (error instanceof Error && error.message.startsWith("CDP request timeout")) {
235953
- markTimedOut();
235954
- throw new SnapshotCaptureTimeout(phase, method, timeoutMs, partialPath);
235982
+ const timeoutError = new SnapshotCaptureTimeout(phase, method, timeoutMs, partialPath);
235983
+ markTimedOut(timeoutError);
235984
+ throw timeoutError;
235955
235985
  }
235956
235986
  throw error;
235957
235987
  });
@@ -236055,21 +236085,25 @@ async function captureSnapshotSubstrate(options) {
236055
236085
  if (skipCollectors.has(collector.name)) return { name: collector.name, status: "skipped" };
236056
236086
  const startedAt = Date.now();
236057
236087
  let requestTimeouts = 0;
236088
+ let timeoutError;
236058
236089
  const collectorWriter = makeCollectorWriter(ctx.write);
236059
236090
  try {
236060
236091
  await collector.fn({
236061
236092
  ...ctx,
236062
- client: boundedClient(client, `collector:${collector.name}`, dir, () => {
236093
+ client: boundedClient(client, `collector:${collector.name}`, dir, (error) => {
236063
236094
  requestTimeouts += 1;
236095
+ timeoutError ??= error;
236064
236096
  }),
236065
236097
  write: collectorWriter.writer
236066
236098
  });
236099
+ if (collector.name === "styles" && timeoutError) throw timeoutError;
236067
236100
  const ms = Date.now() - startedAt;
236068
236101
  collectorWriter.commit();
236069
236102
  return requestTimeouts > 0 ? { name: collector.name, status: "degraded", ms, requestTimeouts } : { name: collector.name, status: "ok", ms };
236070
- } catch {
236103
+ } catch (error) {
236071
236104
  const ms = Date.now() - startedAt;
236072
236105
  collectorWriter.discard();
236106
+ if (collector.name === "styles" && error instanceof SnapshotCaptureTimeout) throw error;
236073
236107
  return { name: collector.name, status: "error", ms, ...requestTimeouts > 0 ? { requestTimeouts } : {} };
236074
236108
  }
236075
236109
  };
@@ -242178,7 +242212,7 @@ CDP_PORT / CDP_TARGET pin the browser + tab for orchestrators \u2014 precedence:
242178
242212
  explicit flag > active session > env.`;
242179
242213
  }
242180
242214
  function printVersion() {
242181
- const declared = "1.3.20";
242215
+ const declared = "1.3.21";
242182
242216
  if (declared) {
242183
242217
  console.log(declared);
242184
242218
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.3.20",
3
+ "version": "1.3.21",
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"
@@ -52,6 +52,33 @@ export function pageInputDeps(): PageInputDeps {
52
52
  return deps;
53
53
  }
54
54
 
55
+ export interface PageInputScreenshot {
56
+ readonly screenshot: string | null;
57
+ readonly screenshotWarning: string | null;
58
+ }
59
+
60
+ /** A dispatched page action stays successful when only its follow-up screenshot times out. */
61
+ export async function capturePageInputScreenshot(
62
+ client: Parameters<typeof autoScreenshot>[0],
63
+ action: string,
64
+ label: string,
65
+ noScreenshot?: boolean,
66
+ ): Promise<PageInputScreenshot> {
67
+ try {
68
+ return {
69
+ screenshot: await deps.autoScreenshot(client, action, label, noScreenshot),
70
+ screenshotWarning: null,
71
+ };
72
+ } catch (error) {
73
+ const message = error instanceof Error ? error.message : String(error);
74
+ if (!/^CDP request timeout \(\d+ms\): Page\.captureScreenshot$/.test(message)) throw error;
75
+ return {
76
+ screenshot: null,
77
+ screenshotWarning: `automatic screenshot timed out after the page action completed; no screenshot was saved (${message})`,
78
+ };
79
+ }
80
+ }
81
+
55
82
  /**
56
83
  * The verb's settle window: `--settle <ms>` (including 0) wins outright;
57
84
  * otherwise the default is keyed off active-session presence (the in-session
@@ -147,7 +174,7 @@ input:
147
174
  --settle <ms> network-settle window applied after the click (default: 1000; 2500 with an active session; 0 disables)
148
175
  --no-screenshot skip the auto-screenshot
149
176
  output:
150
- <clicked backend-node-id=… role=… name=…> — resolved identity, dispatched coordinates, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
177
+ <clicked backend-node-id=… role=… name=…> — resolved identity, dispatched coordinates, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
151
178
  effects:
152
179
  scrolls the target into view, then dispatches a real mouse press/release at its center; writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
153
180
 
@@ -177,8 +204,8 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
177
204
  const resolved = await resolveLiveTarget(live, target);
178
205
  if (!resolved.ok) return { failure: resolved } as const;
179
206
  const dispatch = await clickResolved(live, resolved);
180
- const screenshot = await deps.autoScreenshot(client, 'click', target, parsed.noScreenshot);
181
- return { dispatch, screenshot } as const;
207
+ const screenshotResult = await capturePageInputScreenshot(client, 'click', target, parsed.noScreenshot);
208
+ return { dispatch, ...screenshotResult } as const;
182
209
  },
183
210
  );
184
211
 
@@ -186,12 +213,13 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
186
213
  return emitResolutionError(parsed, 'page click', outcome.failure);
187
214
  }
188
215
 
189
- const { dispatch, screenshot } = outcome;
216
+ const { dispatch, screenshot, screenshotWarning } = outcome;
190
217
  const rows: FactLine[] = [
191
218
  fact`clicked ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) at x=${dispatch.x} y=${dispatch.y}`,
192
219
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
193
220
  ];
194
221
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
222
+ if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
195
223
 
196
224
  emitResult(
197
225
  {
@@ -13,6 +13,7 @@ import { resolveLiveTarget, scrollResolved, type LiveClient } from '../../../int
13
13
  import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
14
14
  import {
15
15
  pageInputDeps,
16
+ capturePageInputScreenshot,
16
17
  effectiveSettle,
17
18
  emitInvalidInput,
18
19
  emitResolutionError,
@@ -26,7 +27,7 @@ input:
26
27
  --settle <ms> network-settle window applied after the scroll (default: 1000; 2500 with an active session; 0 disables)
27
28
  --no-screenshot skip the auto-screenshot
28
29
  output:
29
- <scrolled backend-node-id=… role=… name=…> — resolved identity, destination, the container's resulting scrollTop, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
30
+ <scrolled backend-node-id=… role=… name=…> — resolved identity, destination, the container's resulting scrollTop, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
30
31
  effects:
31
32
  assigns the container's scrollTop in-page (may trigger lazy-load network); writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
32
33
 
@@ -74,8 +75,8 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
74
75
  // Same landmark shape `motion rec --do scroll:` records; carried by
75
76
  // the one mutating call when the transport records landmarks.
76
77
  const dispatch = await scrollResolved(live, resolved, to, { mark: `scroll:${target},to=${to}` });
77
- const screenshot = await deps.autoScreenshot(client, 'scroll', target, parsed.noScreenshot);
78
- return { dispatch, screenshot } as const;
78
+ const screenshotResult = await capturePageInputScreenshot(client, 'scroll', target, parsed.noScreenshot);
79
+ return { dispatch, ...screenshotResult } as const;
79
80
  },
80
81
  );
81
82
 
@@ -83,12 +84,13 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
83
84
  return emitResolutionError(parsed, 'page scroll', outcome.failure);
84
85
  }
85
86
 
86
- const { dispatch, screenshot } = outcome;
87
+ const { dispatch, screenshot, screenshotWarning } = outcome;
87
88
  const rows: FactLine[] = [
88
89
  fact`scrolled ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) to ${dispatch.to} — scrollTop now ${dispatch.scrollTop}`,
89
90
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
90
91
  ];
91
92
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
93
+ if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
92
94
 
93
95
  emitResult(
94
96
  {
@@ -18,6 +18,7 @@ import {
18
18
  import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
19
19
  import {
20
20
  pageInputDeps,
21
+ capturePageInputScreenshot,
21
22
  effectiveSettle,
22
23
  emitInvalidInput,
23
24
  emitResolutionError,
@@ -31,7 +32,7 @@ input:
31
32
  --settle <ms> network-settle window applied after typing (default: 500; 1500 with an active session; 0 disables)
32
33
  --no-screenshot skip the auto-screenshot
33
34
  output:
34
- <typed> — the typed text, the resolved field identity (backend-node-id, role, name) and focus-click coordinates when --into was given, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
35
+ <typed> — the typed text, the resolved field identity (backend-node-id, role, name) and focus-click coordinates when --into was given, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
35
36
  effects:
36
37
  dispatches a real focus click (when --into) followed by real text insertion; writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
37
38
 
@@ -69,8 +70,8 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
69
70
  await typeText(live, textArg);
70
71
  }
71
72
  // Screenshot label identifies the field, never the typed content.
72
- const screenshot = await deps.autoScreenshot(client, 'type', parsed.into ?? 'focused element', parsed.noScreenshot);
73
- return { dispatch, screenshot } as const;
73
+ const screenshotResult = await capturePageInputScreenshot(client, 'type', parsed.into ?? 'focused element', parsed.noScreenshot);
74
+ return { dispatch, ...screenshotResult } as const;
74
75
  },
75
76
  );
76
77
 
@@ -78,7 +79,7 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
78
79
  return emitResolutionError(parsed, 'page type', outcome.failure);
79
80
  }
80
81
 
81
- const { dispatch, screenshot } = outcome;
82
+ const { dispatch, screenshot, screenshotWarning } = outcome;
82
83
  const rows: FactLine[] = [
83
84
  dispatch
84
85
  ? fact`typed "${textArg}" into ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}), focus click at x=${dispatch.x} y=${dispatch.y}`
@@ -86,6 +87,7 @@ export async function cmdPageType(parsed: ParsedArgs, _args: string[]): Promise<
86
87
  fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
87
88
  ];
88
89
  if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
90
+ if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
89
91
 
90
92
  emitResult(
91
93
  {
@@ -35,6 +35,8 @@ export type { Specificity, CSSRange, WinningDeclaration } from './style-provenan
35
35
 
36
36
  const STYLES_MAX_ELEMENTS = 150;
37
37
  const PROVENANCE_CONCURRENCY = 8;
38
+ /** Bounds queued provenance work without abandoning in-flight CDP requests on the shared snap connection. Matches snapshot.ts's per-request bound but remains local to avoid a collector/orchestrator import cycle. */
39
+ const PROVENANCE_BUDGET_MS = 5_000;
38
40
 
39
41
  /** Full computed-style snapshot tracked per element — broad, cheap (no CDP round trip; read via `getComputedStyle` in one `Runtime.evaluate`). */
40
42
  const COMPUTED_PROPERTIES = [
@@ -95,6 +97,10 @@ export interface StylesCoverage {
95
97
  keptElements: number;
96
98
  /** `true` when {@link STYLES_MAX_ELEMENTS} dropped one or more candidate elements from enumeration — an explicit fact (I-5), never a silent cap. */
97
99
  elementsTruncated: boolean;
100
+ /** `true` when the bounded provenance pass stopped starting element reads before every enumerated element was inspected; completed in-flight reads are still included. */
101
+ provenanceTruncated: boolean;
102
+ /** Present only when {@link provenanceTruncated} is true; names the factual bound that stopped additional provenance requests. */
103
+ provenanceTruncationReason?: 'collector-time-budget';
98
104
  }
99
105
 
100
106
  export interface StylesReport {
@@ -205,18 +211,33 @@ function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: n
205
211
  return backendNodeId === undefined ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId };
206
212
  }
207
213
 
208
- /** Limits expensive per-element CDP provenance reads without changing document order. */
209
- async function mapWithConcurrency<T, R>(values: readonly T[], limit: number, fn: (value: T, index: number) => Promise<R>): Promise<R[]> {
210
- const result = new Array<R>(values.length);
214
+ /**
215
+ * Limits expensive per-element CDP provenance reads without changing document
216
+ * order. Once its budget expires it starts no more reads, but awaits the
217
+ * current worker batch so no CDP work is left running under the next collector.
218
+ */
219
+ async function mapWithConcurrency<T, R>(
220
+ values: readonly T[],
221
+ limit: number,
222
+ budgetMs: number,
223
+ fn: (value: T, index: number) => Promise<R>,
224
+ ): Promise<{ values: Array<R | undefined>; truncated: boolean }> {
225
+ const result = Array<R | undefined>(values.length).fill(undefined);
226
+ const deadline = Date.now() + budgetMs;
211
227
  let next = 0;
228
+ let truncated = false;
212
229
  const worker = async (): Promise<void> => {
213
230
  while (next < values.length) {
231
+ if (Date.now() >= deadline) {
232
+ truncated = true;
233
+ return;
234
+ }
214
235
  const index = next++;
215
236
  result[index] = await fn(values[index]!, index);
216
237
  }
217
238
  };
218
239
  await Promise.all(Array.from({ length: Math.min(limit, values.length) }, worker));
219
- return result;
240
+ return { values: result, truncated };
220
241
  }
221
242
 
222
243
  // ============================================================================
@@ -305,48 +326,49 @@ async function collectStylesInner(
305
326
 
306
327
  const sourceCache = new Map<string, Promise<ResolvedSourceLocation>>();
307
328
 
308
- const elements = await mapWithConcurrency(facts, PROVENANCE_CONCURRENCY, async (fact, index): Promise<StylesElementRecord> => {
309
- const ref = resolved[index];
310
- const computed = normalizeComputed(fact.computed);
311
-
312
- // A `noDeclarationRecord` is reserved for a SUCCESSFUL matched-styles response that genuinely had
313
- // no candidate for a property — an honest "nothing declares this" fact. When provenance itself
314
- // couldn't be inspected (no CDP nodeId, or `CSS.getMatchedStylesForNode` failed), that's a different
315
- // fact — "unknown", not "none" — so `winningDeclarations` is left empty and `provenanceUnavailable`
316
- // is set, rather than conflating the two into misleading no-declaration records.
317
- let winningDeclarations: WinningDeclaration[];
318
- let provenanceUnavailable: boolean | undefined;
319
- if (ref?.nodeId !== undefined) {
320
- try {
321
- const matched = (await client.send('CSS.getMatchedStylesForNode', {
322
- nodeId: ref.nodeId,
323
- })) as CDPMatchedStylesResponse;
324
- winningDeclarations = await buildWinningDeclarations(
325
- client,
326
- matched,
327
- computed,
328
- sourceCache,
329
- styleSheetUrls,
330
- PROVENANCE_PROPERTIES,
331
- );
332
- } catch {
333
- winningDeclarations = [];
334
- provenanceUnavailable = true;
335
- }
336
- } else {
329
+ const provenance = await mapWithConcurrency(facts, PROVENANCE_CONCURRENCY, PROVENANCE_BUDGET_MS, async (fact, index): Promise<StylesElementRecord> => {
330
+ const ref = resolved[index];
331
+ const computed = normalizeComputed(fact.computed);
332
+
333
+ // A `noDeclarationRecord` is reserved for a SUCCESSFUL matched-styles response that genuinely had
334
+ // no candidate for a property — an honest "nothing declares this" fact. When provenance itself
335
+ // couldn't be inspected (no CDP nodeId, or `CSS.getMatchedStylesForNode` failed), that's a different
336
+ // fact — "unknown", not "none" — so `winningDeclarations` is left empty and `provenanceUnavailable`
337
+ // is set, rather than conflating the two into misleading no-declaration records.
338
+ let winningDeclarations: WinningDeclaration[];
339
+ let provenanceUnavailable: boolean | undefined;
340
+ if (ref?.nodeId !== undefined) {
341
+ try {
342
+ const matched = (await client.send('CSS.getMatchedStylesForNode', {
343
+ nodeId: ref.nodeId,
344
+ })) as CDPMatchedStylesResponse;
345
+ winningDeclarations = await buildWinningDeclarations(
346
+ client,
347
+ matched,
348
+ computed,
349
+ sourceCache,
350
+ styleSheetUrls,
351
+ PROVENANCE_PROPERTIES,
352
+ );
353
+ } catch {
337
354
  winningDeclarations = [];
338
355
  provenanceUnavailable = true;
339
356
  }
357
+ } else {
358
+ winningDeclarations = [];
359
+ provenanceUnavailable = true;
360
+ }
340
361
 
341
- return {
342
- id: `s-${index}`,
343
- selector: sanitizeString(fact.cssPath),
344
- ...resolvedIdentity(ref?.backendNodeId),
345
- computed,
346
- winningDeclarations,
347
- provenanceUnavailable,
348
- };
349
- });
362
+ return {
363
+ id: `s-${index}`,
364
+ selector: sanitizeString(fact.cssPath),
365
+ ...resolvedIdentity(ref?.backendNodeId),
366
+ computed,
367
+ winningDeclarations,
368
+ provenanceUnavailable,
369
+ };
370
+ });
371
+ const elements = provenance.values.filter((element): element is StylesElementRecord => element !== undefined);
350
372
 
351
373
  ctx.write.json('styles.json', {
352
374
  elements,
@@ -356,7 +378,9 @@ async function collectStylesInner(
356
378
  shadowRootsNotWalked: inventory.shadowRootsNotWalked,
357
379
  totalCandidateElements: inventory.total,
358
380
  keptElements: elements.length,
359
- elementsTruncated: inventory.total > elements.length,
381
+ elementsTruncated: inventory.total > facts.length,
382
+ provenanceTruncated: provenance.truncated,
383
+ ...(provenance.truncated ? { provenanceTruncationReason: 'collector-time-budget' as const } : {}),
360
384
  },
361
385
  styleSheetHeaders: styleSheetHeaders,
362
386
  identity,
@@ -199,28 +199,30 @@ export class SnapshotCaptureTimeout extends Error {
199
199
  }
200
200
 
201
201
  /** Bounds one snapshot CDP request and labels the phase that owns it. */
202
- function boundedClient(client: CDPClient, phase: string, partialPath: string, onTimeout?: () => void): CDPClient {
202
+ function boundedClient(client: CDPClient, phase: string, partialPath: string, onTimeout?: (error: SnapshotCaptureTimeout) => void): CDPClient {
203
203
  const bounded = Object.create(client) as CDPClient;
204
204
  bounded.send = (method: string, params: Record<string, unknown> = {}, timeout = 60_000, sessionId?: string): Promise<unknown> => {
205
205
  const timeoutMs = Math.min(timeout, SNAPSHOT_REQUEST_TIMEOUT_MS);
206
206
  let timer: NodeJS.Timeout | undefined;
207
207
  let timedOut = false;
208
- const markTimedOut = (): void => {
208
+ const markTimedOut = (error: SnapshotCaptureTimeout): void => {
209
209
  if (timedOut) return;
210
210
  timedOut = true;
211
- onTimeout?.();
211
+ onTimeout?.(error);
212
212
  };
213
213
  const deadline = new Promise<never>((_, reject) => {
214
214
  timer = setTimeout(() => {
215
- markTimedOut();
216
- reject(new SnapshotCaptureTimeout(phase, method, timeoutMs, partialPath));
215
+ const error = new SnapshotCaptureTimeout(phase, method, timeoutMs, partialPath);
216
+ markTimedOut(error);
217
+ reject(error);
217
218
  }, timeoutMs);
218
219
  });
219
220
  const request = Promise.resolve().then(() => client.send(method, params, timeoutMs, sessionId)).catch((error: unknown) => {
220
221
  if (error instanceof SnapshotCaptureTimeout) throw error;
221
222
  if (error instanceof Error && error.message.startsWith('CDP request timeout')) {
222
- markTimedOut();
223
- throw new SnapshotCaptureTimeout(phase, method, timeoutMs, partialPath);
223
+ const timeoutError = new SnapshotCaptureTimeout(phase, method, timeoutMs, partialPath);
224
+ markTimedOut(timeoutError);
225
+ throw timeoutError;
224
226
  }
225
227
  throw error;
226
228
  });
@@ -387,25 +389,34 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
387
389
 
388
390
  const startedAt = Date.now();
389
391
  let requestTimeouts = 0;
392
+ let timeoutError: SnapshotCaptureTimeout | undefined;
390
393
  const collectorWriter = makeCollectorWriter(ctx.write);
391
394
  try {
392
- // Await the collector itself, not a wall-clock race around it. Each CDP
393
- // request is still bounded at five seconds; awaiting the collector keeps
394
- // any cleanup and pending browser work quiescent before the next shared-
395
- // connection collector starts.
395
+ // Await the collector itself, not a wall-clock race around it. The
396
+ // collector owns its cleanup, and timeout handling below decides whether
397
+ // this capture can safely move on to another shared-connection collector.
396
398
  await collector.fn({
397
399
  ...ctx,
398
- client: boundedClient(client, `collector:${collector.name}`, dir, () => { requestTimeouts += 1; }),
400
+ client: boundedClient(client, `collector:${collector.name}`, dir, (error) => {
401
+ requestTimeouts += 1;
402
+ timeoutError ??= error;
403
+ }),
399
404
  write: collectorWriter.writer,
400
405
  });
406
+ // A styles timeout may be caught per-record by the provenance collector,
407
+ // but its browser operation can still belong to this shared connection.
408
+ // End this capture instead of letting another collector issue commands
409
+ // under that outstanding operation.
410
+ if (collector.name === 'styles' && timeoutError) throw timeoutError;
401
411
  const ms = Date.now() - startedAt;
402
412
  collectorWriter.commit();
403
413
  return requestTimeouts > 0
404
414
  ? { name: collector.name, status: 'degraded', ms, requestTimeouts }
405
415
  : { name: collector.name, status: 'ok', ms };
406
- } catch {
416
+ } catch (error) {
407
417
  const ms = Date.now() - startedAt;
408
418
  collectorWriter.discard();
419
+ if (collector.name === 'styles' && error instanceof SnapshotCaptureTimeout) throw error;
409
420
  return { name: collector.name, status: 'error', ms, ...(requestTimeouts > 0 ? { requestTimeouts } : {}) };
410
421
  }
411
422
  };