@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
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { isRejectedCaptureBaselineHash, isUniformCaptureFingerprint } from './captureBaselineSanity';
4
+
5
+ describe('isRejectedCaptureBaselineHash', () => {
6
+ it('rejects the known blank frame a software WebGPU adapter produces', () => {
7
+ // Nearly committed as ground truth once; every write path must refuse it without anyone looking.
8
+ expect(isRejectedCaptureBaselineHash('a4f2105ecdefec94c5fe749c1dc5f2fb9dd74b9832cba0afcd3434f38c0380d0')).toBe(
9
+ true,
10
+ );
11
+ });
12
+
13
+ it('accepts an ordinary hash, including one differing only in its last character', () => {
14
+ expect(isRejectedCaptureBaselineHash('a4f2105ecdefec94c5fe749c1dc5f2fb9dd74b9832cba0afcd3434f38c0380d1')).toBe(
15
+ false,
16
+ );
17
+ expect(isRejectedCaptureBaselineHash('0b7af17177ffeb0f0f88c03546a86af9a9ef9274116cb05c0d82561b5c1a51be')).toBe(
18
+ false,
19
+ );
20
+ });
21
+ });
22
+
23
+ describe('isUniformCaptureFingerprint', () => {
24
+ it('rejects a fingerprint whose cells are all identical, the blank frame a stability check cannot catch', () => {
25
+ // The real shape that was blessed once: every cell the same colour.
26
+ expect(isUniformCaptureFingerprint('16:' + 'eeddcc'.repeat(256))).toBe(true);
27
+ });
28
+
29
+ it('accepts a frame that varies anywhere, including in only one cell', () => {
30
+ expect(isUniformCaptureFingerprint('16:' + 'eeddcc'.repeat(255) + '112233')).toBe(false);
31
+ expect(isUniformCaptureFingerprint('16:112233' + 'eeddcc'.repeat(255))).toBe(false);
32
+ });
33
+
34
+ it('treats a single-cell or empty payload as uniform, since it can distinguish nothing', () => {
35
+ expect(isUniformCaptureFingerprint('1:aabbcc')).toBe(true);
36
+ expect(isUniformCaptureFingerprint('16:')).toBe(true);
37
+ });
38
+ });
@@ -4,7 +4,7 @@ import { createServer } from 'node:http';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
 
7
- import { afterAll, beforeAll, describe, expect, it } from 'vitest';
7
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
8
8
 
9
9
  import { getCaptureBenchmarkBaselinePath, runCaptureBenchmark } from './captureBenchmark';
10
10
  import { runCaptureValidation } from './captureValidation';
@@ -72,6 +72,34 @@ describe('benchmark and generalized parity browser workflow', () => {
72
72
  expect(gated.targets[1]?.baselineMetric).toBe('referenceRatio');
73
73
  }, 20_000);
74
74
 
