@flighthq/tool-capture 0.3.0 → 0.3.1-next.1041.35b950c

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 (82) hide show
  1. package/README.md +1 -1
  2. package/dist/baselineStore.d.ts +12 -1
  3. package/dist/baselineStore.d.ts.map +1 -1
  4. package/dist/baselineStore.js +83 -6
  5. package/dist/baselineStore.js.map +1 -1
  6. package/dist/bin.js +80 -6
  7. package/dist/bin.js.map +1 -1
  8. package/dist/captureBaselineCoverageManifest.d.ts +80 -0
  9. package/dist/captureBaselineCoverageManifest.d.ts.map +1 -0
  10. package/dist/captureBaselineCoverageManifest.js +178 -0
  11. package/dist/captureBaselineCoverageManifest.js.map +1 -0
  12. package/dist/captureBaselineSanity.d.ts +9 -0
  13. package/dist/captureBaselineSanity.d.ts.map +1 -0
  14. package/dist/captureBaselineSanity.js +32 -0
  15. package/dist/captureBaselineSanity.js.map +1 -0
  16. package/dist/captureBenchmark.d.ts.map +1 -1
  17. package/dist/captureBenchmark.js +5 -1
  18. package/dist/captureBenchmark.js.map +1 -1
  19. package/dist/captureDomReadback.d.ts +4 -0
  20. package/dist/captureDomReadback.d.ts.map +1 -0
  21. package/dist/captureDomReadback.js +51 -0
  22. package/dist/captureDomReadback.js.map +1 -0
  23. package/dist/captureEntry.d.ts +12 -3
  24. package/dist/captureEntry.d.ts.map +1 -1
  25. package/dist/captureEntry.js +87 -36
  26. package/dist/captureEntry.js.map +1 -1
  27. package/dist/captureEntryFilter.d.ts +5 -0
  28. package/dist/captureEntryFilter.d.ts.map +1 -0
  29. package/dist/captureEntryFilter.js +23 -0
  30. package/dist/captureEntryFilter.js.map +1 -0
  31. package/dist/captureFlightPreset.js +10 -4
  32. package/dist/captureFlightPreset.js.map +1 -1
  33. package/dist/captureProtocol.d.ts +3 -0
  34. package/dist/captureProtocol.d.ts.map +1 -1
  35. package/dist/captureProtocol.js.map +1 -1
  36. package/dist/captureResourceFailure.d.ts +4 -0
  37. package/dist/captureResourceFailure.d.ts.map +1 -0
  38. package/dist/captureResourceFailure.js +31 -0
  39. package/dist/captureResourceFailure.js.map +1 -0
  40. package/dist/captureScreenshotHash.d.ts +30 -0
  41. package/dist/captureScreenshotHash.d.ts.map +1 -0
  42. package/dist/captureScreenshotHash.js +108 -0
  43. package/dist/captureScreenshotHash.js.map +1 -0
  44. package/dist/captureServer.d.ts +8 -0
  45. package/dist/captureServer.d.ts.map +1 -1
  46. package/dist/captureServer.js +126 -22
  47. package/dist/captureServer.js.map +1 -1
  48. package/dist/captureSuite.d.ts +11 -0
  49. package/dist/captureSuite.d.ts.map +1 -1
  50. package/dist/captureSuite.js +6 -4
  51. package/dist/captureSuite.js.map +1 -1
  52. package/dist/captureValidation.d.ts +20 -3
  53. package/dist/captureValidation.d.ts.map +1 -1
  54. package/dist/captureValidation.js +222 -70
  55. package/dist/captureValidation.js.map +1 -1
  56. package/dist/captureWorkflow.d.ts.map +1 -1
  57. package/dist/captureWorkflow.js +3 -0
  58. package/dist/captureWorkflow.js.map +1 -1
  59. package/dist/functionalScene3Ds.d.ts.map +1 -1
  60. package/dist/functionalScene3Ds.js +5 -3
  61. package/dist/functionalScene3Ds.js.map +1 -1
  62. package/dist/functionalVerify.d.ts.map +1 -1
  63. package/dist/functionalVerify.js +57 -16
  64. package/dist/functionalVerify.js.map +1 -1
  65. package/package.json +5 -5
  66. package/src/baselineStore.test.ts +210 -7
  67. package/src/bin.test.ts +32 -0
  68. package/src/captureBaselineCoverageManifest.test.ts +368 -0
  69. package/src/captureBaselineSanity.test.ts +38 -0
  70. package/src/captureBenchmark.e2e.test.ts +29 -1
  71. package/src/captureDomReadback.test.ts +35 -0
  72. package/src/captureEntry.e2e.test.ts +108 -0
  73. package/src/captureEntryFilter.test.ts +45 -0
  74. package/src/captureEyes.e2e.test.ts +43 -0
  75. package/src/captureFlightPreset.test.ts +10 -0
  76. package/src/capturePage.test.ts +23 -3
  77. package/src/captureResourceFailure.test.ts +60 -0
  78. package/src/captureScreenshotHash.test.ts +142 -0
  79. package/src/captureServer.test.ts +103 -3
  80. package/src/captureValidation.test.ts +217 -36
  81. package/src/functionalScene3Ds.test.ts +7 -0
  82. package/src/functionalVerify.test.ts +33 -3
