@crouton-kit/capture 1.3.18 → 1.3.20

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.
Files changed (38) hide show
  1. package/bin/capture +4204 -3244
  2. package/package.json +1 -1
  3. package/src/capture.ts +4 -1
  4. package/src/cdp/args.ts +22 -3
  5. package/src/cdp/browser-process.ts +510 -0
  6. package/src/cdp/client.ts +10 -1
  7. package/src/cdp/color-scheme.ts +47 -0
  8. package/src/cdp/commands/measure/index.ts +1 -1
  9. package/src/cdp/commands/measure/snap.ts +50 -8
  10. package/src/cdp/commands/motion/mask.ts +8 -2
  11. package/src/cdp/commands/page/click.ts +14 -0
  12. package/src/cdp/commands/page/elements.ts +2 -2
  13. package/src/cdp/commands/page/index.ts +1 -1
  14. package/src/cdp/commands/page/shot.ts +58 -13
  15. package/src/cdp/commands/tab/index.ts +19 -8
  16. package/src/cdp/commands/tab/launch.ts +73 -0
  17. package/src/cdp/commands/tab/list.ts +14 -2
  18. package/src/cdp/commands/tab/quit.ts +78 -0
  19. package/src/cdp/har-recorder.ts +14 -20
  20. package/src/cdp/measure/collectors/ax.ts +3 -4
  21. package/src/cdp/measure/collectors/forms.ts +4 -6
  22. package/src/cdp/measure/collectors/media.ts +1 -2
  23. package/src/cdp/measure/collectors/states.ts +1 -6
  24. package/src/cdp/measure/collectors/style-provenance.ts +4 -16
  25. package/src/cdp/measure/collectors/styles.ts +21 -10
  26. package/src/cdp/measure/collectors/text.ts +3 -5
  27. package/src/cdp/measure/snapshot.ts +197 -29
  28. package/src/cdp/measure/types.ts +36 -2
  29. package/src/cdp/motion/jank.ts +8 -1
  30. package/src/cdp/screenshot.ts +83 -8
  31. package/src/cdp/targets.ts +5 -0
  32. package/src/cdp/types.ts +6 -0
  33. package/src/har-manager.ts +16 -15
  34. package/src/interact.ts +20 -4
  35. package/src/output/render.ts +2 -0
  36. package/src/registry.ts +1 -1
  37. package/src/session/commands.ts +223 -43
  38. package/src/session/har-redact.ts +125 -0
@@ -13,6 +13,7 @@ import {
13
13
  pickPreferredEndpoint,
14
14
  } from '../../detect.js';
15
15
  import { listTargets } from '../../targets.js';
16
+ import { ownedBrowsersByPort, sweepOwnedBrowsers } from '../../browser-process.js';
16
17
  import { type ParsedArgs } from '../../types.js';
