@flighthq/tool-capture 0.3.0-next.906.07cea63 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +1 -1
  2. package/dist/baselineStore.d.ts +1 -1
  3. package/dist/baselineStore.d.ts.map +1 -1
  4. package/dist/bin.js +19 -3
  5. package/dist/bin.js.map +1 -1
  6. package/dist/captureBrowser.d.ts +1 -0
  7. package/dist/captureBrowser.d.ts.map +1 -1
  8. package/dist/captureBrowser.js +20 -4
  9. package/dist/captureBrowser.js.map +1 -1
  10. package/dist/captureEntry.d.ts +3 -1
  11. package/dist/captureEntry.d.ts.map +1 -1
  12. package/dist/captureEntry.js +72 -23
  13. package/dist/captureEntry.js.map +1 -1
  14. package/dist/captureFlightPreset.js +12 -2
  15. package/dist/captureFlightPreset.js.map +1 -1
  16. package/dist/captureFormat.d.ts +1 -1
  17. package/dist/captureFormat.d.ts.map +1 -1
  18. package/dist/captureFormat.js +2 -1
  19. package/dist/captureFormat.js.map +1 -1
  20. package/dist/capturePage.d.ts +4 -2
  21. package/dist/capturePage.d.ts.map +1 -1
  22. package/dist/capturePage.js +14 -0
  23. package/dist/capturePage.js.map +1 -1
  24. package/dist/captureProtocol.d.ts +2 -0
  25. package/dist/captureProtocol.d.ts.map +1 -1
  26. package/dist/captureProtocol.js.map +1 -1
  27. package/dist/captureRegistryMiss.d.ts +8 -0
  28. package/dist/captureRegistryMiss.d.ts.map +1 -0
  29. package/dist/captureRegistryMiss.js +41 -0
  30. package/dist/captureRegistryMiss.js.map +1 -0
  31. package/dist/captureServer.d.ts.map +1 -1
  32. package/dist/captureServer.js +13 -19
  33. package/dist/captureServer.js.map +1 -1
  34. package/dist/captureSourceHash.d.ts +4 -0
  35. package/dist/captureSourceHash.d.ts.map +1 -0
  36. package/dist/captureSourceHash.js +22 -0
  37. package/dist/captureSourceHash.js.map +1 -0
  38. package/dist/captureSuite.d.ts.map +1 -1
  39. package/dist/captureSuite.js +6 -2
  40. package/dist/captureSuite.js.map +1 -1
  41. package/dist/captureTimeout.d.ts +4 -0
  42. package/dist/captureTimeout.d.ts.map +1 -0
  43. package/dist/captureTimeout.js +39 -0
  44. package/dist/captureTimeout.js.map +1 -0
  45. package/dist/captureValidation.d.ts +39 -0
  46. package/dist/captureValidation.d.ts.map +1 -1
  47. package/dist/captureValidation.js +274 -32
  48. package/dist/captureValidation.js.map +1 -1
  49. package/dist/captureWorkerCount.d.ts +2 -0
  50. package/dist/captureWorkerCount.d.ts.map +1 -0
  51. package/dist/captureWorkerCount.js +20 -0
  52. package/dist/captureWorkerCount.js.map +1 -0
  53. package/dist/captureWorkflow.d.ts +8 -0
  54. package/dist/captureWorkflow.d.ts.map +1 -1
  55. package/dist/captureWorkflow.js +15 -5
  56. package/dist/captureWorkflow.js.map +1 -1
  57. package/dist/contract.d.ts +1 -0
  58. package/dist/contract.d.ts.map +1 -1
  59. package/dist/contract.js +1 -0
  60. package/dist/contract.js.map +1 -1
  61. package/dist/functionalVerify.d.ts +3 -3
  62. package/dist/functionalVerify.d.ts.map +1 -1
  63. package/dist/functionalVerify.js +93 -33
  64. package/dist/functionalVerify.js.map +1 -1
  65. package/package.json +7 -5
  66. package/src/captureEntry.test.ts +36 -0
  67. package/src/captureEyes.e2e.test.ts +32 -1
  68. package/src/captureFlightPreset.test.ts +12 -0
  69. package/src/captureFormat.test.ts +22 -0
  70. package/src/capturePage.test.ts +25 -2
  71. package/src/captureRegistryMiss.test.ts +106 -0
  72. package/src/captureServer.test.ts +31 -5
  73. package/src/captureSourceHash.test.ts +62 -0
  74. package/src/captureTimeout.test.ts +77 -0
  75. package/src/captureValidation.test.ts +221 -1
  76. package/src/captureWorkerCount.test.ts +28 -0
  77. package/src/captureWorkflow.test.ts +8 -1
  78. package/src/functionalVerify.test.ts +73 -3
