@crouton-kit/capture 1.3.20 → 1.4.0

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 (37) hide show
  1. package/bin/capture +2890 -1756
  2. package/package.json +1 -1
  3. package/src/capture.ts +3 -2
  4. package/src/cdp/args.ts +18 -3
  5. package/src/cdp/commands/measure/explain.ts +22 -7
  6. package/src/cdp/commands/measure/index.ts +8 -4
  7. package/src/cdp/commands/measure/map-paint.ts +273 -0
  8. package/src/cdp/commands/measure/snap.ts +34 -7
  9. package/src/cdp/commands/page/click.ts +32 -4
  10. package/src/cdp/commands/page/exec.ts +1 -1
  11. package/src/cdp/commands/page/index.ts +2 -2
  12. package/src/cdp/commands/page/scroll.ts +6 -4
  13. package/src/cdp/commands/page/shot.ts +118 -6
  14. package/src/cdp/commands/page/type.ts +6 -4
  15. package/src/cdp/connection.ts +19 -12
  16. package/src/cdp/measure/collectors/geometry.ts +66 -9
  17. package/src/cdp/measure/collectors/pixels.ts +9 -1
  18. package/src/cdp/measure/collectors/states.ts +243 -4
  19. package/src/cdp/measure/collectors/styles.ts +67 -43
  20. package/src/cdp/measure/explain.ts +123 -23
  21. package/src/cdp/measure/ink-source.ts +116 -0
  22. package/src/cdp/measure/ink.ts +180 -0
  23. package/src/cdp/measure/snapshot.ts +27 -13
  24. package/src/cdp/screenshot.ts +43 -11
  25. package/src/cdp/targets.ts +24 -0
  26. package/src/cdp/types.ts +15 -0
  27. package/src/contracts/index.ts +0 -2
  28. package/src/output/render.ts +1 -0
  29. package/src/output/selector.ts +238 -5
  30. package/src/session/commands.ts +47 -32
  31. package/src/command-help.ts +0 -29
  32. package/src/command-resolution.ts +0 -22
  33. package/src/command-walker.ts +0 -35
  34. package/src/contracts/results.ts +0 -208
  35. package/src/contracts/routes.ts +0 -140
  36. package/src/output/exact-raw.ts +0 -114
  37. package/src/registry.ts +0 -208