@@ -5,13 +5,36 @@ vi.mock('node:child_process', async (importOriginal) => ({
5
5
  spawnSync: childProcessMocks.spawnSync,
6
6
  }));
7
7
 
8
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
- import { join } from 'node:path';
10
+ import { dirname, join } from 'node:path';
11
11
 
12
12
  import { describe, expect, it } from 'vitest';
13
13
 
14
- import { resolveCaptureDirectoryServer, resolveServer, resolveStaticServer } from './captureServer';
14
+ import {
15
+ explainCaptureDistStaleness,
16
+ resolveCaptureDirectoryServer,
17
+ resolveServer,
18
+ resolveStaticServer,
19
+ } from './captureServer';
20
+
21
+ describe('explainCaptureDistStaleness', () => {
22
+ it('reports when the build predates the source it was built from', () => {
23
+ expect(explainCaptureDistStaleness(1_000, 2_000)).toContain('measures the PREVIOUS code');
24
+ });
25
+
26
+ it('stays silent when the build is at least as new as the source', () => {
27
+ expect(explainCaptureDistStaleness(2_000, 1_000)).toBeNull();
28
+ expect(explainCaptureDistStaleness(1_000, 1_000)).toBeNull();
29
+ });
30
+
31
+ it('stays silent when a timestamp could not be read', () => {
32
+ // A comparison that could not be made must not print a reassurance OR a warning: the caller
33
+ // learns nothing either way, and a warning nobody can act on trains people to ignore it.
34
+ expect(explainCaptureDistStaleness(null, 2_000)).toBeNull();
35
+ expect(explainCaptureDistStaleness(1_000, null)).toBeNull();
36
+ });
37
+ });
15
38
 