@@ -1,9 +1,155 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
1
3
  import { tmpdir } from 'node:os';
2
4
  import { join } from 'node:path';
3
5
 
4
6
  import { describe, expect, it, vi } from 'vitest';
5
7
 
6
- import { runCaptureValidation } from './captureValidation';
8
+ import { setBaselineField } from './baselineStore';
9
+ import { setCaptureTimeoutMs } from './captureTimeout';
10
+ import {
11
+ explainCaptureParityUncovered,
12
+ isCaptureRegressionCoverageFailure,
13
+ isUniformCaptureFingerprint,
14
+ explainCaptureVerificationStall,
15
+ isCaptureParityCoverageFailure,
16
+ runCaptureValidation,
17
+ } from './captureValidation';
18
+
19
+ const COVERED: Readonly<Parameters<typeof isCaptureParityCoverageFailure>[0]> = {
20
+ gateParity: true,
21
+ interrupted: false,
22
+ parityComparisons: 0,
23
+ parityUncovered: 1,
24
+ rendererFilterCount: 0,
25
+ };
26
+
27
+ describe('explainCaptureParityUncovered', () => {
28
+ it('names the remedy for an ineligible backend, which differs from the one-backend case', () => {
29
+ // The two states look identical in a "0 comparisons" summary but need opposite fixes, which is why
30
+ // the reason is data rather than a bare skip.
31
+ expect(explainCaptureParityUncovered(0, false)).toContain('parity group');
32
+ expect(explainCaptureParityUncovered(1, false)).toContain('nothing to compare it against');
33
+ });
34
+
35
+ it('points at group membership rather than baselines when groups are declared', () => {
36
+ expect(explainCaptureParityUncovered(0, true)).toContain('parity group');
37
+ expect(explainCaptureParityUncovered(0, true)).not.toContain('fingerprint baseline');
38
+ });
39
+
40
+ it('blames the skip list when several backends were eligible and still produced no pair', () => {
41
+ expect(explainCaptureParityUncovered(3, false)).toContain('parity skip');
42
+ });
43
+ });
44
+
45
+ describe('explainCaptureVerificationStall', () => {
46
+ it('distinguishes a verifier that never registered from one that started and stalled', () => {
47
+ // Opposite remedies: the first is a page/module failure, the second a readback that never finished.
48
+ // The bare "verifier did not run" it replaces covered both.
49
+ expect(explainCaptureVerificationStall(null, 15_000)).toContain('never registered');
50
+ expect(explainCaptureVerificationStall({ state: 'running' }, 15_000)).toContain('stalled');
51
+ });
52
+
53
+ it('reports what it waited AGAINST the budget, the number that decides if cost is the cause', () => {
54
+ const reason = explainCaptureVerificationStall({ state: 'running' }, 15_000);
55
+ expect(reason).toContain('15000ms of 15000ms');
56
+ // A short wait is a different story from one that burned the whole budget, and the reason shows it.
57
+ expect(explainCaptureVerificationStall({ state: 'running' }, 900)).toContain('900ms of 15000ms');
58
+ });
59
+
60
+ // The reason and the wait must never disagree about what the budget was — a message that names a
61
+ // budget the wait did not use is worse than no message, since the whole point of it is to be trusted
62
+ // about how long the wait actually had. Both read the same seam, so a raised budget moves both.
63
+ it('reports the configured budget, not the compiled-in default', () => {
64
+ setCaptureTimeoutMs(45_000);
65
+ try {
66
+ expect(explainCaptureVerificationStall({ state: 'running' }, 20_000)).toContain('20000ms of 45000ms');
67
+ } finally {
68
+ setCaptureTimeoutMs(null);
69
+ }
70
+ });
71
+
72
+ it('names the empty-readback case, which looks like success until the fingerprint is read', () => {
73
+ expect(explainCaptureVerificationStall({ fingerprint: null, state: 'passed' }, 4_000)).toContain('no fingerprint');
74
+ });
75
+
76
+ it('flags a stateless verifier object as a protocol mismatch rather than a stall', () => {
77
+ expect(explainCaptureVerificationStall({}, 4_000)).toContain('protocol');
78
+ });
79
+ });
80
+
81
+ describe('isCaptureParityCoverageFailure', () => {
82
+ it('FAILS a gated run that compared nothing while entries wanted a comparison', () => {
83
+ // The defect this gate exists for: 107 entries skipped, leg green.
84
+ expect(isCaptureParityCoverageFailure({ ...COVERED, parityUncovered: 107 })).toBe(true);
85
+ });
86
+
87
+ it('passes as soon as a single comparison actually ran', () => {
88
+ expect(isCaptureParityCoverageFailure({ ...COVERED, parityComparisons: 1 })).toBe(false);
89
+ });
90
+
91
+ it('does not fire when parity is not being gated', () => {
92
+ expect(isCaptureParityCoverageFailure({ ...COVERED, gateParity: false })).toBe(false);
93
+ });
94
+
95
+ it('exempts an interrupted run, whose remaining entries never ran', () => {
96
+ expect(isCaptureParityCoverageFailure({ ...COVERED, interrupted: true })).toBe(false);
97
+ });
98
+
99
+ it('exempts a run narrowed to ONE renderer, which cannot compare by construction', () => {
100
+ expect(isCaptureParityCoverageFailure({ ...COVERED, rendererFilterCount: 1 })).toBe(false);
101
+ // Two named renderers can still disagree, so that narrowing stays gated.
102
+ expect(isCaptureParityCoverageFailure({ ...COVERED, rendererFilterCount: 2 })).toBe(true);
103
+ });
104
+
105
+ it('does not fire when nothing wanted a comparison in the first place', () => {
106
+ expect(isCaptureParityCoverageFailure({ ...COVERED, parityUncovered: 0 })).toBe(false);
107
+ });
108
+ });
109
+
110
+ describe('isCaptureRegressionCoverageFailure', () => {
111
+ const UNCOVERED = {
112
+ gateRegression: true,
113
+ interrupted: false,
114
+ regressionComparisons: 0,
115
+ regressionUncovered: 100,
116
+ };
117
+
118
+ it('FAILS a gated regression leg that compared nothing, the inert-tier defect', () => {
119
+ // 0 passed / 0 failed / 100 skipped used to read as a clean pass.
120
+ expect(isCaptureRegressionCoverageFailure(UNCOVERED)).toBe(true);
121
+ });
122
+
123
+ it('passes as soon as one comparison actually ran', () => {
124
+ expect(isCaptureRegressionCoverageFailure({ ...UNCOVERED, regressionComparisons: 1 })).toBe(false);
125
+ });
126
+
127
+ it('does not fire when regression is not being gated, or when the run was interrupted', () => {
128
+ expect(isCaptureRegressionCoverageFailure({ ...UNCOVERED, gateRegression: false })).toBe(false);
129
+ expect(isCaptureRegressionCoverageFailure({ ...UNCOVERED, interrupted: true })).toBe(false);
130
+ });
131
+
132
+ it('does not fire when nothing wanted a comparison', () => {
133
+ expect(isCaptureRegressionCoverageFailure({ ...UNCOVERED, regressionUncovered: 0 })).toBe(false);
134
+ });
135
+ });
136
+
137
+ describe('isUniformCaptureFingerprint', () => {
138
+ it('rejects a fingerprint whose cells are all identical, the blank frame a stability check cannot catch', () => {
139
+ // The real shape that was blessed once: every cell the same colour.
140
+ expect(isUniformCaptureFingerprint('16:' + 'eeddcc'.repeat(256))).toBe(true);
141
+ });
142
+
143
+ it('accepts a frame that varies anywhere, including in only one cell', () => {
144
+ expect(isUniformCaptureFingerprint('16:' + 'eeddcc'.repeat(255) + '112233')).toBe(false);
145
+ expect(isUniformCaptureFingerprint('16:112233' + 'eeddcc'.repeat(255))).toBe(false);
146
+ });
147
+
148
+ it('treats a single-cell or empty payload as uniform, since it can distinguish nothing', () => {
149
+ expect(isUniformCaptureFingerprint('1:aabbcc')).toBe(true);
150
+ expect(isUniformCaptureFingerprint('16:')).toBe(true);
151
+ });
152
+ });
7
153
 