17
18
  import {
18
19
  data,
@@ -30,8 +31,8 @@ const USAGE = `capture tab list — discover CDP endpoints and list the page tab
30
31
  input:
31
32
  --port <port> probe one endpoint only, instead of discovering every localhost CDP endpoint
32
33
 
33
- output: <tabs endpoints=… tabs=…> — one section per reachable endpoint (port, app, page-tab count, preferred marker) 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.
34
- effects: read-only — probes local CDP endpoints over HTTP/WebSocket; no page-observable writes.`;
34
+ 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.
35
+ 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.`;
35
36
 
36
37
  export interface TabRow {
37
38
  id: string;
@@ -45,6 +46,9 @@ export interface EndpointTabs {
45
46
  app?: string;
46
47
  /** Marked on the endpoint auto-discovery would pick for port-less commands. */
47
48
  preferred: boolean;
49
+ /** The pid of the capture-launched browser serving this endpoint, when this
50
+ * is one capture owns — absent for every foreign/user-owned endpoint. */
51
+ ownedPid?: number;
48
52
  pages: TabRow[];
49
53
  }
50
54
 
@@ -76,6 +80,7 @@ export function buildTabsResult(
76
80
  ...(ep.app !== undefined ? [line(text` — `, data(ep.app, 80))] : []),
77
81
  fact` — ${ep.pages.length} page tab(s)`,
78
82
  ...(ep.preferred ? [text` [preferred]`] : []),
83
+ ...(ep.ownedPid !== undefined ? [line(fact` [capture-owned pid=${ep.ownedPid}]`)] : []),
79
84
  );
80
85
  const rows = ep.pages.map((t) =>
81
86
  line(text` `, data(t.id.slice(0, 8)), text` "`, data(t.title, 120), text`" `, data(t.url, 300)),
@@ -121,6 +126,11 @@ export async function cmdTabList(parsed: ParsedArgs, _args: string[]): Promise<v
121
126
  return;
122
127
  }
123
128
 
129
+ // The probe agents run constantly is also where abandoned owned browsers
130
+ // die: sweeping here is what keeps a launch from outliving the agent that
131
+ // made it, without any daemon in the picture.
132
+ await sweepOwnedBrowsers();
133
+ const owned = ownedBrowsersByPort();
124
134
  const reachable: EndpointTabs[] = [];
125
135
  const unreachable: UnreachableEndpoint[] = [];
126
136
 
@@ -130,6 +140,7 @@ export async function cmdTabList(parsed: ParsedArgs, _args: string[]): Promise<v
130
140
  reachable.push({
131
141
  port: parsed.port,
132
142
  preferred: false,
143
+ ...(owned.has(parsed.port) ? { ownedPid: owned.get(parsed.port)!.pid } : {}),
133
144
  pages: targets
134
145
  .filter((t) => t.type === 'page')
135
146
  .map((t) => ({ id: t.id, title: t.title, url: t.url })),
@@ -149,6 +160,7 @@ export async function cmdTabList(parsed: ParsedArgs, _args: string[]): Promise<v
149
160
  port: ep.port,
150
161
  app: ep.app,
151
162
  preferred: ep.port === preferredPort,
163
+ ...(owned.has(ep.port) ? { ownedPid: owned.get(ep.port)!.pid } : {}),
152
164
  pages: targets
153
165
  .filter((t) => t.type === 'page')
154
166
  .map((t) => ({ id: t.id, title: t.title, url: t.url })),
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `capture tab quit [<port>] [--all]` — stop browsers capture started.
3
+ *
4
+ * Named `quit` rather than `close` deliberately: `capture tab close` closes one
5
+ * TAB inside a browser, this ends the browser process itself. It reaches only
6
+ * browsers in capture's own registry, verified by PID birth — a port capture
7
+ * never launched (the user's own browser, or one an agent started by hand)
8
+ * matches nothing here and is never signalled.
9
+ */
10
+ import { quitOwnedBrowsers, sweepOwnedBrowsers, type ReapedBrowser } from '../../browser-process.js';
11
+ import { invalidInput } from '../../../errors.js';
12
+ import { type ParsedArgs } from '../../types.js';
13
+ import { data, emitResult, fact, line, lineList, text, type RenderableResult } from '../../../output/render.js';
14
+
15
+ const USAGE = `capture tab quit — stop a browser capture started.
16
+
17
+ input:
18
+ <port> the port of a capture-launched browser (as reported by tab launch / tab list)
19
+ --all stop every browser capture launched
20
+
21
+ output: <browser-quit stopped=…> — one line per stopped browser (port, pid) and the reason it ended.
22
+ effects: SIGTERM (then SIGKILL if needed) to capture-launched browsers only, verified by process-birth identity, and removes their private profiles. A browser capture did not start is never signalled — a port with no capture record stops nothing.`;
23
+
24
+ /** Pure `<browser-quit>` result builder — exported for tests. */
25
+ export function buildBrowserQuitResult(stopped: readonly ReapedBrowser[], selector: string): RenderableResult {
26
+ return {
27
+ tag: 'browser-quit',
28
+ attrs: { stopped: stopped.length },
29
+ summary:
30
+ stopped.length === 0
31
+ ? line(text`no capture-launched browser matched `, data(selector), text`; nothing was signalled.`)
32
+ : fact`stopped ${stopped.length} capture-launched browser(s).`,
33
+ sections:
34
+ stopped.length === 0
35
+ ? []
36
+ : [
37
+ lineList(
38
+ stopped.map((browser) =>
39
+ line(
40
+ text`port `,
41
+ data(browser.port ?? 'unstarted'),
42
+ fact` pid ${browser.pid} `,
43
+ data(browser.reason),
44
+ ),
45
+ ),
46
+ ),
47
+ ],
48
+ };
49
+ }
50
+
51
+ export async function cmdTabQuit(parsed: ParsedArgs, _args: string[]): Promise<void> {
52
+ if (parsed.help) {
53
+ console.log(USAGE);
54
+ return;
55
+ }
56
+
57
+ const token = parsed.positional[0];
58
+ if (!parsed.all && token === undefined) {
59
+ throw invalidInput('received: no target; expected: capture tab quit <port> or capture tab quit --all.', 'missing_argument');
60
+ }
61
+ if (parsed.all && token !== undefined) {
62
+ throw invalidInput('received: both a port and --all; expected: one or the other.');
63
+ }
64
+ if (token !== undefined && !/^\d+$/.test(token)) {
65
+ throw invalidInput(`Invalid port: ${token}.`);
66
+ }
67
+ const port = token === undefined ? undefined : Number(token);
68
+
69
+ // The sweep runs first so a browser that already exited is reported as
70
+ // reaped rather than as "no match" for the port the caller remembered.
71
+ const swept = await sweepOwnedBrowsers();
72
+ const stopped = await quitOwnedBrowsers({ port, all: parsed.all });
73
+ const matchedSweep = swept.filter((browser) => parsed.all || browser.port === port);
74
+
75
+ emitResult(buildBrowserQuitResult([...stopped, ...matchedSweep], parsed.all ? '--all' : String(port)), {
76
+ json: parsed.json,
77
+ });
78
+ }
@@ -164,8 +164,6 @@ export class HARRecorder {
164
164
  private readonly recordedKeys = new Set<string>();
165
165
  /** Snapshot mode only — streaming mode retains no materialized values. */
166
166
  private readonly materializedByKey = new Map<string, Materialized>();
167
- /** Last terminal remains comparable until this CDP id opens its next generation. */
168
- private readonly closedTerminals = new Map<string, Terminal>();
169
167
  /** Sockets opened while this recorder is attached — CDP does not replay creation events for pre-existing sockets. */
170
168
  private readonly webSockets = new Map<string, WebSocketConnection>();
171
169
  private readonly bodyTasks = new Set<Promise<void>>();
@@ -245,11 +243,13 @@ export class HARRecorder {
245
243
  const hasRedirect = 'redirectResponse' in params && params.redirectResponse !== undefined;
246
244
  if (previous && !hasRedirect) fail('duplicate active requestId without redirectResponse');
247
245
  if (hasRedirect && previous) {
248
- const redirect = this.parseResponse(params.redirectResponse, 'redirectResponse');
249
- if (redirect.status < 300 || redirect.status > 399) fail('redirectResponse.status');
246
+ // CDP uses status 0 when a redirect response is observable but never
247
+ // reached HTTP (for example, an extension redirect during navigation).
248
+ // Preserve that factual response rather than aborting the whole session.
249
+ const redirect = this.parseResponse(params.redirectResponse, 'redirectResponse', true);
250
+ if (redirect.status !== 0 && (redirect.status < 300 || redirect.status > 399)) fail('redirectResponse.status');
250
251
  this.assignTerminal(previous, { kind: 'redirect', timestamp: monotonic }, redirect);
251
252
  }
252
- this.closedTerminals.delete(requestId);
253
253
  const generation = this.nextGenerationByRequestId.get(requestId) ?? 1;
254
254
  this.nextGenerationByRequestId.set(requestId, generation + 1);
255
255
  this.activeByRequestId.set(requestId, {
@@ -338,11 +338,11 @@ export class HARRecorder {
338
338
  });
339
339
  }
340
340
 
341
- private parseResponse(value: unknown, name: string): RedirectResponse {
341
+ private parseResponse(value: unknown, name: string, allowNoHttpStatus = false): RedirectResponse {
342
342
  if (!isPlainObject(value)) fail(name);
343
343
  nonempty(value.url, `${name}.url`);
344
344
  const status = finite(value.status, `${name}.status`);
345
- if (!Number.isInteger(status) || status < 100 || status > 599) fail(`${name}.status`);
345
+ if (!Number.isInteger(status) || status < 0 || status > 599 || (status < 100 && !(allowNoHttpStatus && status === 0))) fail(`${name}.status`);
346
346
  return { status, headers: headers(value.headers, `${name}.headers`) };
347
347
  }
348
348
 
@@ -366,11 +366,13 @@ export class HARRecorder {
366
366
  const requestId = this.eventRequestId(params);
367
367
  if (!requestId) return;
368
368
  const current = this.activeByRequestId.get(requestId);
369
- const closed = this.closedTerminals.has(requestId);
370
- if (!current && !closed) return;
369
+ // Chromium can follow a loadingFailed with loadingFinished for the same
370
+ // request (for example, a blocked iframe during navigation). Once the
371
+ // first terminal materializes its generation, a later terminal cannot be
372
+ // assigned safely, so the first factual terminal remains authoritative.
373
+ if (!current) return;
371
374
  const event = params as Record<string, unknown>;
372
375
  const terminal: Terminal = { kind: 'finished', timestamp: finite(event.timestamp, 'timestamp'), encodedDataLength: finite(event.encodedDataLength, 'encodedDataLength') };
373
- if (!current) return this.compareClosedTerminal(requestId, terminal);
374
376
  if (!current.response) fail('loadingFinished without responseReceived');
375
377
  this.assignTerminal(current, terminal);
376
378
  }
@@ -379,8 +381,7 @@ export class HARRecorder {
379
381
  const requestId = this.eventRequestId(params);
380
382
  if (!requestId) return;
381
383
  const current = this.activeByRequestId.get(requestId);
382
- const closed = this.closedTerminals.has(requestId);
383
- if (!current && !closed) return;
384
+ if (!current) return;
384
385
  const event = params as Record<string, unknown>;
385
386
  const terminal: Terminal = {
386
387
  kind: 'failed',
@@ -390,22 +391,15 @@ export class HARRecorder {
390
391
  blockedReason: event.blockedReason === undefined ? null : typeof event.blockedReason === 'string' ? event.blockedReason : fail('blockedReason'),
391
392
  resourceType: event.type === undefined ? null : typeof event.type === 'string' ? event.type : fail('type'),
392
393
  };
393
- if (!current) return this.compareClosedTerminal(requestId, terminal);
394
394
  this.assignTerminal(current, terminal);
395
395
  }
396
396
 
397
- private compareClosedTerminal(requestId: string, terminal: Terminal): void {
398
- const previous = this.closedTerminals.get(requestId);
399
- if (previous && !same(previous, terminal)) fail('conflicting terminal event');
400
- }
401
-
402
397
  private assignTerminal(current: Generation, terminal: Terminal, redirectResponse?: RedirectResponse): void {
403
398
  if (current.terminal) {
404
- if (!same(current.terminal, terminal)) fail('conflicting terminal event');
399
+ if (!same(current.terminal, terminal)) fail(`conflicting terminal event for requestId ${current.requestId} generation ${current.generation}`);
405
400
  return;
406
401
  }
407
402
  current.terminal = terminal;
408
- this.closedTerminals.set(current.requestId, terminal);
409
403
  if (redirectResponse) {
410
404
  current.redirectResponse = redirectResponse;
411
405
  this.materialize(current, { state: 'not_applicable', reason: 'redirect' });
@@ -7,10 +7,9 @@
7
7
  * shared with `styles.json`/`media.json` per the U04 contract — no
8
8
  * coordination with those files is needed.
9
9
  *
10
- * "geometry ids ... when possible": U07's `geometry.json` is a
11
- * concurrently-written sibling collector (`Promise.all` in `snapshot.ts`,
12
- * no cross-collector read ordering) that this file must not depend on or
13
- * read at capture time. Read here as "attach a `rect` fact directly per AX
10
+ * "geometry ids ... when possible": `geometry.json` is a separate
11
+ * collector artifact that this file does not depend on or read at capture
12
+ * time. Read here as "attach a `rect` fact directly per AX
14
13
  * node via a best-effort `DOM.getBoxModel` lookup" rather than a join
15
14
  * against `geometry.json` — a deliberate, bounded reading documented in
16
15
  * the U08 build-plan report, not a defect.
@@ -41,9 +41,8 @@
41
41
  * turns into a `backendNodeId`. A page can predefine a setter for any
42
42
  * global name it can guess; it can observe nothing here, because nothing
43
43
  * is ever set on it. No DOM mutation and no page-observable global either,
44
- * so nothing in the emitted `screenshot.png`/`dom.html`/any other baseline
45
- * collector running concurrently in the same `Promise.all` can ever
46
- * observe this collector having run.
44
+ * so nothing in the emitted `screenshot.png`/`dom.html` can observe this
45
+ * collector having run.
47
46
  */
48
47
 
49
48
  import type { CDPClient } from '../../client.js';
@@ -628,9 +627,8 @@ async function resolveIndexedObjectIds(
628
627
  export const collectForms: Collector = async (ctx) => {
629
628
  // The walk's return value is a plain in-memory `{ facts, elements }`
630
629
  // object — never a `window` global, never a live-DOM attribute — so
631
- // nothing another baseline collector or the `screenshot.png`/`dom.html`
632
- // capture running concurrently in the same `Promise.all` (see
633
- // `snapshot.ts`) could ever observe. `Runtime.evaluate({returnByValue:
630
+ // nothing another baseline artifact capture could ever observe.
631
+ // `Runtime.evaluate({returnByValue:
634
632
  // false})` hands that object back as a CDP `RemoteObject` (an `objectId`
635
633
  // with zero page visibility into it); every held `objectId` (the
636
634
  // container, `facts`, `elements`) is released via `Runtime.releaseObject`
@@ -4,8 +4,7 @@
4
4
  * state, and (for `object-fit`-bearing elements) computed crop/letterbox
5
5
  * facts. One `Runtime.evaluate` gathers raw per-element facts in-page;
6
6
  * `backendNodeId` correlation reuses `resolveNodeIds` from `./styles.js`
7
- * (this file issues its own `DOM.getDocument` call — collectors run
8
- * concurrently, so no CDP call is shared across files).
7
+ * while issuing its own `DOM.getDocument` call.
9
8
  *
10
9
  * This is a `phase: 'baseline'` collector, so it must not create or pin
11
10
  * any page-observable state: `canvas.getContext(...)` is deliberately NOT
@@ -5,12 +5,7 @@
5
5
  * Written ONLY when at least one `--state` was requested (`ctx.state.length
6
6
  * > 0`); otherwise this is a no-op (no file written). Owned by U12.
7
7
  *
8
- * Independent of the other 13 collectors (they all fan out via
9
- * `Promise.all` in `snapshot.ts` — there is no cross-collector read
10
- * ordering, and `geometry.json`/`styles.json`/`hittest.json` may not exist
11
- * yet when this runs), so this collector drives its own minimal
12
- * geometry/style/hittest re-capture per forced element rather than reading
13
- * those files. Scope: top-frame light DOM only — elements inside an
8
+ * This collector drives its own minimal geometry/style/hittest re-capture per forced element rather than reading those files. Scope: top-frame light DOM only — elements inside an
14
9
  * iframe or a shadow root are out of reach of `DOM.querySelectorAll`'s
15
10
  * plain (non-piercing) subtree walk and are not visited here; frame/shadow
16
11
  * stitching is `geometry.json`'s concern, not this collector's.
@@ -29,22 +29,10 @@
29
29
  * re-invoking `CSS.enable` while the domain is ALREADY enabled is a
30
30
  * no-op that redelivers nothing — only a `CSS.disable` + `CSS.enable`
31
31
  * pair forces Chrome to resend every currently-known stylesheet's
32
- * header). Both `styles.ts` and `layers.ts` run in the same `baseline`
33
- * `Promise.all` (see `snapshot.ts`), and `enableDomainsForSnap` already
34
- * enabled CSS once before either collector starts — so a caller-owned
35
- * listener registered only after that point would otherwise never see
36
- * the headers for stylesheets already parsed at that time. Each caller
37
- * runs `captureStyleSheetHeaders` independently, as the very first thing
38
- * it does (before any other CSS-domain call), and awaits it before
39
- * issuing any of its own `CSS.*` calls. Because listener registration is
40
- * synchronous (before any `await`), and JS never yields to socket I/O
41
- * mid-way through `Promise.all`'s synchronous dispatch of every baseline
42
- * collector, both this file's and the other collector's listeners are
43
- * always attached before either's disable/enable pair reaches Chrome —
44
- * so neither can miss the other's redelivery burst, and once both
45
- * callers' own `captureStyleSheetHeaders` calls resolve, the CSS domain
46
- * is stably enabled for the rest of the snapshot (neither file disables
47
- * it again).
32
+ * header). `enableDomainsForSnap` already enabled CSS before either
33
+ * collector starts, so each collector captures its own headers as its
34
+ * first CSS-domain action and awaits that capture before its provenance
35
+ * reads. This keeps either artifact self-contained.
48
36
  */
49
37
 
50
38
  import { resolveAuthoredSourceLocation, type ResolvedSourceLocation } from '../../source-map.js';
@@ -14,9 +14,8 @@
14
14
  * file's `CSS.getMatchedStylesForNode` calls (which need a CDP `nodeId`,
15
15
  * not just a `backendNodeId`) and reused by `media.ts` for its own