16
39
  describe('resolveCaptureDirectoryServer', () => {
17
40
  it('serves an already-built directory and shuts down', async () => {
@@ -36,6 +59,83 @@ describe('resolveServer', () => {
36
59
  });
37
60
 
38
61
  describe('resolveStaticServer', () => {
62
+ it('refuses a stale functional dist that omits currently discovered routes', async () => {
63
+ const root = mkdtempSync(join(tmpdir(), 'capture-static-stale-'));
64
+ const dist = join(root, 'tools', 'functional', 'dist');
65
+ const scenes = join(root, 'functional', 'scenes');
66
+ mkdirSync(dist, { recursive: true });
67
+ mkdirSync(scenes, { recursive: true });
68
+ writeFileSync(join(dist, 'index.html'), '<h1>old build</h1>');
69
+ writeFileSync(join(scenes, 'new-scene.webgl.ts'), 'export {};');
70
+ writeFileSync(join(scenes, 'new-scene.webgpu.ts'), 'export {};');
71
+
72
+ try {
73
+ await expect(resolveStaticServer({ tool: 'functional', root })).rejects.toThrow(
74
+ /Functional dist is missing 2 of 2 discovered routes.*npm run build:functional.*tests\/new-scene\/webgl\/, tests\/new-scene\/webgpu\//,
75
+ );
76
+ } finally {
77
+ rmSync(root, { recursive: true, force: true });
78
+ }
79
+ });
80
+
81
+ it.each([
82
+ ['an existing scene', 'functional/scenes/existing-scene.webgl.ts'],
83
+ ['its Vite config', 'tools/functional/vite.config.ts'],
84
+ ['the shared harness', 'tools/harness/render.ts'],
85
+ ])('refuses a functional dist older than %s', async (_description, newerSource) => {
86
+ const root = mkdtempSync(join(tmpdir(), 'capture-static-old-'));
87
+ const dist = join(root, 'tools', 'functional', 'dist');
88
+ const route = join(dist, 'tests', 'existing-scene', 'webgl');
89
+ const scene = join(root, 'functional', 'scenes', 'existing-scene.webgl.ts');
90
+ mkdirSync(route, { recursive: true });
91
+ mkdirSync(join(root, 'functional', 'scenes'), { recursive: true });
92
+ writeFileSync(join(dist, 'index.html'), '<h1>old build</h1>');
93
+ writeFileSync(join(route, 'index.html'), '<h1>old route</h1>');
94
+ writeFileSync(scene, 'export {};');
95
+ mkdirSync(dirname(join(root, newerSource)), { recursive: true });
96
+ if (join(root, newerSource) !== scene) writeFileSync(join(root, newerSource), 'export {};');
97
+ utimesSync(scene, 1, 1);
98
+ utimesSync(join(dist, 'index.html'), 2, 2);
99
+ utimesSync(join(route, 'index.html'), 2, 2);
100
+ utimesSync(join(root, newerSource), 3, 3);
101
+
102
+ try {
103
+ await expect(resolveStaticServer({ tool: 'functional', root })).rejects.toThrow(
104
+ /static build is older.*npm run build:functional/,
105
+ );
106
+ } finally {
107
+ rmSync(root, { recursive: true, force: true });
108
+ }
109
+ });
110
+
111
+ it('serves a complete functional dist newer than its build inputs', async () => {
112
+ const root = mkdtempSync(join(tmpdir(), 'capture-static-fresh-'));
113
+ const dist = join(root, 'tools', 'functional', 'dist');
114
+ const route = join(dist, 'tests', 'existing-scene', 'webgl');
115
+ const scene = join(root, 'functional', 'scenes', 'existing-scene.webgl.ts');
116
+ const config = join(root, 'tools', 'functional', 'vite.config.ts');
117
+ const harness = join(root, 'tools', 'harness', 'render.ts');
118
+ mkdirSync(route, { recursive: true });
119
+ mkdirSync(join(root, 'functional', 'scenes'), { recursive: true });
120
+ mkdirSync(join(root, 'tools', 'harness'), { recursive: true });
121
+ writeFileSync(join(dist, 'index.html'), '<h1>fresh build</h1>');
122
+ writeFileSync(join(route, 'index.html'), '<h1>fresh route</h1>');
123
+ writeFileSync(scene, 'export {};');
124
+ writeFileSync(config, 'export default {};');
125
+ writeFileSync(harness, 'export {};');
126
+ for (const source of [scene, config, harness]) utimesSync(source, 1, 1);
127
+ for (const output of [join(dist, 'index.html'), join(route, 'index.html')]) utimesSync(output, 2, 2);
128
+
129
+ let server;
130
+ try {
131
+ server = await resolveStaticServer({ tool: 'functional', root });
132
+ expect(await (await fetch(`${server.url}/tests/existing-scene/webgl/`)).text()).toContain('fresh route');
133
+ } finally {
134
+ server?.kill();
135
+ rmSync(root, { recursive: true, force: true });
136
+ }
137
+ });
138
+
39
139
  it('uses npm rather than treating an inherited npx CLI as the npm CLI', async () => {
40
140
  const root = mkdtempSync(join(tmpdir(), 'capture-static-'));
41
141
  const dist = join(root, 'examples', 'runners', 'web', 'dist');
@@ -6,9 +6,14 @@ import { join } from 'node:path';
6
6
  import { describe, expect, it, vi } from 'vitest';
7
7
 
8
8
  import { setBaselineField } from './baselineStore';
9
+ import {
10
+ readCaptureBaselineCoverageManifest,
11
+ writeCaptureBaselineCoverageManifest,
12
+ } from './captureBaselineCoverageManifest';
9
13
  import { setCaptureTimeoutMs } from './captureTimeout';
10
14
  import {
11
15
  explainCaptureParityUncovered,
16
+ formatCaptureParityRanking,
12
17
  isCaptureRegressionCoverageFailure,
13
18
  isUniformCaptureFingerprint,
14
19
  explainCaptureVerificationStall,
@@ -40,6 +45,23 @@ describe('explainCaptureParityUncovered', () => {
40
45
  it('blames the skip list when several backends were eligible and still produced no pair', () => {
41
46
  expect(explainCaptureParityUncovered(3, false)).toContain('parity skip');
42
47
  });
48
+
49
+ it('names the missing reference instead of blaming the skip list, which is a different remedy', () => {
50
+ // Same eligible count as the skip case above, opposite fix: shortening the skip list does nothing
51
+ // while the reference itself is absent, and the reference can be absent without any skip involved.
52
+ const reason = explainCaptureParityUncovered(3, true, ['webgl-vs-canvas → canvas']);
53
+ expect(reason).toContain('webgl-vs-canvas → canvas');
54
+ expect(reason).toContain('NO pairs');
55
+ expect(reason).not.toContain('parity skip');
56
+ });
57
+
58
+ it('still blames ineligibility when NOTHING is eligible, since that is the root cause', () => {
59
+ // The reference is trivially absent when no renderer is eligible at all — reporting the reference
60
+ // there would name a symptom and hide the cause.
61
+ expect(explainCaptureParityUncovered(0, true, ['webgl-vs-canvas → canvas'])).toContain(
62
+ 'no renderer in any parity group is eligible',
63
+ );
64
+ });
43
65
  });
44
66
 
45
67
  describe('explainCaptureVerificationStall', () => {
@@ -78,6 +100,38 @@ describe('explainCaptureVerificationStall', () => {
78
100
  });
79
101
  });
80
102
 
103
+ describe('formatCaptureParityRanking', () => {
104
+ it('ranks measured distances widest first and states the median beside them', () => {
105
+ const text = formatCaptureParityRanking([
106
+ { distance: 0.02, entry: 'shape-fill', kind: 'parity', renderers: ['webgl', 'webgpu'] },
107
+ { distance: 11.11, entry: 'effect-glitch', kind: 'parity', renderers: ['webgl', 'webgpu'] },
108
+ { distance: 0.5, entry: 'effect-dither', kind: 'parity', renderers: ['webgl', 'webgpu'] },
109
+ ]);
110
+
111
+ // The verdict for all three is "pass"; only the ranking distinguishes them.
112
+ expect(text).toContain('3 compared');
113
+ expect(text!.indexOf('effect-glitch')).toBeLessThan(text!.indexOf('effect-dither'));
114
+ expect(text!.indexOf('effect-dither')).toBeLessThan(text!.indexOf('shape-fill'));
115
+ });
116
+
117
+ it('returns null when nothing was compared', () => {
118
+ // An empty ranking would read as agreement; no ranking says the comparison did not happen.
119
+ expect(formatCaptureParityRanking([])).toBeNull();
120
+ expect(formatCaptureParityRanking([{ entry: 'shape-fill', kind: 'baseline' }])).toBeNull();
121
+ });
122
+
123
+ it('names how many rows it withheld', () => {
124
+ const many = Array.from({ length: 14 }, (_, index) => ({
125
+ distance: index,
126
+ entry: `scene-${index}`,
127
+ kind: 'parity',
128
+ renderers: ['webgl', 'webgpu'],
129
+ }));
130
+
131
+ expect(formatCaptureParityRanking(many)).toContain('4 more not shown');
132
+ });
133
+ });
134
+
81
135
  describe('isCaptureParityCoverageFailure', () => {
82
136
  it('FAILS a gated run that compared nothing while entries wanted a comparison', () => {
83
137
  // The defect this gate exists for: 107 entries skipped, leg green.
@@ -151,7 +205,170 @@ describe('isUniformCaptureFingerprint', () => {
151
205
  });
152
206
  });
153
207
 
208
+ function createRegressionFixture(
209
+ currentSource: string,
210
+ recordedSourceHash: string,
211
+ ): { root: string; kill: () => void } {
212
+ const root = mkdtempSync(join(tmpdir(), 'capture-regression-freshness-'));
213
+ const scenes = join(root, 'functional', 'scenes');
214
+ mkdirSync(scenes, { recursive: true });
215
+ writeFileSync(join(scenes, 'sample.canvas.ts'), currentSource);
216
+ setBaselineField(root, 'functional', 'sample', 'canvas', 'fingerprint', '2:000000000000000000ffffff');
217
+ setBaselineField(root, 'functional', 'sample', 'canvas', 'sourceHash', recordedSourceHash);
218
+ return { root, kill: vi.fn() };
219
+ }
220
+
221
+ function sha256(source: string): string {
222
+ return createHash('sha256').update(source).digest('hex');
223
+ }
224
+
225
+ async function validateRegressionFixture(root: string, kill: () => void) {
226
+ return runCaptureValidation({
227
+ subject: 'functional',
228
+ entries: [{ name: 'sample', renderers: ['canvas'] }],
229
+ server: { url: 'http://unused.invalid', kill },
230
+ root,
231
+ gateParity: false,
232
+ // These assert how a FAILURE is classified, so the run legitimately produces a failing summary.
233
+ // Printing it would put a real-looking "✗ FAILED" block in the CI log of a file whose tests all
234
+ // pass, which is the kind of noise that teaches a reader to scroll past a genuine one.
235
+ quiet: true,
236
+ fingerprints: { sample: { canvas: '2:ffffffffffffffffff000000' } },
237
+ browserSession: {
238
+ browser: { close: vi.fn() } as never,
239
+ context: { newPage: vi.fn() } as never,
240
+ },
241
+ });
242
+ }
243
+
244
+ async function validateParityFixture(input: Readonly<Record<string, unknown>>) {
245
+ return runCaptureValidation({
246
+ subject: 'functional',
247
+ entries: [{ name: 'sample', renderers: ['canvas', 'webgl'] }],
248
+ server: { url: 'http://unused.invalid', kill: vi.fn() },
249
+ root: join(tmpdir(), 'tool-capture-parity-reference-fixture'),
250
+ gateParity: true,
251
+ quiet: true,
252
+ // Supplied so neither renderer reloads a page; both are parity-eligible and IDENTICAL, so any pair
253
+ // that gets built passes. A failing pair would confound "no comparison" with "a failed comparison".
254
+ fingerprints: { sample: { canvas: '1:000000', webgl: '1:000000' } },
255
+ browserSession: {
256
+ browser: { close: vi.fn() } as never,
257
+ context: { newPage: vi.fn() } as never,
258
+ },
259
+ ...input,
260
+ } as never);
261
+ }
262
+
154
263
  describe('runCaptureValidation', () => {
264
+ // The acceptance path must not become the hole. Writing the manifest from an early return reported
265
+ // exit 0 over a leg with real regression failures — the same "reports green" defect the manifest
266
+ // exists to close, one level up. Coverage is accepted; the run's own verdict still stands.
267
+ it('accepts new coverage WITHOUT masking the regression failures of the same run', async () => {
268
+ const { root, kill } = createRegressionFixture('export const scene = 1;\n', sha256('export const scene = 1;\n'));
269
+ mkdirSync(join(root, 'scripts'), { recursive: true });
270
+ const result = await runCaptureValidation({
271
+ subject: 'functional',
272
+ entries: [{ name: 'sample', renderers: ['canvas'] }],
273
+ server: { url: 'http://unused.invalid', kill },
274
+ root,
275
+ gateParity: false,
276
+ quiet: true,
277
+ updateCoverage: true,
278
+ fingerprints: { sample: { canvas: '2:ffffffffffffffffff000000' } },
279
+ browserSession: {
280
+ browser: { close: vi.fn() } as never,
281
+ context: { newPage: vi.fn() } as never,
282
+ },
283
+ });
284
+ expect(result.regressionFailures).toBe(1);
285
+ expect(result.shouldFail).toBe(true);
286
+ // A target with a FAILING comparison still has baseline evidence, so it is still covered.
287
+ expect(readCaptureBaselineCoverageManifest(root).subjects.functional).toEqual({
288
+ 'sample/canvas': ['fingerprint'],
289
+ });
290
+ });
291
+
292
+ // The blanket refusal this replaces was too coarse: one flaky target blocked every acceptance. The
293
+ // precise rule is that a run retires only what it POSITIVELY DETERMINED to be uncovered, so a target
294
+ // that never loaded keeps its pin — it cannot be retired by its own flakiness, and it blocks nothing.
295
+ it('keeps the pin of a target it could not load, instead of retiring it', async () => {
296
+ const { root, kill } = createRegressionFixture('export const scene = 1;\n', sha256('export const scene = 1;\n'));
297
+ mkdirSync(join(root, 'scripts'), { recursive: true });
298
+ writeCaptureBaselineCoverageManifest(root, 'functional', { 'sample/canvas': ['fingerprint'] });
299
+ const result = await runCaptureValidation({
300
+ subject: 'functional',
301
+ entries: [{ name: 'sample', renderers: ['canvas'] }],
302
+ server: { url: 'http://unused.invalid', kill },
303
+ root,
304
+ gateParity: false,
305
+ quiet: true,
306
+ updateCoverage: true,
307
+ fingerprints: {},
308
+ browserSession: {
309
+ browser: { close: vi.fn() } as never,
310
+ context: { newPage: vi.fn().mockRejectedValue(new Error('page unavailable')) } as never,
311
+ },
312
+ });
313
+ expect(result.loadFailures).toBeGreaterThan(0);
314
+ expect(result.shouldFail).toBe(true);
315
+ expect(readCaptureBaselineCoverageManifest(root).subjects.functional).toEqual({
316
+ 'sample/canvas': ['fingerprint'],
317
+ });
318
+ });
319
+
320
+ it('yields NO PAIRS when a declared parity reference is skipped, instead of falling back to all-pairs', async () => {
321
+ // The defect: `present.includes(reference)` is false both when a group declares no reference and
322
+ // when a skip removed the one it declared, so the second case inherited the first case's all-pairs
323
+ // fallback. The run then compared a DIFFERENT set of backends under the group's name and reported
324
+ // success. Two eligible renderers here, so a fallback would produce exactly one pair — the
325
+ // assertion is that it produces none.
326
+ const result = await validateParityFixture({
327
+ parityGroups: { 'ref-group': { targets: ['canvas', 'webgl'], reference: 'canvas' } },
328
+ paritySkip: { sample: ['canvas'] },
329
+ });
330
+
331
+ expect(result.parityPasses + result.parityFailures).toBe(0);
332
+ expect(result.parityUncovered).toBe(1);
333
+ const uncovered = result.checks.find((check) => check.kind === 'parity');
334
+ expect(uncovered?.status).toBe('skipped');
335
+ expect(uncovered?.message).toContain('ref-group → canvas');
336
+ });
337
+
338
+ it("still compares all pairs for a group that declares NO reference, which is that branch's real job", async () => {
339
+ // The negative control for the fix: the all-pairs branch must survive for the case it was written
340
+ // for. Same two renderers, same skip absent — one pair, and the scene is covered.
341
+ const result = await validateParityFixture({
342
+ parityGroups: { 'open-group': { targets: ['canvas', 'webgl'] } },
343
+ paritySkip: {},
344
+ });
345
+
346
+ expect(result.parityPasses + result.parityFailures).toBe(1);
347
+ expect(result.parityUncovered).toBe(0);
348
+ });
349
+
350
+ it('KEEPS all-pairs when the declared reference is simply not a column in the scene', async () => {
351
+ // NOT the same as a skip, and the difference is 85 real comparisons. The built-in group declares
352
+ // reference 'canvas' once for EVERY scene, and 83 functional scenes have no canvas column at all —
353
+ // 3D material, mesh, light and shadow scenes are webgl/webgpu only. Treating "never there" as
354
+ // "removed" drops the entire 3D suite's cross-backend coverage to a WARN: measured 253 → 168
355
+ // comparisons on the functional suite before this case was split back out.
356
+ const result = await validateParityFixture({
357
+ parityGroups: { visual: { targets: ['dom', 'canvas', 'webgl', 'webgpu'], reference: 'canvas' } },
358
+ paritySkip: {},
359
+ entries: [{ name: 'sample', renderers: ['webgl', 'webgpu'] }],
360
+ fingerprints: { sample: { webgl: '1:000000', webgpu: '1:000000' } },
361
+ });
362
+
363
+ expect(result.parityPasses + result.parityFailures).toBe(1);
364
+ expect(result.parityUncovered).toBe(0);
365
+ // The coverage is KEPT and the claim is made TRUE. Keeping the pairs while still labelling them
366
+ // 'visual:webgl·webgpu' is the false-claim state: the group asserts a canvas reference it never
367
+ // used. Asserting the label is what stops coverage and honesty from drifting apart.
368
+ const passed = result.checks.find((check) => check.kind === 'parity' && check.status === 'passed');
369
+ expect(passed?.message).toContain('all-pairs, no canvas column');
370
+ });
371
+
155
372
  it('is a callable fingerprint-validation orchestrator', () => {
156
373
  expect(typeof runCaptureValidation).toBe('function');
157
374
  });
@@ -216,39 +433,3 @@ describe('runCaptureValidation', () => {
216
433
  }
217
434
  });
218
435
  });
219
-
220
- function createRegressionFixture(
221
- currentSource: string,
222
- recordedSourceHash: string,
223
- ): { root: string; kill: () => void } {
224
- const root = mkdtempSync(join(tmpdir(), 'capture-regression-freshness-'));
225
- const scenes = join(root, 'functional', 'scenes');
226
- mkdirSync(scenes, { recursive: true });
227
- writeFileSync(join(scenes, 'sample.canvas.ts'), currentSource);
228
- setBaselineField(root, 'functional', 'sample', 'canvas', 'fingerprint', '1:000000');
229
- setBaselineField(root, 'functional', 'sample', 'canvas', 'sourceHash', recordedSourceHash);
230
- return { root, kill: vi.fn() };
231
- }
232
-
233
- function sha256(source: string): string {
234
- return createHash('sha256').update(source).digest('hex');
235
- }
236
-
237
- async function validateRegressionFixture(root: string, kill: () => void) {
238
- return runCaptureValidation({
239
- subject: 'functional',
240
- entries: [{ name: 'sample', renderers: ['canvas'] }],
241
- server: { url: 'http://unused.invalid', kill },
242
- root,
243
- gateParity: false,
244
- // These assert how a FAILURE is classified, so the run legitimately produces a failing summary.
245
- // Printing it would put a real-looking "✗ FAILED" block in the CI log of a file whose tests all
246
- // pass, which is the kind of noise that teaches a reader to scroll past a genuine one.
247
- quiet: true,
248
- fingerprints: { sample: { canvas: '1:ffffff' } },
249
- browserSession: {
250
- browser: { close: vi.fn() } as never,
251
- context: { newPage: vi.fn() } as never,
252
- },
253
- });
254
- }
@@ -28,6 +28,12 @@ describe('discoverFunctionalScene3Ds', () => {
28
28
  expect(discoverFunctionalScene3Ds(dir)).toEqual([{ name: 'foo', renderers: ['dom', 'canvas', 'webgl', 'webgpu'] }]);
29
29
  });
30
30
 
31
+ it('keeps every default backend when one has a specific-file override', () => {
32
+ touch('foo.ts');
33
+ touch('foo.dom.ts');
34
+ expect(discoverFunctionalScene3Ds(dir)).toEqual([{ name: 'foo', renderers: ['dom', 'canvas', 'webgl', 'webgpu'] }]);
35
+ });
36
+
31
37
  it('collects backend-specific files into one entry in default-backend order', () => {
32
38
  touch('bar.webgl.ts');
33
39
  touch('bar.canvas.ts');
@@ -44,6 +50,7 @@ describe('discoverFunctionalScene3Ds', () => {
44
50
 
45
51
  describe('functionalScene3DFile', () => {
46
52
  it('prefers the backend-specific file when it exists', () => {
53
+ touch('bar.ts');
47
54
  touch('bar.webgl.ts');
48
55
  expect(functionalScene3DFile(dir, 'bar', 'webgl')).toBe(join(dir, 'bar.webgl.ts'));
49
56
  });
@@ -17,6 +17,7 @@ interface VerificationWindowLike {
17
17
  __ftTarget?: unknown;
18
18
  __ftVerification?: unknown;
19
19
  __ftRenderImage?: unknown;
20
+ __ftProvideDomRenderPixels?: (readback: { data: Uint8ClampedArray; height: number; width: number } | null) => void;
20
21
  __ftBenchmarkTarget?: { run(): void | Promise<void> };
21
22
  __ftCaptureTimeoutMs?: number;
22
23
  __ftRealRequestAnimationFrame?: (callback: FrameRequestCallback) => number;
@@ -57,6 +58,7 @@ function resetVerificationWindow(): void {
57
58
  w.__ftTarget = undefined;
58
59
  w.__ftVerification = undefined;
59
60
  w.__ftRenderImage = undefined;
61
+ w.__ftProvideDomRenderPixels = undefined;
60
62
  w.__ftBenchmarkTarget = undefined;
61
63
  w.__ftRealRequestAnimationFrame = undefined;
62
64
  w.__ftCaptureTimeoutMs = undefined;
@@ -77,6 +79,12 @@ function domTarget(element: HTMLElement): FunctionalTarget {
77
79
  };
78
80
  }
79
81
 
82
+ function provideDomRenderPixels(data = new Uint8ClampedArray([0, 0, 0, 255, 255, 255, 255, 255])): void {
83
+ const provide = (window as unknown as VerificationWindowLike).__ftProvideDomRenderPixels;
84
+ expect(provide).toBeTypeOf('function');
85
+ provide?.({ data, height: 1, width: 2 });
86
+ }
87
+
80
88
  describe('publishFunctionalRenderSync', () => {
81
89
  it('returns false when no target is registered', () => {
82
90
  expect(publishFunctionalRenderSync('webgl')).toBe(false);
@@ -128,12 +136,34 @@ describe('runRenderVerification', () => {
128
136
  expect(verification()).toMatchObject({ state: 'failed' });
129
137
  });
130
138
 
131
- it('passes a DOM render that emitted content and records the verification', async () => {
139
+ it('runs DOM pixels through coverage, the scene oracle, and fingerprinting', async () => {
140
+ const host = document.createElement('div');
141
+ host.appendChild(document.createElement('span'));
142
+ registerFunctionalTarget(domTarget(host));
143
+ const assertRender = vi.fn();
144
+ const run = runRenderVerification({ assertRender }, 'dom');
145
+ provideDomRenderPixels();
146
+ await run;
147
+
148
+ expect(assertRender).toHaveBeenCalledOnce();
149
+ expect(verification()).toMatchObject({
150
+ render: 'dom',
151
+ state: 'passed',
152
+ coverage: 0.5,
153
+ fingerprint: expect.stringMatching(/^16:/),
154
+ error: null,
155
+ });
156
+ });
157
+
158
+ it('rejects nonempty DOM structure whose supplied pixels are blank', async () => {
132
159
  const host = document.createElement('div');
133
160
  host.appendChild(document.createElement('span'));
134
161
  registerFunctionalTarget(domTarget(host));
135
- await runRenderVerification({}, 'dom');
136
- expect(verification()).toMatchObject({ render: 'dom', state: 'passed', error: null });
162
+ const run = runRenderVerification({}, 'dom');
163
+ provideDomRenderPixels(new Uint8ClampedArray([0, 0, 0, 255, 0, 0, 0, 255]));
164
+
165
+ await expect(run).rejects.toThrow(/coverage/);
166
+ expect(verification()).toMatchObject({ state: 'failed', stage: 'measuring' });
137
167
  });
138
168
 
139
169
  it('does not wait for an animation frame on webgpu', async () => {