@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,697 @@
1
+ /**
2
+ * `layers.json` collector — the compositor-layer map read during capture:
3
+ * layers, bounds, compositing reasons, per-node (owning backend node)
4
+ * membership, and — best-effort, "where available" — the WINNING
5
+ * declaration (not merely a matching one) behind a layer-triggering style
6
+ * property, with its declared value, specificity/importance, and
7
+ * authored/generated source location. The cascade/specificity/source
8
+ * resolution and the `CSS.styleSheetAdded` header-capture helper are
9
+ * shared with `styles.ts` via `./style-provenance.js` — one implementation
10
+ * of "which rule actually wins" for both collectors.
11
+ *
12
+ * Two independent facts describe availability and paint order, each with
13
+ * an explicit `available` flag rather than a silent empty array:
14
+ *
15
+ * - `layerTree` — CDP's `LayerTree` domain is event-driven, not
16
+ * pull-based: there is no `LayerTree.getLayers` command. `LayerTree.enable`
17
+ * is idempotent (already called once by `enableDomainsForSnap`), so this
18
+ * collector's own enable is a bare RE-enable. Real Chrome's compositor
19
+ * delivers `LayerTree.layerTreeDidChange` when a frame is actually
20
+ * PRODUCED, not merely when the domain is (re-)enabled: verified on live
21
+ * Chrome 150, a bare re-enable redelivers the tree only ~1/5 of the time
22
+ * (the first-ever enable's delivery is consumed by nobody, since
23
+ * `enableDomainsForSnap` enabled before any listener attached), while
24
+ * forcing a frame via `Page.captureScreenshot` delivers it 5/5. So after
25
+ * re-enabling, this collector provokes delivery by forcing frames — a
26
+ * read-only `Page.captureScreenshot` paint (no page mutation) spammed a
27
+ * few times across the listener window, stopped the instant the event
28
+ * settles. The one-shot listener is removed (`client.off`) on BOTH the
29
+ * event and the timeout path so no retained closure leaks for the
30
+ * connection's lifetime. When the event never arrives (timeout — e.g. a
31
+ * non-compositing headless sandbox) or the client can't deliver events,
32
+ * `layerTree` is `{ available: false, reason }` and `layers` is empty — an
33
+ * explicit unavailability fact, never an ambiguous empty array.
34
+ *
35
+ * - `paintOrder` — the authoritative paint order comes from
36
+ * `DOMSnapshot.captureSnapshot({includePaintOrder:true})` (a pull-based
37
+ * command, no listener), reported as `backendNodeId`s in paint order.
38
+ * `layerPaintOrder` retains the LayerTree layer-id delivery order as a
39
+ * secondary fact. When DOMSnapshot is unavailable, `paintOrder` is
40
+ * `{ available: false, reason }`.
41
+ *
42
+ * - `membership` — per-layer, per-node membership: which painted nodes
43
+ * (by `backendNodeId`) composite into which layer, e.g. "owns 184 painted
44
+ * nodes" / "`.toast-container` paints into layer 2". CDP exposes no
45
+ * direct node→layer command; this is derived from two real facts the
46
+ * collector already reads — each layer's owning `backendNodeId` (from
47
+ * `LayerTree.layerTreeDidChange`) and the DOM ancestor chain for every
48
+ * painted node (from the SAME `DOMSnapshot.captureSnapshot` call used for
49
+ * `paintOrder`, whose `nodes.parentIndex` this collector also reads). A
50
+ * painted node belongs to the nearest ancestor (or itself) that owns a
51
+ * layer; a node with no such ancestor falls back to the topmost layer
52
+ * that owns no node (the root/document layer), matching how a
53
+ * non-self-painting DOM subtree inherits its nearest compositing
54
+ * ancestor's layer in real Chrome. When the same DOMSnapshot data backing
55
+ * `paintOrder` is unavailable, or there are no layers to assign into,
56
+ * `membership` is `{ available: false, reason }` and every layer's
57
+ * member fields are empty — never a silently fabricated mapping.
58
+ *
59
+ * Three PER-LAYER reads are individually best-effort and each carries its
60
+ * own explicit `…Unavailable:true` marker (I-5) rather than silently
61
+ * coercing a failed read into the same shape as a genuine observation:
62
+ * `LayerTree.compositingReasons` failing (a throw, OR a malformed response where
63
+ * NEITHER `compositingReasons` NOR `compositingReasonIds` resolves to an actual
64
+ * array — absent, `null`, or any other non-array value)
65
+ * serializes `compositingReasons: []` with `compositingReasonsUnavailable: true`,
66
+ * DISTINCT from a genuinely reasonless layer's `compositingReasons: []` with no
67
+ * marker (a response that DOES carry one of the two fields as a real array, even
68
+ * an empty one); `DOM.describeNode` failing (a throw, OR a response with no `node` /
69
+ * no `node.nodeName` — CDP documents `nodeName` as a required field of
70
+ * `DOM.Node`, so its absence is always a malformed read, never a real node
71
+ * with no name) serializes `selector: null` with `selectorUnavailable: true`;
72
+ * and a `resolveStyleProvenance` failure (node-id push, `CSS.getMatchedStylesForNode`,
73
+ * a `CSS.getComputedStyleForNode` read that throws or returns no `computedStyle`
74
+ * array at all, or cascade resolution) serializes an absent `styleProvenance` with
75
+ * `styleProvenanceUnavailable: true`, DISTINCT from a genuinely absent
76
+ * `styleProvenance` when resolution succeeded but no author declaration won any
77
+ * tracked property. All three markers are set only when the layer has an owning
78
+ * `backendNodeId` (nothing to resolve otherwise) and are absent (not `false`) on
79
+ * success, mirroring `styles.ts`'s `provenanceUnavailable` convention.
80
+ *
81
+ * Two further PER-LAYER fields, `drawsContent` and `paintCount`, are CDP-
82
+ * documented REQUIRED fields of `LayerTree.Layer` — a `LayerTree.layerTreeDidChange`
83
+ * event whose layer entry omits either, OR carries either as the WRONG runtime type
84
+ * (checked via `typeof`, e.g. a malformed `null`), is a malformed delivery for that
85
+ * layer, not a genuine `false`/`0` observation. Each carries its own
86
+ * `drawsContentUnavailable:true` / `paintCountUnavailable:true` marker (I-5) when
87
+ * the field was absent or mistyped, absent (not `false`) when the field was
88
+ * genuinely present with the right type, independent of each other and of
89
+ * the three markers above.
90
+ */
91
+
92
+ import { capArray, sanitizeString } from '../redaction.js';
93
+ import type { ResolvedSourceLocation } from '../../source-map.js';
94
+ import type { CDPClient } from '../../client.js';
95
+ import type { Collector } from '../types.js';
96
+ import {
97
+ buildWinningDeclarations,
98
+ captureStyleSheetHeaders,
99
+ type CDPMatchedStylesResponse,
100
+ type WinningDeclaration,
101
+ } from './style-provenance.js';
102
+
103
+ const LAYER_EVENT_TIMEOUT_MS = 2000;
104
+ /** Cap on the number of compositor layers serialized; excess is reported as a factual `layersTruncated` count. */
105
+ const MAX_LAYERS = 500;
106
+ /** Cap on the number of paint-order backend node ids serialized; excess is reported as a factual `truncated` count on `paintOrder`. */
107
+ const MAX_PAINT_ORDER_NODES = 2000;
108
+ /** Cap on the number of member backend node ids serialized per layer; excess is reported as a factual `membersTruncated` count on that layer (the layer's `memberCount` itself stays uncapped). */
109
+ const MAX_LAYER_MEMBERS = 2000;
110
+
111
+ // Best-effort style provenance: CSS properties that can trigger compositing.
112
+ // For a layer's owning node, the FIRST of these (in this priority order)
113
+ // that has an author-declared winning rule is reported, via the shared
114
+ // winning-declaration engine (./style-provenance.js) — the same cascade/
115
+ // specificity/importance/source resolution `styles.ts` uses, not merely
116
+ // the last matching selector. Any failure is swallowed, since the
117
+ // acceptance bar for this field is "where available".
118
+ const LAYER_AFFECTING_PROPERTIES = [
119
+ 'transform',
120
+ 'will-change',
121
+ 'opacity',
122
+ 'filter',
123
+ 'position',
124
+ 'isolation',
125
+ 'contain',
126
+ 'backdrop-filter',
127
+ 'mix-blend-mode',
128
+ ];
129
+
130
+ interface RawLayer {
131
+ readonly layerId: string;
132
+ readonly parentLayerId?: string;
133
+ readonly backendNodeId?: number;
134
+ readonly offsetX: number;
135
+ readonly offsetY: number;
136
+ readonly width: number;
137
+ readonly height: number;
138
+ readonly paintCount?: number;
139
+ readonly drawsContent?: boolean;
140
+ }
141
+
142
+ interface CompositingReasonsResult {
143
+ readonly compositingReasons?: string[];
144
+ readonly compositingReasonIds?: string[];
145
+ }
146
+
147
+ interface DescribedNode {
148
+ readonly nodeName?: string;
149
+ readonly attributes?: string[];
150
+ }
151
+
152
+ interface RawLayersResult {
153
+ readonly layers: RawLayer[];
154
+ readonly available: boolean;
155
+ readonly reason?: string;
156
+ }
157
+
158
+ function selectorFromDescribedNode(node: DescribedNode | undefined): string | null {
159
+ if (!node?.nodeName) return null;
160
+ const tag = node.nodeName.toLowerCase();
161
+ const attrs = node.attributes ?? [];
162
+ let id = '';
163
+ let className = '';
164
+ for (let i = 0; i + 1 < attrs.length; i += 2) {
165
+ if (attrs[i] === 'id' && attrs[i + 1]) id = `#${attrs[i + 1]}`;
166
+ if (attrs[i] === 'class' && attrs[i + 1]) {
167
+ className = `.${attrs[i + 1].trim().split(/\s+/).filter(Boolean).join('.')}`;
168
+ }
169
+ }
170
+ return `${tag}${id}${className}`;
171
+ }
172
+
173
+ /** Removes a `LayerTree.layerTreeDidChange` handler from whatever removal API the client exposes (`off` on the real `CDPClient`, `off`/`removeListener` on an `EventEmitter` test double). A no-op when the client has no removal API — the settled guard still prevents a late event from doing anything. */
174
+ function removeLayerListener(client: CDPClient, handler: (params: unknown) => void): void {
175
+ const removable = client as unknown as {
176
+ off?: (event: string, handler: (params: unknown) => void) => void;
177
+ removeListener?: (event: string, handler: (params: unknown) => void) => void;
178
+ };
179
+ if (typeof removable.off === 'function') removable.off('LayerTree.layerTreeDidChange', handler);
180
+ else if (typeof removable.removeListener === 'function') removable.removeListener('LayerTree.layerTreeDidChange', handler);
181
+ }
182
+
183
+ /**
184
+ * Waits for the next `LayerTree.layerTreeDidChange` event (or a timeout),
185
+ * then re-enables the domain to (re-)trigger delivery. Returns an explicit
186
+ * availability result: the delivered layers with `available:true`, or an
187
+ * empty set with `available:false` + a `reason` when the client can't
188
+ * deliver events at all, none arrives within the timeout, or the event
189
+ * DOES arrive but with its `layers` field absent — CDP documents `layers`
190
+ * as an OPTIONAL param, absent when the renderer isn't in layer-tree/
191
+ * compositing mode; treating that absence as a genuine empty tree
192
+ * (`available:true, layers:[]`) would coerce "we have no layer-tree data
193
+ * right now" into the same shape as "we successfully queried and there are
194
+ * zero compositor layers" (I-5) — so it gets its own reason, distinct from
195
+ * both the timeout and a literal `layers:[]` the event actually delivered.
196
+ * The one-shot listener is removed on all three settle paths.
197
+ */
198
+ async function collectRawLayers(client: CDPClient): Promise<RawLayersResult> {
199
+ if (typeof client.on !== 'function') {
200
+ await client.send('LayerTree.enable');
201
+ return { layers: [], available: false, reason: 'client-lacks-event-support' };
202
+ }
203
+
204
+ let settled = false;
205
+ const eventPromise = new Promise<{ layers: RawLayer[] } | 'timeout' | 'missing-layers'>((resolve) => {
206
+ const handler = (params: unknown): void => {
207
+ if (settled) return;
208
+ settled = true;
209
+ clearTimeout(timer);
210
+ removeLayerListener(client, handler);
211
+ const raw = (params as { layers?: RawLayer[] } | undefined)?.layers;
212
+ resolve(Array.isArray(raw) ? { layers: raw } : 'missing-layers');
213
+ };
214
+ const timer = setTimeout(() => {
215
+ if (settled) return;
216
+ settled = true;
217
+ removeLayerListener(client, handler);
218
+ resolve('timeout');
219
+ }, LAYER_EVENT_TIMEOUT_MS);
220
+ client.on('LayerTree.layerTreeDidChange', handler);
221
+ });
222
+
223
+ await client.send('LayerTree.enable');
224
+ // `LayerTree.enable` is idempotent and `enableDomainsForSnap` already made
225
+ // the first-ever enable call before this collector runs — so this is a bare
226
+ // RE-enable, and real Chrome's compositor only (re)delivers
227
+ // `layerTreeDidChange` when a frame is actually PRODUCED, not merely when
228
+ // the domain is (re-)enabled. Verified on live Chrome 150: a bare re-enable
229
+ // delivers the tree ~1/5 of the time (the first enable's delivery is
230
+ // consumed by nobody, since `enableDomainsForSnap` enabled before any
231
+ // listener was attached), whereas forcing a frame via `Page.captureScreenshot`
232
+ // delivers it 5/5. So provoke delivery by forcing frames: a read-only paint
233
+ // (`Page.captureScreenshot` mutates no page state), spammed a few times
234
+ // across the listener window so at least one frame lands before the
235
+ // timeout, and stopped the instant the event settles. Best-effort — if the
236
+ // screenshots all fail (or the runtime genuinely never composites, e.g. a
237
+ // non-compositing headless sandbox), the timeout path still yields an
238
+ // honest `no-layertree-event-within-timeout` unavailability fact rather
239
+ // than fabricated layer data.
240
+ void (async () => {
241
+ for (let i = 0; i < 8 && !settled; i++) {
242
+ try {
243
+ await client.send('Page.captureScreenshot', { format: 'png' });
244
+ } catch {
245
+ // best-effort frame production — a failed screenshot must never abort
246
+ // the layer read; the event may still arrive, or the timeout path
247
+ // reports the honest unavailable fact.
248
+ }
249
+ if (settled) break;
250
+ await new Promise((r) => setTimeout(r, 200));
251
+ }
252
+ })();
253
+
254
+ const result = await eventPromise;
255
+ if (result === 'timeout') return { layers: [], available: false, reason: 'no-layertree-event-within-timeout' };
256
+ if (result === 'missing-layers') return { layers: [], available: false, reason: 'layertree-event-missing-layers' };
257
+ return { layers: result.layers, available: true };
258
+ }
259
+
260
+ // ============================================================================
261
+ // DOMSnapshot — the authoritative paint-order AND per-node layer-membership
262
+ // source, both derived from the one `DOMSnapshot.captureSnapshot` call.
263
+ // ============================================================================
264
+
265
+ interface DOMSnapshotResult {
266
+ readonly documents?: Array<{
267
+ readonly nodes?: { readonly backendNodeId?: number[]; readonly parentIndex?: number[] };
268
+ readonly layout?: { readonly nodeIndex?: number[]; readonly paintOrders?: number[] };
269
+ }>;
270
+ }
271
+
272
+ export interface PaintOrderFact {
273
+ readonly available: boolean;
274
+ readonly reason?: string;
275
+ /** `backendNodeId`s in ascending paint order (back-to-front), when available. */
276
+ readonly backendNodeIds: readonly number[];
277
+ /** Factual count of paint-order entries dropped by the {@link MAX_PAINT_ORDER_NODES} cap. */
278
+ readonly truncated: number;
279
+ }
280
+
281
+ interface DomTreeSnapshot {
282
+ readonly available: boolean;
283
+ readonly reason?: string;
284
+ /** Index into the arrays below → `backendNodeId` for every node in the captured document. */
285
+ readonly backendNodeIds: readonly number[];
286
+ /** Index into the arrays below → parent's index in the same arrays, or -1 for a node with no parent in this capture. */
287
+ readonly parentIndex: readonly number[];
288
+ /** For each painted (laid-out) node, its index into `backendNodeIds`/`parentIndex`, in capture order. */
289
+ readonly nodeIndexOrder: readonly number[];
290
+ /** Paint rank per entry in `nodeIndexOrder`, same length, same order. */
291
+ readonly paintOrders: readonly number[];
292
+ }
293
+
294
+ const UNAVAILABLE_DOM_TREE_SNAPSHOT = { backendNodeIds: [], parentIndex: [], nodeIndexOrder: [], paintOrders: [] } as const;
295
+
296
+ /**
297
+ * One `DOMSnapshot.captureSnapshot({includePaintOrder:true})` call backs both
298
+ * `paintOrder` (via {@link derivePaintOrderFact}) and per-node layer
299
+ * `membership` (via {@link computeLayerMembership}): each layout node carries
300
+ * a `paintOrders` rank and maps back (via `layout.nodeIndex`) to a
301
+ * `nodes.backendNodeId`, and `nodes.parentIndex` gives the DOM ancestor chain
302
+ * membership walks. Best-effort: any failure (domain unavailable, missing
303
+ * arrays) returns `available:false` with a `reason`, never a throw.
304
+ */
305
+ async function collectDomTreeSnapshot(client: CDPClient): Promise<DomTreeSnapshot> {
306
+ let snapshot: DOMSnapshotResult;
307
+ try {
308
+ snapshot = (await client.send('DOMSnapshot.captureSnapshot', {
309
+ computedStyles: [],
310
+ includePaintOrder: true,
311
+ })) as DOMSnapshotResult;
312
+ } catch {
313
+ return { available: false, reason: 'domsnapshot-unavailable', ...UNAVAILABLE_DOM_TREE_SNAPSHOT };
314
+ }
315
+
316
+ const doc = snapshot.documents?.[0];
317
+ const nodeIndexOrder = doc?.layout?.nodeIndex;
318
+ const paintOrders = doc?.layout?.paintOrders;
319
+ const backendNodeIds = doc?.nodes?.backendNodeId;
320
+ const parentIndex = doc?.nodes?.parentIndex;
321
+ if (!nodeIndexOrder || !paintOrders || !backendNodeIds || !parentIndex) {
322
+ return { available: false, reason: 'domsnapshot-missing-paint-order', ...UNAVAILABLE_DOM_TREE_SNAPSHOT };
323
+ }
324
+ return { available: true, backendNodeIds, parentIndex, nodeIndexOrder, paintOrders };
325
+ }
326
+
327
+ /** Sorts the DOM-tree snapshot's painted nodes by ascending paint rank into the authoritative composited paint order. */
328
+ function derivePaintOrderFact(tree: DomTreeSnapshot): PaintOrderFact {
329
+ if (!tree.available) return { available: false, reason: tree.reason, backendNodeIds: [], truncated: 0 };
330
+
331
+ const pairs: Array<{ paint: number; id: number }> = [];
332
+ for (let i = 0; i < tree.nodeIndexOrder.length && i < tree.paintOrders.length; i++) {
333
+ const id = tree.backendNodeIds[tree.nodeIndexOrder[i]];
334
+ const paint = tree.paintOrders[i];
335
+ if (typeof id === 'number' && typeof paint === 'number') pairs.push({ paint, id });
336
+ }
337
+ pairs.sort((a, b) => a.paint - b.paint);
338
+
339
+ const { items, truncated } = capArray(
340
+ pairs.map((p) => p.id),
341
+ MAX_PAINT_ORDER_NODES,
342
+ );
343
+ return { available: true, backendNodeIds: items, truncated };
344
+ }
345
+
346
+ export interface LayerMembershipFact {
347
+ readonly available: boolean;
348
+ readonly reason?: string;
349
+ /** Painted nodes that could not be matched to any layer via DOM ancestry (no layer-owning ancestor and no root/document layer to fall back to) — a factual gap, never a silent drop. */
350
+ readonly unassignedCount: number;
351
+ }
352
+
353
+ interface LayerMembershipResolution {
354
+ readonly fact: LayerMembershipFact;
355
+ /** `layerId` → the full (uncapped) set of member `backendNodeId`s, in first-seen paint order. Empty when `fact.available` is false. */
356
+ readonly membersByLayerId: ReadonlyMap<string, readonly number[]>;
357
+ }
358
+
359
+ /**
360
+ * Derives per-layer node membership from two real facts already read by
361
+ * `collectLayers`: each layer's owning `backendNodeId` (from
362
+ * `LayerTree.layerTreeDidChange`) and the DOM ancestor chain for every
363
+ * painted node (from `tree.parentIndex`, the same DOMSnapshot call backing
364
+ * `paintOrder`). A painted node's layer is the nearest ancestor-or-self that
365
+ * owns a layer; failing that, the topmost layer that owns no node (the
366
+ * root/document layer) — the same containment a non-self-painting DOM
367
+ * subtree has in real Chrome. A node with neither is `unassignedCount`, not a
368
+ * fabricated assignment. Requires both an available DOM-tree snapshot and at
369
+ * least one layer to assign into; otherwise `available:false` with a reason.
370
+ */
371
+ function computeLayerMembership(tree: DomTreeSnapshot, rawLayers: readonly RawLayer[]): LayerMembershipResolution {
372
+ if (!tree.available) return { fact: { available: false, reason: tree.reason, unassignedCount: 0 }, membersByLayerId: new Map() };
373
+ if (rawLayers.length === 0) return { fact: { available: false, reason: 'no-layers-to-assign-membership', unassignedCount: 0 }, membersByLayerId: new Map() };
374
+
375
+ const indexByBackendNodeId = new Map<number, number>();
376
+ tree.backendNodeIds.forEach((id, idx) => {
377
+ if (typeof id === 'number') indexByBackendNodeId.set(id, idx);
378
+ });
379
+
380
+ const layerIdByOwnerIndex = new Map<number, string>();
381
+ let fallbackLayerId: string | null = null;
382
+ for (const layer of rawLayers) {
383
+ if (layer.backendNodeId !== undefined) {
384
+ const ownerIndex = indexByBackendNodeId.get(layer.backendNodeId);
385
+ if (ownerIndex !== undefined) layerIdByOwnerIndex.set(ownerIndex, layer.layerId);
386
+ } else if (fallbackLayerId === null) {
387
+ // The first layer with no owning node is the root/document layer — the
388
+ // catch-all for any painted node with no layer-owning ancestor.
389
+ fallbackLayerId = layer.layerId;
390
+ }
391
+ }
392
+
393
+ const resolvedByIndex = new Map<number, string | null>();
394
+ function resolveLayerForIndex(nodeIndex: number, guard = 0): string | null {
395
+ if (resolvedByIndex.has(nodeIndex)) return resolvedByIndex.get(nodeIndex) ?? null;
396
+ const direct = layerIdByOwnerIndex.get(nodeIndex);
397
+ if (direct !== undefined) {
398
+ resolvedByIndex.set(nodeIndex, direct);
399
+ return direct;
400
+ }
401
+ const parent = tree.parentIndex[nodeIndex];
402
+ // `guard` bounds a malformed/cyclic parentIndex to the node count so a bad capture can't infinite-loop.
403
+ if (parent === undefined || parent < 0 || parent === nodeIndex || guard >= tree.backendNodeIds.length) {
404
+ resolvedByIndex.set(nodeIndex, fallbackLayerId);
405
+ return fallbackLayerId;
406
+ }
407
+ const resolved = resolveLayerForIndex(parent, guard + 1);
408
+ resolvedByIndex.set(nodeIndex, resolved);
409
+ return resolved;
410
+ }
411
+
412
+ const membersByLayerId = new Map<string, number[]>();
413
+ const seenPerLayer = new Map<string, Set<number>>();
414
+ let unassignedCount = 0;
415
+ for (const nodeIndex of tree.nodeIndexOrder) {
416
+ const backendNodeId = tree.backendNodeIds[nodeIndex];
417
+ if (typeof backendNodeId !== 'number') continue;
418
+ const layerId = resolveLayerForIndex(nodeIndex);
419
+ if (layerId === null) {
420
+ unassignedCount++;
421
+ continue;
422
+ }
423
+ let seen = seenPerLayer.get(layerId);
424
+ if (!seen) {
425
+ seen = new Set();
426
+ seenPerLayer.set(layerId, seen);
427
+ }
428
+ if (seen.has(backendNodeId)) continue; // a node can have multiple layout/paint entries (e.g. fragmented text)
429
+ seen.add(backendNodeId);
430
+ const members = membersByLayerId.get(layerId) ?? [];
431
+ members.push(backendNodeId);
432
+ membersByLayerId.set(layerId, members);
433
+ }
434
+
435
+ return { fact: { available: true, unassignedCount }, membersByLayerId };
436
+ }
437
+
438
+ interface CDPComputedStyleResponse {
439
+ readonly computedStyle?: Array<{ name: string; value: string }>;
440
+ }
441
+
442
+ /**
443
+ * Fetches computed values (via `CSS.getComputedStyleForNode`, not a page-side
444
+ * `Runtime.evaluate` — layers.ts issues no in-page script) for exactly
445
+ * `properties`, defaulting each to `null`. Returns an explicit `available`
446
+ * fact (I-5) alongside the map: `available:false` when the call throws OR
447
+ * the response carries no `computedStyle` array at all — a genuine read
448
+ * failure, DISTINCT from a successful response that simply has no entry for
449
+ * a given tracked property (which stays `null` with `available:true`, a
450
+ * genuine "this property has no computed value here" observation). Callers
451
+ * must not treat `available:false` as "resolved, every property computed to
452
+ * nothing" — propagate it instead.
453
+ */
454
+ async function fetchComputedStyle(
455
+ client: CDPClient,
456
+ nodeId: number,
457
+ properties: readonly string[],
458
+ ): Promise<{ computed: Record<string, string | null>; available: boolean }> {
459
+ const computed: Record<string, string | null> = {};
460
+ for (const property of properties) computed[property] = null;
461
+ try {
462
+ const response = (await client.send('CSS.getComputedStyleForNode', { nodeId })) as CDPComputedStyleResponse;
463
+ if (!Array.isArray(response.computedStyle)) return { computed, available: false };
464
+ for (const entry of response.computedStyle) {
465
+ if (Object.prototype.hasOwnProperty.call(computed, entry.name)) computed[entry.name] = entry.value;
466
+ }
467
+ return { computed, available: true };
468
+ } catch {
469
+ return { computed, available: false };
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Resolves the winning declaration for `backendNodeId`'s owning node across
475
+ * {@link LAYER_AFFECTING_PROPERTIES}, via the shared cascade/specificity/
476
+ * source-resolution engine (`./style-provenance.js`) — the same one
477
+ * `styles.ts` uses — and reports the FIRST property (in priority order)
478
+ * that has an actual author-declared winner. A property with no author
479
+ * declaration is not a layer "cause", so it is skipped in favor of the
480
+ * next candidate rather than reported as a false no-declaration cause.
481
+ *
482
+ * `unavailable:true` (I-5) is DISTINCT from `declaration` simply being
483
+ * `undefined`: `declaration:undefined, unavailable:false` is a genuine
484
+ * "no layer-affecting author declaration found" observation (the node's
485
+ * identity was resolved and its matched styles were successfully read —
486
+ * there just wasn't a winning candidate for any tracked property);
487
+ * `unavailable:true` means the node-id push, `CSS.getMatchedStylesForNode`,
488
+ * the computed-style read (`CSS.getComputedStyleForNode` throwing or
489
+ * returning no `computedStyle` array — see {@link fetchComputedStyle}), or
490
+ * the cascade resolution itself failed, so no observation was made at all.
491
+ * A failed computed-style read specifically must not read as "resolved, no
492
+ * author declaration": the DECLARED winner from `CSS.getMatchedStylesForNode`
493
+ * could still resolve on its own, but its rounded computed `value` would
494
+ * then be silently wrong (always `null`) rather than honestly unavailable, so
495
+ * the whole per-layer observation is marked unavailable instead of reporting
496
+ * a half-true result. Mirrors `styles.ts`'s `provenanceUnavailable` marker
497
+ * for the analogous per-element path.
498
+ */
499
+ async function resolveStyleProvenance(
500
+ client: CDPClient,
501
+ backendNodeId: number,
502
+ styleSheetUrls: Map<string, string>,
503
+ sourceCache: Map<string, Promise<ResolvedSourceLocation>>,
504
+ ): Promise<{ declaration?: WinningDeclaration; unavailable: boolean }> {
505
+ try {
506
+ const pushed = (await client.send('DOM.pushNodesByBackendIdsToFrontend', { backendNodeIds: [backendNodeId] })) as { nodeIds?: number[] };
507
+ const nodeId = pushed.nodeIds?.[0];
508
+ if (!nodeId) return { unavailable: true };
509
+
510
+ const [matched, computedResult] = await Promise.all([
511
+ client.send('CSS.getMatchedStylesForNode', { nodeId }) as Promise<CDPMatchedStylesResponse>,
512
+ fetchComputedStyle(client, nodeId, LAYER_AFFECTING_PROPERTIES),
513
+ ]);
514
+ if (!computedResult.available) return { unavailable: true };
515
+
516
+ const declarations = await buildWinningDeclarations(
517
+ client,
518
+ matched,
519
+ computedResult.computed,
520
+ sourceCache,
521
+ styleSheetUrls,
522
+ LAYER_AFFECTING_PROPERTIES,
523
+ );
524
+ return { declaration: declarations.find((d) => d.selector !== null), unavailable: false };
525
+ } catch {
526
+ return { unavailable: true };
527
+ }
528
+ }
529
+
530
+ export interface LayerRecord {
531
+ readonly id: string;
532
+ readonly backendNodeId: number | null;
533
+ readonly selector: string | null;
534
+ readonly bounds: { x: number; y: number; width: number; height: number };
535
+ readonly layerPaintOrder: number;
536
+ readonly parentLayerId: string | null;
537
+ readonly drawsContent: boolean;
538
+ /** `true` (I-5) when this `LayerTree.Layer` delivery omitted the (CDP-required) `drawsContent` field — `drawsContent` is then `false` because the field could not be read, not because the layer genuinely doesn't draw content. Absent (not `false`) when the field was present. */
539
+ readonly drawsContentUnavailable?: true;
540
+ readonly paintCount: number;
541
+ /** `true` (I-5) when this `LayerTree.Layer` delivery omitted the (CDP-required) `paintCount` field — `paintCount` is then `0` because the field could not be read, not because the layer genuinely has zero paints. Absent (not `false`) when the field was present. */
542
+ readonly paintCountUnavailable?: true;
543
+ readonly compositingReasons: string[];
544
+ /** `true` (I-5) when `LayerTree.compositingReasons` failed for this layer — `compositingReasons` is then `[]` because the read could not be made, not because the layer genuinely has no compositing reasons. Absent (not `false`) when the read succeeded, matching this file's other honesty markers. */
545
+ readonly compositingReasonsUnavailable?: true;
546
+ readonly styleProvenance?: WinningDeclaration;
547
+ /** `true` (I-5) when this layer has an owning `backendNodeId` but style-provenance resolution failed (`DOM.pushNodesByBackendIdsToFrontend`, `CSS.getMatchedStylesForNode`, or cascade resolution itself failed) — DISTINCT from `styleProvenance` simply being absent because no author declaration won any tracked property while resolution succeeded. Mirrors `styles.ts`'s `provenanceUnavailable`. Absent (not `false`) when resolution was attempted and succeeded (with or without a winning declaration), and never set when the layer has no owning node at all (nothing to resolve). */
548
+ readonly styleProvenanceUnavailable?: true;
549
+ /** `true` (I-5) when this layer has an owning `backendNodeId` but `DOM.describeNode` failed — `selector` is then `null` because the read could not be made, not because the described node genuinely carried no name. Absent (not `false`) when the read succeeded, and never set when the layer has no owning node at all. */
550
+ readonly selectorUnavailable?: true;
551
+ /** Full (uncapped) count of painted nodes composited into this layer — "owns N painted nodes". `0` and unpopulated `memberBackendNodeIds` when the top-level `membership` fact is `available:false`. */
552
+ readonly memberCount: number;
553
+ /** Member `backendNodeId`s, capped at {@link MAX_LAYER_MEMBERS}; the join key back to `geometry.json`/other element-bearing artifacts. */
554
+ readonly memberBackendNodeIds: readonly number[];
555
+ /** Factual count of member ids dropped by the {@link MAX_LAYER_MEMBERS} cap — `memberCount` itself is never capped. */
556
+ readonly membersTruncated: number;
557
+ }
558
+
559
+ export interface LayersReport {
560
+ /** Explicit LayerTree availability fact — `available:false` + `reason` when the layer tree could not be read, never a silently empty `layers`. */
561
+ readonly layerTree: { readonly available: boolean; readonly reason?: string };
562
+ readonly layers: readonly LayerRecord[];
563
+ /** Factual count of layers dropped by the {@link MAX_LAYERS} cap. */
564
+ readonly layersTruncated: number;
565
+ /** Authoritative paint order from `DOMSnapshot` (`backendNodeId`s, back-to-front), with its own availability fact. */
566
+ readonly paintOrder: PaintOrderFact;
567
+ /** Secondary paint-order proxy: LayerTree layer-id delivery order. */
568
+ readonly layerPaintOrder: readonly string[];
569
+ /** Per-node layer-membership availability fact — `available:false` + `reason` (never a silently empty per-layer member list) when it could not be derived; see each `LayerRecord`'s `memberBackendNodeIds`/`memberCount` for the per-layer facts. */
570
+ readonly membership: LayerMembershipFact;
571
+ /** Availability of the `CSS.styleSheetAdded` header capture `styleProvenance` resolution depends on (I-5) — `available:false` means any missing `sourceStyleSheetUrl`/`generated` source on a `styleProvenance` below is a capture failure, not "genuinely no source". */
572
+ readonly styleSheetHeaders: { readonly available: boolean; readonly reason?: string };
573
+ }
574
+
575
+ export const collectLayers: Collector = async (ctx) => {
576
+ const { client } = ctx;
577
+ // Registers the CSS.styleSheetAdded listener and forces header redelivery
578
+ // BEFORE anything else touches the CSS domain (see style-provenance.ts's
579
+ // module doc) — run alongside the (CSS-domain-independent) raw layer tree
580
+ // and paint order reads rather than serialized after them.
581
+ const [
582
+ { urls: styleSheetUrls, stop: stopTrackingStyleSheets, available: styleSheetHeadersAvailable, reason: styleSheetHeadersReason },
583
+ rawResult,
584
+ domTree,
585
+ ] = await Promise.all([captureStyleSheetHeaders(client), collectRawLayers(client), collectDomTreeSnapshot(client)]);
586
+ const sourceCache = new Map<string, Promise<ResolvedSourceLocation>>();
587
+ const paintOrder = derivePaintOrderFact(domTree);
588
+ const { fact: membership, membersByLayerId } = rawResult.available
589
+ ? computeLayerMembership(domTree, rawResult.layers)
590
+ : { fact: { available: false, reason: `layertree-unavailable: ${rawResult.reason}`, unassignedCount: 0 }, membersByLayerId: new Map<string, readonly number[]>() };
591
+
592
+ try {
593
+ const { items: rawLayers, truncated: layersTruncated } = capArray(rawResult.layers, MAX_LAYERS);
594
+
595
+ const records: LayerRecord[] = await Promise.all(
596
+ rawLayers.map(async (layer, index) => {
597
+ // I-5: an explicit try/catch (not `.catch(() => ({}))`) so a failed read can be
598
+ // distinguished from a genuinely reasonless layer — both would otherwise serialize as
599
+ // the identical `compositingReasons: []` shape.
600
+ let compositingReasons: string[] = [];
601
+ let compositingReasonsUnavailable: true | undefined;
602
+ try {
603
+ const reasons = (await client.send('LayerTree.compositingReasons', { layerId: layer.layerId })) as CompositingReasonsResult;
604
+ // A genuine field is an actual array (even empty). A field present but the WRONG type
605
+ // (e.g. `null`, a string) is just as malformed as the field being absent entirely —
606
+ // `Array.isArray`, not a `=== undefined` check, is what distinguishes "CDP sent us real
607
+ // data" from "CDP sent us a field name with nothing real in it".
608
+ const reasonsArray = Array.isArray(reasons.compositingReasons) ? reasons.compositingReasons : undefined;
609
+ const reasonIdsArray = Array.isArray(reasons.compositingReasonIds) ? reasons.compositingReasonIds : undefined;
610
+ if (reasonsArray === undefined && reasonIdsArray === undefined) {
611
+ // Neither field resolved to a real array — CDP always returns at least one of the two as
612
+ // an actual array (possibly empty) on a genuinely reasonless layer, so this is a read
613
+ // failure, not a real observation, and must not silently coerce into the same `[]` shape.
614
+ compositingReasonsUnavailable = true;
615
+ } else {
616
+ compositingReasons = reasonsArray?.length ? reasonsArray : (reasonIdsArray ?? []);
617
+ }
618
+ } catch {
619
+ compositingReasonsUnavailable = true;
620
+ }
621
+
622
+ const members = membership.available ? (membersByLayerId.get(layer.layerId) ?? []) : [];
623
+ const { items: memberBackendNodeIds, truncated: membersTruncated } = capArray(members, MAX_LAYER_MEMBERS);
624
+
625
+ let selector: string | null = null;
626
+ let selectorUnavailable: true | undefined;
627
+ let styleProvenance: LayerRecord['styleProvenance'];
628
+ let styleProvenanceUnavailable: true | undefined;
629
+ if (layer.backendNodeId !== undefined) {
630
+ try {
631
+ const described = (await client.send('DOM.describeNode', { backendNodeId: layer.backendNodeId })) as { node?: DescribedNode };
632
+ const raw = selectorFromDescribedNode(described.node);
633
+ if (raw != null) {
634
+ selector = sanitizeString(raw);
635
+ } else {
636
+ // `selectorFromDescribedNode` returns null ONLY when `node`/`node.nodeName` is absent —
637
+ // CDP documents `nodeName` as a required field of `DOM.Node`, so a real described node
638
+ // never lacks one. A null result here is always a malformed response, not a genuinely
639
+ // nameless node, and must not silently coerce into the same `selector:null` shape a
640
+ // failed read produces.
641
+ selector = null;
642
+ selectorUnavailable = true;
643
+ }
644
+ } catch {
645
+ selector = null;
646
+ selectorUnavailable = true;
647
+ }
648
+ const styleResult = await resolveStyleProvenance(client, layer.backendNodeId, styleSheetUrls, sourceCache);
649
+ styleProvenance = styleResult.declaration;
650
+ if (styleResult.unavailable) styleProvenanceUnavailable = true;
651
+ }
652
+
653
+ // I-5 / borderline guidance: `drawsContent`/`paintCount` are CDP-required `LayerTree.Layer`
654
+ // fields — a runtime TYPE check (not `=== undefined`), because a malformed delivery can carry
655
+ // the field as the wrong type (e.g. `null`) just as easily as omit it outright, and either
656
+ // shape is equally a read failure, not a genuine `false`/`0` observation.
657
+ const drawsContentPresent = typeof layer.drawsContent === 'boolean';
658
+ const paintCountPresent = typeof layer.paintCount === 'number';
659
+
660
+ return {
661
+ id: layer.layerId,
662
+ backendNodeId: layer.backendNodeId ?? null,
663
+ selector,
664
+ bounds: { x: layer.offsetX, y: layer.offsetY, width: layer.width, height: layer.height },
665
+ layerPaintOrder: index,
666
+ parentLayerId: layer.parentLayerId ?? null,
667
+ drawsContent: drawsContentPresent ? layer.drawsContent! : false,
668
+ paintCount: paintCountPresent ? layer.paintCount! : 0,
669
+ compositingReasons,
670
+ memberCount: members.length,
671
+ memberBackendNodeIds,
672
+ membersTruncated,
673
+ ...(compositingReasonsUnavailable ? { compositingReasonsUnavailable } : {}),
674
+ ...(styleProvenance ? { styleProvenance } : {}),
675
+ ...(styleProvenanceUnavailable ? { styleProvenanceUnavailable } : {}),
676
+ ...(selectorUnavailable ? { selectorUnavailable } : {}),
677
+ ...(drawsContentPresent ? {} : { drawsContentUnavailable: true as const }),
678
+ ...(paintCountPresent ? {} : { paintCountUnavailable: true as const }),
679
+ };
680
+ }),
681
+ );
682
+
683
+ const report: LayersReport = {
684
+ layerTree: rawResult.available ? { available: true } : { available: false, reason: rawResult.reason },
685
+ layers: records,
686
+ layersTruncated,
687
+ paintOrder,
688
+ layerPaintOrder: records.map((r) => r.id),
689
+ membership,
690
+ styleSheetHeaders: styleSheetHeadersAvailable ? { available: true } : { available: false, reason: styleSheetHeadersReason },
691
+ };
692
+
693
+ ctx.write.json('layers.json', report);
694
+ } finally {
695
+ stopTrackingStyleSheets();
696
+ }
697
+ };