16
16
  * per-element `backendNodeId` correlation. Both `styles.ts` and `media.ts`
17
- * independently issue their own `Runtime.evaluate`/`DOM.getDocument`
18
- * calls — they run concurrently in `snapshot.ts`'s `Promise.all`, so there
19
- * is no cross-collector call sharing beyond this shared, stateless logic.
17
+ * independently issue their own `Runtime.evaluate`/`DOM.getDocument` calls,
18
+ * so there is no cross-collector call sharing beyond this shared, stateless logic.
20
19
  */
21
20
 
22
21
  import type { ResolvedSourceLocation } from '../../source-map.js';
@@ -35,6 +34,7 @@ export { computeSpecificity, resolveNodeIds };
35
34
  export type { Specificity, CSSRange, WinningDeclaration } from './style-provenance.js';
36
35
 
37
36
  const STYLES_MAX_ELEMENTS = 150;
37
+ const PROVENANCE_CONCURRENCY = 8;
38
38
 
39
39
  /** Full computed-style snapshot tracked per element — broad, cheap (no CDP round trip; read via `getComputedStyle` in one `Runtime.evaluate`). */
40
40
  const COMPUTED_PROPERTIES = [
@@ -205,6 +205,20 @@ function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: n
205
205
  return backendNodeId === undefined ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId };
