@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.
- package/README.md +1 -1
- package/dist/baselineStore.d.ts +12 -1
- package/dist/baselineStore.d.ts.map +1 -1
- package/dist/baselineStore.js +83 -6
- package/dist/baselineStore.js.map +1 -1
- package/dist/bin.js +80 -6
- package/dist/bin.js.map +1 -1
- package/dist/captureBaselineCoverageManifest.d.ts +80 -0
- package/dist/captureBaselineCoverageManifest.d.ts.map +1 -0
- package/dist/captureBaselineCoverageManifest.js +178 -0
- package/dist/captureBaselineCoverageManifest.js.map +1 -0
- package/dist/captureBaselineSanity.d.ts +9 -0
- package/dist/captureBaselineSanity.d.ts.map +1 -0
- package/dist/captureBaselineSanity.js +32 -0
- package/dist/captureBaselineSanity.js.map +1 -0
- package/dist/captureBenchmark.d.ts.map +1 -1
- package/dist/captureBenchmark.js +5 -1
- package/dist/captureBenchmark.js.map +1 -1
- package/dist/captureDomReadback.d.ts +4 -0
- package/dist/captureDomReadback.d.ts.map +1 -0
- package/dist/captureDomReadback.js +51 -0
- package/dist/captureDomReadback.js.map +1 -0
- package/dist/captureEntry.d.ts +12 -3
- package/dist/captureEntry.d.ts.map +1 -1
- package/dist/captureEntry.js +87 -36
- package/dist/captureEntry.js.map +1 -1
- package/dist/captureEntryFilter.d.ts +5 -0
- package/dist/captureEntryFilter.d.ts.map +1 -0
- package/dist/captureEntryFilter.js +23 -0
- package/dist/captureEntryFilter.js.map +1 -0
- package/dist/captureFlightPreset.js +10 -4
- package/dist/captureFlightPreset.js.map +1 -1
- package/dist/captureProtocol.d.ts +3 -0
- package/dist/captureProtocol.d.ts.map +1 -1
- package/dist/captureProtocol.js.map +1 -1
- package/dist/captureResourceFailure.d.ts +4 -0
- package/dist/captureResourceFailure.d.ts.map +1 -0
- package/dist/captureResourceFailure.js +31 -0
- package/dist/captureResourceFailure.js.map +1 -0
- package/dist/captureScreenshotHash.d.ts +30 -0
- package/dist/captureScreenshotHash.d.ts.map +1 -0
- package/dist/captureScreenshotHash.js +108 -0
- package/dist/captureScreenshotHash.js.map +1 -0
- package/dist/captureServer.d.ts +8 -0
- package/dist/captureServer.d.ts.map +1 -1
- package/dist/captureServer.js +126 -22
- package/dist/captureServer.js.map +1 -1
- package/dist/captureSuite.d.ts +11 -0
- package/dist/captureSuite.d.ts.map +1 -1
- package/dist/captureSuite.js +6 -4
- package/dist/captureSuite.js.map +1 -1
- package/dist/captureValidation.d.ts +20 -3
- package/dist/captureValidation.d.ts.map +1 -1
- package/dist/captureValidation.js +222 -70
- package/dist/captureValidation.js.map +1 -1
- package/dist/captureWorkflow.d.ts.map +1 -1
- package/dist/captureWorkflow.js +3 -0
- package/dist/captureWorkflow.js.map +1 -1
- package/dist/functionalScene3Ds.d.ts.map +1 -1
- package/dist/functionalScene3Ds.js +5 -3
- package/dist/functionalScene3Ds.js.map +1 -1
- package/dist/functionalVerify.d.ts.map +1 -1
- package/dist/functionalVerify.js +57 -16
- package/dist/functionalVerify.js.map +1 -1
- package/package.json +5 -5
- package/src/baselineStore.test.ts +210 -7
- package/src/bin.test.ts +32 -0
- package/src/captureBaselineCoverageManifest.test.ts +368 -0
- package/src/captureBaselineSanity.test.ts +38 -0
- package/src/captureBenchmark.e2e.test.ts +29 -1
- package/src/captureDomReadback.test.ts +35 -0
- package/src/captureEntry.e2e.test.ts +108 -0
- package/src/captureEntryFilter.test.ts +45 -0
- package/src/captureEyes.e2e.test.ts +43 -0
- package/src/captureFlightPreset.test.ts +10 -0
- package/src/capturePage.test.ts +23 -3
- package/src/captureResourceFailure.test.ts +60 -0
- package/src/captureScreenshotHash.test.ts +142 -0
- package/src/captureServer.test.ts +103 -3
- package/src/captureValidation.test.ts +217 -36
- package/src/functionalScene3Ds.test.ts +7 -0
- 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 {
|
|
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('
|
|
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
|
-
|
|
136
|
-
|
|
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 () => {
|