@crouton-kit/capture 1.3.6 → 1.3.8

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 (43) hide show
  1. package/bin/capture +3269 -1281
  2. package/package.json +4 -1
  3. package/src/capture.ts +30 -0
  4. package/src/cdp/args.ts +113 -10
  5. package/src/cdp/bridge/client.ts +49 -0
  6. package/src/cdp/bridge/protocol.ts +137 -0
  7. package/src/cdp/bridge/server.ts +241 -0
  8. package/src/cdp/bridge/spawn.ts +127 -0
  9. package/src/cdp/client.ts +7 -1
  10. package/src/cdp/commands/bridge-serve.ts +43 -0
  11. package/src/cdp/commands/cdp.ts +203 -0
  12. package/src/cdp/commands/measure/census.ts +31 -0
  13. package/src/cdp/commands/measure/check.ts +35 -0
  14. package/src/cdp/commands/measure/diff.ts +32 -0
  15. package/src/cdp/commands/measure/explain.ts +31 -0
  16. package/src/cdp/commands/measure/index.ts +114 -0
  17. package/src/cdp/commands/measure/map-focus.ts +24 -0
  18. package/src/cdp/commands/measure/map-layers.ts +25 -0
  19. package/src/cdp/commands/measure/map-scroll.ts +24 -0
  20. package/src/cdp/commands/measure/snap.ts +36 -0
  21. package/src/cdp/commands/measure/sweep.ts +30 -0
  22. package/src/cdp/commands/motion/index.ts +60 -0
  23. package/src/cdp/commands/motion/jank.ts +22 -0
  24. package/src/cdp/commands/motion/mask.ts +23 -0
  25. package/src/cdp/commands/motion/rec.ts +33 -0
  26. package/src/cdp/commands/motion/response.ts +26 -0
  27. package/src/cdp/commands/motion/timeline.ts +27 -0
  28. package/src/cdp/coordinates.ts +158 -0
  29. package/src/cdp/dispatch.ts +8 -0
  30. package/src/cdp/domains.ts +75 -0
  31. package/src/cdp/recorder-bridge.ts +521 -0
  32. package/src/cdp/source-map.ts +309 -0
  33. package/src/cdp/targets.ts +8 -1
  34. package/src/cdp/timing.ts +82 -0
  35. package/src/cdp/types.ts +71 -0
  36. package/src/cdp/usage.ts +9 -0
  37. package/src/output/artifact.ts +520 -0
  38. package/src/output/diff.ts +111 -0
  39. package/src/output/render.ts +593 -0
  40. package/src/output/selector.ts +185 -0
  41. package/src/session/artifacts.ts +229 -0
  42. package/src/session/commands.ts +183 -20
  43. package/src/session-context.ts +50 -8