206
206
  }
207
207
 
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);
211
+ let next = 0;
212
+ const worker = async (): Promise<void> => {
213
+ while (next < values.length) {
214
+ const index = next++;
215
+ result[index] = await fn(values[index]!, index);
216
+ }
217
+ };
218
+ await Promise.all(Array.from({ length: Math.min(limit, values.length) }, worker));
219
+ return result;
220
+ }
221
+
208
222
  // ============================================================================
209
223
  // Collector
210
224
  // ============================================================================
@@ -213,9 +227,8 @@ export const collectStyles: Collector = async (ctx) => {
213
227
  const { client } = ctx;
214
228
 
215
229
  // Registers the CSS.styleSheetAdded listener and forces header redelivery
216
- // BEFORE anything else touches the CSS domain — see style-provenance.ts's
217
- // module doc for why this must run first and why it's safe alongside the
218
- // concurrently-running `layers` collector's own copy of the same step.
230
+ // BEFORE this collector uses the CSS domain — see style-provenance.ts's
231
+ // module doc for why header capture must begin before provenance reads.
219
232
  const {
220
233
  urls: styleSheetUrls,
221
234
  stop: stopTrackingStyleSheets,
@@ -292,8 +305,7 @@ async function collectStylesInner(
292
305
 
293
306
  const sourceCache = new Map<string, Promise<ResolvedSourceLocation>>();
294
307
 
295
- const elements: StylesElementRecord[] = await Promise.all(
296
- facts.map(async (fact, index) => {
308
+ const elements = await mapWithConcurrency(facts, PROVENANCE_CONCURRENCY, async (fact, index): Promise<StylesElementRecord> => {
297
309
  const ref = resolved[index];
298
310
  const computed = normalizeComputed(fact.computed);
299
311
 
@@ -334,8 +346,7 @@ async function collectStylesInner(
334
346
  winningDeclarations,
335
347
  provenanceUnavailable,
336
348
  };
337
- }),
338
- );
349
+ });
339
350
 
340
351
  ctx.write.json('styles.json', {
341
352
  elements,
@@ -27,8 +27,7 @@
27
27
  * identifier `CSS.getPlatformFontsForNode` accepts — and `backendNodeId`.
28
28
  * No `setAttribute`/`removeAttribute` pair and no page-observable global
29
29
  * either, so nothing in the emitted `screenshot.png`/`dom.html`/any other
30
- * baseline collector running concurrently in the same `Promise.all` (see
31
- * `snapshot.ts`) can ever observe this collector having run.
30
+ * baseline artifact capture can ever observe this collector having run.
32
31
  */
33
32
 
34
33
  import type { CDPClient } from '../../client.js';
@@ -421,9 +420,8 @@ async function resolveIndexedObjectIds(
421
420
  export const collectText: Collector = async (ctx) => {
422
421
  // The walk's return value is a plain in-memory `{ facts, elements }`
423
422
  // object — never a `window` global, never a live-DOM attribute — so
424
- // nothing another baseline collector or the `screenshot.png`/`dom.html`
425
- // capture running concurrently in the same `Promise.all` (see
426
- // `snapshot.ts`) could ever observe. `Runtime.evaluate({returnByValue:
423
+ // nothing another baseline artifact capture could ever observe.
424
+ // `Runtime.evaluate({returnByValue:
427
425
  // false})` hands that object back as a CDP `RemoteObject` (an `objectId`
428
426
  // with zero page visibility into it); every held `objectId` (the
429
427
  // container, `facts`, `elements`) is released via `Runtime.releaseObject`