@crouton-kit/capture 1.3.8 → 1.3.9

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 (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -0,0 +1,355 @@
1
+ /**
2
+ * `styles.json` collector — computed style values plus winning-rule
3
+ * provenance (which selector/rule wins a property, by what specificity,
4
+ * from where in the authored source) for a bounded, document-order set of
5
+ * elements.
6
+ *
7
+ * The cascade/specificity/source-resolution engine and the
8
+ * `CSS.styleSheetAdded` header-capture helper live in `./style-provenance.js`
9
+ * — shared with `layers.ts`'s layer-triggering-property provenance so both
10
+ * collectors report identical winning-declaration semantics instead of two
11
+ * parallel implementations. This file also exports {@link resolveNodeIds}
12
+ * and {@link computeSpecificity} (re-exported from that shared module) —
13
+ * the CDP `nodeId`+`backendNodeId` correlation helper built for this
14
+ * file's `CSS.getMatchedStylesForNode` calls (which need a CDP `nodeId`,
15
+ * not just a `backendNodeId`) and reused by `media.ts` for its own
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.
20
+ */
21
+
22
+ import type { ResolvedSourceLocation } from '../../source-map.js';
23
+ import { sanitizeString } from '../redaction.js';
24
+ import type { Collector } from '../types.js';
25
+ import {
26
+ buildWinningDeclarations,
27
+ captureStyleSheetHeaders,
28
+ computeSpecificity,
29
+ resolveNodeIds,
30
+ type CDPMatchedStylesResponse,
31
+ type WinningDeclaration,
32
+ } from './style-provenance.js';
33
+
34
+ export { computeSpecificity, resolveNodeIds };
35
+ export type { Specificity, CSSRange, WinningDeclaration } from './style-provenance.js';
36
+
37
+ const STYLES_MAX_ELEMENTS = 150;
38
+
39
+ /** Full computed-style snapshot tracked per element — broad, cheap (no CDP round trip; read via `getComputedStyle` in one `Runtime.evaluate`). */
40
+ const COMPUTED_PROPERTIES = [
41
+ 'display', 'position', 'top', 'right', 'bottom', 'left', 'width', 'height',
42
+ 'min-width', 'min-height', 'max-width', 'max-height',
43
+ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
44
+ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
45
+ 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', 'border-radius',
46
+ 'box-sizing', 'z-index', 'opacity', 'visibility', 'overflow-x', 'overflow-y',
47
+ 'color', 'background-color', 'font-family', 'font-size', 'font-weight', 'line-height',
48
+ 'text-align', 'text-overflow', 'white-space',
49
+ 'flex-direction', 'flex-wrap', 'justify-content', 'align-items', 'align-self', 'flex-grow', 'flex-shrink', 'flex-basis',
50
+ 'grid-template-columns', 'grid-template-rows',
51
+ 'transform', 'filter', 'cursor', 'pointer-events',
52
+ ] as const;
53
+
54
+ /** The smaller subset tracked for winning-declaration provenance — bounded by the `CSS.getMatchedStylesForNode` round trip cost per element. */
55
+ const PROVENANCE_PROPERTIES = [
56
+ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
57
+ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
58
+ 'width', 'height', 'color', 'background-color', 'font-size', 'font-weight',
59
+ 'border-radius', 'z-index', 'display', 'position',
60
+ ] as const;
61
+
62
+ // ============================================================================
63
+ // Output shape
64
+ // ============================================================================
65
+
66
+ export interface StylesElementRecord {
67
+ id: string;
68
+ selector?: string;
69
+ /** `null` when this element's identity could not be resolved (see {@link identityUnresolved}) — never simply omitted (I-3). */
70
+ backendNodeId: number | null;
71
+ /** `true` when {@link backendNodeId} is `null` because this element's `cssPath` selector did not resolve to a CDP node — absent (not `false`) when resolved, matching hittest.ts's convention. */
72
+ identityUnresolved?: true;
73
+ computed: Record<string, string | null>;
74
+ winningDeclarations: WinningDeclaration[];
75
+ /** `true` when node correlation or `CSS.getMatchedStylesForNode` failed for this element — `winningDeclarations` is empty because provenance could not be inspected, not because no rule matched anything. This is DISTINCT from {@link identityUnresolved}: `provenanceUnavailable` means "couldn't inspect the winning CSS rule", not "couldn't resolve this element's identity" — an element can have a resolved `backendNodeId` and still have `provenanceUnavailable` (e.g. `CSS.getMatchedStylesForNode` itself failed). */
76
+ provenanceUnavailable?: boolean;
77
+ }
78
+
79
+ /**
80
+ * Explicit factual scope for `styles.json` (D5). The in-page enumeration is
81
+ * `document.querySelectorAll('*')` on the top document — light-DOM only,
82
+ * non-piercing (`pierce:false` in spirit): it walks neither nested iframe
83
+ * documents nor shadow roots. Absent iframe/shadow styles are a stated scope
84
+ * boundary, not a negative fact. Counts state exactly what was skipped.
85
+ */
86
+ export interface StylesCoverage {
87
+ scope: 'top-document';
88
+ /** Count of `<iframe>` elements in the top document whose contents are NOT enumerated. */
89
+ iframesNotWalked: number;
90
+ /** Count of shadow-root hosts in the top document whose shadow trees are NOT enumerated. */
91
+ shadowRootsNotWalked: number;
92
+ /** Total elements that passed the tag/visible-rect filter — the uncapped candidate count, before {@link STYLES_MAX_ELEMENTS} applies. */
93
+ totalCandidateElements: number;
94
+ /** Elements actually emitted in `elements[]` — `<= totalCandidateElements`, and `<= STYLES_MAX_ELEMENTS`. */
95
+ keptElements: number;
96
+ /** `true` when {@link STYLES_MAX_ELEMENTS} dropped one or more candidate elements from enumeration — an explicit fact (I-5), never a silent cap. */
97
+ elementsTruncated: boolean;
98
+ }
99
+
100
+ export interface StylesReport {
101
+ elements: StylesElementRecord[];
102
+ coverage: StylesCoverage;
103
+ /** Availability of the `CSS.styleSheetAdded` header capture this snap's provenance resolution depends on (I-5) — `available:false` means any missing `sourceStyleSheetUrl`/`generated` source below is a capture failure, not "genuinely no source". */
104
+ styleSheetHeaders: { available: boolean; reason?: string };
105
+ /** Explicit identity-resolution availability fact (I-4), mirroring media.json: `available:false` means `backendNodeId` was never attempted for ANY element this run (`DOM.getDocument`/`resolveNodeIds` failed) — distinct from a per-element `backendNodeId` simply being `null` because that one element's selector didn't resolve while the system was healthy. */
106
+ identity: { available: true } | { available: false; reason: string };
107
+ /** `false` when the `STYLES_SCRIPT` inventory `Runtime.evaluate` itself failed (threw, or returned no `value`) — `elements: []` is then "could not collect", not "genuinely no styleable elements" (I-5). Always `true` on a normal run. */
108
+ available: boolean;
109
+ /** Present only when `available` is `false`. */
110
+ unavailableReason?: StylesUnavailableReason;
111
+ }
112
+
113
+ /** Fixed, factual reason `STYLES_SCRIPT`'s `Runtime.evaluate` could not be read (never a raw exception message, which is unbounded/page-influenced) — present only when {@link StylesReport.available} is `false`. */
114
+ export type StylesUnavailableReason = 'styles-evaluate-returned-no-value' | 'styles-evaluate-threw';
115
+
116
+ // ============================================================================
117
+ // In-page script — cssPath + computed style facts, no DOM domain calls
118
+ // ============================================================================
119
+
120
+ // `html`/`body` are excluded: `cssPathFromBody` is rooted AT `document.body`, so it produces `''` for
121
+ // the body element itself and `body > html` for the (out-of-subtree) html element — neither is a valid
122
+ // `body > ...` selector for `resolveNodeIds` to query. Both are structural roots, not measurable subjects.
123
+ const EXCLUDED_TAGS = ['html', 'body', 'script', 'style', 'head', 'meta', 'link', 'title', 'template', 'noscript', 'br', 'wbr'];
124
+
125
+ interface StylesFact {
126
+ cssPath: string;
127
+ computed: Record<string, string | null>;
128
+ }
129
+
130
+ interface StylesInventory {
131
+ elements: StylesFact[];
132
+ /** Total elements passing the tag/visible-rect filter, counted even past the {@link STYLES_MAX_ELEMENTS} cap (I-5) — `elements.length` alone cannot distinguish "the page had exactly this many" from "the cap stopped counting". */
133
+ total: number;
134
+ iframesNotWalked: number;
135
+ shadowRootsNotWalked: number;
136
+ }
137
+
138
+ const STYLES_SCRIPT = `/* __captureStylesInventory */
139
+ (function() {
140
+ var EXCLUDED = ${JSON.stringify(EXCLUDED_TAGS)};
141
+ var PROPS = ${JSON.stringify(COMPUTED_PROPERTIES)};
142
+ var excludedSet = {};
143
+ for (var e = 0; e < EXCLUDED.length; e++) excludedSet[EXCLUDED[e]] = true;
144
+
145
+ function cssPathFromBody(el) {
146
+ var parts = [];
147
+ var node = el;
148
+ var depth = 0;
149
+ while (node && node.nodeType === 1 && node !== document.body && depth < 40) {
150
+ var parent = node.parentElement;
151
+ if (!parent) { parts.unshift(node.tagName.toLowerCase()); break; }
152
+ var same = Array.prototype.filter.call(parent.children, function(c) { return c.tagName === node.tagName; });
153
+ var idx = same.indexOf(node) + 1;
154
+ parts.unshift(node.tagName.toLowerCase() + ':nth-of-type(' + idx + ')');
155
+ node = parent;
156
+ depth++;
157
+ }
158
+ return parts.join(' > ');
159
+ }
160
+
161
+ var all = document.querySelectorAll('*');
162
+ // Scope facts (D5): the enumeration is light-DOM only (querySelectorAll does not
163
+ // pierce iframe documents or shadow roots), so count what is deliberately skipped.
164
+ var iframesNotWalked = 0;
165
+ var shadowRootsNotWalked = 0;
166
+ for (var c = 0; c < all.length; c++) {
167
+ if (all[c].tagName === 'IFRAME') iframesNotWalked++;
168
+ if (all[c].shadowRoot) shadowRootsNotWalked++;
169
+ }
170
+ var out = [];
171
+ var total = 0;
172
+ // Scans the FULL element set (not stopping at the cap) so total is an honest count of every
173
+ // candidate the cap dropped, not just an estimate (I-5) — the enumeration itself is cheap
174
+ // relative to the getComputedStyle() calls this loop already skips once the cap is reached.
175
+ for (var i = 0; i < all.length; i++) {
176
+ var el = all[i];
177
+ var tag = el.tagName.toLowerCase();
178
+ if (excludedSet[tag]) continue;
179
+ var rect = el.getBoundingClientRect();
180
+ if (rect.width <= 0 && rect.height <= 0) continue;
181
+ total++;
182
+ if (out.length >= ${STYLES_MAX_ELEMENTS}) continue;
183
+ var cs;
184
+ try { cs = getComputedStyle(el); } catch (err) { continue; }
185
+ var computed = {};
186
+ for (var p = 0; p < PROPS.length; p++) {
187
+ try { computed[PROPS[p]] = cs.getPropertyValue(PROPS[p]); } catch (err2) { computed[PROPS[p]] = null; }
188
+ }
189
+ out.push({ cssPath: cssPathFromBody(el), computed: computed });
190
+ }
191
+ return { elements: out, total: total, iframesNotWalked: iframesNotWalked, shadowRootsNotWalked: shadowRootsNotWalked };
192
+ })();`;
193
+
194
+ function normalizeComputed(raw: Record<string, string | null> | undefined): Record<string, string | null> {
195
+ const out: Record<string, string | null> = {};
196
+ for (const prop of COMPUTED_PROPERTIES) {
197
+ const value = raw?.[prop];
198
+ out[prop] = typeof value === 'string' ? sanitizeString(value) : null;
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /** Honest `{ backendNodeId, identityUnresolved }` pair for an element-bearing record — mirrors hittest.ts's `resolvedIdentity` helper (I-3/I-5, inlined locally per collector rather than imported/shared): a resolved identity carries just `backendNodeId`; an unresolved one carries `backendNodeId: null` + `identityUnresolved: true`, never a silently-omitted field. */
204
+ function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: number | null; identityUnresolved?: true } {
205
+ return backendNodeId === undefined ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId };
206
+ }
207
+
208
+ // ============================================================================
209
+ // Collector
210
+ // ============================================================================
211
+
212
+ export const collectStyles: Collector = async (ctx) => {
213
+ const { client } = ctx;
214
+
215
+ // 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.
219
+ const {
220
+ urls: styleSheetUrls,
221
+ stop: stopTrackingStyleSheets,
222
+ available: styleSheetHeadersAvailable,
223
+ reason: styleSheetHeadersReason,
224
+ } = await captureStyleSheetHeaders(client);
225
+ try {
226
+ await collectStylesInner(ctx, styleSheetUrls, { available: styleSheetHeadersAvailable, reason: styleSheetHeadersReason });
227
+ } finally {
228
+ stopTrackingStyleSheets();
229
+ }
230
+ };
231
+
232
+ async function collectStylesInner(
233
+ ctx: Parameters<Collector>[0],
234
+ styleSheetUrls: Map<string, string>,
235
+ styleSheetHeaders: { available: boolean; reason?: string },
236
+ ): Promise<void> {
237
+ const { client } = ctx;
238
+
239
+ // I-5: a missing `value` (the eval failed/returned nothing) is currently coerced into an empty
240
+ // inventory, indistinguishable from a genuinely element-free page unless the failure itself is
241
+ // surfaced as an explicit report-level fact.
242
+ let inventory: StylesInventory;
243
+ let available = true;
244
+ let unavailableReason: StylesUnavailableReason | undefined;
245
+ try {
246
+ const evalResponse = (await client.send('Runtime.evaluate', {
247
+ expression: STYLES_SCRIPT,
248
+ returnByValue: true,
249
+ })) as { result?: { value?: StylesInventory } };
250
+ const value = evalResponse.result?.value;
251
+ if (value === undefined) {
252
+ inventory = { elements: [], total: 0, iframesNotWalked: 0, shadowRootsNotWalked: 0 };
253
+ available = false;
254
+ unavailableReason = 'styles-evaluate-returned-no-value';
255
+ } else {
256
+ inventory = value;
257
+ }
258
+ } catch {
259
+ inventory = { elements: [], total: 0, iframesNotWalked: 0, shadowRootsNotWalked: 0 };
260
+ available = false;
261
+ unavailableReason = 'styles-evaluate-threw';
262
+ }
263
+ const facts = inventory.elements;
264
+
265
+ // I-3/I-4: distinguishes a whole-run identity-resolution failure (DOM.getDocument/resolveNodeIds
266
+ // never even ran) from a per-element miss (that one element's selector didn't resolve while the
267
+ // system was healthy) — mirrors media.ts's `identity` fact exactly.
268
+ let documentNodeId: number | undefined;
269
+ let identity: StylesReport['identity'] = { available: true };
270
+ try {
271
+ const docResponse = (await client.send('DOM.getDocument', { depth: 0 })) as { root?: { nodeId: number } };
272
+ documentNodeId = docResponse.root?.nodeId;
273
+ if (documentNodeId === undefined) {
274
+ identity = { available: false, reason: 'dom-getdocument-unavailable' };
275
+ }
276
+ } catch {
277
+ documentNodeId = undefined;
278
+ identity = { available: false, reason: 'dom-getdocument-unavailable' };
279
+ }
280
+
281
+ let resolved: Array<{ nodeId?: number; backendNodeId?: number } | undefined>;
282
+ if (documentNodeId !== undefined) {
283
+ try {
284
+ resolved = await resolveNodeIds(client, documentNodeId, facts.map((f) => f.cssPath));
285
+ } catch {
286
+ resolved = facts.map(() => undefined);
287
+ identity = { available: false, reason: 'resolve-node-ids-failed' };
288
+ }
289
+ } else {
290
+ resolved = facts.map(() => undefined);
291
+ }
292
+
293
+ const sourceCache = new Map<string, Promise<ResolvedSourceLocation>>();
294
+
295
+ const elements: StylesElementRecord[] = await Promise.all(
296
+ facts.map(async (fact, index) => {
297
+ const ref = resolved[index];
298
+ const computed = normalizeComputed(fact.computed);
299
+
300
+ // A `noDeclarationRecord` is reserved for a SUCCESSFUL matched-styles response that genuinely had
301
+ // no candidate for a property — an honest "nothing declares this" fact. When provenance itself
302
+ // couldn't be inspected (no CDP nodeId, or `CSS.getMatchedStylesForNode` failed), that's a different
303
+ // fact — "unknown", not "none" — so `winningDeclarations` is left empty and `provenanceUnavailable`
304
+ // is set, rather than conflating the two into misleading no-declaration records.
305
+ let winningDeclarations: WinningDeclaration[];
306
+ let provenanceUnavailable: boolean | undefined;
307
+ if (ref?.nodeId !== undefined) {
308
+ try {
309
+ const matched = (await client.send('CSS.getMatchedStylesForNode', {
310
+ nodeId: ref.nodeId,
311
+ })) as CDPMatchedStylesResponse;
312
+ winningDeclarations = await buildWinningDeclarations(
313
+ client,
314
+ matched,
315
+ computed,
316
+ sourceCache,
317
+ styleSheetUrls,
318
+ PROVENANCE_PROPERTIES,
319
+ );
320
+ } catch {
321
+ winningDeclarations = [];
322
+ provenanceUnavailable = true;
323
+ }
324
+ } else {
325
+ winningDeclarations = [];
326
+ provenanceUnavailable = true;
327
+ }
328
+
329
+ return {
330
+ id: `s-${index}`,
331
+ selector: sanitizeString(fact.cssPath),
332
+ ...resolvedIdentity(ref?.backendNodeId),
333
+ computed,
334
+ winningDeclarations,
335
+ provenanceUnavailable,
336
+ };
337
+ }),
338
+ );
339
+
340
+ ctx.write.json('styles.json', {
341
+ elements,
342
+ coverage: {
343
+ scope: 'top-document',
344
+ iframesNotWalked: inventory.iframesNotWalked,
345
+ shadowRootsNotWalked: inventory.shadowRootsNotWalked,
346
+ totalCandidateElements: inventory.total,
347
+ keptElements: elements.length,
348
+ elementsTruncated: inventory.total > elements.length,
349
+ },
350
+ styleSheetHeaders: styleSheetHeaders,
351
+ identity,
352
+ available,
353
+ ...(unavailableReason ? { unavailableReason } : {}),
354
+ } satisfies StylesReport);
355
+ }