@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.
- package/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- 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
|
+
};
|