75
+ it('fails a gating run with no committed benchmark baselines', async () => {
76
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
77
+ try {
78
+ const result = await runCaptureBenchmark({
79
+ subject: 'unconfigured',
80
+ entries: [{ name: 'work', renderers: ['dom'], routes: { dom: 'dom' } }],
81
+ server: { url: baseUrl, kill() {} },
82
+ root,
83
+ iterations: 1,
84
+ samples: 3,
85
+ sampleDurationMs: 1,
86
+ warmupIterations: 0,
87
+ reportPath: false,
88
+ });
89
+
90
+ expect(result).toMatchObject({
91
+ failed: 0,
92
+ passed: 0,
93
+ skipped: 1,
94
+ shouldFail: true,
95
+ targets: [{ status: 'skipped', error: 'no benchmark baseline' }],
96
+ });
97
+ expect(log).toHaveBeenCalledWith(expect.stringContaining('benchmark gate unconfigured'));
98
+ } finally {
99
+ log.mockRestore();
100
+ }
101
+ }, 20_000);
102
+
75
103
  it('compares DOM and arbitrary WASM-labelled targets through an explicit visual group', async () => {
76
104
  const result = await runCaptureValidation({
77
105
  subject: 'fixture',
@@ -0,0 +1,35 @@
1
+ import { Buffer } from 'node:buffer';
2
+
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import { provideCaptureDomRenderPixels } from './captureDomReadback';
6
+
7
+ describe('provideCaptureDomRenderPixels', () => {
8
+ it('screenshots the registered DOM target and supplies its encoded image to the page bridge', async () => {
9
+ const dispose = vi.fn();
10
+ const screenshot = vi.fn().mockResolvedValue(Buffer.from('pixels'));
11
+ const evaluate = vi.fn().mockResolvedValue(true);
12
+ const page = {
13
+ evaluate,
14
+ evaluateHandle: vi.fn().mockResolvedValue({
15
+ asElement: () => ({ screenshot }),
16
+ dispose,
17
+ }),
18
+ };
19
+
20
+ await expect(provideCaptureDomRenderPixels(page as never)).resolves.toBe(true);
21
+ expect(screenshot).toHaveBeenCalledWith({ animations: 'disabled' });
22
+ expect(evaluate.mock.calls[0]?.[1]).toBe(`data:image/png;base64,${Buffer.from('pixels').toString('base64')}`);
23
+ expect(dispose).toHaveBeenCalledOnce();
24
+ });
25
+
26
+ it('reports that no readback was supplied when the page has no DOM target', async () => {
27
+ const dispose = vi.fn();
28
+ const page = {
29
+ evaluateHandle: vi.fn().mockResolvedValue({ asElement: () => null, dispose }),
30
+ };
31
+
32
+ await expect(provideCaptureDomRenderPixels(page as never)).resolves.toBe(false);
33
+ expect(dispose).toHaveBeenCalledOnce();
34
+ });
35
+ });
@@ -0,0 +1,108 @@
1
+ // The entry-side half of the oracle-invocation record. functionalVerify owns SETTING it (two-armed
2
+ // there: invoked vs absent); this file owns CARRYING it into status.json, and specifically on the
3
+ // path where the oracle threw. That path is the whole point of the field — an oracle that ran and
4
+ // rejected the frame is the case an agent most needs to distinguish from one that never ran — and it
5
+ // is the path that discarded the record, so the failing arm below fails against the previous shape.
6
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
7
+ import { createServer } from 'node:http';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
12
+
13
+ import { launchBrowser } from './captureBrowser';
14
+ import { captureEntry } from './captureEntry';
15
+ import { CAPTURE_PROTOCOL_VERSION } from './captureProtocol';
16
+
17
+ // Both pages publish a terminal FAILED verification, exactly as runRenderVerification leaves the page
18
+ // when it throws. They differ only in how far the verifier got: past the assert step with the scene's
19
+ // oracle called, or short of it. Publishing the record directly (rather than bundling the real
20
+ // verifier) keeps this test about the entry's carry-through; functionalVerify.test.ts covers the
21
+ // branch that produces the value.
22
+ const verificationPage = (stage: string, oracle: string | null, error: string): string =>
23
+ `<!doctype html><canvas width="320" height="180"></canvas><script>
24
+ const ctx = document.querySelector('canvas').getContext('2d');
25
+ ctx.fillStyle = '#123'; ctx.fillRect(0, 0, 320, 180);
26
+ window.__ftVerification = {
27
+ protocolVersion: ${CAPTURE_PROTOCOL_VERSION},
28
+ render: 'canvas',
29
+ coverage: 0.5,
30
+ fingerprint: null,
31
+ state: 'failed',
32
+ stage: ${JSON.stringify(stage)},
33
+ error: ${JSON.stringify(error)},
34
+ ${oracle === null ? '' : `oracle: ${JSON.stringify(oracle)},`}
35
+ };
36
+ </script>`;
37
+
38
+ const ORACLE_ERROR = '[scene/canvas] no wide continuous ink run found — the stroke does not appear to be drawn';
39
+ const READBACK_ERROR = '[verify:canvas] blank render: no readable render bitmap';
40
+
41
+ const pages: Record<string, string> = {
42
+ '/oracle-rejected': verificationPage('asserting', 'invoked', ORACLE_ERROR),
43
+ '/failed-before-asserting': verificationPage('readingBack', null, READBACK_ERROR),
44
+ };
45
+
46
+ interface CaptureEntryStatus {
47
+ error: string | null;
48
+ oracle: 'absent' | 'invoked' | null;
49
+ state: string;
50
+ }
51
+
52
+ describe('captureEntry browser contract', () => {
53
+ const artifactRoot = mkdtempSync(join(tmpdir(), 'tool-capture-entry-'));
54
+ const server = createServer((request, response) => {
55
+ response.setHeader('content-type', 'text/html');
56
+ response.end(pages[(request.url ?? '').split('?')[0]] ?? 'not found');
57
+ });
58
+ let baseUrl = '';
59
+
60
+ const captureStatus = async (name: string): Promise<CaptureEntryStatus> => {
61
+ const session = await launchBrowser({ verify: true });
62
+ try {
63
+ await captureEntry({
64
+ baseUrl,
65
+ context: session.context,
66
+ entry: { name, renderers: ['canvas'], route: () => name },
67
+ outBase: artifactRoot,
68
+ renderers: ['canvas'],
69
+ root: artifactRoot,
70
+ tool: 'functional',
71
+ verify: true,
72
+ });
73
+ } finally {
74
+ await session.browser.close();
75
+ }
76
+ const path = join(artifactRoot, 'functional', name, 'canvas', 'status.json');
77
+ return JSON.parse(readFileSync(path, 'utf8')) as CaptureEntryStatus;
78
+ };
79
+
80
+ beforeAll(async () => {
81
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
82
+ const address = server.address();
83
+ if (address === null || typeof address === 'string') throw new Error('fixture server did not bind');
84
+ baseUrl = `http://127.0.0.1:${address.port}`;
85
+ });
86
+
87
+ afterAll(async () => {
88
+ await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
89
+ rmSync(artifactRoot, { recursive: true, force: true });
90
+ });
91
+
92
+ it('records the oracle as invoked when the oracle itself rejected the frame', async () => {
93
+ const status = await captureStatus('oracle-rejected');
94
+ expect(status.state).toBe('error');
95
+ expect(status.error).toContain('no wide continuous ink run');
96
+ // The defect this pins: the error status used to hardcode null here, so a target whose oracle ran
97
+ // and threw was indistinguishable in the artifact from one that never called an oracle at all —
98
+ // and the artifact then said "never invoked" while carrying an error only the oracle can produce.
99
+ expect(status.oracle).toBe('invoked');
100
+ }, 30_000);
101
+
102
+ it('leaves the oracle unrecorded when the run failed before reaching the assert step', async () => {
103
+ const status = await captureStatus('failed-before-asserting');
104
+ expect(status.state).toBe('error');
105
+ expect(status.error).toContain('blank render');
106
+ expect(status.oracle).toBeNull();
107
+ }, 30_000);
108
+ });
@@ -0,0 +1,45 @@
1
+ import { selectCaptureEntriesByName } from './captureEntryFilter';
2
+
3
+ const entries = [
4
+ { name: 'material-depth' },
5
+ { name: 'material-depth-orthographic' },
6
+ { name: 'material-vertex-color' },
7
+ { name: 'material-vertex-color-interpolated' },
8
+ ];
9
+
10
+ describe('selectCaptureEntriesByName', () => {
11
+ it('returns every entry when neither selector is given', () => {
12
+ expect(selectCaptureEntriesByName(entries, undefined, undefined)).toHaveLength(4);
13
+ });
14
+
15
+ // The defect this exists to fix: a name contained in a longer name cannot be selected alone by
16
+ // substring, so a WRITING run under `--filter` rewrites ground truth for an entry nobody named.
17
+ it('substring selection silently takes the longer neighbours too', () => {
18
+ expect(selectCaptureEntriesByName(entries, 'material-depth', undefined).map((e) => e.name)).toEqual([
19
+ 'material-depth',
20
+ 'material-depth-orthographic',
21
+ ]);
22
+ });
23
+
24
+ it('exact selection takes ONLY the name given', () => {
25
+ expect(selectCaptureEntriesByName(entries, undefined, 'material-depth').map((e) => e.name)).toEqual([
26
+ 'material-depth',
27
+ ]);
28
+ });
29
+
30
+ it('exact selection of a name that is a prefix of another still takes only that one', () => {
31
+ expect(selectCaptureEntriesByName(entries, undefined, 'material-vertex-color').map((e) => e.name)).toEqual([
32
+ 'material-vertex-color',
33
+ ]);
34
+ });
35
+
36
+ it('selects nothing for an exact name no entry carries, rather than falling back to substring', () => {
37
+ expect(selectCaptureEntriesByName(entries, undefined, 'material-dep')).toEqual([]);
38
+ });
39
+
40
+ // Two different selections cannot both be meant; silently preferring one would be the same class of
41
+ // quiet widening this module exists to remove.
42
+ it('refuses both selectors at once', () => {
43
+ expect(() => selectCaptureEntriesByName(entries, 'material', 'material-depth')).toThrow(/not both/);
44
+ });
45
+ });
@@ -28,6 +28,9 @@ const pages: Record<string, string> = {
28
28
  '/flaky': `<!doctype html><canvas width="320" height="180"></canvas><script>
29
29
  const ctx=document.querySelector('canvas').getContext('2d'); ctx.fillStyle='#123'; ctx.fillRect(0,0,320,180); ctx.fillStyle='#0cf'; ctx.fillRect(40,30,240,120);
30
30
  </script>`,
31
+ '/failed-resources': `<!doctype html><script src="/missing-resource.js"></script><script src="/server-error-resource.js"></script><script src="/transport-failure-resource.js"></script><canvas width="320" height="180"></canvas><script>
32
+ const ctx=document.querySelector('canvas').getContext('2d'); ctx.fillStyle='#123'; ctx.fillRect(0,0,320,180); ctx.fillStyle='#0cf'; ctx.fillRect(40,30,240,120);
33
+ </script>`,
31
34
  '/loop': `<!doctype html><canvas width="320" height="180"></canvas><script>
32
35
  const canvas=document.querySelector('canvas'); const ctx=canvas.getContext('2d'); let frame=0;
33
36
  function draw() { frame++; ctx.fillStyle='#123'; ctx.fillRect(0,0,320,180); ctx.fillStyle='#0cf'; ctx.fillRect(frame,30,240,120); requestAnimationFrame(draw); }
@@ -42,6 +45,20 @@ describe('capture eyes browser contract', () => {
42
45
  const artifactRoot = mkdtempSync(join(tmpdir(), 'tool-capture-eyes-'));
43
46
  let flakyRequests = 0;
44
47
  const server = createServer((request, response) => {
48
+ if (request.url === '/missing-resource.js') {
49
+ response.statusCode = 404;
50
+ response.end('missing');
51
+ return;
52
+ }
53
+ if (request.url === '/server-error-resource.js') {
54
+ response.statusCode = 503;
55
+ response.end('unavailable');
56
+ return;
57
+ }
58
+ if (request.url === '/transport-failure-resource.js') {
59
+ request.socket.destroy();
60
+ return;
61
+ }
45
62
  if (request.url === '/flaky' && flakyRequests++ === 0) {
46
63
  request.socket.destroy();
47
64
  return;
@@ -123,4 +140,30 @@ describe('capture eyes browser contract', () => {
123
140
  await session.browser.close();
124
141
  }
125
142
  }, 20_000);
143
+
144
+ it('names every failed resource URL across HTTP and transport failures', async () => {
145
+ const outDir = join(artifactRoot, 'failed-resources');
146
+ const diagnostics = await captureUrl(`${baseUrl}/failed-resources`, { outDir, maxRetries: 0 });
147
+ expect(diagnostics).toMatchObject({ blank: false, usable: true });
148
+
149
+ const logs = readFileSync(join(outDir, 'logs.jsonl'), 'utf8')
150
+ .split('\n')
151
+ .filter((line) => line !== '')
152
+ .map((line) => JSON.parse(line) as { channel?: string; data?: { msg?: string } });
153
+ const networkMessages = logs.filter((entry) => entry.channel === 'network').map((entry) => entry.data?.msg ?? '');
154
+ expect(networkMessages).toHaveLength(3);
155
+ expect(networkMessages).toEqual(
156
+ expect.arrayContaining([
157
+ expect.stringMatching(new RegExp(`${baseUrl}/missing-resource\\.js \\(HTTP 404`)),
158
+ expect.stringMatching(new RegExp(`${baseUrl}/server-error-resource\\.js \\(HTTP 503`)),
159
+ expect.stringMatching(new RegExp(`${baseUrl}/transport-failure-resource\\.js \\(net::ERR_`)),
160
+ ]),
161
+ );
162
+
163
+ const browserResourceMessages = logs
164
+ .map((entry) => entry.data?.msg ?? '')
165
+ .filter((message) => message.startsWith('Failed to load resource:'));
166
+ expect(browserResourceMessages.length).toBeGreaterThan(0);
167
+ expect(browserResourceMessages.every((message) => message.includes(`${baseUrl}/`))).toBe(true);
168
+ }, 15_000);
126
169
  });
@@ -26,4 +26,14 @@ describe('getFlightCaptureValidationPreset', () => {
26
26
  expect(getFlightCaptureValidationPreset('examples').fingerprintSkip).toEqual(['playingsound']);
27
27
  expect(getFlightCaptureValidationPreset('custom').fingerprintSkip).toEqual([]);
28
28
  });
29
+
30
+ it('keeps only the parity exceptions backed by current renderer behavior', () => {
31
+ expect(getFlightCaptureValidationPreset('functional').paritySkip).toEqual({
32
+ 'effect-lens-distortion': ['canvas'],
33
+ 'effect-lens-flare': ['canvas'],
34
+ 'effect-posterize': ['canvas'],
35
+ 'effect-vignette': ['canvas'],
36
+ 'effect-god-rays': 'all',
37
+ });
38
+ });
29
39
  });
@@ -19,12 +19,26 @@ function resetCapturePageWindow(): void {
19
19
  __ftTarget?: unknown;
20
20
  __ftVerification?: unknown;
21
21
  __ftBenchmarkTarget?: unknown;
22
+ __ftProvideDomRenderPixels?: (readback: { data: Uint8ClampedArray; height: number; width: number } | null) => void;
22
23
  };
23
24
  flags.__flightCapture = undefined;
24
25
  flags.__flightCaptureVerify = undefined;
25
26
  flags.__ftTarget = undefined;
26
27
  flags.__ftVerification = undefined;
27
28
  flags.__ftBenchmarkTarget = undefined;
29
+ flags.__ftProvideDomRenderPixels = undefined;
30
+ }
31
+
32
+ async function provideDomRenderPixels(): Promise<void> {
33
+ const flags = window as unknown as {
34
+ __ftProvideDomRenderPixels?: (readback: { data: Uint8ClampedArray; height: number; width: number } | null) => void;
35
+ };
36
+ await vi.waitFor(() => expect(flags.__ftProvideDomRenderPixels).toBeTypeOf('function'));
37
+ flags.__ftProvideDomRenderPixels?.({
38
+ data: new Uint8ClampedArray([0, 0, 0, 255, 255, 255, 255, 255]),
39
+ height: 1,
40
+ width: 2,
41
+ });
28
42
  }
29
43
 
30
44
  beforeEach(resetCapturePageWindow);
@@ -49,7 +63,9 @@ describe('installCaptureElementTarget', () => {
49
63
  it('adapts a renderer-owned DOM element without requiring a Flight render state', async () => {
50
64
  const element = document.createElement('div');
51
65
  element.textContent = 'reference renderer';
52
- await expect(installCaptureElementTarget({ renderer: 'dom', element, verify: true })).resolves.toMatchObject({
66
+ const verification = installCaptureElementTarget({ renderer: 'dom', element, verify: true });
67
+ await provideDomRenderPixels();
68
+ await expect(verification).resolves.toMatchObject({
53
69
  render: 'dom',
54
70
  state: 'passed',
55
71
  });
@@ -67,7 +83,7 @@ describe('installCaptureTarget', () => {
67
83
  const render = vi.fn(() => {
68
84
  element.textContent = 'ready';
69
85
  });
70
- const result = await installCaptureTarget({
86
+ const verification = installCaptureTarget({
71
87
  renderer: 'dom',
72
88
  state: { element } as unknown as DomRenderState,
73
89
  width: 800,
@@ -75,6 +91,8 @@ describe('installCaptureTarget', () => {
75
91
  render,
76
92
  verify: true,
77
93
  });
94
+ await provideDomRenderPixels();
95
+ const result = await verification;
78
96
 
79
97
  expect(render).toHaveBeenCalledOnce();
80
98
  expect(result).toMatchObject({
@@ -116,6 +134,8 @@ describe('verifyCaptureTarget', () => {
116
134
  width: 1,
117
135
  height: 1,
118
136
  });
119
- await expect(verifyCaptureTarget({}, 'dom')).resolves.toMatchObject({ state: 'passed' });
137
+ const verification = verifyCaptureTarget({}, 'dom');
138
+ await provideDomRenderPixels();
139
+ await expect(verification).resolves.toMatchObject({ state: 'passed' });
120
140
  });
121
141
  });
@@ -0,0 +1,60 @@
1
+ import type { ConsoleMessage, Page, Request, Response } from '@playwright/test';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import { formatCaptureConsoleMessage, listenForCaptureResourceFailures } from './captureResourceFailure';
5
+
6
+ describe('formatCaptureConsoleMessage', () => {
7
+ it('adds the source URL when Chromium omits it from a resource error', () => {
8
+ const message = {
9
+ location: () => ({ url: 'https://flight.test/missing.png' }),
10
+ text: () => 'Failed to load resource: the server responded with a status of 404',
11
+ } as ConsoleMessage;
12
+
13
+ expect(formatCaptureConsoleMessage(message)).toBe(
14
+ 'Failed to load resource: the server responded with a status of 404 (https://flight.test/missing.png)',
15
+ );
16
+ });
17
+
18
+ it('does not repeat a URL already present in the message', () => {
19
+ const message = {
20
+ location: () => ({ url: 'https://flight.test/missing.png' }),
21
+ text: () => 'resource failed: https://flight.test/missing.png',
22
+ } as ConsoleMessage;
23
+
24
+ expect(formatCaptureConsoleMessage(message)).toBe('resource failed: https://flight.test/missing.png');
25
+ });
26
+ });
27
+
28
+ describe('listenForCaptureResourceFailures', () => {
29
+ it('names HTTP and transport failures without duplicating one failed response', () => {
30
+ const listeners = new Map<string, (event: unknown) => void>();
31
+ const page = {
32
+ on: vi.fn((event: string, listener: (event: unknown) => void) => {
33
+ listeners.set(event, listener);
34
+ return page;
35
+ }),
36
+ } as unknown as Page;
37
+ const failures: string[] = [];
38
+ listenForCaptureResourceFailures(page, (message) => failures.push(message));
39
+
40
+ const missingRequest = {} as Request;
41
+ listeners.get('response')?.({
42
+ request: () => missingRequest,
43
+ status: () => 404,
44
+ statusText: () => 'Not Found',
45
+ url: () => 'https://flight.test/missing.png',
46
+ } as Response);
47
+ listeners.get('requestfailed')?.(missingRequest);
48
+
49
+ const transportRequest = {
50
+ failure: () => ({ errorText: 'net::ERR_CONNECTION_RESET' }),
51
+ url: () => 'https://flight.test/dropped.png',
52
+ } as Request;
53
+ listeners.get('requestfailed')?.(transportRequest);
54
+
55
+ expect(failures).toEqual([
56
+ 'resource load failed: https://flight.test/missing.png (HTTP 404 Not Found)',
57
+ 'resource load failed: https://flight.test/dropped.png (net::ERR_CONNECTION_RESET)',
58
+ ]);
59
+ });
60
+ });
@@ -0,0 +1,142 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import type { CaptureScreenshotHashPage } from './captureScreenshotHash';
4
+ import { hashCaptureScreenshotPixels, hashCaptureScreenshotPixelsOrNull } from './captureScreenshotHash';
5
+
6
+ // WHAT THESE TESTS COVER, AND WHAT THEY DO NOT.
7
+ //
8
+ // The decode itself runs in a real browser and is exercised by the functional capture suite, not here —
9
+ // there is no PNG decoder in Node, which is the whole reason the decode was put in the page. So these
10
+ // pin the CONTRACT around the decode: the screenshot reaches the page, the page's digest is what comes
11
+ // back, and a page that cannot decode fails loudly instead of degrading to something that looks like a
12
+ // hash. A test double supplies the digest, so none of this asserts that a PNG decodes correctly.
13
+ //
14
+ // ★ THE SAME LIMIT APPLIES TO THE BLANK GUARD, and it is measured rather than assumed: deleting the
15
+ // in-page blank CHECK leaves every test here passing, because the double returns the 'blank' sentinel
16
+ // directly. What is covered is the Node-side handling — that a blank result throws instead of being
17
+ // hashed, and that the caller's threshold is what reaches the page. The coverage computation itself is
18
+ // only exercised by a real capture against a real browser.
19
+
20
+ describe('hashCaptureScreenshotPixels', () => {
21
+ it('sends the screenshot to the page as base64 and returns the digest the page computed', () => {
22
+ const digest = 'a'.repeat(64);
23
+ let received: string | null = null;
24
+ const page: CaptureScreenshotHashPage = {
25
+ evaluate: async (_fn, argument) => {
26
+ received = (argument as { base64: string }).base64;
27
+ return digest as never;
28
+ },
29
+ };
30
+
31
+ return hashCaptureScreenshotPixels(page, new Uint8Array([137, 80, 78, 71]), 0.001).then((hash) => {
32
+ expect(received).toBe(Buffer.from([137, 80, 78, 71]).toString('base64'));
33
+ expect(hash).toBe(digest);
34
+ });
35
+ });
36
+
37
+ it('forwards a null blank threshold, so a verified scene keeps its own definition of blank', () => {
38
+ // The scene's verifier has already applied that scene's declared minCoverage. Null is how the caller
39
+ // says "already ruled on"; if it were coerced to a number here, a deliberately-uniform frame would be
40
+ // refused by a threshold the scene explicitly opted out of. Asserted at this seam because the blank
41
+ // comparison itself runs in the page, which these doubles do not execute.
42
+ const thresholds: Array<number | null> = [];
43
+ const page: CaptureScreenshotHashPage = {
44
+ evaluate: async (_fn, argument) => {
45
+ thresholds.push((argument as { blankCoverage: number | null }).blankCoverage);
46
+ return 'c'.repeat(64) as never;
47
+ },
48
+ };
49
+ const screenshot = new Uint8Array([137, 80, 78, 71]);
50
+
51
+ return hashCaptureScreenshotPixels(page, screenshot, null)
52
+ .then(() => hashCaptureScreenshotPixels(page, screenshot, 0.001))
53
+ .then(() => {
54
+ expect(thresholds).toEqual([null, 0.001]);
55
+ });
56
+ });
57
+
58
+ it('does NOT hash the encoded bytes — the defect this replaces', () => {
59
+ // The regression lock. If someone reverts this to sha256 over the PNG buffer, the returned hash
60
+ // becomes the encoded-bytes digest and this fails. Stated as a value rather than a shape so the
61
+ // comparison is against the exact thing that used to be written into baselines.
62
+ const screenshot = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
63
+ const encodedBytesDigest = createHash('sha256').update(screenshot).digest('hex');
64
+ const pageDigest = 'b'.repeat(64);
65
+ const page: CaptureScreenshotHashPage = { evaluate: async () => pageDigest as never };
66
+
67
+ return hashCaptureScreenshotPixels(page, screenshot, 0.001).then((hash) => {
68
+ expect(hash).toBe(pageDigest);
69
+ expect(hash).not.toBe(encodedBytesDigest);
70
+ });
71
+ });
72
+
73
+ it('THROWS when the page cannot produce a digest, rather than degrading to a different quantity', () => {
74
+ // A fallback to the encoded-bytes hash would write a value into the column that means something
75
+ // different from every other value in it, and nothing downstream could tell them apart. A failed
76
+ // capture is recoverable; a silently different quantity is not.
77
+ const page: CaptureScreenshotHashPage = { evaluate: async () => null as never };
78
+
79
+ return expect(hashCaptureScreenshotPixels(page, new Uint8Array([1]), 0.001)).rejects.toThrow(
80
+ /rather than a sha256 hex digest/,
81
+ );
82
+ });
83
+
84
+ it('rejects a page result that is string-shaped but not a digest', () => {
85
+ // `page.evaluate` returning an error string, or a truncated result, must not be written as a hash.
86
+ const page: CaptureScreenshotHashPage = { evaluate: async () => 'not-a-digest' as never };
87
+
88
+ return expect(hashCaptureScreenshotPixels(page, new Uint8Array([1]), 0.001)).rejects.toThrow(/not-a-digest/);
89
+ });
90
+ });
91
+
92
+ describe('hashCaptureScreenshotPixels blank guard', () => {
93
+ it('THROWS when the page reports the decoded frame is blank, rather than hashing nothing', () => {
94
+ // The failure shape a digest-shape check cannot see: a decode that succeeds with correct dimensions
95
+ // and byte count and contains no render. A re-read of a WebGPU swapchain does exactly this, and the
96
+ // resulting hash is stable, identical across scenes, and meaningless. Content has to be checked,
97
+ // because the shape is already right.
98
+ const page: CaptureScreenshotHashPage = { evaluate: async () => 'blank' as never };
99
+
100
+ return expect(hashCaptureScreenshotPixels(page, new Uint8Array([1]), 0.001)).rejects.toThrow(
101
+ /decoded screenshot is blank/,
102
+ );
103
+ });
104
+
105
+ it('passes the caller-supplied blank threshold into the page rather than holding its own', () => {
106
+ // One definition of "blank" for the whole capture path: the caller hands over the same
107
+ // OBSERVE_BLANK_COVERAGE the rest of captureEntry compares against. Two thresholds in adjacent code
108
+ // is how the next reader concludes they must mean different things.
109
+ let received: number | null = null;
110
+ const page: CaptureScreenshotHashPage = {
111
+ evaluate: async (_fn, argument) => {
112
+ received = (argument as { blankCoverage: number }).blankCoverage;
113
+ return 'd'.repeat(64) as never;
114
+ },
115
+ };
116
+
117
+ return hashCaptureScreenshotPixels(page, new Uint8Array([1]), 0.001).then(() => {
118
+ expect(received).toBe(0.001);
119
+ });
120
+ });
121
+ });
122
+
123
+ describe('hashCaptureScreenshotPixelsOrNull', () => {
124
+ it('returns null instead of throwing, so a failed capture still records its error', () => {
125
+ // The error path may be reached BECAUSE the page crashed. Throwing there would lose the error
126
+ // report on its way out — an unrecorded failure in place of a recorded one.
127
+ const page: CaptureScreenshotHashPage = {
128
+ evaluate: async () => {
129
+ throw new Error('Target page, context or browser has been closed');
130
+ },
131
+ };
132
+
133
+ return expect(hashCaptureScreenshotPixelsOrNull(page, new Uint8Array([1]), 0.001)).resolves.toBeNull();
134
+ });
135
+
136
+ it('returns the digest when the page CAN produce one, so the error path is not degraded by default', () => {
137
+ const digest = 'c'.repeat(64);
138
+ const page: CaptureScreenshotHashPage = { evaluate: async () => digest as never };
139
+
140
+ return expect(hashCaptureScreenshotPixelsOrNull(page, new Uint8Array([1]), 0.001)).resolves.toBe(digest);
141
+ });
142
+ });