@@ -272,6 +272,7 @@ export const collectPixels: Collector = async (ctx) => {
272
272
  ctx.write.json('pixels.json', {
273
273
  scope: PIXELS_SCOPE,
274
274
  elements: [],
275
+ cropsByBackend: {},
275
276
  backgroundOverrideRestored,
276
277
  captureFailed: true,
277
278
  elementsTotal: 0,
@@ -311,6 +312,10 @@ export const collectPixels: Collector = async (ctx) => {
311
312
  const described = await Promise.all(nodeIds.map((nodeId) => describeElementForCrop(client, nodeId)));
312
313
 
313
314
  const elements: PixelElementRecord[] = [];
315
+ // Direct lookup from a live/snapshot backend id to its existing crop path.
316
+ // The element rows remain the authoritative complete records; this map
317
+ // removes filename-search arithmetic for the common `backend:<id>` lookup.
318
+ const cropsByBackend: Record<string, string> = {};
314
319
  let index = 0;
315
320
  let elementsSkipped = 0;
316
321
  let elementsReadFailed = 0;
@@ -361,6 +366,7 @@ export const collectPixels: Collector = async (ctx) => {
361
366
  const cropFilename = `crops/${cropFileBase(index, entry)}.png`;
362
367
  ctx.write.binary(cropFilename, encodePng(applyMaskTransparent(normalCrop, mask)));
363
368
 
369
+ const crop = `${ctx.snapId}/${cropFilename}`;
364
370
  elements.push({
365
371
  id: `px-${index}`,
366
372
  backendNodeId: entry.backendNodeId,
@@ -372,7 +378,7 @@ export const collectPixels: Collector = async (ctx) => {
372
378
  ...(entry.clipInfo.unavailable
373
379
  ? { ancestorClipUnavailable: true as const, ancestorClipUnavailableReason: entry.clipInfo.unavailableReason }
374
380
  : {}),
375
- crop: `${ctx.snapId}/${cropFilename}`,
381
+ crop,
376
382
  maskedPixelFraction: round3(mask.count / (pixelRect.width * pixelRect.height)),
377
383
  hash: averageHash(normalCrop, mask),
378
384
  avgColor: averageColor(normalCrop, mask),
@@ -381,12 +387,14 @@ export const collectPixels: Collector = async (ctx) => {
381
387
  alphaFraction: alphaFraction(transparentCrop, mask),
382
388
  visiblePixelFraction: visiblePixelFraction(transparentCrop, mask),
383
389
  });
390
+ if (entry.backendNodeId !== null) cropsByBackend[String(entry.backendNodeId)] = crop;
384
391
  index += 1;
385
392
  }
386
393
 
387
394
  ctx.write.json('pixels.json', {
388
395
  scope: PIXELS_SCOPE,
389
396
  elements,
397
+ cropsByBackend,
390
398
  backgroundOverrideRestored,
391
399
  captureFailed: false,
392
400
  elementsTotal,
@@ -85,6 +85,11 @@
85
85
  * === after) — a baseline row for the matrix, not a forced capture.
86
86
  */
87
87
 
88
+ import * as fs from 'node:fs';
89
+ import * as path from 'node:path';
90
+ import { PNG } from 'pngjs';
91
+ import pixelmatch from 'pixelmatch';
92
+
88
93
  import type { CDPClient } from '../../client.js';
89
94
  import type { Rect } from '../../coordinates.js';
90
95
  import { forcePseudoStateForNode, type ForcedPseudoClass } from '../../domains.js';
@@ -101,6 +106,12 @@ const MAX_SELECTOR_MATCHES = 10;
101
106
  const MAX_AUTO_ELEMENTS = 8;
102
107
  /** Per-field length cap applied NODE-SIDE to page-controlled strings (text, aria-label). Passed to {@link sanitizeString} as `{ max }` — a tighter bound than the shared default. */
103
108
  const MAX_STRING_LEN = 200;
109
+ /** Bounds forced-state evidence to the target subtree plus following sibling subtrees; omitted candidates are reported in `affected.truncated`. */
110
+ const MAX_AFFECTED_ELEMENTS = 100;
111
+ /** Default CSS-pixel inset/outset around the forced target's border rect included in its raster diff. */
112
+ const DEFAULT_STATE_DIFF_PADDING_CSS_PX = 8;
113
+ /** Two animation frames let style invalidation reach paint before the forced-state screenshot; this costs roughly 33ms at 60Hz per supported force. */
114
+ const STATE_CAPTURE_SETTLE_FRAMES = 2;
104
115
 
105
116
  const CONCRETE_STATES = ['hover', 'focus', 'active', 'checked', 'open', 'disabled', 'invalid'] as const;
106
117
  type ConcreteState = (typeof CONCRETE_STATES)[number];
@@ -207,6 +218,26 @@ interface HittestDelta {
207
218
  readonly changed: boolean;
208
219
  }
209
220
 
221
+ interface StateAffectedRecord {
222
+ readonly relation: 'target' | 'descendant' | 'peer-sibling';
223
+ readonly selector?: string;
224
+ readonly backendNodeId: number | null;
225
+ readonly identityUnresolved?: true;
226
+ readonly geometry: GeometryDelta;
227
+ readonly style: StyleDelta;
228
+ readonly hittest: HittestDelta;
229
+ }
230
+
231
+ interface StateRasterArtifact {
232
+ readonly screenshot: string;
233
+ readonly diff?: string;
234
+ readonly viewportScale: { readonly available: boolean; readonly innerWidth: number; readonly innerHeight: number; readonly scaleX: number; readonly scaleY: number; readonly unavailableReason?: 'viewport-read-unavailable' };
235
+ readonly diffCrop: { readonly paddingCssPx: number; readonly borderRect: Rect };
236
+ readonly diffPixelCount?: number;
237
+ readonly changedPixelBboxCss?: Rect;
238
+ readonly diffUnavailableReason?: 'baseline-unavailable' | 'dimension-mismatch';
239
+ }
240
+
210
241
  interface StateElementRecord {
211
242
  readonly id: string;
212
243
  readonly state: string;
@@ -229,6 +260,10 @@ interface StateElementRecord {
229
260
  readonly geometry?: GeometryDelta;
230
261
  readonly style?: StyleDelta;
231
262
  readonly hittest?: HittestDelta;
263
+ /** Target, light-DOM descendants, and following sibling subtrees measured under this force. `truncated` names candidates omitted by the explicit cap. */
264
+ readonly affected?: { readonly elements: readonly StateAffectedRecord[]; readonly total: number; readonly kept: number; readonly truncated: boolean };
265
+ /** Rendered evidence captured while this exact force remained active. */
266
+ readonly raster?: StateRasterArtifact;
232
267
  /** `true` when the pre- or post-force `evalFacts` read itself failed (evaluate threw, or resolved with no `value`) — distinguishes "could not read" from a genuine `{exists:false}`/"not found" measured outcome (I-5). Present only on that failure path. */
233
268
  readonly factsUnavailable?: true;
234
269
  readonly factsUnavailableReason?: FactsUnavailableReason;
@@ -684,8 +719,178 @@ function zeroDelta(before: Rect): GeometryDelta {
684
719
  return { before, after: before, delta: { dx: 0, dy: 0, dwidth: 0, dheight: 0 }, changed: false };
685
720
  }
686
721
 
722
+ interface DomNode {
723
+ readonly nodeId?: number;
724
+ readonly nodeType?: number;
725
+ readonly nodeName?: string;
726
+ readonly backendNodeId?: number;
727
+ readonly attributes?: readonly string[];
728
+ readonly children?: readonly DomNode[];
729
+ }
730
+
731
+ interface AffectedSubject {
732
+ readonly nodeId: number;
733
+ readonly relation: StateAffectedRecord['relation'];
734
+ readonly selector?: string;
735
+ readonly backendNodeId: number | null;
736
+ readonly identityUnresolved?: true;
737
+ }
738
+
739
+ function elementNodes(node: DomNode, out: DomNode[]): void {
740
+ if (node.nodeType === 1 && typeof node.nodeId === 'number') out.push(node);
741
+ for (const child of node.children ?? []) elementNodes(child, out);
742
+ }
743
+
744
+ function findNodeAndParent(node: DomNode, nodeId: number, parent?: DomNode): { node: DomNode; parent?: DomNode } | undefined {
745
+ if (node.nodeId === nodeId) return { node, parent };
746
+ for (const child of node.children ?? []) {
747
+ const found = findNodeAndParent(child, nodeId, node);
748
+ if (found) return found;
749
+ }
750
+ return undefined;
751
+ }
752
+
753
+ /** Resolves a bounded subject population without re-requesting the document, because a new DOM root invalidates the frontend node id that CSS.forcePseudoState must restore. */
754
+ async function affectedSubjects(client: CDPClient, targetNodeId: number, target: AffectedSubject): Promise<{ subjects: AffectedSubject[]; total: number }> {
755
+ let targetObjectId: string | undefined;
756
+ let siblingsObjectId: string | undefined;
757
+ try {
758
+ const nodes: Array<{ node: DomNode; relation: StateAffectedRecord['relation'] }> = [];
759
+ const targetDescription = (await client.send('DOM.describeNode', { nodeId: targetNodeId, depth: -1, pierce: false })) as { node?: DomNode };
760
+ if (!targetDescription.node) return { subjects: [target], total: 1 };
761
+ const targetSubtree: DomNode[] = [];
762
+ elementNodes(targetDescription.node, targetSubtree);
763
+ for (const node of targetSubtree) nodes.push({ node, relation: node.nodeId === targetNodeId ? 'target' : 'descendant' });
764
+ const resolved = (await client.send('DOM.resolveNode', { nodeId: targetNodeId })) as { object?: { objectId?: string } };
765
+ targetObjectId = resolved.object?.objectId;
766
+ if (targetObjectId) {
767
+ const siblings = (await client.send('Runtime.callFunctionOn', { objectId: targetObjectId, functionDeclaration: 'function __captureStateFollowingPeers(){ const all = Array.from(this.parentElement ? this.parentElement.children : []); return all.slice(all.indexOf(this) + 1); }' })) as { result?: { objectId?: string } };
768
+ siblingsObjectId = siblings.result?.objectId;
769
+ if (siblingsObjectId) {
770
+ const properties = (await client.send('Runtime.getProperties', { objectId: siblingsObjectId, ownProperties: true })) as { result?: Array<{ name?: string; value?: { objectId?: string } }> };
771
+ for (const property of properties.result ?? []) {
772
+ if (!/^\d+$/.test(property.name ?? '') || !property.value?.objectId) continue;
773
+ const requested = (await client.send('DOM.requestNode', { objectId: property.value.objectId })) as { nodeId?: number };
774
+ if (!requested.nodeId) continue;
775
+ const siblingDescription = (await client.send('DOM.describeNode', { nodeId: requested.nodeId, depth: -1, pierce: false })) as { node?: DomNode };
776
+ if (!siblingDescription.node) continue;
777
+ const subtree: DomNode[] = [];
778
+ elementNodes(siblingDescription.node, subtree);
779
+ for (const node of subtree) nodes.push({ node, relation: 'peer-sibling' });
780
+ }
781
+ }
782
+ }
783
+ const subjects = nodes.slice(0, MAX_AFFECTED_ELEMENTS).map(({ node, relation }) => ({
784
+ nodeId: node.nodeId!, relation, selector: buildSelector(node.nodeName, node.attributes), backendNodeId: node.backendNodeId ?? null,
785
+ ...(node.backendNodeId === undefined ? { identityUnresolved: true as const } : {}),
786
+ }));
787
+ return { subjects: subjects.length ? subjects : [target], total: nodes.length || 1 };
788
+ } catch {
789
+ return { subjects: [target], total: 1 };
790
+ } finally {
791
+ for (const objectId of [siblingsObjectId, targetObjectId]) if (objectId) {
792
+ try { await client.send('Runtime.releaseObject', { objectId }); } catch { /* best-effort object release */ }
793
+ }
794
+ }
795
+ }
796
+
797
+ function nodeFactsFunction(): string {
798
+ return `function __captureStateNodeFacts() {
799
+ const el = this;
800
+ const rect = el.getBoundingClientRect();
801
+ const cs = getComputedStyle(el);
802
+ const style = {};
803
+ for (const p of ${JSON.stringify(STYLE_PROPS)}) style[p] = cs.getPropertyValue(p);
804
+ const cx = rect.left + rect.width / 2;
805
+ const cy = rect.top + rect.height / 2;
806
+ const inViewport = rect.width > 0 && rect.height > 0 && cx >= 0 && cy >= 0 && cx <= window.innerWidth && cy <= window.innerHeight;
807
+ const stack = inViewport ? document.elementsFromPoint(cx, cy) : [];
808
+ const top = stack[0] || null;
809
+ return { exists: true, tag: el.tagName, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, style, hit: { isTarget: top === el, topTag: top ? top.tagName : null }, text: (el.textContent || '').trim(), axName: el.getAttribute('aria-label') };
810
+ }`;
811
+ }
812
+
813
+ async function evalFactsForNode(client: CDPClient, nodeId: number, backendNodeId: number | null): Promise<ElementFacts> {
814
+ let objectId: string | undefined;
815
+ try {
816
+ // Child node ids in a depth-recursive DOM.describeNode response are not guaranteed to be frontend-bound; backend ids are the cross-call identity Chrome promises.
817
+ const resolved = (await client.send('DOM.resolveNode', backendNodeId === null ? { nodeId } : { backendNodeId })) as { object?: { objectId?: string } };
818
+ objectId = resolved.object?.objectId;
819
+ if (!objectId) return { exists: false, factsUnavailable: true, factsUnavailableReason: 'facts-evaluate-returned-no-value' };
820
+ const response = (await client.send('Runtime.callFunctionOn', { objectId, functionDeclaration: nodeFactsFunction(), returnByValue: true })) as { result?: { value?: ElementFacts } };
821
+ return response.result?.value ?? { exists: false, factsUnavailable: true, factsUnavailableReason: 'facts-evaluate-returned-no-value' };
822
+ } catch {
823
+ return { exists: false, factsUnavailable: true, factsUnavailableReason: 'facts-evaluate-threw' };
824
+ } finally {
825
+ if (objectId) {
826
+ try { await client.send('Runtime.releaseObject', { objectId }); } catch { /* object lifetime is browser-owned if release fails */ }
827
+ }
828
+ }
829
+ }
830
+
831
+ function deltaForFacts(before: ElementFacts, after: ElementFacts): { geometry: GeometryDelta; style: StyleDelta; hittest: HittestDelta } | undefined {
832
+ if (!before.exists || !after.exists || before.factsUnavailable || after.factsUnavailable || !before.rect || !after.rect || !before.style || !after.style || !before.hit || !after.hit) return undefined;
833
+ const geometryChanged = before.rect.x !== after.rect.x || before.rect.y !== after.rect.y || before.rect.width !== after.rect.width || before.rect.height !== after.rect.height;
834
+ return {
835
+ geometry: { before: before.rect, after: after.rect, delta: { dx: after.rect.x - before.rect.x, dy: after.rect.y - before.rect.y, dwidth: after.rect.width - before.rect.width, dheight: after.rect.height - before.rect.height }, changed: geometryChanged },
836
+ style: { changed: STYLE_PROPS.filter((property) => before.style![property] !== after.style![property]), before: sanitizeStyleValues(before.style), after: sanitizeStyleValues(after.style) },
837
+ hittest: { before: before.hit, after: after.hit, changed: before.hit.isTarget !== after.hit.isTarget || before.hit.topTag !== after.hit.topTag },
838
+ };
839
+ }
840
+
841
+ async function settleForcedState(client: CDPClient): Promise<void> {
842
+ await client.send('Runtime.evaluate', { expression: `(async function __captureStatePaintSettle(){ for (let i = 0; i < ${STATE_CAPTURE_SETTLE_FRAMES}; i += 1) await new Promise(requestAnimationFrame); return true; })()`, awaitPromise: true, returnByValue: true });
843
+ }
844
+
845
+ function round3(value: number): number { return Math.round(value * 1000) / 1000; }
846
+
847
+ /** Captures the active forced state and a target-border-plus-padding pixel diff against screenshot.png. */
848
+ async function captureStateRaster(ctx: import('../types.js').SnapshotContext, client: CDPClient, state: string, id: string, borderRect: Rect): Promise<StateRasterArtifact | undefined> {
849
+ const baselinePath = path.join(ctx.dir, 'screenshot.png');
850
+ if (!fs.existsSync(baselinePath)) return undefined;
851
+ const response = (await client.send('Page.captureScreenshot', { format: 'png' })) as { data: string };
852
+ const statePng = Buffer.from(response.data, 'base64');
853
+ const imageName = `states/${sanitizeToken(state) || 'state'}-${id}.png`;
854
+ ctx.write.binary(imageName, statePng);
855
+ const stateImage = PNG.sync.read(statePng);
856
+ const viewport = await client.send('Runtime.evaluate', { expression: '({w: window.innerWidth, h: window.innerHeight})', returnByValue: true }) as { result?: { value?: { w?: number; h?: number } } };
857
+ const w = viewport.result?.value?.w;
858
+ const h = viewport.result?.value?.h;
859
+ const available = typeof w === 'number' && w > 0 && typeof h === 'number' && h > 0;
860
+ const scaleX = available ? stateImage.width / w! : 1;
861
+ const scaleY = available ? stateImage.height / h! : 1;
862
+ const paddingCssPx = (ctx as import('../types.js').SnapshotContext & { stateDiffPaddingCssPx?: number }).stateDiffPaddingCssPx ?? DEFAULT_STATE_DIFF_PADDING_CSS_PX;
863
+ const raster: StateRasterArtifact = { screenshot: imageName, viewportScale: { available, innerWidth: available ? w! : 0, innerHeight: available ? h! : 0, scaleX: round3(scaleX), scaleY: round3(scaleY), ...(available ? {} : { unavailableReason: 'viewport-read-unavailable' as const }) }, diffCrop: { paddingCssPx, borderRect } };
864
+ const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
865
+ if (baseline.width !== stateImage.width || baseline.height !== stateImage.height) return { ...raster, diffUnavailableReason: 'dimension-mismatch' };
866
+ const x0 = Math.max(0, Math.floor((borderRect.x - paddingCssPx) * scaleX));
867
+ const y0 = Math.max(0, Math.floor((borderRect.y - paddingCssPx) * scaleY));
868
+ const x1 = Math.min(stateImage.width, Math.ceil((borderRect.x + borderRect.width + paddingCssPx) * scaleX));
869
+ const y1 = Math.min(stateImage.height, Math.ceil((borderRect.y + borderRect.height + paddingCssPx) * scaleY));
870
+ const width = Math.max(1, x1 - x0);
871
+ const height = Math.max(1, y1 - y0);
872
+ const before = new PNG({ width, height });
873
+ const after = new PNG({ width, height });
874
+ PNG.bitblt(baseline, before, x0, y0, width, height, 0, 0);
875
+ PNG.bitblt(stateImage, after, x0, y0, width, height, 0, 0);
876
+ const diff = new PNG({ width, height });
877
+ const diffPixelCount = pixelmatch(before.data, after.data, diff.data, width, height, { threshold: 0.1, diffMask: true });
878
+ const diffName = `states/${sanitizeToken(state) || 'state'}-${id}-diff.png`;
879
+ ctx.write.binary(diffName, PNG.sync.write(diff));
880
+ let minX = width, minY = height, maxX = -1, maxY = -1;
881
+ for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
882
+ const offset = (y * width + x) * 4;
883
+ if (diff.data[offset] || diff.data[offset + 1] || diff.data[offset + 2] || diff.data[offset + 3]) { minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); }
884
+ }
885
+ const changedPixelBboxCss = maxX >= 0
886
+ ? { x: round3((x0 + minX) / scaleX), y: round3((y0 + minY) / scaleY), width: round3((maxX - minX + 1) / scaleX), height: round3((maxY - minY + 1) / scaleY) }
887
+ : undefined;
888
+ return { ...raster, diff: diffName, diffPixelCount, ...(changedPixelBboxCss ? { changedPixelBboxCss } : {}) };
889
+ }
890
+
687
891
  /** Captures one requested state on one already-resolved node: baseline facts, force (when not `normal`), post-force facts, delta, and — always, even on error — restoration. */
688
892
  async function captureOneElement(
893
+ ctx: import('../types.js').SnapshotContext,
689
894
  client: CDPClient,
690
895
  selector: string,
691
896
  index: number,
@@ -722,6 +927,8 @@ async function captureOneElement(
722
927
 
723
928
  const axName = before.axName ? sanitizeString(before.axName, { max: MAX_STRING_LEN }) : undefined;
724
929
  const text = before.text ? sanitizeString(before.text, { max: MAX_STRING_LEN }) : undefined;
930
+ const population = await affectedSubjects(client, nodeId, { nodeId, relation: 'target', selector: resolvedSelector, backendNodeId, ...(identityUnresolved ? { identityUnresolved } : {}) });
931
+ const affectedBefore = await Promise.all(population.subjects.map((subject) => subject.nodeId === nodeId ? Promise.resolve(before) : evalFactsForNode(client, subject.nodeId, subject.backendNodeId)));
725
932
 
726
933
  if (state === 'normal') {
727
934
  const rect = before.rect!;
@@ -739,6 +946,16 @@ async function captureOneElement(
739
946
  geometry: zeroDelta(rect),
740
947
  style: { changed: [], before: style, after: style },
741
948
  hittest: { before: hit, after: hit, changed: false },
949
+ affected: {
950
+ elements: population.subjects.flatMap((subject, subjectIndex) => {
951
+ const facts = affectedBefore[subjectIndex]!;
952
+ const delta = deltaForFacts(facts, facts);
953
+ return delta ? [{ relation: subject.relation, selector: subject.selector, backendNodeId: subject.backendNodeId, ...(subject.identityUnresolved ? { identityUnresolved: true as const } : {}), ...delta }] : [];
954
+ }),
955
+ total: population.total,
956
+ kept: population.subjects.length,
957
+ truncated: population.total > population.subjects.length,
958
+ },
742
959
  };
743
960
  }
744
961
 
@@ -752,6 +969,8 @@ async function captureOneElement(
752
969
  let restored = true;
753
970
  let afterFactsUnavailableReason: FactsUnavailableReason | undefined;
754
971
  let forceReadUnavailableReason: ForceReadUnavailableReason | undefined;
972
+ let affectedAfter: ElementFacts[] = [];
973
+ let raster: StateRasterArtifact | undefined;
755
974
 
756
975
  try {
757
976
  if (isPseudo) {
@@ -805,6 +1024,8 @@ async function captureOneElement(
805
1024
  }
806
1025
 
807
1026
  if (supported) {
1027
+ await settleForcedState(client);
1028
+ raster = await captureStateRaster(ctx, client, state, id, before.rect!);
808
1029
  after = await evalFacts(client, selector, index);
809
1030
  if (after.factsUnavailable) {
810
1031
  supported = false;
@@ -813,9 +1034,8 @@ async function captureOneElement(
813
1034
  } else if (!after.exists) {
814
1035
  supported = false;
815
1036
  reason = 'element no longer present after forcing state';
816
- } else if (!(await identityStillMatches(client, selector, index, identity.backendNodeId))) {
817
- supported = false;
818
- reason = 'post-force facts no longer resolve to the original element (identity check failed)';
1037
+ } else {
1038
+ affectedAfter = await Promise.all(population.subjects.map((subject) => subject.nodeId === nodeId ? Promise.resolve(after!) : evalFactsForNode(client, subject.nodeId, subject.backendNodeId)));
819
1039
  }
820
1040
  }
821
1041
  } catch (err) {
@@ -831,6 +1051,12 @@ async function captureOneElement(
831
1051
  }
832
1052
  }
833
1053
 
1054
+ // Re-resolving selector[index] can invalidate CSS's frontend node id, so it happens only after the `finally` restored the forced state.
1055
+ if (supported && !(await identityStillMatches(client, selector, index, identity.backendNodeId))) {
1056
+ supported = false;
1057
+ reason = 'post-force facts no longer resolve to the original element (identity check failed)';
1058
+ }
1059
+
834
1060
  if (!supported) {
835
1061
  return {
836
1062
  id,
@@ -866,6 +1092,16 @@ async function captureOneElement(
866
1092
  const changedStyleProps = STYLE_PROPS.filter((p) => rawBeforeStyle[p] !== rawAfterStyle[p]);
867
1093
  const hitChanged = beforeHit.isTarget !== afterHit.isTarget || beforeHit.topTag !== afterHit.topTag;
868
1094
 
1095
+ const affected = {
1096
+ elements: population.subjects.flatMap((subject, subjectIndex) => {
1097
+ const delta = deltaForFacts(affectedBefore[subjectIndex]!, affectedAfter[subjectIndex]!);
1098
+ return delta ? [{ relation: subject.relation, selector: subject.selector, backendNodeId: subject.backendNodeId, ...(subject.identityUnresolved ? { identityUnresolved: true as const } : {}), ...delta }] : [];
1099
+ }),
1100
+ total: population.total,
1101
+ kept: population.subjects.length,
1102
+ truncated: population.total > population.subjects.length,
1103
+ };
1104
+
869
1105
  return {
870
1106
  id,
871
1107
  state,
@@ -889,6 +1125,8 @@ async function captureOneElement(
889
1125
  },
890
1126
  style: { changed: changedStyleProps, before: beforeStyle, after: afterStyle },
891
1127
  hittest: { before: beforeHit, after: afterHit, changed: hitChanged },
1128
+ affected,
1129
+ ...(raster ? { raster } : {}),
892
1130
  };
893
1131
  }
894
1132
 
@@ -965,6 +1203,7 @@ export const collectStates: Collector = async (ctx) => {
965
1203
  const nodeId = nodeIds[i];
966
1204
  const identity = await describeNode(client, nodeId);
967
1205
  const record = await captureOneElement(
1206
+ ctx,
968
1207
  client,
969
1208
  selector,
970
1209
  i,
@@ -980,7 +1219,7 @@ export const collectStates: Collector = async (ctx) => {
980
1219
 
981
1220
  ctx.write.json('states.json', {
982
1221
  requested: ctx.state.map((s) => sanitizeString(s)),
983
- scope: { root: 'top-document', shadowDom: 'light-only' },
1222
+ scope: { root: 'top-document', shadowDom: 'light-only', affected: { descendants: true, peerSiblings: 'following-sibling-subtrees', maxElements: MAX_AFFECTED_ELEMENTS } },
984
1223
  elements,
985
1224
  truncatedRequests,
986
1225
  });
@@ -35,6 +35,8 @@ export type { Specificity, CSSRange, WinningDeclaration } from './style-provenan
35
35
 
36
36
  const STYLES_MAX_ELEMENTS = 150;
37
37
  const PROVENANCE_CONCURRENCY = 8;
38
+ /** Bounds queued provenance work without abandoning in-flight CDP requests on the shared snap connection. Matches snapshot.ts's per-request bound but remains local to avoid a collector/orchestrator import cycle. */
39
+ const PROVENANCE_BUDGET_MS = 5_000;
38
40
 
39
41
  /** Full computed-style snapshot tracked per element — broad, cheap (no CDP round trip; read via `getComputedStyle` in one `Runtime.evaluate`). */
40
42
  const COMPUTED_PROPERTIES = [
@@ -95,6 +97,10 @@ export interface StylesCoverage {
95
97
  keptElements: number;
96
98
  /** `true` when {@link STYLES_MAX_ELEMENTS} dropped one or more candidate elements from enumeration — an explicit fact (I-5), never a silent cap. */
97
99
  elementsTruncated: boolean;
100
+ /** `true` when the bounded provenance pass stopped starting element reads before every enumerated element was inspected; completed in-flight reads are still included. */
101
+ provenanceTruncated: boolean;
102
+ /** Present only when {@link provenanceTruncated} is true; names the factual bound that stopped additional provenance requests. */
103
+ provenanceTruncationReason?: 'collector-time-budget';
98
104
  }
99
105
 
100
106
  export interface StylesReport {
@@ -205,18 +211,33 @@ function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: n
205
211
  return backendNodeId === undefined ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId };
206
212
  }
207
213
 
208
- /** Limits expensive per-element CDP provenance reads without changing document order. */
209
- async function mapWithConcurrency<T, R>(values: readonly T[], limit: number, fn: (value: T, index: number) => Promise<R>): Promise<R[]> {
210
- const result = new Array<R>(values.length);
214
+ /**
215
+ * Limits expensive per-element CDP provenance reads without changing document
216
+ * order. Once its budget expires it starts no more reads, but awaits the
217
+ * current worker batch so no CDP work is left running under the next collector.
218
+ */
219
+ async function mapWithConcurrency<T, R>(
220
+ values: readonly T[],
221
+ limit: number,
222
+ budgetMs: number,
223
+ fn: (value: T, index: number) => Promise<R>,
224
+ ): Promise<{ values: Array<R | undefined>; truncated: boolean }> {
225
+ const result = Array<R | undefined>(values.length).fill(undefined);
226
+ const deadline = Date.now() + budgetMs;
211
227
  let next = 0;
228
+ let truncated = false;
212
229
  const worker = async (): Promise<void> => {
213
230
  while (next < values.length) {
231
+ if (Date.now() >= deadline) {
232
+ truncated = true;
233
+ return;
234
+ }
214
235
  const index = next++;
215
236
  result[index] = await fn(values[index]!, index);
216
237
  }
217
238
  };
218
239
  await Promise.all(Array.from({ length: Math.min(limit, values.length) }, worker));
219
- return result;
240
+ return { values: result, truncated };
220
241
  }
221
242
 
222
243
  // ============================================================================
@@ -305,48 +326,49 @@ async function collectStylesInner(
305
326
 
306
327
  const sourceCache = new Map<string, Promise<ResolvedSourceLocation>>();
307
328
 
308
- const elements = await mapWithConcurrency(facts, PROVENANCE_CONCURRENCY, async (fact, index): Promise<StylesElementRecord> => {
309
- const ref = resolved[index];
310
- const computed = normalizeComputed(fact.computed);
311
-
312
- // A `noDeclarationRecord` is reserved for a SUCCESSFUL matched-styles response that genuinely had
313
- // no candidate for a property — an honest "nothing declares this" fact. When provenance itself
314
- // couldn't be inspected (no CDP nodeId, or `CSS.getMatchedStylesForNode` failed), that's a different
315
- // fact — "unknown", not "none" — so `winningDeclarations` is left empty and `provenanceUnavailable`
316
- // is set, rather than conflating the two into misleading no-declaration records.
317
- let winningDeclarations: WinningDeclaration[];
318
- let provenanceUnavailable: boolean | undefined;
319
- if (ref?.nodeId !== undefined) {
320
- try {
321
- const matched = (await client.send('CSS.getMatchedStylesForNode', {
322
- nodeId: ref.nodeId,
323
- })) as CDPMatchedStylesResponse;
324
- winningDeclarations = await buildWinningDeclarations(
325
- client,
326
- matched,
327
- computed,
328
- sourceCache,
329
- styleSheetUrls,
330
- PROVENANCE_PROPERTIES,
331
- );
332
- } catch {
333
- winningDeclarations = [];
334
- provenanceUnavailable = true;
335
- }
336
- } else {
329
+ const provenance = await mapWithConcurrency(facts, PROVENANCE_CONCURRENCY, PROVENANCE_BUDGET_MS, async (fact, index): Promise<StylesElementRecord> => {
330
+ const ref = resolved[index];
331
+ const computed = normalizeComputed(fact.computed);
332
+
333
+ // A `noDeclarationRecord` is reserved for a SUCCESSFUL matched-styles response that genuinely had
334
+ // no candidate for a property — an honest "nothing declares this" fact. When provenance itself
335
+ // couldn't be inspected (no CDP nodeId, or `CSS.getMatchedStylesForNode` failed), that's a different
336
+ // fact — "unknown", not "none" — so `winningDeclarations` is left empty and `provenanceUnavailable`
337
+ // is set, rather than conflating the two into misleading no-declaration records.
338
+ let winningDeclarations: WinningDeclaration[];
339
+ let provenanceUnavailable: boolean | undefined;
340
+ if (ref?.nodeId !== undefined) {
341
+ try {
342
+ const matched = (await client.send('CSS.getMatchedStylesForNode', {
343
+ nodeId: ref.nodeId,
344
+ })) as CDPMatchedStylesResponse;
345
+ winningDeclarations = await buildWinningDeclarations(
346
+ client,
347
+ matched,
348
+ computed,
349
+ sourceCache,
350
+ styleSheetUrls,
351
+ PROVENANCE_PROPERTIES,
352
+ );
353
+ } catch {
337
354
  winningDeclarations = [];
338
355
  provenanceUnavailable = true;
339
356
  }
357
+ } else {
358
+ winningDeclarations = [];
359
+ provenanceUnavailable = true;
360
+ }
340
361
 
341
- return {
342
- id: `s-${index}`,
343
- selector: sanitizeString(fact.cssPath),
344
- ...resolvedIdentity(ref?.backendNodeId),
345
- computed,
346
- winningDeclarations,
347
- provenanceUnavailable,
348
- };
349
- });
362
+ return {
363
+ id: `s-${index}`,
364
+ selector: sanitizeString(fact.cssPath),
365
+ ...resolvedIdentity(ref?.backendNodeId),
366
+ computed,
367
+ winningDeclarations,
368
+ provenanceUnavailable,
369
+ };
370
+ });
371
+ const elements = provenance.values.filter((element): element is StylesElementRecord => element !== undefined);
350
372
 
351
373
  ctx.write.json('styles.json', {
352
374
  elements,
@@ -356,7 +378,9 @@ async function collectStylesInner(
356
378
  shadowRootsNotWalked: inventory.shadowRootsNotWalked,
357
379
  totalCandidateElements: inventory.total,
358
380
  keptElements: elements.length,
359
- elementsTruncated: inventory.total > elements.length,
381
+ elementsTruncated: inventory.total > facts.length,
382
+ provenanceTruncated: provenance.truncated,
383
+ ...(provenance.truncated ? { provenanceTruncationReason: 'collector-time-budget' as const } : {}),
360
384
  },
361
385
  styleSheetHeaders: styleSheetHeaders,
362
386
  identity,