8
154
  describe('runCaptureValidation', () => {
9
155
  it('is a callable fingerprint-validation orchestrator', () => {
@@ -19,6 +165,7 @@ describe('runCaptureValidation', () => {
19
165
  server: { url: 'http://unused.invalid', kill },
20
166
  root: join(tmpdir(), 'tool-capture-reuse-fixture'),
21
167
  report: true,
168
+ quiet: true,
22
169
  fingerprints: { sample: { canvas: '1:000000' } },
23
170
  browserSession: {
24
171
  browser: { close: vi.fn() } as never,
@@ -31,4 +178,77 @@ describe('runCaptureValidation', () => {
31
178
  expect(result.skipped).toBe(1);
32
179
  expect(kill).toHaveBeenCalledOnce();
33
180
  });
181
+
182
+ it('classifies a failed regression with a changed scene source as recapture debt', async () => {
183
+ const fixture = createRegressionFixture('changed scene', sha256('captured scene'));
184
+ try {
185
+ const result = await validateRegressionFixture(fixture.root, fixture.kill);
186
+ const failure = result.checks.find((check) => check.kind === 'regression');
187
+
188
+ expect(result.shouldFail).toBe(true);
189
+ expect(failure).toMatchObject({
190
+ currentSourceHash: sha256('changed scene'),
191
+ recordedSourceHash: sha256('captured scene'),
192
+ sourceHashStatus: 'changed',
193
+ });
194
+ expect(failure?.message).toContain('recapture owed by the scene owner');
195
+ } finally {
196
+ rmSync(fixture.root, { recursive: true, force: true });
197
+ }
198
+ });
199
+
200
+ it('classifies a failed regression with unchanged scene source as environment drift', async () => {
201
+ const source = 'unchanged scene';
202
+ const fixture = createRegressionFixture(source, sha256(source));
203
+ try {
204
+ const result = await validateRegressionFixture(fixture.root, fixture.kill);
205
+ const failure = result.checks.find((check) => check.kind === 'regression');
206
+
207
+ expect(result.shouldFail).toBe(true);
208
+ expect(failure).toMatchObject({
209
+ currentSourceHash: sha256(source),
210
+ recordedSourceHash: sha256(source),
211
+ sourceHashStatus: 'unchanged',
212
+ });
213
+ expect(failure?.message).toContain('environment drift; never rebaseline');
214
+ } finally {
215
+ rmSync(fixture.root, { recursive: true, force: true });
216
+ }
217
+ });
34
218
  });
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
+ }
@@ -0,0 +1,28 @@
1
+ import { resolveCaptureWorkerCount } from './captureWorkerCount';
2
+
3
+ describe('resolveCaptureWorkerCount', () => {
4
+ it('derives the default from available parallelism with host headroom', () => {
5
+ expect(resolveCaptureWorkerCount(undefined, undefined, 4)).toBe(3);
6
+ expect(resolveCaptureWorkerCount(undefined, undefined, 1)).toBe(1);
7
+ });
8
+
9
+ it('keeps the automatic default at the four-worker expedient ceiling', () => {
10
+ expect(resolveCaptureWorkerCount(undefined, undefined, 16)).toBe(4);
11
+ });
12
+
13
+ it('lets the environment pin a worker count above the automatic ceiling', () => {
14
+ expect(resolveCaptureWorkerCount(undefined, '8', 16)).toBe(8);
15
+ });
16
+
17
+ it('lets the command-line flag override the environment', () => {
18
+ expect(resolveCaptureWorkerCount('2', '8', 16)).toBe(2);
19
+ });
20
+
21
+ it('falls back to adaptive sizing for an invalid environment override', () => {
22
+ expect(resolveCaptureWorkerCount(undefined, 'invalid', 8)).toBe(4);
23
+ });
24
+
25
+ it('preserves the existing minimum of one for an explicit zero', () => {
26
+ expect(resolveCaptureWorkerCount('0', undefined, 8)).toBe(1);
27
+ });
28
+ });
@@ -8,11 +8,17 @@ describe('runCaptureBatch', () => {
8
8
  });
9
9
 
10
10
  it('rejects an empty batch before launching browser work', async () => {
11
- await expect(runCaptureBatch({ subjects: [] })).rejects.toThrow(/No capture batch subjects/);
11
+ await expect(
12
+ runCaptureBatch({
13
+ quiet: true,
14
+ subjects: [],
15
+ }),
16
+ ).rejects.toThrow(/No capture batch subjects/);
12
17
  });
13
18
 
14
19
  it('continues after a subject setup error and aggregates the verdict', async () => {
15
20
  const result = await runCaptureBatch({
21
+ quiet: true,
16
22
  subjectWorkerCount: 2,
17
23
  subjects: [
18
24
  {
@@ -46,6 +52,7 @@ describe('runCaptureWorkflow', () => {
46
52
  it('owns the subject server once across the workflow', async () => {
47
53
  let kills = 0;
48
54
  const result = await runCaptureWorkflow({
55
+ quiet: true,
49
56
  subject: 'fixture',
50
57
  entries: [],
51
58
  server: { url: 'http://localhost:1', kill: () => kills++ },
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import type { DomRenderState } from '@flighthq/types/contract';
4
- import { afterEach, describe, expect, it } from 'vitest';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
6
  import type { FunctionalTarget } from './functionalVerify';
7
7
  import {
@@ -18,19 +18,52 @@ interface VerificationWindowLike {
18
18
  __ftVerification?: unknown;
19
19
  __ftRenderImage?: unknown;
20
20
  __ftBenchmarkTarget?: { run(): void | Promise<void> };
21
+ __ftCaptureTimeoutMs?: number;
22
+ __ftRealRequestAnimationFrame?: (callback: FrameRequestCallback) => number;
23
+ }
24
+
25
+ // Stands in for the browser having stopped delivering frames: callbacks are accepted and never invoked.
26
+ // The verifier reads the harness-stashed rAF, so replacing that one covers the path it actually takes.
27
+ function stubNeverFiringAnimationFrames(): void {
28
+ (window as unknown as VerificationWindowLike).__ftRealRequestAnimationFrame = () => 0;
29
+ }
30
+
31
+ // Drives the frame wait to its deadline under fake timers and asserts the bound it actually used. The
32
+ // elapsed time is the assertion: advancing just short of the expected deadline must not settle it.
33
+ async function expectFrameWaitTimeout(expectedMs: number): Promise<void> {
34
+ vi.useFakeTimers();
35
+ try {
36
+ const run = runRenderVerification({}, 'webgl');
37
+ const rejected = expect(run).rejects.toThrow(
38
+ new RegExp(`stalled in stage awaitingFrame: no animation frame within ${expectedMs}ms`),
39
+ );
40
+ let settled = false;
41
+ void run.catch(() => (settled = true));
42
+ await vi.advanceTimersByTimeAsync(expectedMs - 1);
43
+ expect(settled).toBe(false);
44
+ await vi.advanceTimersByTimeAsync(1);
45
+ await rejected;
46
+ } finally {
47
+ vi.useRealTimers();
48
+ }
21
49
  }
22
50
 
23
51
  function verification(): Record<string, unknown> {
24
52
  return (window as unknown as VerificationWindowLike).__ftVerification as Record<string, unknown>;
25
53
  }
26
54
 
27
- afterEach(() => {
55
+ function resetVerificationWindow(): void {
28
56
  const w = window as unknown as VerificationWindowLike;
29
57
  w.__ftTarget = undefined;
30
58
  w.__ftVerification = undefined;
31
59
  w.__ftRenderImage = undefined;
32
60
  w.__ftBenchmarkTarget = undefined;
33
- });
61
+ w.__ftRealRequestAnimationFrame = undefined;
62
+ w.__ftCaptureTimeoutMs = undefined;
63
+ }
64
+
65
+ beforeEach(resetVerificationWindow);
66
+ afterEach(resetVerificationWindow);
34
67
 
35
68
  // A minimal DOM target — the only backend snapshot/verification handles without a real GPU context.
36
69
  function domTarget(element: HTMLElement): FunctionalTarget {
@@ -102,6 +135,43 @@ describe('runRenderVerification', () => {
102
135
  await runRenderVerification({}, 'dom');
103
136
  expect(verification()).toMatchObject({ render: 'dom', state: 'passed', error: null });
104
137
  });
138
+
139
+ it('does not wait for an animation frame on webgpu', async () => {
140
+ // webgpu pixels are already in the retained capture buffer when the verifier runs, so it must not
141
+ // depend on the browser scheduling a frame for a canvas it never presents. With rAF stubbed out
142
+ // entirely, this reaches the readback and fails there — if it still waited, it would never return.
143
+ stubNeverFiringAnimationFrames();
144
+
145
+ await expect(runRenderVerification({}, 'webgpu')).rejects.toThrow(/no readable render bitmap/);
146
+ expect(verification()).toMatchObject({ state: 'failed', stage: 'readingBack' });
147
+ });
148
+
149
+ it('fails by name when the animation frame never arrives, naming the stage it sat in', async () => {
150
+ stubNeverFiringAnimationFrames();
151
+
152
+ // No injected budget: the wait falls back to the runner's default ceiling and takes its share of it.
153
+ await expectFrameWaitTimeout(4_000);
154
+
155
+ // The failure is terminal and carries its location — the runner's outer wait would otherwise have
156
+ // reported the bare `state: pending` this replaces.
157
+ expect(verification()).toMatchObject({ state: 'failed', stage: 'awaitingFrame' });
158
+ });
159
+
160
+ it('scales its wait with the budget the runner injected', async () => {
161
+ // The regression this prevents: the runner raised its budget for a contended machine and the page's
162
+ // own bounds stayed pinned to the old ceiling, turning slow-but-working captures into hard failures.
163
+ stubNeverFiringAnimationFrames();
164
+ (window as unknown as VerificationWindowLike).__ftCaptureTimeoutMs = 45_000;
165
+
166
+ await expectFrameWaitTimeout(12_000);
167
+ });
168
+
169
+ it('ignores an injected budget that is not a usable number', async () => {
170
+ stubNeverFiringAnimationFrames();
171
+ (window as unknown as VerificationWindowLike).__ftCaptureTimeoutMs = 0;
172
+
173
+ await expectFrameWaitTimeout(4_000);
174
+ });
105
175
  });
106
176
 
107
177
  describe('snapshotFunctionalRender', () => {