@crouton-kit/capture 1.4.2 → 1.4.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 (110) hide show
  1. package/README.md +178 -0
  2. package/bin/capture +31278 -7312
  3. package/package.json +3 -1
  4. package/src/capture.ts +21 -18
  5. package/src/cdp/args.ts +269 -49
  6. package/src/cdp/bridge/server.ts +6 -5
  7. package/src/cdp/bridge/spawn.ts +41 -97
  8. package/src/cdp/commands/bridge-serve.ts +11 -21
  9. package/src/cdp/commands/cdp.ts +44 -22
  10. package/src/cdp/commands/heap/census.ts +61 -0
  11. package/src/cdp/commands/heap/common.ts +77 -0
  12. package/src/cdp/commands/heap/diff.ts +92 -0
  13. package/src/cdp/commands/heap/index.ts +38 -0
  14. package/src/cdp/commands/heap/objects.ts +42 -0
  15. package/src/cdp/commands/heap/retainers.ts +52 -0
  16. package/src/cdp/commands/heap/snapshot.ts +94 -0
  17. package/src/cdp/commands/lib.ts +9 -17
  18. package/src/cdp/commands/lighthouse.ts +414 -0
  19. package/src/cdp/commands/measure/census.ts +2 -1
  20. package/src/cdp/commands/measure/check.ts +70 -16
  21. package/src/cdp/commands/measure/diff.ts +1 -2
  22. package/src/cdp/commands/measure/explain.ts +44 -7
  23. package/src/cdp/commands/measure/index.ts +24 -32
  24. package/src/cdp/commands/measure/map-ax.ts +2 -1
  25. package/src/cdp/commands/measure/map-focus.ts +2 -1
  26. package/src/cdp/commands/measure/map-layers.ts +5 -3
  27. package/src/cdp/commands/measure/map-scroll.ts +2 -1
  28. package/src/cdp/commands/measure/snap.ts +5 -4
  29. package/src/cdp/commands/measure/sweep.ts +16 -11
  30. package/src/cdp/commands/measure/text.ts +147 -0
  31. package/src/cdp/commands/motion/index.ts +10 -18
  32. package/src/cdp/commands/motion/jank.ts +13 -22
  33. package/src/cdp/commands/motion/mask.ts +10 -9
  34. package/src/cdp/commands/motion/rec.ts +228 -26
  35. package/src/cdp/commands/motion/timeline.ts +1 -1
  36. package/src/cdp/commands/page/click.ts +15 -5
  37. package/src/cdp/commands/page/elements.ts +43 -11
  38. package/src/cdp/commands/page/exec.ts +53 -18
  39. package/src/cdp/commands/page/index.ts +20 -20
  40. package/src/cdp/commands/page/inspect.ts +338 -0
  41. package/src/cdp/commands/page/navigate.ts +83 -38
  42. package/src/cdp/commands/page/repeat.ts +176 -0
  43. package/src/cdp/commands/page/scroll.ts +10 -5
  44. package/src/cdp/commands/page/shot.ts +66 -31
  45. package/src/cdp/commands/page/type.ts +6 -2
  46. package/src/cdp/commands/perf/index.ts +32 -0
  47. package/src/cdp/commands/perf/insights.ts +214 -0
  48. package/src/cdp/commands/perf/trace.ts +235 -0
  49. package/src/cdp/commands/perf/vitals.ts +76 -0
  50. package/src/cdp/commands/session/collectors.ts +54 -0
  51. package/src/cdp/commands/stub.ts +10 -0
  52. package/src/cdp/commands/tab/close.ts +2 -2
  53. package/src/cdp/commands/tab/index.ts +16 -23
  54. package/src/cdp/commands/tab/launch.ts +1 -1
  55. package/src/cdp/commands/tab/mock/index.ts +24 -0
  56. package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
  57. package/src/cdp/commands/tab/mock/start.ts +65 -0
  58. package/src/cdp/commands/tab/mock/stop.ts +39 -0
  59. package/src/cdp/commands/tab/network.ts +22 -13
  60. package/src/cdp/commands/tab/reset.ts +11 -12
  61. package/src/cdp/connection.ts +19 -39
  62. package/src/cdp/detect.ts +27 -7
  63. package/src/cdp/dispatch.ts +25 -4
  64. package/src/cdp/domains.ts +2 -2
  65. package/src/cdp/har-recorder.ts +0 -1
  66. package/src/cdp/heap-snapshot.ts +647 -0
  67. package/src/cdp/host/client.ts +22 -0
  68. package/src/cdp/host/collector.ts +74 -0
  69. package/src/cdp/host/collectors/heap.ts +79 -0
  70. package/src/cdp/host/collectors/intercept.ts +247 -0
  71. package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
  72. package/src/cdp/host/collectors/trace.ts +108 -0
  73. package/src/cdp/host/core.ts +217 -0
  74. package/src/cdp/host/handle.ts +47 -0
  75. package/src/cdp/host/held-client.ts +91 -0
  76. package/src/cdp/host/kinds.ts +97 -0
  77. package/src/cdp/host/lifecycle.ts +123 -0
  78. package/src/cdp/host/server.ts +88 -0
  79. package/src/cdp/measure/check.ts +181 -16
  80. package/src/cdp/measure/collectors/hittest.ts +16 -10
  81. package/src/cdp/measure/collectors/scroll.ts +15 -7
  82. package/src/cdp/measure/collectors/style-provenance.ts +11 -0
  83. package/src/cdp/measure/collectors/styles.ts +4 -4
  84. package/src/cdp/measure/explain.ts +14 -2
  85. package/src/cdp/measure/map-layers.ts +52 -8
  86. package/src/cdp/measure/snapshot.ts +5 -3
  87. package/src/cdp/measure/sweep.ts +29 -6
  88. package/src/cdp/measure/text.ts +227 -0
  89. package/src/cdp/motion/jank.ts +29 -83
  90. package/src/cdp/motion/mask.ts +18 -9
  91. package/src/cdp/motion/recorder.ts +86 -731
  92. package/src/cdp/scope-lock.ts +9 -9
  93. package/src/cdp/screenshot.ts +51 -21
  94. package/src/cdp/timing.ts +5 -5
  95. package/src/cdp/trace-analysis.ts +320 -0
  96. package/src/cdp/types.ts +31 -1
  97. package/src/cdp.ts +4 -6
  98. package/src/har-manager.ts +2 -4
  99. package/src/interact.ts +50 -23
  100. package/src/output/artifact.ts +49 -5
  101. package/src/output/render.ts +60 -9
  102. package/src/output/selection.ts +6 -0
  103. package/src/session/artifacts.ts +122 -12
  104. package/src/session/commands.ts +348 -113
  105. package/src/session/coordinator.ts +0 -93
  106. package/src/session/har-redact.ts +29 -23
  107. package/src/session-context.ts +13 -9
  108. package/src/cdp/bridge/protocol.ts +0 -224
  109. package/src/cdp/recorder-client.ts +0 -326
  110. package/src/cdp/tab-lock.ts +0 -132
