@crouton-kit/capture 1.3.8 → 1.3.10

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,643 @@
1
+ /**
2
+ * Shared cascade/winning-declaration + generated-source-header provenance
3
+ * engine, used by both `styles.ts` (per-element computed-style provenance)
4
+ * and `layers.ts` (layer-triggering-property provenance) — ONE
5
+ * implementation of "which declaration wins a property, by what
6
+ * specificity/importance, from where in the authored/generated source"
7
+ * instead of two parallel ones. Each caller supplies its own `nodeId` +
8
+ * `CSS.getMatchedStylesForNode` response, its own computed-value map, and
9
+ * its own property list; this module owns only the cascade math and the
10
+ * source resolution.
11
+ *
12
+ * **Judgment call (inherited from the styles collector's original v1
13
+ * design):** the cascade/specificity model is a simplified v1 for a
14
+ * measurement tool — no `@layer`, no full `:not()`/`:is()` specificity
15
+ * nuance, no cross-origin/user-agent importance ordering beyond a
16
+ * two-tier important/non-important split. Good enough to report "which
17
+ * rule won and why" for the overwhelming majority of real pages; not a
18
+ * spec-perfect cascade resolver. A selector using `:where()` (spec:
19
+ * always zero specificity) or `:is()`/`:matches()` (spec: the specificity
20
+ * of its most specific argument) is scored by this module as an ordinary
21
+ * pseudo-class token instead — every candidate whose matching selector
22
+ * contains either construct is flagged via `winnerApproximate` /
23
+ * `winnerApproximateReason:'selector-specificity-where-is-present'` (the
24
+ * v1 winner is still reported, never silently re-picked).
25
+ *
26
+ * ## Stylesheet header capture — {@link captureStyleSheetHeaders}
27
+ * `CSS.styleSheetAdded` only fires for a stylesheet at the moment CDP's
28
+ * CSS domain transitions from disabled to enabled (proven empirically:
29
+ * re-invoking `CSS.enable` while the domain is ALREADY enabled is a
30
+ * no-op that redelivers nothing — only a `CSS.disable` + `CSS.enable`
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).
48
+ */
49
+
50
+ import { resolveAuthoredSourceLocation, type ResolvedSourceLocation } from '../../source-map.js';
51
+ import { sanitizeString } from '../redaction.js';
52
+ import type { CDPClient } from '../../client.js';
53
+
54
+ // ============================================================================
55
+ // Output shape
56
+ // ============================================================================
57
+
58
+ export interface Specificity {
59
+ a: number;
60
+ b: number;
61
+ c: number;
62
+ }
63
+
64
+ export interface CSSRange {
65
+ startLine: number;
66
+ startColumn: number;
67
+ endLine: number;
68
+ endColumn: number;
69
+ }
70
+
71
+ export interface WinningDeclaration {
72
+ property: string;
73
+ /** The COMPUTED style value (`getComputedStyle`) for this property — not the raw declaration text, which can differ (custom properties, relative units, shorthands). */
74
+ value: string | null;
75
+ /** The raw winning declaration's own value text, as authored/serialized by CDP — `undefined` for the no-declaration case. */
76
+ declaredValue?: string;
77
+ /** `'inline'` for the `style` attribute, a selector string for a rule, or `null` when nothing declares this property. */
78
+ selector: string | null;
79
+ /** `"a-b-c"` display form, `null` for inline wins or the no-declaration case. */
80
+ specificity: string | null;
81
+ important?: boolean;
82
+ sourceStyleSheetId?: string;
83
+ /** The real stylesheet `sourceURL` from a `CSS.styleSheetAdded` header, when one was observed for `sourceStyleSheetId` — the human-meaningful counterpart to the opaque id. */
84
+ sourceStyleSheetUrl?: string;
85
+ range?: CSSRange;
86
+ authored?: { file: string; line: number; column: number };
87
+ /** The generated (post-build) CSS location, set for BOTH authored and generated results — the source-map fallback fact preserved even when no map resolves. */
88
+ generated?: { sourceURL: string; line: number; column: number };
89
+ mediaQuery?: string;
90
+ containerQuery?: string;
91
+ /** Present (and `null`) only when no candidate declares this property at all — an explicit "no declaration" fact, not an omission. */
92
+ source?: null;
93
+ /**
94
+ * `true` when the candidate set for this property contains a rule this simplified cascade
95
+ * engine cannot correctly order or place: a cascade `@layer`, an `@scope`, an unresolved
96
+ * `revert`/`revert-layer` keyword, a matching selector using `:where()`/`:is()` (whose real
97
+ * specificity this module's simplified {@link computeSpecificity} does not model), or a
98
+ * declaration whose authored source position could not be traced at all (never the whole-rule
99
+ * or `0:0` fallback pretending to be exact) — the reported `selector`/`declaredValue`/`range`
100
+ * is the simplified model's best guess, NOT a guaranteed spec-accurate winner. Absent (not
101
+ * `false`) when every candidate was fully resolvable, matching every other honesty flag in
102
+ * this module.
103
+ */
104
+ winnerApproximate?: boolean;
105
+ /** Why `winnerApproximate` is set — `'cascade-layers-present'`, `'cascade-scope-present'`, `'revert-keyword-present'`, `'selector-specificity-where-is-present'`, or `'source-range-unresolved'`. */
106
+ winnerApproximateReason?: string;
107
+ /**
108
+ * `true` (I-4/I-5/I-10) when authored/generated source resolution was ATTEMPTED for this
109
+ * declaration (a real property/rule range and `styleSheetId` existed to seed the lookup) but
110
+ * {@link resolveAuthoredSourceLocation} REJECTED — e.g. a malformed source-map `mappings`
111
+ * string throws synchronously inside the VLQ decoder — rather than resolving normally. This is
112
+ * DISTINCT from a genuine no-authored-source case: `resolveAuthoredSourceLocation` itself
113
+ * degrades a missing/unparseable/unfetchable source map to an honest `GeneratedLocation`
114
+ * (never a rejection) for the ordinary minified/no-source-map-production case, so `authored`
115
+ * absent with this flag ALSO absent still honestly means "no authored source (minified/prod)".
116
+ * `authored` absent WITH this flag set means the opposite: resolution could not complete at
117
+ * all, so whether an authored source exists is genuinely unknown — a failed read, not a
118
+ * negative observation. `generated` is still populated from the already-known ref position
119
+ * (the queried generated location, not something the failed resolution had to compute) so the
120
+ * generated fallback fact is preserved even when resolution itself could not run.
121
+ */
122
+ sourceResolutionUnavailable?: boolean;
123
+ /** The caught error's message, or `'source-resolution-failed'` when the caught value carries none. */
124
+ sourceResolutionUnavailableReason?: string;
125
+ }
126
+
127
+ // ============================================================================
128
+ // computeSpecificity — a simplified {a,b,c} selector specificity tuple
129
+ // ============================================================================
130
+
131
+ /**
132
+ * Computes a simplified `{a,b,c}` (id / class-attr-pseudoclass / type-pseudoelement)
133
+ * specificity tuple for a CSS selector string. Strips recognized tokens in
134
+ * cascade-relevant order so overlapping character classes (e.g. `:hover`
135
+ * vs `::before`) aren't double-counted. Does not model `@layer`, full
136
+ * `:not()`/`:is()` specificity delegation, or `:where()` (zero-specificity)
137
+ * — a documented v1 simplification, not a spec-perfect implementation.
138
+ */
139
+ export function computeSpecificity(selectorText: string): Specificity {
140
+ let s = selectorText;
141
+ let a = 0;
142
+ let b = 0;
143
+ let c = 0;
144
+
145
+ const idMatches = s.match(/#[\w-]+/g);
146
+ a += idMatches ? idMatches.length : 0;
147
+ s = s.replace(/#[\w-]+/g, ' ');
148
+
149
+ const attrMatches = s.match(/\[[^\]]*\]/g);
150
+ b += attrMatches ? attrMatches.length : 0;
151
+ s = s.replace(/\[[^\]]*\]/g, ' ');
152
+
153
+ const classMatches = s.match(/\.[\w-]+/g);
154
+ b += classMatches ? classMatches.length : 0;
155
+ s = s.replace(/\.[\w-]+/g, ' ');
156
+
157
+ const pseudoElementMatches = s.match(/::[\w-]+|:(?:before|after|first-line|first-letter)\b/g);
158
+ c += pseudoElementMatches ? pseudoElementMatches.length : 0;
159
+ s = s.replace(/::[\w-]+|:(?:before|after|first-line|first-letter)\b/g, ' ');
160
+
161
+ const pseudoClassMatches = s.match(/:[\w-]+(\([^)]*\))?/g);
162
+ b += pseudoClassMatches ? pseudoClassMatches.length : 0;
163
+ s = s.replace(/:[\w-]+(\([^)]*\))?/g, ' ');
164
+
165
+ const identMatches = s.match(/[a-zA-Z][a-zA-Z0-9-]*/g);
166
+ c += identMatches ? identMatches.length : 0;
167
+
168
+ return { a, b, c };
169
+ }
170
+
171
+ function compareSpecificity(x: Specificity, y: Specificity): number {
172
+ return x.a - y.a || x.b - y.b || x.c - y.c;
173
+ }
174
+
175
+ // ============================================================================
176
+ // resolveNodeIds — shared backend-node-id correlation (used by media.ts too)
177
+ // ============================================================================
178
+
179
+ /**
180
+ * Resolves each `path` (a `cssPathFromBody`-style `tag:nth-of-type(n) > ...`
181
+ * string rooted at `body`) to its CDP `nodeId` (needed for
182
+ * `CSS.getMatchedStylesForNode`) and `backendNodeId` (the stable cross-file
183
+ * join key), via `DOM.querySelector` + `DOM.describeNode`. Sequential,
184
+ * best-effort per path — a failed lookup pushes `undefined` rather than
185
+ * aborting the batch; one bad path never drops the rest.
186
+ */
187
+ export async function resolveNodeIds(
188
+ client: CDPClient,
189
+ documentNodeId: number,
190
+ paths: readonly string[],
191
+ ): Promise<Array<{ nodeId?: number; backendNodeId?: number } | undefined>> {
192
+ const results: Array<{ nodeId?: number; backendNodeId?: number } | undefined> = [];
193
+
194
+ for (const path of paths) {
195
+ try {
196
+ const queryResult = (await client.send('DOM.querySelector', {
197
+ nodeId: documentNodeId,
198
+ selector: `body > ${path}`,
199
+ })) as { nodeId?: number };
200
+ const nodeId = queryResult.nodeId;
201
+ if (!nodeId) {
202
+ results.push(undefined);
203
+ continue;
204
+ }
205
+ try {
206
+ const described = (await client.send('DOM.describeNode', { nodeId })) as {
207
+ node?: { backendNodeId?: number };
208
+ };
209
+ results.push({ nodeId, backendNodeId: described.node?.backendNodeId });
210
+ } catch {
211
+ results.push({ nodeId });
212
+ }
213
+ } catch {
214
+ results.push(undefined);
215
+ }
216
+ }
217
+
218
+ return results;
219
+ }
220
+
221
+ // ============================================================================
222
+ // Raw CDP response shapes (modeled locally; no CDP protocol package)
223
+ // ============================================================================
224
+
225
+ export interface CDPCSSProperty {
226
+ name: string;
227
+ value: string;
228
+ important?: boolean;
229
+ implicit?: boolean;
230
+ range?: CSSRange;
231
+ /**
232
+ * Present ONLY on a real, literally-authored SHORTHAND declaration (e.g. `margin`) — the
233
+ * individual longhand properties (and their parsed values) it expands to. Real CDP
234
+ * responses give each of these longhand names NO `range` of its own (the longhand text
235
+ * never literally appears in source — only the shorthand's own `range`, on THIS entry,
236
+ * is a real authored position) — see {@link buildRangeByProperty}.
237
+ */
238
+ longhandProperties?: Array<{ name: string; value: string }>;
239
+ }
240
+
241
+ export interface CDPSelectorItem {
242
+ text: string;
243
+ range?: CSSRange;
244
+ }
245
+
246
+ export interface CDPCSSRule {
247
+ styleSheetId?: string;
248
+ selectorList: { selectors: CDPSelectorItem[]; text: string };
249
+ origin: string;
250
+ style: { cssProperties: CDPCSSProperty[]; styleSheetId?: string; range?: CSSRange };
251
+ media?: Array<{ text: string }>;
252
+ containerQueries?: Array<{ text: string }>;
253
+ /** Non-empty when the rule is nested inside one or more `@layer` blocks — a cascade-layer construct this simplified engine does not order (see {@link detectUnresolvedCascadeConstruct}). */
254
+ layers?: Array<{ text: string }>;
255
+ /** Non-empty when the rule is nested inside an `@scope` block — a construct this simplified engine does not order (see {@link detectUnresolvedCascadeConstruct}). */
256
+ scopes?: Array<{ text: string }>;
257
+ }
258
+
259
+ export interface CDPMatchedStylesResponse {
260
+ inlineStyle?: { cssProperties: CDPCSSProperty[] };
261
+ matchedCSSRules?: Array<{ rule: CDPCSSRule; matchingSelectors: number[] }>;
262
+ }
263
+
264
+ // ============================================================================
265
+ // Cascade winner selection
266
+ // ============================================================================
267
+
268
+ interface Candidate {
269
+ value: string;
270
+ important: boolean;
271
+ selectorText: string;
272
+ specificity: Specificity;
273
+ isInline: boolean;
274
+ rule?: CDPCSSRule;
275
+ range?: CSSRange;
276
+ /**
277
+ * Set when this candidate is only reportable approximately: its rule uses a construct
278
+ * {@link detectUnresolvedCascadeConstruct} identifies as unorderable, its matching selector
279
+ * uses a specificity construct {@link detectSelectorSpecificityApproximation} identifies as
280
+ * unscoreable, or its authored source position could not be traced at all (see `range`'s
281
+ * doc on {@link buildCandidates} — the whole-rule/`0:0` fallback is never reported as exact).
282
+ */
283
+ approximateReason?: string;
284
+ }
285
+
286
+ /**
287
+ * Identifies the ONE class of matched-rule construct this simplified cascade engine cannot
288
+ * correctly order: cascade `@layer`s (layer declaration order, not specificity/source-order,
289
+ * decides the winner across layers), `@scope` (proximity, not specificity/source-order, can
290
+ * decide the winner), and the `revert`/`revert-layer` keywords (which resolve to a DIFFERENT
291
+ * cascade origin/layer entirely, not to "this candidate's value"). Returns the honest reason
292
+ * string for {@link WinningDeclaration.winnerApproximate}, or `undefined` when the candidate is
293
+ * fully within this engine's supported model — recognizing exactly what can be parsed exactly
294
+ * and flagging the rest, never silently guessing (no lenient fallback path).
295
+ */
296
+ function detectUnresolvedCascadeConstruct(rule: CDPCSSRule, declaredValue: string): string | undefined {
297
+ if (rule.layers && rule.layers.length > 0) return 'cascade-layers-present';
298
+ if (rule.scopes && rule.scopes.length > 0) return 'cascade-scope-present';
299
+ const normalized = declaredValue.trim().toLowerCase();
300
+ if (normalized === 'revert' || normalized === 'revert-layer') return 'revert-keyword-present';
301
+ return undefined;
302
+ }
303
+
304
+ /**
305
+ * Identifies the other class of construct this simplified {@link computeSpecificity} cannot
306
+ * score correctly: `:where()` (spec: always zero specificity, regardless of its argument) and
307
+ * `:is()`/`:matches()` (spec: the specificity of its most specific argument) — this module's
308
+ * specificity model treats every pseudo-class token, including these, as a flat `b += 1`
309
+ * contribution, so a selector using either can be scored higher OR lower than its real spec
310
+ * specificity, and the winner picked among competing candidates may not be the true spec
311
+ * winner. Returns the honest reason string for {@link WinningDeclaration.winnerApproximate}
312
+ * (the v1 winner is still reported — this flags it, it never re-picks), or `undefined` when
313
+ * the selector uses neither construct.
314
+ */
315
+ function detectSelectorSpecificityApproximation(selectorText: string): string | undefined {
316
+ return /:(?:where|is|matches)\(/i.test(selectorText) ? 'selector-specificity-where-is-present' : undefined;
317
+ }
318
+
319
+ /**
320
+ * Maps each property name declared (directly or via a shorthand) in `rule` to the source
321
+ * `range` of the declaration that actually sets it: its own literal `range` when directly
322
+ * authored (`color: red;`), or the enclosing SHORTHAND's `range` when the property is only
323
+ * ever expressed through that shorthand's {@link CDPCSSProperty.longhandProperties} (e.g.
324
+ * `margin-top` from `margin: 10px 20px;`, which itself carries no `range` of its own — the
325
+ * literal `margin-top` text never appears in source). Real CDP responses additionally append,
326
+ * after every rule's literally-authored declarations, a flattened restatement of every
327
+ * resolved longhand value with NO `range` of its own — a convenience duplicate, not a new
328
+ * source location, so it is never itself a map source; iterating `cssProperties` in CDP's own
329
+ * delivery order and overwriting on each RANGED match keeps this map's value the LAST
330
+ * (cascade-winning) authored source for that property name, matching the same "later
331
+ * declaration wins" source-order semantics {@link pickWinner} already applies to candidate
332
+ * selection.
333
+ */
334
+ function buildRangeByProperty(rule: CDPCSSRule): Map<string, CSSRange> {
335
+ const rangeByProperty = new Map<string, CSSRange>();
336
+ for (const p of rule.style.cssProperties) {
337
+ if (!p.range) continue;
338
+ rangeByProperty.set(p.name, p.range);
339
+ if (p.longhandProperties) {
340
+ for (const longhand of p.longhandProperties) rangeByProperty.set(longhand.name, p.range);
341
+ }
342
+ }
343
+ return rangeByProperty;
344
+ }
345
+
346
+ /**
347
+ * A rule's selector list can have multiple selectors (`.card, #hero`), and CDP reports every selector
348
+ * in the list that matches the node via `matchingSelectors` (indices), not just the primary one. Use
349
+ * the matching selector with the HIGHEST specificity — a rule wins its cascade slot by its best-matching
350
+ * selector, not its first-listed one. Ties keep the first-encountered match (deterministic).
351
+ */
352
+ function pickHighestSpecificitySelectorText(rule: CDPCSSRule, matchingSelectors: number[] | undefined): string {
353
+ if (!matchingSelectors || matchingSelectors.length === 0) return rule.selectorList.text;
354
+ let bestText: string | undefined;
355
+ let bestSpecificity: Specificity | undefined;
356
+ for (const idx of matchingSelectors) {
357
+ const sel = rule.selectorList.selectors[idx];
358
+ if (!sel) continue;
359
+ const specificity = computeSpecificity(sel.text);
360
+ if (!bestSpecificity || compareSpecificity(specificity, bestSpecificity) > 0) {
361
+ bestSpecificity = specificity;
362
+ bestText = sel.text;
363
+ }
364
+ }
365
+ return bestText ?? rule.selectorList.text;
366
+ }
367
+
368
+ function buildCandidates(property: string, matched: CDPMatchedStylesResponse): Candidate[] {
369
+ const candidates: Candidate[] = [];
370
+
371
+ for (const entry of matched.matchedCSSRules ?? []) {
372
+ const rule = entry.rule;
373
+ if (rule.origin !== 'regular') continue;
374
+ // CSS importance is resolved BEFORE source order: a rule can declare the same property more than
375
+ // once with different importance (e.g. `color: red !important; color: blue;`), and the `!important`
376
+ // one must win regardless of position. So push every non-implicit same-property declaration as its
377
+ // own candidate, in source order, and let pickWinner's importance partition + source-order tiebreak
378
+ // decide — do NOT collapse to a single "last declaration" here, which would discard importance info.
379
+ const selectorText = pickHighestSpecificitySelectorText(rule, entry.matchingSelectors);
380
+ const specificity = computeSpecificity(selectorText);
381
+ const rangeByProperty = buildRangeByProperty(rule);
382
+ for (const p of rule.style.cssProperties) {
383
+ if (p.name !== property || p.implicit === true) continue;
384
+ // `p.range` covers a literally-authored entry (direct declaration OR a shorthand
385
+ // itself); `rangeByProperty` backfills the accurate authored source for CDP's
386
+ // range-less restatement/synthesized-longhand entries. When NEITHER traces the property's
387
+ // real position, `rule.style.range` is a last-resort SUBSTITUTE (the whole rule's span
388
+ // standing in for one property's position) — that substitution is flagged approximate,
389
+ // never reported as exact. A property with no range anywhere (not even a rule-level one)
390
+ // stays honestly absent instead — an acknowledged gap, not a fabricated claim.
391
+ const resolvedRange = p.range ?? rangeByProperty.get(p.name);
392
+ const usedRuleRangeFallback = resolvedRange === undefined && rule.style.range !== undefined;
393
+ candidates.push({
394
+ value: p.value,
395
+ important: p.important === true,
396
+ selectorText,
397
+ specificity,
398
+ isInline: false,
399
+ rule,
400
+ range: resolvedRange ?? rule.style.range,
401
+ approximateReason:
402
+ detectUnresolvedCascadeConstruct(rule, p.value) ??
403
+ detectSelectorSpecificityApproximation(selectorText) ??
404
+ (usedRuleRangeFallback ? 'source-range-unresolved' : undefined),
405
+ });
406
+ }
407
+ }
408
+
409
+ for (const p of matched.inlineStyle?.cssProperties ?? []) {
410
+ if (p.name !== property || p.implicit === true) continue;
411
+ candidates.push({
412
+ value: p.value,
413
+ important: p.important === true,
414
+ selectorText: 'inline',
415
+ specificity: { a: 0, b: 0, c: 0 },
416
+ isInline: true,
417
+ range: p.range,
418
+ });
419
+ }
420
+
421
+ return candidates;
422
+ }
423
+
424
+ /**
425
+ * Picks the winning candidate: important declarations beat non-important
426
+ * ones outright (a two-tier split, not a full important-origin ordering).
427
+ * Within WHICHEVER pool is in play (important or non-important), an
428
+ * inline `style` declaration always wins — the spec treats the style
429
+ * attribute as a higher cascade tier than any selector-based rule, at
430
+ * the SAME importance level, so inline `!important` beats stylesheet
431
+ * `!important` too, not just inline-vs-non-important. Otherwise the
432
+ * highest `{a,b,c}` specificity wins, ties broken by later position in
433
+ * the candidate list (a document-order proxy for "declared later wins").
434
+ */
435
+ function pickWinner(candidates: Candidate[]): Candidate | undefined {
436
+ if (candidates.length === 0) return undefined;
437
+
438
+ const importantPool = candidates.filter((c) => c.important);
439
+ const nonImportantPool = candidates.filter((c) => !c.important);
440
+ const pool = importantPool.length > 0 ? importantPool : nonImportantPool;
441
+
442
+ for (let i = pool.length - 1; i >= 0; i--) {
443
+ if (pool[i].isInline) return pool[i];
444
+ }
445
+
446
+ let best: Candidate | undefined;
447
+ for (const candidate of pool) {
448
+ if (!best || compareSpecificity(candidate.specificity, best.specificity) >= 0) {
449
+ best = candidate;
450
+ }
451
+ }
452
+ return best;
453
+ }
454
+
455
+ function noDeclarationRecord(property: string, value: string | null): WinningDeclaration {
456
+ return { property, value, selector: null, specificity: null, source: null };
457
+ }
458
+
459
+ /**
460
+ * Resolves the winning declaration (+ authored/generated source, via
461
+ * source-map fallback) for each property in `properties`, given one
462
+ * `CSS.getMatchedStylesForNode` response covering all of them. Shared by
463
+ * `styles.ts` (a bounded per-element property set) and `layers.ts` (the
464
+ * fixed layer-affecting property set).
465
+ */
466
+ export async function buildWinningDeclarations(
467
+ client: CDPClient,
468
+ matched: CDPMatchedStylesResponse,
469
+ computed: Record<string, string | null>,
470
+ sourceCache: Map<string, Promise<ResolvedSourceLocation>>,
471
+ styleSheetUrls: Map<string, string>,
472
+ properties: readonly string[],
473
+ ): Promise<WinningDeclaration[]> {
474
+ const declarations: WinningDeclaration[] = [];
475
+
476
+ for (const property of properties) {
477
+ const candidates = buildCandidates(property, matched);
478
+ const winner = pickWinner(candidates);
479
+ if (!winner) {
480
+ declarations.push(noDeclarationRecord(property, computed[property] ?? null));
481
+ continue;
482
+ }
483
+
484
+ const specificityStr = winner.isInline ? null : `${winner.specificity.a}-${winner.specificity.b}-${winner.specificity.c}`;
485
+
486
+ // Honesty check (I-4/I-10): if ANY candidate in play for this property — not just the
487
+ // reported winner — uses a construct outside the simplified model (a cascade/scope/revert
488
+ // construct, a `:where()`/`:is()` selector this specificity model can't score, or a
489
+ // declaration whose authored source position couldn't be traced), the winner itself may be
490
+ // wrong or its `range` approximate (e.g. a lower-specificity candidate in a higher-priority
491
+ // `@layer` is the REAL winner in real Chrome; see the adversarial cascade-layers fixture
492
+ // this flag exists for). Flag rather than silently claim exactness.
493
+ const winnerApproximateReason = candidates.map((c) => c.approximateReason).find((reason) => reason !== undefined);
494
+
495
+ // Raw sourceURL is used ONLY as an input to source-map resolution below (it must stay
496
+ // byte-exact for relative sourceMappingURL joining). The EMITTED field is sanitized
497
+ // through the shared authority — provenance URLs are page-controlled strings (D1).
498
+ const rawSourceStyleSheetUrl = winner.rule?.styleSheetId ? styleSheetUrls.get(winner.rule.styleSheetId) : undefined;
499
+ const sourceStyleSheetUrl = rawSourceStyleSheetUrl !== undefined ? sanitizeString(rawSourceStyleSheetUrl) : undefined;
500
+
501
+ let authored: { file: string; line: number; column: number } | undefined;
502
+ let generated: { sourceURL: string; line: number; column: number } | undefined;
503
+ let sourceResolutionUnavailable: boolean | undefined;
504
+ let sourceResolutionUnavailableReason: string | undefined;
505
+ // Source resolution requires a REAL property/rule range to seed the lookup — without one
506
+ // there is no authored/generated position to resolve, only a fabricated 0:0 substitute. When
507
+ // `winner.range` is undefined (no declaration range and no `rule.style.range` fallback
508
+ // either), leave `authored`/`generated` honestly undefined rather than defaulting line/column
509
+ // to 0 and reporting a fake location (I-4: honest absence must hold for `authored`/
510
+ // `generated`, not just `range`).
511
+ if (!winner.isInline && winner.rule?.styleSheetId && winner.range) {
512
+ const line = winner.range.startLine;
513
+ const column = winner.range.startColumn;
514
+ const styleSheetId = winner.rule.styleSheetId;
515
+ const key = `${styleSheetId}:${line}:${column}`;
516
+ try {
517
+ let pending = sourceCache.get(key);
518
+ if (!pending) {
519
+ pending = resolveAuthoredSourceLocation(client, {
520
+ styleSheetId,
521
+ sourceURL: rawSourceStyleSheetUrl,
522
+ line,
523
+ column,
524
+ });
525
+ sourceCache.set(key, pending);
526
+ }
527
+ const location = await pending;
528
+ // `generated` is set for BOTH branches: when a source map resolves, `authored` is the
529
+ // mapped location and `generated` is the pre-map (post-build) location it came from; when
530
+ // no map resolves, `generated` alone carries the real generated URL/line/column — the
531
+ // fallback fact that must never be discarded.
532
+ if (location.kind === 'authored') {
533
+ authored = { file: sanitizeString(location.file), line: location.line, column: location.column };
534
+ generated = {
535
+ sourceURL: sanitizeString(location.generated.sourceURL),
536
+ line: location.generated.line,
537
+ column: location.generated.column,
538
+ };
539
+ } else {
540
+ generated = { sourceURL: sanitizeString(location.sourceURL), line: location.line, column: location.column };
541
+ }
542
+ } catch (err) {
543
+ // Source-map resolution never aborts the WHOLE snapshot for this property, but a REJECTED
544
+ // resolution (e.g. a malformed VLQ `mappings` string throwing inside the decoder) must not
545
+ // be silently indistinguishable from `resolveAuthoredSourceLocation`'s own honest "no
546
+ // authored source" outcome (a genuine minified/no-source-map production build, which it
547
+ // reports as a normal `GeneratedLocation`, never a rejection) — I-4/I-10. Mark the failure
548
+ // explicitly, and preserve the generated (pre-map) fallback fact from the already-known
549
+ // query position — that position was never in question, only the map lookup on top of it.
550
+ sourceResolutionUnavailable = true;
551
+ sourceResolutionUnavailableReason = err instanceof Error ? err.message : 'source-resolution-failed';
552
+ generated = { sourceURL: sanitizeString(rawSourceStyleSheetUrl ?? styleSheetId), line, column };
553
+ }
554
+ }
555
+
556
+ declarations.push({
557
+ property,
558
+ value: computed[property] ?? null,
559
+ declaredValue: sanitizeString(winner.value),
560
+ selector: winner.isInline ? 'inline' : sanitizeString(winner.selectorText),
561
+ specificity: specificityStr,
562
+ important: winner.important,
563
+ sourceStyleSheetId: winner.rule?.styleSheetId,
564
+ sourceStyleSheetUrl,
565
+ range: winner.range,
566
+ authored,
567
+ generated,
568
+ mediaQuery: winner.rule?.media?.length ? sanitizeString(winner.rule.media.map((m) => m.text).join(' ; ')) : undefined,
569
+ containerQuery: winner.rule?.containerQueries?.length
570
+ ? sanitizeString(winner.rule.containerQueries.map((cq) => cq.text).join(' ; '))
571
+ : undefined,
572
+ winnerApproximate: winnerApproximateReason !== undefined ? true : undefined,
573
+ winnerApproximateReason,
574
+ sourceResolutionUnavailable,
575
+ sourceResolutionUnavailableReason,
576
+ });
577
+ }
578
+
579
+ return declarations;
580
+ }
581
+
582
+ // ============================================================================
583
+ // captureStyleSheetHeaders — real stylesheet URL/id correlation
584
+ // ============================================================================
585
+
586
+ /** CDP `CSS.styleSheetAdded` header shape (only the two fields this module reads). */
587
+ interface StyleSheetHeader {
588
+ readonly styleSheetId?: string;
589
+ readonly sourceURL?: string;
590
+ }
591
+
592
+ /**
593
+ * Registers a `CSS.styleSheetAdded` listener FIRST, then forces Chrome to
594
+ * redeliver a header for every currently-known stylesheet by cycling
595
+ * `CSS.disable` + `CSS.enable` (see this module's doc comment for why a
596
+ * bare re-`enable` is insufficient and why the disable/enable pair is
597
+ * safe here), and returns the populated `styleSheetId -> sourceURL` map
598
+ * plus a `stop()` that removes the listener (any stylesheets added
599
+ * *during* the rest of collection still update the map live, until
600
+ * `stop()` is called), plus an explicit `available`/`reason` fact (I-5):
601
+ * when the client can't deliver events at all, or the disable/enable
602
+ * cycle itself throws, `available` is `false` with a `reason` — an empty
603
+ * `urls` map is still returned (an aborted collector is worse than a
604
+ * degraded one), but callers must propagate the unavailable fact rather
605
+ * than let a resulting missing `sourceStyleSheetUrl`/source read as
606
+ * "genuinely no source" when it is really "couldn't check".
607
+ */
608
+ export async function captureStyleSheetHeaders(
609
+ client: CDPClient,
610
+ ): Promise<{ urls: Map<string, string>; stop: () => void; available: boolean; reason?: string }> {
611
+ const urls = new Map<string, string>();
612
+ if (typeof client.on !== 'function') {
613
+ return { urls, stop: () => {}, available: false, reason: 'client-lacks-event-support' };
614
+ }
615
+
616
+ const handler = (params: unknown): void => {
617
+ const header = (params as { header?: StyleSheetHeader } | undefined)?.header;
618
+ if (header?.styleSheetId && typeof header.sourceURL === 'string' && header.sourceURL.length > 0) {
619
+ urls.set(header.styleSheetId, header.sourceURL);
620
+ }
621
+ };
622
+ client.on('CSS.styleSheetAdded', handler);
623
+
624
+ const removable = client as unknown as {
625
+ off?: (event: string, handler: (params: unknown) => void) => void;
626
+ removeListener?: (event: string, handler: (params: unknown) => void) => void;
627
+ };
628
+ const stop = (): void => {
629
+ if (typeof removable.off === 'function') removable.off('CSS.styleSheetAdded', handler);
630
+ else if (typeof removable.removeListener === 'function') removable.removeListener('CSS.styleSheetAdded', handler);
631
+ };
632
+
633
+ try {
634
+ await client.send('CSS.disable');
635
+ await client.send('CSS.enable');
636
+ } catch {
637
+ // Proceed with whatever headers (if any) were captured before the failure, but flag it —
638
+ // an empty `urls` map from here is otherwise indistinguishable from "no stylesheets exist".
639
+ return { urls, stop, available: false, reason: 'stylesheet-header-redelivery-failed' };
640
+ }
641
+
642
+ return { urls, stop, available: true };
643
+ }