@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,464 @@
1
+ /**
2
+ * `queries.json` collector — active media queries, container queries, and
3
+ * the environment facts (viewport, DPR, color scheme, ...) they resolved
4
+ * against. Page-level facts only: one `Runtime.evaluate` round trip that
5
+ * walks `document.styleSheets` in-page. No DOM domain calls, no per-element
6
+ * `backendNodeId` correlation — that machinery (`resolveNodeIds` in
7
+ * `./styles.js`) exists for `styles.ts`/`media.ts`'s per-element records,
8
+ * which this file doesn't produce.
9
+ */
10
+
11
+ import { capArray, sanitizeString } from '../redaction.js';
12
+ import type { Collector } from '../types.js';
13
+
14
+ const MAX_MEDIA_QUERIES = 50;
15
+ const MAX_CONTAINER_QUERIES = 30;
16
+ const MAX_AFFECTED_SELECTORS = 20;
17
+
18
+ // ============================================================================
19
+ // Output shape
20
+ // ============================================================================
21
+
22
+ export interface QueryEnvironment {
23
+ width: number;
24
+ height: number;
25
+ dpr: number;
26
+ colorScheme: 'dark' | 'light';
27
+ pointer: 'coarse' | 'fine' | 'none';
28
+ hover: 'hover' | 'none';
29
+ reducedMotion: 'reduce' | 'no-preference';
30
+ forcedColors: 'active' | 'none';
31
+ }
32
+
33
+ export interface MediaQueryRecord {
34
+ query?: string;
35
+ /** Absent (not `false`) when {@link matchUnavailable} is `true` — a thrown `matchMedia(mq).matches` read must not read as "genuinely non-matching" (MARK #29). */
36
+ matched?: boolean;
37
+ /** MARK #29 (I-4/I-5): `true` when the in-page `window.matchMedia(mq).matches` call threw for this query — {@link matched} is then withheld rather than defaulted to `false`. Absent (not `false`) on a successful read. */
38
+ matchUnavailable?: true;
39
+ affectedSelectors?: string[];
40
+ /** Present only when this record's `affectedSelectors` exceeded {@link MAX_AFFECTED_SELECTORS} — the set was truncated, not exhaustively empty of more matches. */
41
+ affectedSelectorsTruncated?: boolean;
42
+ /** Present instead of the fields above when a cross-origin stylesheet's rules were unreadable. */
43
+ error?: string;
44
+ }
45
+
46
+ export interface ContainerQueryRecord {
47
+ containerName: string | null;
48
+ /** The querying container's selector, or `null` when no matching descendant/ancestor container could be resolved — OR when {@link resolutionUnavailable} is `true` (a read failure, not a genuine "no container"; check {@link resolutionUnavailable} before reading this `null` as "no matching container"). */
49
+ containerSelector: string | null;
50
+ containerSize: { width: number; height: number } | null;
51
+ query: string;
52
+ /** `null` means either the condition text didn't parse under the supported `(min|max)-(width|height)` grammar (an explicit "unknown", not a guess) OR {@link resolutionUnavailable} is `true` — check {@link resolutionUnavailable} to tell the two apart. */
53
+ matched: boolean | null;
54
+ affectedSelectors: string[];
55
+ /** Present only when this record's `affectedSelectors` exceeded {@link MAX_AFFECTED_SELECTORS}. */
56
+ affectedSelectorsTruncated?: boolean;
57
+ /** MARK #28 (I-4/I-5): `true` when the in-page container-resolution walk (`document.querySelector` on the first affected selector, then the ancestor `getComputedStyle`/container-name walk) threw for this rule — {@link containerSelector}/{@link containerSize}/{@link matched} staying `null` is then "could not resolve", distinguishable from a genuine "no matching container" or "condition didn't parse". Absent (not `false`) on a successful resolution attempt (including one that legitimately found no container). */
58
+ resolutionUnavailable?: true;
59
+ /** Fixed, factual reason paired with {@link resolutionUnavailable}. */
60
+ resolutionUnavailableReason?: 'container-resolution-threw';
61
+ }
62
+
63
+ export interface QueriesReport {
64
+ environment: QueryEnvironment;
65
+ mediaQueries: MediaQueryRecord[];
66
+ /** True when the page had more media-query rules (including unreadable cross-origin sheets) than {@link MAX_MEDIA_QUERIES} — `mediaQueries` was truncated, not an exhaustive count. */
67
+ mediaQueriesTruncated: boolean;
68
+ containerQueries: ContainerQueryRecord[];
69
+ /** True when the page had more `@container` rules than {@link MAX_CONTAINER_QUERIES}. */
70
+ containerQueriesTruncated: boolean;
71
+ /** MARK #30 (I-5): count of rules/subtrees the in-page walk (`walkRules`) skipped because inspecting them threw — present (and > 0) only when at least one occurred. Distinct from {@link mediaQueriesTruncated}/{@link containerQueriesTruncated} (those describe the KEPT-set cap on rules that WERE read); this describes rules that were never successfully read at all, so `mediaQueries`/`containerQueries` may be missing entries no cap fact accounts for. */
72
+ ruleWalkErrors?: number;
73
+ /** Explicit scope fact (D5): the walk enumerates `document.styleSheets` of the top document only — cross-origin sheets are already surfaced as an `error` record, and iframe stylesheets are out of scope, a stated boundary rather than a negative fact. */
74
+ coverage: { scope: 'top-document' };
75
+ /** `false` when the `QUERIES_SCRIPT` `Runtime.evaluate` itself failed (threw, or returned no `value`) — the default-environment/empty-arrays shape below is then "could not collect", not "genuinely queryless page" (I-5). Always `true` on a normal run, including one where the page genuinely has no media/container queries. */
76
+ available: boolean;
77
+ /** Present only when `available` is `false`. */
78
+ unavailableReason?: QueriesUnavailableReason;
79
+ }
80
+
81
+ /** Fixed, factual reason `QUERIES_SCRIPT`'s `Runtime.evaluate` could not be read (never a raw exception message, which is unbounded/page-influenced) — present only when {@link QueriesReport.available} is `false`. `queries-facts-malformed` (Layer 2, I-5) covers the eval succeeding with a present but structurally malformed top-level value (e.g. missing `environment`/`mediaQueries`/`containerQueries`) — distinct from a missing value entirely. */
82
+ export type QueriesUnavailableReason = 'queries-evaluate-returned-no-value' | 'queries-evaluate-threw' | 'queries-facts-malformed';
83
+
84
+ // ============================================================================
85
+ // In-page script
86
+ // ============================================================================
87
+
88
+ const QUERIES_SCRIPT = `/* __captureQueriesInventory */
89
+ (function() {
90
+ function cssPathFromBody(el) {
91
+ var parts = [];
92
+ var node = el;
93
+ var depth = 0;
94
+ while (node && node.nodeType === 1 && node !== document.body && depth < 40) {
95
+ var parent = node.parentElement;
96
+ if (!parent) { parts.unshift(node.tagName.toLowerCase()); break; }
97
+ var same = Array.prototype.filter.call(parent.children, function(c) { return c.tagName === node.tagName; });
98
+ var idx = same.indexOf(node) + 1;
99
+ parts.unshift(node.tagName.toLowerCase() + ':nth-of-type(' + idx + ')');
100
+ node = parent;
101
+ depth++;
102
+ }
103
+ return parts.join(' > ');
104
+ }
105
+ function collectSelectors(cssRules) {
106
+ var out = [];
107
+ var seen = {};
108
+ if (!cssRules) return { selectors: out, truncated: false };
109
+ for (var i = 0; i < cssRules.length; i++) {
110
+ var sel = cssRules[i].selectorText;
111
+ if (sel && !seen[sel]) {
112
+ seen[sel] = true;
113
+ out.push(sel);
114
+ }
115
+ }
116
+ var truncated = out.length > ${MAX_AFFECTED_SELECTORS};
117
+ return { selectors: truncated ? out.slice(0, ${MAX_AFFECTED_SELECTORS}) : out, truncated: truncated };
118
+ }
119
+ function evaluateContainerQuery(text, size) {
120
+ var re = /\\((min|max)-(width|height):\\s*([\\d.]+)px\\)/g;
121
+ var m;
122
+ var found = false;
123
+ var ok = true;
124
+ while ((m = re.exec(text)) !== null) {
125
+ found = true;
126
+ var kind = m[1], axis = m[2], val = parseFloat(m[3]);
127
+ var actual = axis === 'width' ? size.width : size.height;
128
+ if (kind === 'min' && !(actual >= val)) ok = false;
129
+ if (kind === 'max' && !(actual <= val)) ok = false;
130
+ }
131
+ if (!found) return null;
132
+ var stripped = text
133
+ .replace(/\\((min|max)-(width|height):\\s*[\\d.]+px\\)/g, '')
134
+ .replace(/\\band\\b/g, '')
135
+ .replace(/\\s+/g, '');
136
+ if (stripped.length > 0) return null;
137
+ return ok;
138
+ }
139
+ function conditionOnly(query, containerName) {
140
+ // rule.conditionText for a NAMED @container rule includes the name as a leading token
141
+ // ("outer (min-width: 10px)"), which evaluateContainerQuery's emptiness check can't parse —
142
+ // strip exactly that leading name token (plain string slicing, not a regex built from
143
+ // page-controlled text) before evaluating; unnamed rules pass query through untouched.
144
+ if (!containerName) return query;
145
+ var trimmed = query.replace(/^\s+/, '');
146
+ if (trimmed.slice(0, containerName.length) !== containerName) return query;
147
+ return trimmed.slice(containerName.length).replace(/^\s+/, '');
148
+ }
149
+ function describeContainerRule(rule) {
150
+ var containerName = rule.containerName || null;
151
+ var query = rule.conditionText || '';
152
+ var selectorResult = collectSelectors(rule.cssRules);
153
+ var selectors = selectorResult.selectors;
154
+ var containerSelector = null;
155
+ var containerSize = null;
156
+ var matched = null;
157
+ var resolutionUnavailable = false;
158
+ var firstSelector = selectors.length ? selectors[0] : null;
159
+ if (firstSelector) {
160
+ try {
161
+ var el = document.querySelector(firstSelector);
162
+ // Walk ANCESTORS, not the affected element itself — an element matched by a container-query rule is
163
+ // the thing being sized/styled, not (necessarily) the container doing the querying; starting from
164
+ // the element itself would misreport its own containment as its query container when it happens to be one.
165
+ var node = el ? el.parentElement : null;
166
+ var depth2 = 0;
167
+ var container = null;
168
+ while (node && depth2 < 20) {
169
+ var nodeStyle = getComputedStyle(node);
170
+ var ct = nodeStyle.containerType;
171
+ var isContainer = ct && ct !== 'normal';
172
+ if (isContainer) {
173
+ // Unnamed rule: the nearest ancestor container of any name satisfies the query. Named rule:
174
+ // only an ancestor whose own (possibly space-separated) container-name list contains the
175
+ // queried name does — a nearer, differently-named container is not a match and the walk
176
+ // continues past it toward the actual named ancestor.
177
+ if (!containerName) {
178
+ container = node;
179
+ break;
180
+ }
181
+ var names = (nodeStyle.containerName || '').split(/\s+/).filter(Boolean);
182
+ if (names.indexOf(containerName) !== -1) {
183
+ container = node;
184
+ break;
185
+ }
186
+ }
187
+ node = node.parentElement;
188
+ depth2++;
189
+ }
190
+ if (container) {
191
+ containerSelector = cssPathFromBody(container);
192
+ containerSize = { width: container.clientWidth, height: container.clientHeight };
193
+ matched = evaluateContainerQuery(conditionOnly(query, containerName), containerSize);
194
+ }
195
+ } catch (e) {
196
+ // MARK #28 (I-4/I-5): the resolution attempt itself threw (querySelector/ancestor walk/
197
+ // getComputedStyle on some node) -- containerSelector/containerSize/matched staying null must
198
+ // be distinguishable from "resolution ran cleanly and legitimately found no container" or
199
+ // "condition text didn't parse" (both already honest null cases via evaluateContainerQuery).
200
+ containerSelector = null;
201
+ containerSize = null;
202
+ matched = null;
203
+ resolutionUnavailable = true;
204
+ }
205
+ }
206
+ return {
207
+ containerName: containerName,
208
+ containerSelector: containerSelector,
209
+ containerSize: containerSize,
210
+ query: query,
211
+ matched: matched,
212
+ affectedSelectors: selectors,
213
+ affectedSelectorsTruncated: selectorResult.truncated,
214
+ resolutionUnavailable: resolutionUnavailable,
215
+ resolutionUnavailableReason: resolutionUnavailable ? 'container-resolution-threw' : undefined,
216
+ };
217
+ }
218
+ function walkRules(rules, depth, mediaOut, containerOut, counts) {
219
+ if (!rules || depth > 5) return;
220
+ for (var i = 0; i < rules.length; i++) {
221
+ var rule = rules[i];
222
+ try {
223
+ if (typeof CSSMediaRule !== 'undefined' && rule instanceof CSSMediaRule) {
224
+ counts.media++;
225
+ if (mediaOut.length < ${MAX_MEDIA_QUERIES}) {
226
+ var mq = rule.media.mediaText;
227
+ // MARK #29 (I-4/I-5): a thrown matchMedia(...).matches read must not read as "genuinely
228
+ // non-matching" -- withhold matched and flag matchUnavailable instead of defaulting to false.
229
+ var matched;
230
+ var matchUnavailable = false;
231
+ try { matched = window.matchMedia(mq).matches; } catch (e) { matchUnavailable = true; }
232
+ var mqSelectors = collectSelectors(rule.cssRules);
233
+ mediaOut.push({
234
+ query: mq,
235
+ matched: matchUnavailable ? undefined : matched,
236
+ matchUnavailable: matchUnavailable ? true : undefined,
237
+ affectedSelectors: mqSelectors.selectors,
238
+ affectedSelectorsTruncated: mqSelectors.truncated,
239
+ });
240
+ }
241
+ walkRules(rule.cssRules, depth + 1, mediaOut, containerOut, counts);
242
+ } else if (typeof CSSContainerRule !== 'undefined' && rule instanceof CSSContainerRule) {
243
+ counts.container++;
244
+ if (containerOut.length < ${MAX_CONTAINER_QUERIES}) {
245
+ containerOut.push(describeContainerRule(rule));
246
+ }
247
+ walkRules(rule.cssRules, depth + 1, mediaOut, containerOut, counts);
248
+ } else if (rule.cssRules) {
249
+ walkRules(rule.cssRules, depth + 1, mediaOut, containerOut, counts);
250
+ }
251
+ } catch (e) {
252
+ // MARK #30 (I-5): a rule/subtree read threw while walking -- it is dropped from mediaOut/
253
+ // containerOut with NO other trace unless counted here; surface the drop count so it isn't
254
+ // silently invisible (distinct from mediaQueriesTruncated/containerQueriesTruncated, which
255
+ // describe the KEPT-set cap on rules that WERE read successfully).
256
+ counts.ruleWalkErrors++;
257
+ }
258
+ }
259
+ }
260
+
261
+ var mediaQueries = [];
262
+ var containerQueries = [];
263
+ var counts = { media: 0, container: 0, ruleWalkErrors: 0 };
264
+ for (var s = 0; s < document.styleSheets.length; s++) {
265
+ var rules;
266
+ try {
267
+ rules = document.styleSheets[s].cssRules;
268
+ } catch (e) {
269
+ counts.media++;
270
+ if (mediaQueries.length < ${MAX_MEDIA_QUERIES}) mediaQueries.push({ error: 'cross-origin' });
271
+ continue;
272
+ }
273
+ walkRules(rules, 0, mediaQueries, containerQueries, counts);
274
+ }
275
+ var mediaQueriesTruncated = counts.media > ${MAX_MEDIA_QUERIES};
276
+ var containerQueriesTruncated = counts.container > ${MAX_CONTAINER_QUERIES};
277
+
278
+ var environment = {
279
+ width: window.innerWidth,
280
+ height: window.innerHeight,
281
+ dpr: window.devicePixelRatio,
282
+ colorScheme: matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
283
+ pointer: matchMedia('(pointer: coarse)').matches ? 'coarse' : (matchMedia('(pointer: fine)').matches ? 'fine' : 'none'),
284
+ hover: matchMedia('(hover: hover)').matches ? 'hover' : 'none',
285
+ reducedMotion: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'reduce' : 'no-preference',
286
+ forcedColors: matchMedia('(forced-colors: active)').matches ? 'active' : 'none',
287
+ };
288
+
289
+ return {
290
+ environment: environment,
291
+ mediaQueries: mediaQueries,
292
+ mediaQueriesTruncated: mediaQueriesTruncated,
293
+ containerQueries: containerQueries,
294
+ containerQueriesTruncated: containerQueriesTruncated,
295
+ ruleWalkErrors: counts.ruleWalkErrors,
296
+ };
297
+ })();`;
298
+
299
+ // ============================================================================
300
+ // Node-side normalization — the SINGLE cap authority for every
301
+ // page-controlled query/selector string. The in-page walk does NO capping;
302
+ // each string is length-capped here via the shared `sanitizeString`.
303
+ // ============================================================================
304
+
305
+ /** Caps a page-controlled string (media/container query text, selector, container name) through the shared `sanitizeString` length cap, or returns `undefined` for a non-string. */
306
+ function sanitizeStr(value: unknown): string | undefined {
307
+ if (typeof value !== 'string') return undefined;
308
+ return sanitizeString(value);
309
+ }
310
+
311
+ /**
312
+ * Sanitizes a page-controlled selector array and reports whether the shared
313
+ * `capArray` cap dropped anything, ORed with the in-page walk's own
314
+ * `truncated` flag — a defensive re-cap (the in-page script already bounds
315
+ * the array to {@link MAX_AFFECTED_SELECTORS}) rather than the primary
316
+ * source of the fact.
317
+ */
318
+ function sanitizeSelectors(value: unknown, pageTruncated: boolean): { selectors: string[]; truncated: boolean } {
319
+ const arr = Array.isArray(value) ? value : [];
320
+ const { items, truncated } = capArray(arr, MAX_AFFECTED_SELECTORS);
321
+ return { selectors: items.map((v) => sanitizeStr(v) ?? ''), truncated: pageTruncated || truncated > 0 };
322
+ }
323
+
324
+ type NormalizedQueriesFacts = Omit<QueriesReport, 'available' | 'unavailableReason'>;
325
+
326
+ function normalizeReport(raw: Partial<QueriesReport> | undefined): NormalizedQueriesFacts {
327
+ const environment = (raw?.environment ?? {}) as Partial<QueryEnvironment>;
328
+ const rawMediaQueries = Array.isArray(raw?.mediaQueries) ? raw!.mediaQueries : [];
329
+ const rawContainerQueries = Array.isArray(raw?.containerQueries) ? raw!.containerQueries : [];
330
+ const { items: mediaQueries, truncated: mediaQueriesCapped } = capArray(rawMediaQueries, MAX_MEDIA_QUERIES);
331
+ const { items: containerQueries, truncated: containerQueriesCapped } = capArray(rawContainerQueries, MAX_CONTAINER_QUERIES);
332
+
333
+ return {
334
+ environment: {
335
+ width: typeof environment.width === 'number' ? environment.width : 0,
336
+ height: typeof environment.height === 'number' ? environment.height : 0,
337
+ dpr: typeof environment.dpr === 'number' ? environment.dpr : 1,
338
+ colorScheme: environment.colorScheme === 'dark' ? 'dark' : 'light',
339
+ pointer: environment.pointer === 'coarse' || environment.pointer === 'fine' ? environment.pointer : 'none',
340
+ hover: environment.hover === 'hover' ? 'hover' : 'none',
341
+ reducedMotion: environment.reducedMotion === 'reduce' ? 'reduce' : 'no-preference',
342
+ forcedColors: environment.forcedColors === 'active' ? 'active' : 'none',
343
+ },
344
+ mediaQueries: mediaQueries.map((mq) => {
345
+ const m = mq as MediaQueryRecord;
346
+ if (typeof m.error === 'string') return { error: sanitizeStr(m.error) };
347
+ const selectors = sanitizeSelectors(m.affectedSelectors, m.affectedSelectorsTruncated === true);
348
+ // MARK #29: a query whose in-page matchMedia(...).matches read threw carries
349
+ // matchUnavailable:true -- matched must be withheld (undefined), never coerced to `false`
350
+ // ("genuinely non-matching") by the `m.matched === true` comparison that ran on every record before.
351
+ const matchUnavailable = m.matchUnavailable === true;
352
+ return {
353
+ query: sanitizeStr(m.query),
354
+ matched: matchUnavailable ? undefined : m.matched === true,
355
+ matchUnavailable: matchUnavailable || undefined,
356
+ affectedSelectors: selectors.selectors,
357
+ affectedSelectorsTruncated: selectors.truncated || undefined,
358
+ };
359
+ }),
360
+ mediaQueriesTruncated: raw?.mediaQueriesTruncated === true || mediaQueriesCapped > 0,
361
+ containerQueries: containerQueries.map((cq) => {
362
+ const c = cq as ContainerQueryRecord;
363
+ const selectors = sanitizeSelectors(c.affectedSelectors, c.affectedSelectorsTruncated === true);
364
+ // MARK #28: a rule whose in-page container-resolution walk threw carries
365
+ // resolutionUnavailable:true -- containerSelector/containerSize/matched stay null (as they
366
+ // already do on a genuine "no container"/"unsupported condition"), but this marker is what
367
+ // lets a downstream reader tell the two apart.
368
+ const resolutionUnavailable = c.resolutionUnavailable === true;
369
+ return {
370
+ containerName: sanitizeStr(c.containerName) ?? null,
371
+ containerSelector: sanitizeStr(c.containerSelector) ?? null,
372
+ containerSize:
373
+ c.containerSize && typeof c.containerSize.width === 'number' && typeof c.containerSize.height === 'number'
374
+ ? { width: c.containerSize.width, height: c.containerSize.height }
375
+ : null,
376
+ query: sanitizeStr(c.query) ?? '',
377
+ matched: c.matched === null || c.matched === undefined ? null : c.matched === true,
378
+ affectedSelectors: selectors.selectors,
379
+ affectedSelectorsTruncated: selectors.truncated || undefined,
380
+ resolutionUnavailable: resolutionUnavailable || undefined,
381
+ resolutionUnavailableReason: resolutionUnavailable ? 'container-resolution-threw' : undefined,
382
+ };
383
+ }),
384
+ containerQueriesTruncated: raw?.containerQueriesTruncated === true || containerQueriesCapped > 0,
385
+ // MARK #30: a positive in-page ruleWalkErrors count means one or more rules/subtrees threw
386
+ // while being walked and were dropped with no OTHER trace -- surface it, not silently absent.
387
+ ruleWalkErrors: typeof raw?.ruleWalkErrors === 'number' && raw.ruleWalkErrors > 0 ? raw.ruleWalkErrors : undefined,
388
+ coverage: { scope: 'top-document' },
389
+ };
390
+ }
391
+
392
+ // ============================================================================
393
+ // Collector
394
+ // ============================================================================
395
+
396
+ /**
397
+ * Whole-file sweep (Layer 2, I-5): `QUERIES_SCRIPT` always returns this exact top-level shape on a
398
+ * successful, non-throwing run -- unconditionally, from a single object literal at the end of its
399
+ * IIFE. `normalizeReport` previously read each of these fields off `raw` with `?? <default>`
400
+ * (`raw?.environment ?? {}`, `Array.isArray(raw?.mediaQueries) ? ... : []`), silently absorbing a
401
+ * malformed top-level value (an unlikely but possible corrupted/truncated CDP payload) into a
402
+ * benign-looking empty/default report indistinguishable from "genuinely queryless page" -- exactly
403
+ * the class flagged for sibling collectors' `walkValue?.records ?? []` / `meta.elementsTruncated ?? 0`
404
+ * reads. Validate the immediate top-level shape here so a malformed (but present) value is reported
405
+ * `available:false` rather than silently defaulted; `normalizeReport` keeps its per-field `??`
406
+ * defaults ONLY as the best-effort echo written under that explicit malformed marker.
407
+ */
408
+ function isWellFormedQueriesFacts(raw: Partial<QueriesReport>): boolean {
409
+ const env = raw.environment as Partial<QueryEnvironment> | undefined;
410
+ const envWellFormed =
411
+ typeof env === 'object' &&
412
+ env !== null &&
413
+ typeof env.width === 'number' &&
414
+ typeof env.height === 'number' &&
415
+ typeof env.dpr === 'number' &&
416
+ typeof env.colorScheme === 'string' &&
417
+ typeof env.pointer === 'string' &&
418
+ typeof env.hover === 'string' &&
419
+ typeof env.reducedMotion === 'string' &&
420
+ typeof env.forcedColors === 'string';
421
+ return (
422
+ envWellFormed &&
423
+ Array.isArray(raw.mediaQueries) &&
424
+ Array.isArray(raw.containerQueries) &&
425
+ typeof raw.mediaQueriesTruncated === 'boolean' &&
426
+ typeof raw.containerQueriesTruncated === 'boolean' &&
427
+ // review-flagged gap: QUERIES_SCRIPT always returns `ruleWalkErrors` as a number -- a malformed
428
+ // value here must not silently coerce to `undefined` ("zero errors") under available:true.
429
+ typeof raw.ruleWalkErrors === 'number'
430
+ );
431
+ }
432
+
433
+ export const collectQueries: Collector = async (ctx) => {
434
+ // I-5: a failed/no-value evaluate is currently coerced by `normalizeReport` into a default
435
+ // environment (0x0, dpr 1) plus empty arrays — indistinguishable from a genuinely queryless
436
+ // page unless the failure itself is surfaced as an explicit report-level fact.
437
+ let rawValue: Partial<QueriesReport> | undefined;
438
+ let available = true;
439
+ let unavailableReason: QueriesUnavailableReason | undefined;
440
+ try {
441
+ const response = (await ctx.client.send('Runtime.evaluate', {
442
+ expression: QUERIES_SCRIPT,
443
+ returnByValue: true,
444
+ })) as { result?: { value?: Partial<QueriesReport> } };
445
+ rawValue = response.result?.value;
446
+ if (rawValue === undefined) {
447
+ available = false;
448
+ unavailableReason = 'queries-evaluate-returned-no-value';
449
+ } else if (!isWellFormedQueriesFacts(rawValue)) {
450
+ available = false;
451
+ unavailableReason = 'queries-facts-malformed';
452
+ }
453
+ } catch {
454
+ available = false;
455
+ unavailableReason = 'queries-evaluate-threw';
456
+ }
457
+
458
+ const facts = normalizeReport(rawValue);
459
+ ctx.write.json('queries.json', {
460
+ ...facts,
461
+ available,
462
+ ...(unavailableReason ? { unavailableReason } : {}),
463
+ } satisfies QueriesReport);
464
+ };