@@ -1,4 +1,5 @@
1
1
  import type { LayerRecord, LayersReport } from './collectors/layers.js';
2
+ import { selectRecords } from '../../output/selection.js';
2
3
  import type { SnapMeta } from '../../session/artifacts.js';
3
4
  import {
4
5
  annotateUnstableFacts,
@@ -27,6 +28,22 @@ interface LayerFact {
27
28
  readonly rect: Rect;
28
29
  }
29
30
 
31
+ const INLINE_IDENTIFIER_LIMIT = 50;
32
+
33
+ function boundedIdentifiers(ids: readonly number[], total: number): { listed: readonly number[]; omitted: number } {
34
+ const listed = ids.slice(0, INLINE_IDENTIFIER_LIMIT);
35
+ return { listed, omitted: Math.max(0, total - listed.length) };
36
+ }
37
+
38
+ function layerJson(layer: LayerRecord): Record<string, unknown> {
39
+ const members = boundedIdentifiers(layer.memberBackendNodeIds, layer.memberCount);
40
+ return {
41
+ ...layer,
42
+ memberBackendNodeIds: members.listed,
43
+ memberBackendNodeIdsOmitted: members.omitted,
44
+ };
45
+ }
46
+
30
47
  function sourceFor(layer: LayerRecord): string | undefined {
31
48
  const provenance = layer.styleProvenance;
32
49
  if (!provenance) return undefined;
@@ -75,8 +92,12 @@ function formatLayer(layer: LayerRecord, membershipAvailable: boolean, caveats:
75
92
  else if (layer.compositingReasons.length) details.push(fact`Compositing reasons: ${layer.compositingReasons.join(', ')}`);
76
93
  else details.push(text`Compositing reasons: none reported.`);
77
94
  if (!membershipAvailable) details.push(text`Node membership was unavailable for this layer.`);
78
- else if (layer.membersTruncated) details.push(fact`Node membership: ${layer.memberCount} painted node(s); first ${layer.memberBackendNodeIds.length} backend id(s): ${layer.memberBackendNodeIds.join(', ')}; ${layer.membersTruncated} additional id(s) not listed.`);
79
- else details.push(fact`Node membership: ${layer.memberCount} painted node(s); backend id(s): ${layer.memberBackendNodeIds.join(', ') || '(none)'}.`);
95
+ else {
96
+ const members = boundedIdentifiers(layer.memberBackendNodeIds, layer.memberCount);
97
+ details.push(members.omitted
98
+ ? line(fact`Node membership: ${layer.memberCount} painted node(s); first ${members.listed.length} backend id(s): `, data(members.listed.join(', ') || '(none)', 1000), fact`; ${members.omitted} id(s) omitted.`)
99
+ : line(fact`Node membership: ${layer.memberCount} painted node(s); backend id(s): `, data(members.listed.join(', ') || '(none)', 1000), text`.`));
100
+ }
80
101
  if (layer.styleProvenance) {
81
102
  const provenance = layer.styleProvenance;
82
103
  details.push(line(
@@ -104,14 +125,16 @@ function formatLayer(layer: LayerRecord, membershipAvailable: boolean, caveats:
104
125
 
105
126
  function paintOrderLine(report: LayersReport, caveats: readonly UnstableCaveat[]): FactLine {
106
127
  if (!report.paintOrder.available) return fact`DOMSnapshot paint order unavailable${report.paintOrder.reason ? `: ${report.paintOrder.reason}` : ''}.`;
107
- const ids = report.paintOrder.backendNodeIds;
108
- const suffix = report.paintOrder.truncated ? `; ${report.paintOrder.truncated} additional backend node id(s) not listed` : '';
109
- const base = fact`DOMSnapshot paint order (back-to-front): ${ids.join(', ') || '(no painted backend nodes recorded)'}${suffix}.`;
128
+ const total = report.paintOrder.backendNodeIds.length + report.paintOrder.truncated;
129
+ const ids = boundedIdentifiers(report.paintOrder.backendNodeIds, total);
130
+ const base = ids.omitted
131
+ ? line(fact`DOMSnapshot paint order (back-to-front): ${total} painted node(s); first ${ids.listed.length} backend id(s): `, data(ids.listed.join(', ') || '(none)', 1000), fact`; ${ids.omitted} id(s) omitted.`)
132
+ : line(fact`DOMSnapshot paint order (back-to-front): ${total} painted node(s); backend id(s): `, data(ids.listed.join(', ') || '(none)', 1000), text`.`);
110
133
  const caveat = caveatLine(caveats);
111
134
  return caveat ? lineList([base, caveat]) : base;
112
135
  }
113
136
 
114
- export function buildMeasureMapLayersResult(ref: SnapRef): RenderableResult {
137
+ export function buildMeasureMapLayersResult(ref: SnapRef, limit?: number): RenderableResult {
115
138
  const report = readLayers<LayersReport>(ref);
116
139
  const meta = readMeta<SnapMeta>(ref);
117
140
  const regions = unstableRegionsFor(ref);
@@ -138,9 +161,15 @@ export function buildMeasureMapLayersResult(ref: SnapRef): RenderableResult {
138
161
  const layerTreeStatus = report.layerTree.available ? 'available' : 'unavailable';
139
162
  const paintOrderStatus = report.paintOrder.available ? 'available' : 'unavailable';
140
163
  const membershipStatus = report.membership.available ? 'available' : 'unavailable';
164
+ const paintedNodeCount = report.paintOrder.backendNodeIds.length + report.paintOrder.truncated;
165
+ const paintOrderJson = report.paintOrder.available
166
+ ? { ...report.paintOrder, paintedNodeCount, ...(() => { const ids = boundedIdentifiers(report.paintOrder.backendNodeIds, paintedNodeCount); return { backendNodeIds: ids.listed, backendNodeIdsOmitted: ids.omitted }; })() }
167
+ : report.paintOrder;
141
168
  const availability = report.layerTree.available
142
169
  ? fact`LayerTree facts available for ${report.layers.length} layer(s). DOMSnapshot paint order is ${paintOrderStatus}.`
143
170
  : fact`LayerTree facts unavailable${report.layerTree.reason ? `: ${report.layerTree.reason}` : ''}. DOMSnapshot paint order is ${paintOrderStatus}.`;
171
+ const displayed = selectRecords(annotated, { limit }, 25);
172
+ const layerPaintOrder = selectRecords(report.layerPaintOrder, { limit }, 25);
144
173
  const sections: FactLine[] = [availability, paintOrderLine(report, paintOrderCaveats)];
145
174
  if (report.layersTruncated) sections.push(fact`${report.layersTruncated} compositor layer(s) were not listed because the snapshot collector capped the layer inventory.`);
146
175
  if (report.membership.available) {
@@ -151,7 +180,8 @@ export function buildMeasureMapLayersResult(ref: SnapRef): RenderableResult {
151
180
  if (!report.styleSheetHeaders.available) {
152
181
  sections.push(fact`Stylesheet-header provenance availability: unavailable${report.styleSheetHeaders.reason ? `: ${report.styleSheetHeaders.reason}` : ''}.`);
153
182
  }
154
- sections.push(...annotated.map(({ layerFact, caveats }) => formatLayer(layerFact.layer, report.membership.available, caveats)));
183
+ if (annotated.length > displayed.length) sections.push(fact`Layer records: listing capped at ${displayed.length} of ${annotated.length} layers (--limit).`);
184
+ sections.push(...displayed.map(({ layerFact, caveats }) => formatLayer(layerFact.layer, report.membership.available, caveats)));
155
185
 
156
186
  return {
157
187
  tag: 'layer-map',
@@ -165,13 +195,27 @@ export function buildMeasureMapLayersResult(ref: SnapRef): RenderableResult {
165
195
  },
166
196
  attrs: {
167
197
  layers: report.layers.length,
198
+ displayed: displayed.length,
168
199
  'layer-tree': layerTreeStatus,
169
200
  'paint-order': paintOrderStatus,
170
201
  membership: membershipStatus,
202
+ 'painted-nodes': report.paintOrder.available ? paintedNodeCount : undefined,
171
203
  settled: meta.settled,
172
204
  },
173
- summary: text`Paint/compositor layer facts, DOMSnapshot paint order, node membership, and available layer-style provenance are reported from layers.json.`,
205
+ summary: report.paintOrder.available
206
+ ? fact`Paint/compositor layer facts and ${paintedNodeCount} DOMSnapshot painted node(s) are reported from layers.json; identifier lists are limited to ${INLINE_IDENTIFIER_LIMIT} per corpus with omitted counts.`
207
+ : text`Paint/compositor layer facts, DOMSnapshot paint order, node membership, and available layer-style provenance are reported from layers.json.`,
174
208
  sections,
209
+ jsonSections: [{
210
+ layerTree: report.layerTree,
211
+ layers: displayed.map(({ layerFact }) => layerJson(layerFact.layer)),
212
+ layersTruncated: report.layersTruncated,
213
+ paintOrder: paintOrderJson,
214
+ layerPaintOrder,
215
+ layerPaintOrderOmitted: report.layerPaintOrder.length - layerPaintOrder.length,
216
+ membership: report.membership,
217
+ styleSheetHeaders: report.styleSheetHeaders,
218
+ }],
175
219
  followUp: fact`Inspect an element's cascade and stacking facts with \`capture measure explain ${ref.id} --selector <selector>\`.`,
176
220
  };
177
221
  }
@@ -182,6 +182,8 @@ function makeCollectorWriter(writer: RollbackWriter): { writer: SnapshotWriter;
182
182
 
183
183
  /** One CDP operation is bounded so a page-side script or browser command that never returns cannot strand the CLI behind CDPClient's 60-second default. */
184
184
  export const SNAPSHOT_REQUEST_TIMEOUT_MS = 5_000;
185
+ /** Scroll-topology sampling can trigger a page's own scroll handler before its next CDP read. Keep that one collector bounded, but long enough to observe the resulting renderer work. */
186
+ export const SCROLL_COLLECTOR_REQUEST_TIMEOUT_MS = 30_000;
185
187
 
186
188
  /** A snapshot operation timed out after writing zero or more partial artifacts. */
187
189
  export class SnapshotCaptureTimeout extends Error {
@@ -199,10 +201,10 @@ export class SnapshotCaptureTimeout extends Error {
199
201
  }
200
202
 
201
203
  /** Bounds one snapshot CDP request and labels the phase that owns it. */
202
- function boundedClient(client: CDPClient, phase: string, partialPath: string, onTimeout?: (error: SnapshotCaptureTimeout) => void): CDPClient {
204
+ function boundedClient(client: CDPClient, phase: string, partialPath: string, onTimeout?: (error: SnapshotCaptureTimeout) => void, requestTimeoutCapMs = SNAPSHOT_REQUEST_TIMEOUT_MS): CDPClient {
203
205
  const bounded = Object.create(client) as CDPClient;
204
206
  bounded.send = (method: string, params: Record<string, unknown> = {}, timeout = 60_000, sessionId?: string): Promise<unknown> => {
205
- const timeoutMs = Math.min(timeout, SNAPSHOT_REQUEST_TIMEOUT_MS);
207
+ const timeoutMs = Math.min(timeout, requestTimeoutCapMs);
206
208
  let timer: NodeJS.Timeout | undefined;
207
209
  let timedOut = false;
208
210
  const markTimedOut = (error: SnapshotCaptureTimeout): void => {
@@ -403,7 +405,7 @@ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions):
403
405
  client: boundedClient(client, `collector:${collector.name}`, dir, (error) => {
404
406
  requestTimeouts += 1;
405
407
  timeoutError ??= error;
406
- }),
408
+ }, collector.name === 'scroll' ? SCROLL_COLLECTOR_REQUEST_TIMEOUT_MS : SNAPSHOT_REQUEST_TIMEOUT_MS),
407
409
  write: collectorWriter.writer,
408
410
  });
409
411
  // A styles timeout may be caught per-record by the provenance collector,
@@ -23,6 +23,8 @@ export interface SweepTransition {
23
23
  readonly before: string;
24
24
  readonly after: string;
25
25
  readonly changes: readonly SweepChange[];
26
+ /** The observed endpoint difference could not be narrowed before the sample budget ran out. */
27
+ readonly uncertainty?: 'sampling_limit';
26
28
  }
27
29
 
28
30
  export interface SweepChange {
@@ -42,6 +44,7 @@ export interface SweepRange {
42
44
  readonly from: number | string;
43
45
  readonly to: number | string;
44
46
  readonly snapId: string;
47
+ readonly snapDir: string;
45
48
  readonly fingerprint: string;
46
49
  }
47
50
 
@@ -91,6 +94,7 @@ export interface SweepRecovery {
91
94
  interface StylesElement {
92
95
  readonly id?: string;
93
96
  readonly selector?: string;
97
+ readonly backendNodeId?: number | null;
94
98
  readonly computed?: Record<string, string | null>;
95
99
  readonly winningDeclarations?: readonly {
96
100
  readonly property?: string;
@@ -194,15 +198,26 @@ function sourceFor(declaration: SweepDeclaration): SweepChange['provenance'] | u
194
198
  return { selector: declaration.selector ?? undefined, specificity: declaration.specificity ?? undefined, source };
195
199
  }
196
200
 
201
+ /** A cross-snapshot element key must survive an inserted/removed sibling; collector-local s-<index> ids do not. */
202
+ function snapshotElementKey(element: StylesElement): string | null {
203
+ if (typeof element.backendNodeId === 'number') return `backend:${element.backendNodeId}`;
204
+ if (element.selector) return `selector:${element.selector}`;
205
+ return element.id && !/^s-\d+$/.test(element.id) ? `id:${element.id}` : null;
206
+ }
207
+
197
208
  /** Extracts changed computed values and the winning declaration for the new value, when captured. */
198
209
  export function changesBetweenSnapshots(beforeDir: string, afterDir: string): SweepChange[] {
199
210
  const before = readJson<StylesReport>(path.join(beforeDir, 'styles.json'));
200
211
  const after = readJson<StylesReport>(path.join(afterDir, 'styles.json'));
201
212
  if (!before?.elements || !after?.elements) return [];
202
- const prior = new Map(before.elements.map((element) => [element.id ?? element.selector ?? '', element]));
213
+ const prior = new Map(before.elements.flatMap((element) => {
214
+ const key = snapshotElementKey(element);
215
+ return key === null ? [] : [[key, element] as const];
216
+ }));
203
217
  const changes: SweepChange[] = [];
204
218
  for (const next of after.elements) {
205
- const key = next.id ?? next.selector ?? '';
219
+ const key = snapshotElementKey(next);
220
+ if (key === null) continue;
206
221
  const old = prior.get(key);
207
222
  if (!old) continue;
208
223
  const properties = new Set([...Object.keys(old.computed ?? {}), ...Object.keys(next.computed ?? {})]);
@@ -218,17 +233,19 @@ export function changesBetweenSnapshots(beforeDir: string, afterDir: string): Sw
218
233
  }
219
234
 
220
235
  /** Builds adjacent observed changes and matching-fingerprint spans; neither denotes unobserved values as stable. */
221
- export function analyzeSweepSamples(axis: SweepAxis, _from: number | string, _to: number | string, samples: readonly SweepSample[]): Pick<SweepArtifact, 'transitions' | 'ranges'> {
236
+ export function analyzeSweepSamples(axis: SweepAxis, _from: number | string, _to: number | string, samples: readonly SweepSample[], uncertainties: readonly SweepUncertainty[] = []): Pick<SweepArtifact, 'transitions' | 'ranges'> {
222
237
  const transitions: SweepTransition[] = [];
223
238
  const ranges: SweepRange[] = [];
224
239
  for (let i = 1; i < samples.length; i += 1) {
225
240
  const previous = samples[i - 1];
226
241
  const current = samples[i];
227
242
  if (previous.fingerprint === current.fingerprint) {
228
- ranges.push({ from: previous.value, to: current.value, snapId: previous.snapId, fingerprint: previous.fingerprint });
243
+ ranges.push({ from: previous.value, to: current.value, snapId: previous.snapId, snapDir: previous.snapDir, fingerprint: previous.fingerprint });
229
244
  continue;
230
245
  }
231
- transitions.push({ bracket: { from: previous.value, to: current.value }, before: previous.snapId, after: current.snapId, changes: changesBetweenSnapshots(previous.snapDir, current.snapDir) });
246
+ const samplingLimited = typeof previous.value === 'number' && typeof current.value === 'number'
247
+ && uncertainties.some((interval) => interval.reason === 'sampling_limit' && interval.from === previous.value && interval.to === current.value);
248
+ transitions.push({ bracket: { from: previous.value, to: current.value }, before: previous.snapId, after: current.snapId, changes: changesBetweenSnapshots(previous.snapDir, current.snapDir), ...(samplingLimited ? { uncertainty: 'sampling_limit' as const } : {}) });
232
249
  }
233
250
  return { transitions, ranges };
234
251
  }
@@ -256,7 +273,13 @@ export async function refineNumericSweep(samples: readonly SweepSample[], tolera
256
273
  continue;
257
274
  }
258
275
  if (!byValue.has(middle)) byValue.set(middle, await capture(middle));
259
- pending.push({ from, to: middle }, { from: middle, to });
276
+ const midpoint = byValue.get(middle)!;
277
+ const left = byValue.get(from)!;
278
+ const right = byValue.get(to)!;
279
+ // One midpoint observes an otherwise-unsampled interval; only a changed
280
+ // fingerprint can justify spending more samples narrowing that half.
281
+ if (left.fingerprint !== midpoint.fingerprint) pending.push({ from, to: middle });
282
+ if (midpoint.fingerprint !== right.fingerprint) pending.push({ from: middle, to });
260
283
  }
261
284
  return { samples: [...byValue.values()].sort((a, b) => Number(a.value) - Number(b.value)), uncertainties };
262
285
  }
@@ -0,0 +1,227 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { PNG } from 'pngjs';
4
+
5
+ import { getActiveSession } from '../../session-context.js';
6
+ import { createOneshotSession } from '../../session/commands.js';
7
+ import { writeBinaryPrivate } from '../../session/artifacts.js';
8
+ import { artifactPath, readGeometry, readMeta, readQueries, readStyles, readText, type SnapRef } from '../../output/artifact.js';
9
+ import { explainSnapshot, type ExplainAmbiguousSelector, type ExplainMissingSelector } from './explain.js';
10
+ import { measureElementContrast } from './check.js';
11
+
12
+ interface TextRecord {
13
+ readonly id?: string;
14
+ readonly geometryId?: string;
15
+ readonly backendNodeId?: number | null;
16
+ readonly selector?: string;
17
+ readonly text?: string;
18
+ readonly textLength?: number;
19
+ readonly lineCount?: number;
20
+ readonly truncated?: boolean;
21
+ readonly truncationStyle?: string;
22
+ readonly scrollWidth?: number;
23
+ readonly clientWidth?: number;
24
+ readonly writingMode?: string;
25
+ readonly direction?: string;
26
+ readonly fallbackUsed?: boolean;
27
+ readonly font?: { readonly family?: string; readonly size?: string; readonly weight?: string; readonly lineHeight?: string };
28
+ readonly platformFontsAvailable?: boolean;
29
+ readonly platformFonts?: readonly { readonly familyName?: string }[];
30
+ readonly lines?: readonly { readonly index?: number; readonly rect?: { readonly x?: number; readonly y?: number; readonly width?: number; readonly height?: number; readonly w?: number; readonly h?: number }; readonly baseline?: number; readonly baselineApproximate?: boolean; readonly wrapAfterChar?: number; readonly wrapAfterCharUnavailable?: boolean }[];
31
+ }
32
+
33
+ interface StyleRecord {
34
+ readonly id?: string;
35
+ readonly geometryId?: string;
36
+ readonly backendNodeId?: number | null;
37
+ readonly selector?: string;
38
+ readonly provenanceUnavailable?: boolean;
39
+ readonly winningDeclarations?: readonly { readonly property?: string; readonly value?: string | null; readonly selector?: string | null; readonly specificity?: string | null; readonly important?: boolean; readonly authored?: { readonly file?: string; readonly line?: number; readonly column?: number }; readonly generated?: { readonly sourceURL?: string; readonly line?: number; readonly column?: number }; readonly sourceStyleSheetUrl?: string; readonly winnerApproximate?: boolean; readonly winnerApproximateReason?: string }[];
40
+ }
41
+
42
+ interface GeometryRecord {
43
+ readonly id: string;
44
+ readonly selector?: string;
45
+ readonly backendNodeId?: number | null;
46
+ readonly domPath?: string;
47
+ }
48
+
49
+ interface TextArtifact {
50
+ readonly elements?: TextRecord[];
51
+ readonly available?: boolean;
52
+ readonly unavailableReason?: string;
53
+ }
54
+
55
+ interface QueriesArtifact {
56
+ readonly available?: boolean;
57
+ readonly unavailableReason?: string;
58
+ readonly environment?: { readonly width?: number; readonly height?: number };
59
+ }
60
+
61
+ export interface TextStyleProvenance {
62
+ readonly element: string;
63
+ readonly property: string;
64
+ readonly value: string;
65
+ readonly selector: string;
66
+ readonly specificity: string;
67
+ readonly source: string;
68
+ readonly uncertainty?: string;
69
+ }
70
+
71
+ interface TextStyleProvenanceReport {
72
+ readonly styles: readonly TextStyleProvenance[];
73
+ readonly unavailableFor: readonly string[];
74
+ }
75
+
76
+ export interface TextCrop {
77
+ readonly path: string;
78
+ readonly requested: { readonly x: number; readonly y: number; readonly w: number; readonly h: number };
79
+ readonly delivered: { readonly x: number; readonly y: number; readonly w: number; readonly h: number };
80
+ readonly scale: { readonly x: number; readonly y: number };
81
+ }
82
+
83
+ export interface TextMeasurement {
84
+ readonly kind: 'measurement';
85
+ readonly ref: SnapRef;
86
+ readonly selector: string;
87
+ readonly matchCount: number;
88
+ readonly element: { readonly id: string; readonly selector?: string; readonly backendNodeId?: number | null; readonly rect?: { readonly x: number; readonly y: number; readonly width?: number; readonly height?: number } | null };
89
+ readonly text?: TextRecord;
90
+ readonly textAvailable: boolean;
91
+ readonly textUnavailableReason?: string;
92
+ readonly contrast: ReturnType<typeof measureElementContrast>;
93
+ readonly styles: readonly TextStyleProvenance[];
94
+ readonly styleProvenanceUnavailableFor: readonly string[];
95
+ readonly meta: { readonly settled?: boolean; readonly settleMs?: number; readonly viewport?: string | null };
96
+ }
97
+
98
+ export type TextSnapshotResult = TextMeasurement | ExplainMissingSelector | ExplainAmbiguousSelector;
99
+
100
+ function sameTarget(record: { backendNodeId?: number | null; id?: string; geometryId?: string }, element: { backendNodeId?: number | null; id: string }): boolean {
101
+ return (typeof element.backendNodeId === 'number' && record.backendNodeId === element.backendNodeId) || record.id === element.id || record.geometryId === element.id;
102
+ }
103
+
104
+ function sourceOf(declaration: NonNullable<StyleRecord['winningDeclarations']>[number]): string {
105
+ if (declaration.authored?.file) return `${declaration.authored.file}:${declaration.authored.line ?? 0}:${declaration.authored.column ?? 0} (authored)`;
106
+ if (declaration.generated?.sourceURL) return `${declaration.generated.sourceURL}:${declaration.generated.line ?? 0}:${declaration.generated.column ?? 0} (generated)`;
107
+ if (declaration.sourceStyleSheetUrl) return `${declaration.sourceStyleSheetUrl} (generated source)`;
108
+ return 'selector-only provenance';
109
+ }
110
+
111
+ function parentPath(domPath: string | undefined): string | undefined {
112
+ if (domPath === undefined) return undefined;
113
+ const index = domPath.lastIndexOf('/');
114
+ return index < 0 ? '' : domPath.slice(0, index);
115
+ }
116
+
117
+ function elementLabel(element: { id: string; selector?: string }): string {
118
+ return element.selector ?? element.id;
119
+ }
120
+
121
+ function styleProvenance(ref: SnapRef, element: GeometryRecord, contrast: ReturnType<typeof measureElementContrast>): TextStyleProvenanceReport {
122
+ const records = readStyles<{ elements?: StyleRecord[] }>(ref).elements ?? [];
123
+ const geometry = readGeometry<{ elements?: GeometryRecord[] }>(ref).elements ?? [];
124
+ const byPath = new Map(geometry.filter((candidate) => candidate.domPath !== undefined).map((candidate) => [candidate.domPath!, candidate]));
125
+ const backgroundElements: GeometryRecord[] = [];
126
+ let candidate: GeometryRecord | undefined = element;
127
+ while (candidate) {
128
+ backgroundElements.push(candidate);
129
+ if (contrast.available && candidate.id === contrast.backgroundSourceElementId) break;
130
+ candidate = byPath.get(parentPath(candidate.domPath) ?? '');
131
+ }
132
+ const targets = new Map<string, { element: GeometryRecord; properties: ReadonlySet<string> }>();
133
+ targets.set(element.id, { element, properties: new Set(['color', 'background-color', 'backgroundColor']) });
134
+ for (const ancestor of backgroundElements) {
135
+ const existing = targets.get(ancestor.id);
136
+ targets.set(ancestor.id, { element: ancestor, properties: existing ? new Set([...existing.properties, 'background-color', 'backgroundColor']) : new Set(['background-color', 'backgroundColor']) });
137
+ }
138
+ const styles: TextStyleProvenance[] = [];
139
+ const unavailableFor: string[] = [];
140
+ for (const { element: target, properties } of targets.values()) {
141
+ const record = records.find((candidate) => sameTarget(candidate, target));
142
+ if (!record || record.provenanceUnavailable) {
143
+ unavailableFor.push(elementLabel(target));
144
+ continue;
145
+ }
146
+ for (const declaration of record.winningDeclarations ?? []) {
147
+ if (!declaration.property || !properties.has(declaration.property)) continue;
148
+ styles.push({
149
+ element: elementLabel(target),
150
+ property: declaration.property,
151
+ value: declaration.value ?? '(no computed value)',
152
+ selector: declaration.selector ?? '(no author declaration)',
153
+ specificity: declaration.specificity ?? (declaration.selector === 'inline' ? 'inline' : 'none'),
154
+ source: sourceOf(declaration),
155
+ ...(declaration.winnerApproximate ? { uncertainty: declaration.winnerApproximateReason ?? 'simplified cascade model' } : {}),
156
+ });
157
+ }
158
+ }
159
+ return { styles, unavailableFor };
160
+ }
161
+
162
+ /** Reads one target's recorded text, font, color, and cascade facts without driving a browser. */
163
+ export function measureTextSnapshot(ref: SnapRef, selector: string): TextSnapshotResult {
164
+ const textArtifact = readText<TextArtifact>(ref);
165
+ if (textArtifact.available === false && selector.startsWith('text:')) throw new Error(`text collection is unavailable${textArtifact.unavailableReason ? `: ${textArtifact.unavailableReason}` : ''}; cannot resolve a text: selector`);
166
+ const selection = explainSnapshot(ref, selector, { text: true });
167
+ if (selection.kind !== 'explanation') return selection;
168
+ const element = selection.element;
169
+ const textAvailable = textArtifact.available !== false;
170
+ const text = textAvailable ? (textArtifact.elements ?? []).find((record) => sameTarget(record, element)) : undefined;
171
+ const contrast = measureElementContrast(ref, element.id);
172
+ const provenance = styleProvenance(ref, element, contrast);
173
+ return {
174
+ kind: 'measurement',
175
+ ref,
176
+ selector,
177
+ matchCount: selection.matchCount,
178
+ element,
179
+ text,
180
+ textAvailable,
181
+ ...(textArtifact.available === false ? { textUnavailableReason: textArtifact.unavailableReason ?? 'no reason was recorded' } : {}),
182
+ contrast,
183
+ styles: provenance.styles,
184
+ styleProvenanceUnavailableFor: provenance.unavailableFor,
185
+ meta: { ...selection.meta, viewport: readMeta<{ viewport?: string | null }>(ref).viewport },
186
+ };
187
+ }
188
+
189
+ function capturedCssViewport(ref: SnapRef): { width: number; height: number } {
190
+ const queries = readQueries<QueriesArtifact>(ref);
191
+ if (queries.available === false) throw new Error(`the snapshot query environment is unavailable${queries.unavailableReason ? `: ${queries.unavailableReason}` : ''}; cannot map CSS geometry to screenshot pixels`);
192
+ const width = queries.environment?.width;
193
+ const height = queries.environment?.height;
194
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width! <= 0 || height! <= 0) throw new Error('the snapshot has no positive recorded CSS viewport in queries.json; cannot map CSS geometry to screenshot pixels');
195
+ return { width: width!, height: height! };
196
+ }
197
+
198
+ /** Writes a crop into the active session or a fresh measure one-shot bundle, never into the source snapshot. */
199
+ export function writeTextCrop(measurement: TextMeasurement, artifactDir?: string): TextCrop {
200
+ const rect = measurement.element.rect;
201
+ const width = rect?.width;
202
+ const height = rect?.height;
203
+ if (!rect || !Number.isFinite(rect.x) || !Number.isFinite(rect.y) || !Number.isFinite(width) || !Number.isFinite(height) || width! <= 0 || height! <= 0) throw new Error('the selected element has no positive recorded geometry for a crop');
204
+ const source = PNG.sync.read(fs.readFileSync(artifactPath(measurement.ref, 'screenshot.png')));
205
+ const capturedViewport = capturedCssViewport(measurement.ref);
206
+ const scaleX = source.width / capturedViewport.width;
207
+ const scaleY = source.height / capturedViewport.height;
208
+ const requested = { x: rect.x, y: rect.y, w: width!, h: height! };
209
+ const x = Math.max(0, Math.floor(requested.x * scaleX));
210
+ const y = Math.max(0, Math.floor(requested.y * scaleY));
211
+ const right = Math.min(source.width, Math.ceil((requested.x + requested.w) * scaleX));
212
+ const bottom = Math.min(source.height, Math.ceil((requested.y + requested.h) * scaleY));
213
+ if (right <= x || bottom <= y) throw new Error('the selected element has no recorded intersection with screenshot.png');
214
+ const crop = new PNG({ width: right - x, height: bottom - y });
215
+ PNG.bitblt(source, crop, x, y, crop.width, crop.height, 0, 0);
216
+ const active = getActiveSession();
217
+ const root = active?.dir ?? createOneshotSession('measure', artifactDir).dir;
218
+ const filename = `text-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.png`;
219
+ const output = path.join(root, 'measure', 'text', filename);
220
+ writeBinaryPrivate(output, PNG.sync.write(crop));
221
+ return {
222
+ path: output,
223
+ requested,
224
+ delivered: { x, y, w: crop.width, h: crop.height },
225
+ scale: { x: scaleX, y: scaleY },
226
+ };
227
+ }