@@ -0,0 +1,521 @@
1
+ /**
2
+ * The recorder bridge: a specialized instance of capture's held bridge
3
+ * (`./bridge/server.ts`) that owns ONE tab-level CDP connection for the
4
+ * lifetime of a `capture motion rec` recording instead of a browser-level
5
+ * one. Same NDJSON-over-unix-socket wire style, same detached-process
6
+ * shape as `session start --hold` — this file adds no new IPC or process
7
+ * model, only the recorder-specific request handling and CDP driving.
8
+ *
9
+ * Lifecycle (see `./bridge/protocol.ts` for the wire messages):
10
+ * - `rec-start` — enables the motion-rec CDP domains, starts
11
+ * `Page.startScreencast` + `Tracing`, injects the Mutation/Resize/
12
+ * PerformanceObserver script, captures the clock baseline, and returns
13
+ * it for the caller's `markers.json` (this module does not write that
14
+ * file — see U14's lifecycle routing).
15
+ * - `cdp` — a CDP request routed through the held tab connection, used by
16
+ * intervening session commands during a composed recording. An optional
17
+ * `mark` brackets the dispatch with two performance.now() reads and
18
+ * appends a labeled input-landmark record to `events.jsonl`.
19
+ * - `rec-stop` — stops screencast + tracing, flushes/tears down the
20
+ * injected observers, and returns frame/event counts + duration for the
21
+ * caller's `meta.json` (this module does not write that file either).
22
+ *
23
+ * Frames land as PNGs under `{recDir}/frames/`; per-frame element rects
24
+ * append to `{recDir}/rects.jsonl`; trace batches, observer entries, input
25
+ * landmarks, and best-effort errors append to `{recDir}/events.jsonl` — all
26
+ * writes go through the secure artifact helpers (`../session/artifacts.js`,
27
+ * U03), never ad-hoc `fs.writeFile`.
28
+ *
29
+ * Known limitation: the injected observer script and `Runtime.addBinding`
30
+ * are only armed once, at `rec-start`. A `navigate` mid-recording wipes the
31
+ * page's JS world (and therefore the observers) same as any full
32
+ * navigation would; re-arming after `Page.frameNavigated` is not built
33
+ * here. `Page.startScreencast`/`Tracing` continue across navigation (they
34
+ * are CDP-session-scoped, not page-scoped), so frames/trace events are
35
+ * unaffected — only the Mutation/Resize/PerformanceObserver stream would
36
+ * gap across a navigate. Flagged for whichever unit drives `motion rec`
37
+ * across a `navigate` command.
38
+ */
39
+
40
+ import * as net from 'net';
41
+ import * as path from 'path';
42
+ import { CDPClient } from './client.js';
43
+ import { findTabByIdAcrossEndpoints } from './targets.js';
44
+ import { enableDomainsForMotionRec } from './domains.js';
45
+ import { readTraceClockBaseline, withDocumentPerformanceNow } from './timing.js';
46
+ import {
47
+ EventBroker,
48
+ listenNdjsonSocket,
49
+ closeNdjsonSocket,
50
+ installProcessCleanup,
51
+ } from './bridge/server.js';
52
+ import {
53
+ type RecorderRequest,
54
+ type RecorderResponse,
55
+ type RecorderClockBaselines,
56
+ type RecCdpRequest,
57
+ } from './bridge/protocol.js';
58
+ import { ensurePrivateDir, appendNdjsonPrivate, writeBinaryPrivate } from '../session/artifacts.js';
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Artifact record shapes
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export interface SampledRect {
65
+ tag: string;
66
+ id: string | null;
67
+ classes: string | null;
68
+ x: number;
69
+ y: number;
70
+ width: number;
71
+ height: number;
72
+ }
73
+
74
+ /** One line of `rects.jsonl` — element quads sampled at one screencast frame. */
75
+ export interface FrameRectsRecord {
76
+ frame: number;
77
+ file: string;
78
+ /** `Page.screencastFrame`'s own `metadata.timestamp` (wall-clock seconds), raw — not baseline-converted. */
79
+ screencastTimestamp: number | null;
80
+ recordedAtWallClockMs: number;
81
+ elements: SampledRect[];
82
+ }
83
+
84
+ /**
85
+ * One line of `events.jsonl`. `kind` is `'input'` for a marked CDP
86
+ * dispatch, `'trace'` for a `Tracing.dataCollected` batch, `'error'` for a
87
+ * best-effort recorder failure, or one of the injected observer's own kinds
88
+ * (`'mutation'` / `'resize'` / `'performance'`) — those carry whatever
89
+ * fields the in-page emitter sent, hence the index signature.
90
+ */
91
+ export interface RecorderEventRecord {
92
+ kind: string;
93
+ recordedAtWallClockMs: number;
94
+ [key: string]: unknown;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // RecorderSession — the CDP driving + artifact-writing core, injectable for
99
+ // tests (accepts anything shaped like the public surface of `CDPClient`).
100
+ // ---------------------------------------------------------------------------
101
+
102
+ export type RecorderCdpClient = Pick<CDPClient, 'send' | 'on' | 'onDisconnect' | 'close'>;
103
+
104
+ export type RecorderState = 'idle' | 'recording' | 'stopped';
105
+
106
+ export interface RecorderSessionOptions {
107
+ client: RecorderCdpClient;
108
+ /** Absolute path to `motion/recs/{recId}` — must resolve under `CAPTURE_ROOT`. */
109
+ recDir: string;
110
+ }
111
+
112
+ export interface RecorderStopSummary {
113
+ frameCount: number;
114
+ eventCount: number;
115
+ durationMs: number;
116
+ }
117
+
118
+ const RECORDER_BINDING_NAME = 'captureRecorderEmit';
119
+
120
+ const TRACE_CATEGORIES =
121
+ 'devtools.timeline,disabled-by-default-devtools.timeline,disabled-by-default-devtools.timeline.frame,loading,blink.user_timing';
122
+
123
+ interface ScreencastFrameParams {
124
+ data: string;
125
+ metadata?: { timestamp?: number; [key: string]: unknown };
126
+ sessionId?: number;
127
+ }
128
+
129
+ /**
130
+ * `domains.ts`/`timing.ts` declare their parameter as the concrete
131
+ * `CDPClient` class (private-field members, so TS won't structurally
132
+ * accept a plain stub there); both only ever call `.send()` on it (see
133
+ * their own doc comments), so this cast is the deliberate, documented seam
134
+ * that keeps `RecorderSession` testable with a stub client while still
135
+ * reusing those shared helpers unchanged.
136
+ */
137
+ function asCDPClient(client: RecorderCdpClient): CDPClient {
138
+ return client as unknown as CDPClient;
139
+ }
140
+
141
+ export class RecorderSession {
142
+ readonly recDir: string;
143
+ readonly framesDir: string;
144
+ readonly eventsPath: string;
145
+ readonly rectsPath: string;
146
+ state: RecorderState = 'idle';
147
+
148
+ private client: RecorderCdpClient;
149
+ private events: EventBroker;
150
+ private frameCount = 0;
151
+ private eventCount = 0;
152
+ private startedAtWallClockMs = 0;
153
+
154
+ constructor(opts: RecorderSessionOptions) {
155
+ this.client = opts.client;
156
+ this.recDir = ensurePrivateDir(opts.recDir);
157
+ this.framesDir = ensurePrivateDir(path.join(this.recDir, 'frames'));
158
+ this.eventsPath = path.join(this.recDir, 'events.jsonl');
159
+ this.rectsPath = path.join(this.recDir, 'rects.jsonl');
160
+ this.events = new EventBroker(this.client);
161
+ }
162
+
163
+ async start(): Promise<RecorderClockBaselines> {
164
+ if (this.state !== 'idle') {
165
+ throw new Error(`recorder cannot start from state "${this.state}"`);
166
+ }
167
+
168
+ await enableDomainsForMotionRec(asCDPClient(this.client));
169
+
170
+ this.client.on('Page.screencastFrame', (params) => {
171
+ void this.handleScreencastFrame(params as ScreencastFrameParams);
172
+ });
173
+ this.client.on('Tracing.dataCollected', (params) => {
174
+ this.handleTraceData(params as { value: unknown[] });
175
+ });
176
+ this.client.on('Runtime.bindingCalled', (params) => {
177
+ this.handleBindingCalled(params as { name: string; payload: string });
178
+ });
179
+
180
+ await this.client.send('Runtime.addBinding', { name: RECORDER_BINDING_NAME });
181
+ await this.client.send('Runtime.evaluate', { expression: buildObserverScript(RECORDER_BINDING_NAME) });
182
+
183
+ await this.client.send('Page.startScreencast', { format: 'png', everyNthFrame: 1 });
184
+ await this.client.send('Tracing.start', { transferMode: 'ReportEvents', categories: TRACE_CATEGORIES });
185
+
186
+ const baseline = await readTraceClockBaseline(asCDPClient(this.client));
187
+ this.startedAtWallClockMs = Date.now();
188
+ this.state = 'recording';
189
+ return baseline;
190
+ }
191
+
192
+ /** Handles a `type: 'cdp'` request — plain passthrough, or bracketed + logged when `mark` is set. */
193
+ async handleCdp(req: RecCdpRequest): Promise<{ result?: unknown; event?: unknown }> {
194
+ const eventPromise = req.waitEvent ? this.events.wait(req.waitEvent, req.timeoutMs ?? 10000) : undefined;
195
+
196
+ if (req.mark) {
197
+ const bracket = await withDocumentPerformanceNow(asCDPClient(this.client), () =>
198
+ this.client.send(req.method, req.params ?? {}, req.timeoutMs ?? 60000),
199
+ );
200
+ this.appendEvent({
201
+ kind: 'input',
202
+ mark: req.mark,
203
+ method: req.method,
204
+ startPerformanceNow: bracket.startPerformanceNow,
205
+ endPerformanceNow: bracket.endPerformanceNow,
206
+ });
207
+ const event = eventPromise ? await eventPromise : undefined;
208
+ return { result: bracket.result, event };
209
+ }
210
+
211
+ const result = await this.client.send(req.method, req.params ?? {}, req.timeoutMs ?? 60000);
212
+ const event = eventPromise ? await eventPromise : undefined;
213
+ return { result, event };
214
+ }
215
+
216
+ async stop(): Promise<RecorderStopSummary> {
217
+ if (this.state !== 'recording') {
218
+ throw new Error(`cannot stop recorder in state "${this.state}"`);
219
+ }
220
+
221
+ try {
222
+ await this.client.send('Page.stopScreencast');
223
+ } catch {
224
+ // Best-effort — the tab/browser may already be gone.
225
+ }
226
+
227
+ const tracingComplete = this.events.wait('Tracing.tracingComplete', 5000).catch(() => undefined);
228
+ try {
229
+ await this.client.send('Tracing.end');
230
+ } catch {
231
+ // Best-effort.
232
+ }
233
+ await tracingComplete;
234
+
235
+ try {
236
+ await this.client.send('Runtime.evaluate', {
237
+ expression: 'window.__captureRecorder && window.__captureRecorder.teardown()',
238
+ });
239
+ } catch {
240
+ // Best-effort — the page may already be gone.
241
+ }
242
+ try {
243
+ await this.client.send('Runtime.removeBinding', { name: RECORDER_BINDING_NAME });
244
+ } catch {
245
+ // Best-effort.
246
+ }
247
+
248
+ this.state = 'stopped';
249
+ return {
250
+ frameCount: this.frameCount,
251
+ eventCount: this.eventCount,
252
+ durationMs: Date.now() - this.startedAtWallClockMs,
253
+ };
254
+ }
255
+
256
+ private appendEvent(record: Omit<RecorderEventRecord, 'recordedAtWallClockMs'>): void {
257
+ this.eventCount++;
258
+ appendNdjsonPrivate(this.eventsPath, {
259
+ ...record,
260
+ recordedAtWallClockMs: Date.now(),
261
+ } satisfies RecorderEventRecord);
262
+ }
263
+
264
+ private async handleScreencastFrame(params: ScreencastFrameParams): Promise<void> {
265
+ const frameIndex = this.frameCount++;
266
+ try {
267
+ if (params.sessionId !== undefined) {
268
+ await this.client.send('Page.screencastFrameAck', { sessionId: params.sessionId });
269
+ }
270
+ } catch {
271
+ // Best-effort ack — losing one ack just risks the browser pausing the stream.
272
+ }
273
+
274
+ const frameName = `frame-${String(frameIndex).padStart(6, '0')}.png`;
275
+ writeBinaryPrivate(path.join(this.framesDir, frameName), Buffer.from(params.data, 'base64'));
276
+
277
+ try {
278
+ const elements = await this.sampleRects();
279
+ appendNdjsonPrivate(this.rectsPath, {
280
+ frame: frameIndex,
281
+ file: frameName,
282
+ screencastTimestamp: params.metadata?.timestamp ?? null,
283
+ recordedAtWallClockMs: Date.now(),
284
+ elements,
285
+ } satisfies FrameRectsRecord);
286
+ } catch (err) {
287
+ // A failed rect sample for one frame shouldn't take the recorder down;
288
+ // the frame PNG above is already written regardless.
289
+ this.appendEvent({
290
+ kind: 'error',
291
+ message: `rect sample failed for frame ${frameIndex}: ${err instanceof Error ? err.message : String(err)}`,
292
+ });
293
+ }
294
+ }
295
+
296
+ private handleTraceData(params: { value: unknown[] }): void {
297
+ this.appendEvent({ kind: 'trace', events: params.value });
298
+ }
299
+
300
+ private handleBindingCalled(params: { name: string; payload: string }): void {
301
+ if (params.name !== RECORDER_BINDING_NAME) return;
302
+ let payload: unknown;
303
+ try {
304
+ payload = JSON.parse(params.payload);
305
+ } catch {
306
+ return;
307
+ }
308
+ if (!payload || typeof payload !== 'object') return;
309
+ this.appendEvent(payload as Omit<RecorderEventRecord, 'recordedAtWallClockMs'>);
310
+ }
311
+
312
+ private async sampleRects(): Promise<SampledRect[]> {
313
+ const evaluation = (await this.client.send('Runtime.evaluate', {
314
+ expression: SAMPLE_RECTS_EXPRESSION,
315
+ returnByValue: true,
316
+ })) as { result: { value?: unknown }; exceptionDetails?: unknown };
317
+ if (evaluation.exceptionDetails) {
318
+ throw new Error(`rect sampling failed: ${JSON.stringify(evaluation.exceptionDetails)}`);
319
+ }
320
+ return Array.isArray(evaluation.result.value) ? (evaluation.result.value as SampledRect[]) : [];
321
+ }
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Wire dispatch — the same "one request per connection, one response"
326
+ // convention the plain bridge uses (see ./bridge/server.ts).
327
+ // ---------------------------------------------------------------------------
328
+
329
+ export async function handleRecorderRequest(
330
+ session: RecorderSession,
331
+ req: RecorderRequest,
332
+ ): Promise<RecorderResponse> {
333
+ try {
334
+ switch (req.type) {
335
+ case 'rec-start': {
336
+ const markers = await session.start();
337
+ return { reqId: req.reqId, ok: true, type: 'rec-start', markers };
338
+ }
339
+ case 'rec-stop': {
340
+ const summary = await session.stop();
341
+ return { reqId: req.reqId, ok: true, type: 'rec-stop', ...summary };
342
+ }
343
+ case 'cdp': {
344
+ const { result, event } = await session.handleCdp(req);
345
+ return { reqId: req.reqId, ok: true, type: 'cdp', result, event };
346
+ }
347
+ default: {
348
+ const exhaustive: never = req;
349
+ throw new Error(`Unknown recorder request: ${JSON.stringify(exhaustive)}`);
350
+ }
351
+ }
352
+ } catch (err) {
353
+ return {
354
+ reqId: req.reqId,
355
+ ok: false,
356
+ type: req.type,
357
+ error: err instanceof Error ? err.message : String(err),
358
+ };
359
+ }
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // Process entrypoint — spawned detached by `./bridge/spawn.ts`'s
364
+ // `startRecorderBridge()`, invoked via `capture __bridge-serve ... recorder
365
+ // <recDir>` (see ./commands/bridge-serve.ts).
366
+ // ---------------------------------------------------------------------------
367
+
368
+ export interface RunRecorderBridgeOptions {
369
+ socketPath: string;
370
+ targetId: string;
371
+ recDir: string;
372
+ port?: number;
373
+ }
374
+
375
+ export async function runRecorderBridge(opts: RunRecorderBridgeOptions): Promise<void> {
376
+ const port = opts.port ?? (await resolvePort());
377
+ const resolved = await findTabByIdAcrossEndpoints(opts.targetId, port);
378
+ if (!resolved?.tab.webSocketDebuggerUrl) {
379
+ throw new Error(`No tab found for target "${opts.targetId}" on port ${port}.`);
380
+ }
381
+
382
+ const client = new CDPClient(resolved.tab.webSocketDebuggerUrl);
383
+ await client.waitReady();
384
+
385
+ const session = new RecorderSession({ client, recDir: opts.recDir });
386
+
387
+ async function handleLine(line: string, socket: net.Socket): Promise<void> {
388
+ let req: RecorderRequest;
389
+ try {
390
+ req = JSON.parse(line);
391
+ } catch {
392
+ return;
393
+ }
394
+ const resp = await handleRecorderRequest(session, req);
395
+ socket.write(JSON.stringify(resp) + '\n');
396
+ }
397
+
398
+ const server = await listenNdjsonSocket(opts.socketPath, handleLine);
399
+ const cleanup = (): void => {
400
+ closeNdjsonSocket(server, opts.socketPath);
401
+ try {
402
+ client.close();
403
+ } catch {
404
+ // Already closed.
405
+ }
406
+ };
407
+ installProcessCleanup(cleanup, client);
408
+ // Intentionally does not resolve further: the open server and the live
409
+ // tab websocket keep this detached process alive until the caller (U14's
410
+ // lifecycle routing) sends SIGTERM via the same `stopBridge()` used for
411
+ // the plain held bridge.
412
+ }
413
+
414
+ async function resolvePort(): Promise<number> {
415
+ const { detectCdpPort } = await import('./detect.js');
416
+ return detectCdpPort();
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // Injected in-page instrumentation
421
+ // ---------------------------------------------------------------------------
422
+
423
+ /**
424
+ * Builds the IIFE injected once at `rec-start` via `Runtime.evaluate`. Sets
425
+ * up MutationObserver/ResizeObserver/PerformanceObserver on the document,
426
+ * each emitting NDJSON-ready records to the host over the CDP binding
427
+ * (`Runtime.addBinding` + `Runtime.bindingCalled`) — the standard CDP
428
+ * page-to-host channel, not a new IPC mechanism. `window.__captureRecorder`
429
+ * exposes `teardown()`, called at `rec-stop` to disconnect the observers.
430
+ */
431
+ function buildObserverScript(bindingName: string): string {
432
+ return `(function() {
433
+ if (window.__captureRecorder) return;
434
+ var BINDING = ${JSON.stringify(bindingName)};
435
+ function emit(kind, payload) {
436
+ try {
437
+ var record = Object.assign({ kind: kind, performanceNowMs: performance.now() }, payload);
438
+ window[BINDING](JSON.stringify(record));
439
+ } catch (e) {}
440
+ }
441
+
442
+ var mutationObserver = new MutationObserver(function(records) {
443
+ emit('mutation', {
444
+ count: records.length,
445
+ types: records.map(function(r) { return r.type; }),
446
+ });
447
+ });
448
+ mutationObserver.observe(document.documentElement, {
449
+ childList: true, subtree: true, attributes: true, characterData: true,
450
+ });
451
+
452
+ var resizeObserver = new ResizeObserver(function(entries) {
453
+ emit('resize', {
454
+ count: entries.length,
455
+ targets: entries.slice(0, 20).map(function(e) {
456
+ var rect = e.contentRect;
457
+ return { tag: e.target && e.target.tagName, width: rect.width, height: rect.height };
458
+ }),
459
+ });
460
+ });
461
+ resizeObserver.observe(document.documentElement);
462
+
463
+ var perfObservers = [];
464
+ ['longtask', 'layout-shift', 'paint', 'mark', 'measure'].forEach(function(type) {
465
+ try {
466
+ if (!window.PerformanceObserver || !PerformanceObserver.supportedEntryTypes ||
467
+ PerformanceObserver.supportedEntryTypes.indexOf(type) === -1) return;
468
+ var po = new PerformanceObserver(function(list) {
469
+ list.getEntries().forEach(function(entry) {
470
+ emit('performance', {
471
+ entryType: entry.entryType,
472
+ name: entry.name,
473
+ startTime: entry.startTime,
474
+ duration: entry.duration,
475
+ value: entry.entryType === 'layout-shift' ? entry.value : undefined,
476
+ hadRecentInput: entry.entryType === 'layout-shift' ? entry.hadRecentInput : undefined,
477
+ });
478
+ });
479
+ });
480
+ po.observe({ type: type, buffered: true });
481
+ perfObservers.push(po);
482
+ } catch (e) {}
483
+ });
484
+
485
+ window.__captureRecorder = {
486
+ teardown: function() {
487
+ try { mutationObserver.disconnect(); } catch (e) {}
488
+ try { resizeObserver.disconnect(); } catch (e) {}
489
+ perfObservers.forEach(function(po) {
490
+ try { po.disconnect(); } catch (e) {}
491
+ });
492
+ },
493
+ };
494
+ })()`;
495
+ }
496
+
497
+ /**
498
+ * Single round-trip element-rect sample, evaluated once per screencast
499
+ * frame. Uses `getBoundingClientRect()` over every element (capped) rather
500
+ * than one CDP `DOM.getBoxModel` round trip per element — the cheap
501
+ * approximation this mechanism unit ships with; quad-accurate geometry
502
+ * (transforms, clipping, frame/shadow stitching) is the `measure snap`
503
+ * substrate's job (`geometry.json`), not the motion recorder's.
504
+ */
505
+ const SAMPLE_RECTS_EXPRESSION = `(function() {
506
+ var out = [];
507
+ var all = document.querySelectorAll('*');
508
+ var max = 2000;
509
+ for (var i = 0; i < all.length && out.length < max; i++) {
510
+ var el = all[i];
511
+ var r = el.getBoundingClientRect();
512
+ if (r.width === 0 && r.height === 0) continue;
513
+ out.push({
514
+ tag: el.tagName.toLowerCase(),
515
+ id: el.id || null,
516
+ classes: typeof el.className === 'string' ? el.className : null,
517
+ x: r.x, y: r.y, width: r.width, height: r.height,
518
+ });
519
+ }
520
+ return out;
521
+ })()`;