@hyperframes/engine 0.6.118 → 0.6.120
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/package.json +24 -7
- package/scripts/generate-lut-reference.py +0 -168
- package/scripts/test-fitTextFontSize-browser.ts +0 -135
- package/src/cdp-headless-experimental.d.ts +0 -54
- package/src/config.test.ts +0 -213
- package/src/config.ts +0 -417
- package/src/index.ts +0 -273
- package/src/services/audioMixer.test.ts +0 -326
- package/src/services/audioMixer.ts +0 -604
- package/src/services/audioMixer.types.ts +0 -35
- package/src/services/audioVolumeEnvelope.test.ts +0 -176
- package/src/services/audioVolumeEnvelope.ts +0 -138
- package/src/services/browserManager.test.ts +0 -330
- package/src/services/browserManager.ts +0 -670
- package/src/services/chunkEncoder.test.ts +0 -1415
- package/src/services/chunkEncoder.ts +0 -831
- package/src/services/chunkEncoder.types.ts +0 -60
- package/src/services/extractionCache.test.ts +0 -199
- package/src/services/extractionCache.ts +0 -216
- package/src/services/fileServer.ts +0 -110
- package/src/services/frameCapture-discardWarmup.test.ts +0 -183
- package/src/services/frameCapture-namePolyfill.test.ts +0 -78
- package/src/services/frameCapture-pollImagesReady.test.ts +0 -153
- package/src/services/frameCapture-staticDedupIndex.test.ts +0 -76
- package/src/services/frameCapture-warmupTicks.test.ts +0 -174
- package/src/services/frameCapture.test.ts +0 -192
- package/src/services/frameCapture.ts +0 -1934
- package/src/services/hdrCapture.test.ts +0 -159
- package/src/services/hdrCapture.ts +0 -315
- package/src/services/parallelCoordinator.test.ts +0 -139
- package/src/services/parallelCoordinator.ts +0 -437
- package/src/services/screenshotService.test.ts +0 -510
- package/src/services/screenshotService.ts +0 -615
- package/src/services/streamingEncoder.test.ts +0 -832
- package/src/services/streamingEncoder.ts +0 -594
- package/src/services/systemMemory.test.ts +0 -324
- package/src/services/systemMemory.ts +0 -180
- package/src/services/videoFrameExtractor.test.ts +0 -1062
- package/src/services/videoFrameExtractor.ts +0 -1139
- package/src/services/videoFrameInjector.test.ts +0 -300
- package/src/services/videoFrameInjector.ts +0 -687
- package/src/services/vp9Options.ts +0 -13
- package/src/types.ts +0 -191
- package/src/utils/alphaBlit.test.ts +0 -1349
- package/src/utils/alphaBlit.ts +0 -1015
- package/src/utils/assertSwiftShader.test.ts +0 -130
- package/src/utils/assertSwiftShader.ts +0 -126
- package/src/utils/ffmpegBinaries.test.ts +0 -43
- package/src/utils/ffmpegBinaries.ts +0 -63
- package/src/utils/ffprobe.test.ts +0 -342
- package/src/utils/ffprobe.ts +0 -457
- package/src/utils/gpuEncoder.test.ts +0 -140
- package/src/utils/gpuEncoder.ts +0 -268
- package/src/utils/hdr.test.ts +0 -191
- package/src/utils/hdr.ts +0 -137
- package/src/utils/hdrCompositing.test.ts +0 -130
- package/src/utils/htmlTemplate.test.ts +0 -42
- package/src/utils/htmlTemplate.ts +0 -42
- package/src/utils/layerCompositor.test.ts +0 -150
- package/src/utils/layerCompositor.ts +0 -58
- package/src/utils/parityContract.ts +0 -1
- package/src/utils/processTracker.test.ts +0 -74
- package/src/utils/processTracker.ts +0 -41
- package/src/utils/readWebGlVendorInfoFromCanvas.ts +0 -52
- package/src/utils/runFfmpeg.test.ts +0 -102
- package/src/utils/runFfmpeg.ts +0 -136
- package/src/utils/shaderTransitions.test.ts +0 -738
- package/src/utils/shaderTransitions.ts +0 -1130
- package/src/utils/uint16-alignment-audit.test.ts +0 -125
- package/src/utils/urlDownloader.test.ts +0 -65
- package/src/utils/urlDownloader.ts +0 -143
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -7
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for `discardWarmupCapture` — the helper distributed chunk workers
|
|
3
|
-
* run before their first real capture to prime `lastFrameCache`.
|
|
4
|
-
*
|
|
5
|
-
* The helper is a thin wrapper around the inner `captureFrameCore`
|
|
6
|
-
* machinery, so its testable contract is post-conditional rather than
|
|
7
|
-
* pixel-level:
|
|
8
|
-
*
|
|
9
|
-
* 1. The wrapper invokes the inner capture exactly once with the supplied
|
|
10
|
-
* `(frameIndex, time)`.
|
|
11
|
-
* 2. After the wrapper returns, the session's perf and BeginFrame damage
|
|
12
|
-
* counters look exactly as they did before — even though the inner
|
|
13
|
-
* capture mutated them.
|
|
14
|
-
* 3. The wrapper writes no file to disk (no path is plumbed through;
|
|
15
|
-
* asserted indirectly by observing that `outputDir` is never read).
|
|
16
|
-
* 4. State is restored even if the inner capture throws.
|
|
17
|
-
*
|
|
18
|
-
* The inner capture is stubbed (the helper accepts an injectable
|
|
19
|
-
* `innerCapture` for exactly this reason). We don't need a real Chrome.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { describe, expect, it } from "vitest";
|
|
23
|
-
import { existsSync, readdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
24
|
-
import { join } from "node:path";
|
|
25
|
-
import { tmpdir } from "node:os";
|
|
26
|
-
import { discardWarmupCapture, type CaptureSession } from "./frameCapture.js";
|
|
27
|
-
|
|
28
|
-
function makeFakeSession(): CaptureSession {
|
|
29
|
-
// The discardWarmupCapture wrapper only reads `capturePerf`,
|
|
30
|
-
// `beginFrameHasDamageCount`, `beginFrameNoDamageCount`. Everything else
|
|
31
|
-
// is unused — leave it as bare-minimum stubs cast through `unknown`.
|
|
32
|
-
return {
|
|
33
|
-
browser: {} as unknown,
|
|
34
|
-
page: {} as unknown,
|
|
35
|
-
options: { width: 1, height: 1, fps: { num: 30, den: 1 } },
|
|
36
|
-
serverUrl: "",
|
|
37
|
-
outputDir: mkdtempSync(join(tmpdir(), "discard-warmup-")),
|
|
38
|
-
onBeforeCapture: null,
|
|
39
|
-
isInitialized: true,
|
|
40
|
-
browserConsoleBuffer: [],
|
|
41
|
-
capturePerf: {
|
|
42
|
-
frames: 7,
|
|
43
|
-
seekMs: 100,
|
|
44
|
-
beforeCaptureMs: 50,
|
|
45
|
-
screenshotMs: 200,
|
|
46
|
-
totalMs: 350,
|
|
47
|
-
},
|
|
48
|
-
captureMode: "beginframe",
|
|
49
|
-
beginFrameTimeTicks: 0,
|
|
50
|
-
beginFrameIntervalMs: 33,
|
|
51
|
-
beginFrameHasDamageCount: 4,
|
|
52
|
-
beginFrameNoDamageCount: 3,
|
|
53
|
-
} as unknown as CaptureSession;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
describe("discardWarmupCapture", () => {
|
|
57
|
-
it("calls the inner capture exactly once with (frameIndex=0, time=0) by default", async () => {
|
|
58
|
-
const session = makeFakeSession();
|
|
59
|
-
try {
|
|
60
|
-
let calls = 0;
|
|
61
|
-
let receivedFrameIndex = -1;
|
|
62
|
-
let receivedTime = -1;
|
|
63
|
-
await discardWarmupCapture(session, undefined, undefined, async (_s, fi, t) => {
|
|
64
|
-
calls++;
|
|
65
|
-
receivedFrameIndex = fi;
|
|
66
|
-
receivedTime = t;
|
|
67
|
-
return { buffer: Buffer.alloc(0), quantizedTime: t, captureTimeMs: 0 };
|
|
68
|
-
});
|
|
69
|
-
expect(calls).toBe(1);
|
|
70
|
-
expect(receivedFrameIndex).toBe(0);
|
|
71
|
-
expect(receivedTime).toBe(0);
|
|
72
|
-
} finally {
|
|
73
|
-
rmSync(session.outputDir, { recursive: true, force: true });
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("passes through caller-supplied (frameIndex, time)", async () => {
|
|
78
|
-
const session = makeFakeSession();
|
|
79
|
-
try {
|
|
80
|
-
let received: { fi: number; t: number } | null = null;
|
|
81
|
-
await discardWarmupCapture(session, 240, 8, async (_s, fi, t) => {
|
|
82
|
-
received = { fi, t };
|
|
83
|
-
return { buffer: Buffer.alloc(0), quantizedTime: t, captureTimeMs: 0 };
|
|
84
|
-
});
|
|
85
|
-
expect(received).toEqual({ fi: 240, t: 8 });
|
|
86
|
-
} finally {
|
|
87
|
-
rmSync(session.outputDir, { recursive: true, force: true });
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("restores perf counters after the inner capture mutates them", async () => {
|
|
92
|
-
const session = makeFakeSession();
|
|
93
|
-
const before = { ...session.capturePerf };
|
|
94
|
-
try {
|
|
95
|
-
await discardWarmupCapture(session, 0, 0, async (s) => {
|
|
96
|
-
s.capturePerf.frames += 1;
|
|
97
|
-
s.capturePerf.seekMs += 12;
|
|
98
|
-
s.capturePerf.beforeCaptureMs += 5;
|
|
99
|
-
s.capturePerf.screenshotMs += 33;
|
|
100
|
-
s.capturePerf.totalMs += 50;
|
|
101
|
-
return { buffer: Buffer.alloc(0), quantizedTime: 0, captureTimeMs: 50 };
|
|
102
|
-
});
|
|
103
|
-
expect(session.capturePerf).toEqual(before);
|
|
104
|
-
} finally {
|
|
105
|
-
rmSync(session.outputDir, { recursive: true, force: true });
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("restores BeginFrame damage counters after the inner capture mutates them", async () => {
|
|
110
|
-
const session = makeFakeSession();
|
|
111
|
-
const hasBefore = session.beginFrameHasDamageCount;
|
|
112
|
-
const noBefore = session.beginFrameNoDamageCount;
|
|
113
|
-
try {
|
|
114
|
-
await discardWarmupCapture(session, 0, 0, async (s) => {
|
|
115
|
-
s.beginFrameHasDamageCount += 10;
|
|
116
|
-
s.beginFrameNoDamageCount += 1;
|
|
117
|
-
return { buffer: Buffer.alloc(0), quantizedTime: 0, captureTimeMs: 0 };
|
|
118
|
-
});
|
|
119
|
-
expect(session.beginFrameHasDamageCount).toBe(hasBefore);
|
|
120
|
-
expect(session.beginFrameNoDamageCount).toBe(noBefore);
|
|
121
|
-
} finally {
|
|
122
|
-
rmSync(session.outputDir, { recursive: true, force: true });
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("restores state even when the inner capture throws", async () => {
|
|
127
|
-
const session = makeFakeSession();
|
|
128
|
-
const perfBefore = { ...session.capturePerf };
|
|
129
|
-
const hasBefore = session.beginFrameHasDamageCount;
|
|
130
|
-
const noBefore = session.beginFrameNoDamageCount;
|
|
131
|
-
try {
|
|
132
|
-
let thrown: unknown;
|
|
133
|
-
try {
|
|
134
|
-
await discardWarmupCapture(session, 0, 0, async (s) => {
|
|
135
|
-
s.capturePerf.frames += 5;
|
|
136
|
-
s.beginFrameNoDamageCount += 2;
|
|
137
|
-
throw new Error("simulated capture failure");
|
|
138
|
-
});
|
|
139
|
-
} catch (err) {
|
|
140
|
-
thrown = err;
|
|
141
|
-
}
|
|
142
|
-
expect((thrown as Error).message).toBe("simulated capture failure");
|
|
143
|
-
// The whole point of `finally { restore }`: failure must not leak
|
|
144
|
-
// inflated counters into the real capture summary.
|
|
145
|
-
expect(session.capturePerf).toEqual(perfBefore);
|
|
146
|
-
expect(session.beginFrameHasDamageCount).toBe(hasBefore);
|
|
147
|
-
expect(session.beginFrameNoDamageCount).toBe(noBefore);
|
|
148
|
-
} finally {
|
|
149
|
-
rmSync(session.outputDir, { recursive: true, force: true });
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("writes no output file to the session's outputDir", async () => {
|
|
154
|
-
const session = makeFakeSession();
|
|
155
|
-
try {
|
|
156
|
-
expect(existsSync(session.outputDir)).toBe(true);
|
|
157
|
-
const before = readdirSync(session.outputDir);
|
|
158
|
-
await discardWarmupCapture(session, 0, 0, async () => ({
|
|
159
|
-
buffer: Buffer.from([0xff, 0xff, 0xff]),
|
|
160
|
-
quantizedTime: 0,
|
|
161
|
-
captureTimeMs: 0,
|
|
162
|
-
}));
|
|
163
|
-
const after = readdirSync(session.outputDir);
|
|
164
|
-
expect(after).toEqual(before);
|
|
165
|
-
} finally {
|
|
166
|
-
rmSync(session.outputDir, { recursive: true, force: true });
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("returns undefined (no result type, so the buffer can't escape)", async () => {
|
|
171
|
-
const session = makeFakeSession();
|
|
172
|
-
try {
|
|
173
|
-
const result = await discardWarmupCapture(session, 0, 0, async () => ({
|
|
174
|
-
buffer: Buffer.from([0x01]),
|
|
175
|
-
quantizedTime: 0,
|
|
176
|
-
captureTimeMs: 1,
|
|
177
|
-
}));
|
|
178
|
-
expect(result).toBeUndefined();
|
|
179
|
-
} finally {
|
|
180
|
-
rmSync(session.outputDir, { recursive: true, force: true });
|
|
181
|
-
}
|
|
182
|
-
});
|
|
183
|
-
});
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { dirname, resolve } from "node:path";
|
|
5
|
-
|
|
6
|
-
// Regression coverage for the `window.__name` no-op shim that
|
|
7
|
-
// `frameCapture.ts` registers via `page.evaluateOnNewDocument`.
|
|
8
|
-
//
|
|
9
|
-
// Background: `@hyperframes/engine` ships raw TypeScript (see
|
|
10
|
-
// `packages/engine/package.json` — main and exports both point at
|
|
11
|
-
// `./src/index.ts`). Downstream transpilers like tsx run esbuild with
|
|
12
|
-
// keepNames=true, which wraps named functions in `__name(fn, "name")`
|
|
13
|
-
// calls. When Puppeteer serializes a `page.evaluate(callback)` argument
|
|
14
|
-
// via `Function.prototype.toString()`, those wrappers travel into the
|
|
15
|
-
// browser and throw `ReferenceError: __name is not defined` unless we
|
|
16
|
-
// install a no-op shim first.
|
|
17
|
-
//
|
|
18
|
-
// These tests intentionally do NOT launch a browser — the rest of this
|
|
19
|
-
// package follows the same pure-unit-test convention. Instead they:
|
|
20
|
-
// 1. Assert the polyfill is wired up at the source level so it cannot
|
|
21
|
-
// be silently removed by a careless edit.
|
|
22
|
-
// 2. Probe the current Vitest runtime so a future maintainer can see at
|
|
23
|
-
// a glance whether nested named functions still get `__name(...)`
|
|
24
|
-
// wrappers under the test transformer. This is advisory: both
|
|
25
|
-
// outcomes are acceptable — the reported observation is what makes
|
|
26
|
-
// the test useful when the upstream behavior shifts.
|
|
27
|
-
|
|
28
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
|
-
const FRAME_CAPTURE_PATH = resolve(__dirname, "frameCapture.ts");
|
|
30
|
-
|
|
31
|
-
describe("frameCapture __name polyfill", () => {
|
|
32
|
-
it("registers a window.__name shim via evaluateOnNewDocument", () => {
|
|
33
|
-
const source = readFileSync(FRAME_CAPTURE_PATH, "utf-8");
|
|
34
|
-
|
|
35
|
-
expect(source).toMatch(/page\.evaluateOnNewDocument\(/);
|
|
36
|
-
expect(source).toMatch(/typeof w\.__name !== "function"/);
|
|
37
|
-
expect(source).toMatch(/w\.__name\s*=\s*<T>/);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("installs the shim before any awaited browser-version checks", () => {
|
|
41
|
-
const source = readFileSync(FRAME_CAPTURE_PATH, "utf-8");
|
|
42
|
-
|
|
43
|
-
const polyfillIndex = source.indexOf("page.evaluateOnNewDocument(");
|
|
44
|
-
const versionIndex = source.indexOf("await browser.version()");
|
|
45
|
-
|
|
46
|
-
expect(polyfillIndex).toBeGreaterThan(-1);
|
|
47
|
-
expect(versionIndex).toBeGreaterThan(-1);
|
|
48
|
-
expect(polyfillIndex).toBeLessThan(versionIndex);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("documents the current transpiler behavior for nested named functions", () => {
|
|
52
|
-
function outer(): { wrapsNested: boolean; wrapsArrow: boolean } {
|
|
53
|
-
// The unused declarations are deliberate: we are inspecting whether the
|
|
54
|
-
// active transpiler rewrites `outer.toString()` to include
|
|
55
|
-
// `__name(nested, ...)` / `__name(arrowNested, ...)` wrappers.
|
|
56
|
-
// eslint-disable-next-line no-unused-vars
|
|
57
|
-
function nested() {
|
|
58
|
-
return 1;
|
|
59
|
-
}
|
|
60
|
-
// eslint-disable-next-line no-unused-vars
|
|
61
|
-
const arrowNested = () => 2;
|
|
62
|
-
const src = outer.toString();
|
|
63
|
-
return {
|
|
64
|
-
wrapsNested: /__name\(\s*nested\s*,/.test(src),
|
|
65
|
-
wrapsArrow: /__name\(\s*\(\)\s*=>\s*2\s*,/.test(src) || /__name\(\s*arrowNested/.test(src),
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const { wrapsNested, wrapsArrow } = outer();
|
|
70
|
-
|
|
71
|
-
// Both outcomes are acceptable; the value of this test is in surfacing
|
|
72
|
-
// the runtime's behavior on the next failure (or first inspection).
|
|
73
|
-
// If both flags become false everywhere this engine is consumed, the
|
|
74
|
-
// polyfill above can probably be dropped. Until then it stays.
|
|
75
|
-
expect(typeof wrapsNested).toBe("boolean");
|
|
76
|
-
expect(typeof wrapsArrow).toBe("boolean");
|
|
77
|
-
});
|
|
78
|
-
});
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for `pollImagesReady` — the image-side analog of `pollVideosReady`.
|
|
3
|
-
*
|
|
4
|
-
* Critical contract:
|
|
5
|
-
* - Successfully loaded image (complete=true, naturalWidth>0) → settled.
|
|
6
|
-
* - Broken / 404 image (complete=true, naturalWidth=0) → settled.
|
|
7
|
-
* This mirrors `pollVideosReady`'s `ve.error` early-exit. Without it,
|
|
8
|
-
* the htmlCompiler 404-fallback path (where a remote <img> URL failed
|
|
9
|
-
* to download and the original URL is preserved) would silently spin
|
|
10
|
-
* the full `pageReadyTimeout` budget waiting for an image that will
|
|
11
|
-
* never load — a 45 s regression vs the pre-PR behavior.
|
|
12
|
-
* - In-flight image (complete=false) → still waiting.
|
|
13
|
-
* - data: URI src → settled (no network fetch).
|
|
14
|
-
* - Empty src → settled (nothing to load).
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { describe, expect, it } from "vitest";
|
|
18
|
-
import type { Page } from "puppeteer-core";
|
|
19
|
-
import { pollImagesReady } from "./frameCapture.js";
|
|
20
|
-
|
|
21
|
-
interface ImageSpec {
|
|
22
|
-
src: string;
|
|
23
|
-
complete: boolean;
|
|
24
|
-
naturalWidth: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Mock `page` whose `evaluate(fn)` invokes `fn` with a Node-side `document`
|
|
28
|
-
// mock that returns synthetic image objects matching the spec. Snapshots the
|
|
29
|
-
// image state at evaluate-time, so callers can mutate `imgs` between polls
|
|
30
|
-
// to simulate progressive load completion.
|
|
31
|
-
function makeMockPage(imgs: () => ImageSpec[]): Page {
|
|
32
|
-
return {
|
|
33
|
-
evaluate: async (fn: () => unknown) => {
|
|
34
|
-
const snapshot = imgs();
|
|
35
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
-
const prevDoc = (globalThis as any).document;
|
|
37
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
38
|
-
(globalThis as any).document = {
|
|
39
|
-
querySelectorAll: () =>
|
|
40
|
-
snapshot.map((spec) => ({
|
|
41
|
-
getAttribute: (attr: string) => (attr === "src" ? spec.src : null),
|
|
42
|
-
complete: spec.complete,
|
|
43
|
-
naturalWidth: spec.naturalWidth,
|
|
44
|
-
})),
|
|
45
|
-
};
|
|
46
|
-
try {
|
|
47
|
-
return await fn();
|
|
48
|
-
} finally {
|
|
49
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
-
(globalThis as any).document = prevDoc;
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
54
|
-
} as any as Page;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
describe("pollImagesReady", () => {
|
|
58
|
-
it("resolves immediately when there are no <img> elements", async () => {
|
|
59
|
-
const page = makeMockPage(() => []);
|
|
60
|
-
const result = await pollImagesReady(page, 1000, 10);
|
|
61
|
-
expect(result).toBe(true);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("resolves immediately when every image has loaded successfully", async () => {
|
|
65
|
-
const page = makeMockPage(() => [
|
|
66
|
-
{ src: "/a.png", complete: true, naturalWidth: 100 },
|
|
67
|
-
{ src: "/b.png", complete: true, naturalWidth: 200 },
|
|
68
|
-
]);
|
|
69
|
-
const result = await pollImagesReady(page, 1000, 10);
|
|
70
|
-
expect(result).toBe(true);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("treats a broken image (complete=true, naturalWidth=0) as settled — does NOT wait for timeout", async () => {
|
|
74
|
-
// This is the bug Magi flagged. Without the broken-image escape, this
|
|
75
|
-
// test would block the full 1000ms timeout and return false.
|
|
76
|
-
const page = makeMockPage(() => [
|
|
77
|
-
{ src: "/a.png", complete: true, naturalWidth: 100 },
|
|
78
|
-
{ src: "https://broken.example.com/404.png", complete: true, naturalWidth: 0 },
|
|
79
|
-
]);
|
|
80
|
-
const t0 = Date.now();
|
|
81
|
-
const result = await pollImagesReady(page, 1000, 10);
|
|
82
|
-
const elapsed = Date.now() - t0;
|
|
83
|
-
expect(result).toBe(true);
|
|
84
|
-
// Must resolve fast — well under the 1000ms timeout.
|
|
85
|
-
expect(elapsed).toBeLessThan(500);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("treats a data: URI src as settled regardless of complete/naturalWidth", async () => {
|
|
89
|
-
const page = makeMockPage(() => [
|
|
90
|
-
{ src: "data:image/svg+xml,%3Csvg/%3E", complete: false, naturalWidth: 0 },
|
|
91
|
-
]);
|
|
92
|
-
const result = await pollImagesReady(page, 1000, 10);
|
|
93
|
-
expect(result).toBe(true);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("treats an empty src as settled (nothing to load)", async () => {
|
|
97
|
-
const page = makeMockPage(() => [{ src: "", complete: false, naturalWidth: 0 }]);
|
|
98
|
-
const result = await pollImagesReady(page, 1000, 10);
|
|
99
|
-
expect(result).toBe(true);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it("waits for an in-flight image and resolves once it completes", async () => {
|
|
103
|
-
// Image starts in-flight, then completes after ~50ms.
|
|
104
|
-
let started = false;
|
|
105
|
-
const startTime = { value: 0 };
|
|
106
|
-
const page = makeMockPage(() => {
|
|
107
|
-
if (!started) {
|
|
108
|
-
started = true;
|
|
109
|
-
startTime.value = Date.now();
|
|
110
|
-
}
|
|
111
|
-
const elapsed = Date.now() - startTime.value;
|
|
112
|
-
const loaded = elapsed >= 50;
|
|
113
|
-
return [{ src: "/slow.png", complete: loaded, naturalWidth: loaded ? 100 : 0 }];
|
|
114
|
-
});
|
|
115
|
-
const result = await pollImagesReady(page, 1000, 10);
|
|
116
|
-
expect(result).toBe(true);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("times out and returns false when an in-flight image never resolves", async () => {
|
|
120
|
-
// Image stays in-flight (complete=false) for the full timeout.
|
|
121
|
-
const page = makeMockPage(() => [
|
|
122
|
-
{ src: "/never-loads.png", complete: false, naturalWidth: 0 },
|
|
123
|
-
]);
|
|
124
|
-
const result = await pollImagesReady(page, 100, 10);
|
|
125
|
-
expect(result).toBe(false);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("mixed batch: loaded + broken + data: + in-flight → waits only on the in-flight image", async () => {
|
|
129
|
-
let resolved = false;
|
|
130
|
-
const start = Date.now();
|
|
131
|
-
const page = makeMockPage(() => {
|
|
132
|
-
const elapsed = Date.now() - start;
|
|
133
|
-
if (elapsed >= 30) resolved = true;
|
|
134
|
-
return [
|
|
135
|
-
{ src: "/loaded.png", complete: true, naturalWidth: 800 },
|
|
136
|
-
{ src: "https://broken.example.com/404.jpg", complete: true, naturalWidth: 0 },
|
|
137
|
-
{ src: "data:image/svg+xml,abc", complete: false, naturalWidth: 0 },
|
|
138
|
-
{
|
|
139
|
-
src: "/in-flight.png",
|
|
140
|
-
complete: resolved,
|
|
141
|
-
naturalWidth: resolved ? 200 : 0,
|
|
142
|
-
},
|
|
143
|
-
];
|
|
144
|
-
});
|
|
145
|
-
const t0 = Date.now();
|
|
146
|
-
const result = await pollImagesReady(page, 1000, 10);
|
|
147
|
-
const elapsed = Date.now() - t0;
|
|
148
|
-
expect(result).toBe(true);
|
|
149
|
-
// Should wait roughly for the in-flight image to settle (~30ms) — not the
|
|
150
|
-
// full timeout. Allow generous slack for CI scheduler jitter.
|
|
151
|
-
expect(elapsed).toBeLessThan(500);
|
|
152
|
-
});
|
|
153
|
-
});
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { captureFrameToBuffer, type CaptureSession } from "./frameCapture.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Regression lock for the static-dedup reuse index.
|
|
6
|
-
*
|
|
7
|
-
* `captureFrameCore` must key the static-frame reuse set on the ABSOLUTE
|
|
8
|
-
* composition frame — derived from `time` (`round(time * fps)`) — NOT the
|
|
9
|
-
* `frameIndex` argument. Distributed / per-worker-range / parallel callers pass
|
|
10
|
-
* a chunk-RELATIVE `frameIndex` (captureStage passes the loop `i`,
|
|
11
|
-
* parallelCoordinator passes `i - outputFrameOffset`) while `staticFrames` is
|
|
12
|
-
* keyed in absolute frames. A prior bug used `frameIndex`, so a chunk with
|
|
13
|
-
* `startFrame > 0` reused the wrong frames (and the right frames missed).
|
|
14
|
-
*
|
|
15
|
-
* The reuse branch returns BEFORE any page interaction, so we can exercise the
|
|
16
|
-
* decision with a stub session whose `page` throws if touched: a dedup HIT
|
|
17
|
-
* returns the cached buffer (page untouched); a MISS proceeds to the page and
|
|
18
|
-
* rejects. Both assertions below FAIL on the pre-fix (relative-index) code.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
const SENTINEL = Buffer.from("cached-anchor-frame");
|
|
22
|
-
|
|
23
|
-
// ponytail: minimal stub of the 40-field CaptureSession — only the fields the
|
|
24
|
-
// reuse decision reads are real; `page` is a trap that throws on any access so
|
|
25
|
-
// a dedup MISS (which falls through to prepareFrameForCapture) rejects loudly.
|
|
26
|
-
function makeSession(staticFrames: Set<number>, fps: { num: number; den: number }): CaptureSession {
|
|
27
|
-
const pageTrap = new Proxy(
|
|
28
|
-
{},
|
|
29
|
-
{
|
|
30
|
-
get() {
|
|
31
|
-
throw new Error("PAGE_TOUCHED");
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
);
|
|
35
|
-
return {
|
|
36
|
-
page: pageTrap,
|
|
37
|
-
options: { fps, format: "jpg" },
|
|
38
|
-
captureMode: "screenshot",
|
|
39
|
-
isInitialized: false,
|
|
40
|
-
staticFrames,
|
|
41
|
-
lastFrameBuffer: SENTINEL,
|
|
42
|
-
staticDedupCount: 0,
|
|
43
|
-
} as unknown as CaptureSession;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
describe("static-dedup reuse keys on absolute frame index (time), not relative frameIndex", () => {
|
|
47
|
-
const fps30 = { num: 30, den: 1 };
|
|
48
|
-
|
|
49
|
-
it("HIT: relative frameIndex=0 but absolute time=90/30 reuses the anchor", async () => {
|
|
50
|
-
const session = makeSession(new Set([90]), fps30);
|
|
51
|
-
// Pre-fix used frameIndex (0) ∉ {90} → would miss → page trap throws.
|
|
52
|
-
const result = await captureFrameToBuffer(session, 0, 90 / 30);
|
|
53
|
-
expect(result.buffer).toBe(SENTINEL);
|
|
54
|
-
expect(session.staticDedupCount).toBe(1);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("MISS: relative frameIndex=90 but absolute time=0 does NOT reuse", async () => {
|
|
58
|
-
const session = makeSession(new Set([90]), fps30);
|
|
59
|
-
// Pre-fix used frameIndex (90) ∈ {90} → would wrongly reuse the anchor.
|
|
60
|
-
await expect(captureFrameToBuffer(session, 90, 0)).rejects.toThrow();
|
|
61
|
-
expect(session.staticDedupCount).toBe(0);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("non-integer fps (29.97) recovers the absolute index exactly", async () => {
|
|
65
|
-
const fps2997 = { num: 30000, den: 1001 };
|
|
66
|
-
const session = makeSession(new Set([100]), fps2997);
|
|
67
|
-
const time = (100 * fps2997.den) / fps2997.num; // absolute frame 100 → time
|
|
68
|
-
const result = await captureFrameToBuffer(session, 7, time);
|
|
69
|
-
expect(result.buffer).toBe(SENTINEL);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("no reuse when the absolute frame is not in the static set", async () => {
|
|
73
|
-
const session = makeSession(new Set([10, 11, 12]), fps30);
|
|
74
|
-
await expect(captureFrameToBuffer(session, 0, 50 / 30)).rejects.toThrow();
|
|
75
|
-
});
|
|
76
|
-
});
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the `driveWarmupTicks` BeginFrame warmup driver.
|
|
3
|
-
*
|
|
4
|
-
* The wall-clock warmup loop in `initializeSession` accumulates a different
|
|
5
|
-
* number of ticks per host CPU speed, which shifts `beginFrameTimeTicks`
|
|
6
|
-
* and breaks byte-identical captures across distributed workers.
|
|
7
|
-
*
|
|
8
|
-
* `lockWarmupTicks=true` clamps the loop to a fixed iteration count
|
|
9
|
-
* (`LOCKED_WARMUP_TICKS = 60`) so the baseline becomes host-independent.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { describe, expect, it } from "vitest";
|
|
13
|
-
import {
|
|
14
|
-
LOCKED_WARMUP_TICKS,
|
|
15
|
-
driveWarmupTicks,
|
|
16
|
-
warmupFrameTimeTicks,
|
|
17
|
-
type WarmupTickState,
|
|
18
|
-
} from "./frameCapture.js";
|
|
19
|
-
|
|
20
|
-
function makeState(): WarmupTickState {
|
|
21
|
-
return { running: true, ticks: 0 };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Run the warmup driver "concurrently" with a simulated page-load that
|
|
25
|
-
// flips `state.running` to false after `pageLoadIterations` of the
|
|
26
|
-
// `tick` callback. Returns the final state.
|
|
27
|
-
async function runWithSimulatedPageLoad(
|
|
28
|
-
lockWarmupTicks: boolean,
|
|
29
|
-
pageLoadIterations: number,
|
|
30
|
-
tickDelay: () => Promise<void> = () => Promise.resolve(),
|
|
31
|
-
): Promise<WarmupTickState> {
|
|
32
|
-
const state = makeState();
|
|
33
|
-
const intervalMs = 33;
|
|
34
|
-
|
|
35
|
-
const tick = async (): Promise<void> => {
|
|
36
|
-
if (state.ticks + 1 === pageLoadIterations) {
|
|
37
|
-
state.running = false;
|
|
38
|
-
}
|
|
39
|
-
await tickDelay();
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
await driveWarmupTicks(
|
|
43
|
-
{
|
|
44
|
-
intervalMs,
|
|
45
|
-
lockWarmupTicks,
|
|
46
|
-
tick,
|
|
47
|
-
sleep: () => Promise.resolve(),
|
|
48
|
-
},
|
|
49
|
-
state,
|
|
50
|
-
);
|
|
51
|
-
return state;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
describe("driveWarmupTicks — unlocked", () => {
|
|
55
|
-
it("stops when state.running flips false", async () => {
|
|
56
|
-
const state = await runWithSimulatedPageLoad(false, 10);
|
|
57
|
-
expect(state.ticks).toBe(10);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("yields different tick counts for different simulated page-load lengths", async () => {
|
|
61
|
-
const fast = await runWithSimulatedPageLoad(false, 5);
|
|
62
|
-
const slow = await runWithSimulatedPageLoad(false, 50);
|
|
63
|
-
expect(fast.ticks).toBe(5);
|
|
64
|
-
expect(slow.ticks).toBe(50);
|
|
65
|
-
expect(fast.ticks).not.toBe(slow.ticks);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("frame time derived as ticks * intervalMs", async () => {
|
|
69
|
-
const state = await runWithSimulatedPageLoad(false, 7);
|
|
70
|
-
expect(warmupFrameTimeTicks(state, 33)).toBe(7 * 33);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("does not iterate when state.running starts false", async () => {
|
|
74
|
-
const state: WarmupTickState = { running: false, ticks: 0 };
|
|
75
|
-
await driveWarmupTicks(
|
|
76
|
-
{
|
|
77
|
-
intervalMs: 33,
|
|
78
|
-
lockWarmupTicks: false,
|
|
79
|
-
tick: async () => {},
|
|
80
|
-
sleep: () => Promise.resolve(),
|
|
81
|
-
},
|
|
82
|
-
state,
|
|
83
|
-
);
|
|
84
|
-
expect(state.ticks).toBe(0);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("counts ticks even when tick callback throws (page not ready yet)", async () => {
|
|
88
|
-
// Pre-acquisition errors are swallowed — the loop must keep spinning so
|
|
89
|
-
// it can drive the page once CDP comes up.
|
|
90
|
-
let stopAt = 5;
|
|
91
|
-
const state = makeState();
|
|
92
|
-
await driveWarmupTicks(
|
|
93
|
-
{
|
|
94
|
-
intervalMs: 33,
|
|
95
|
-
lockWarmupTicks: false,
|
|
96
|
-
tick: async () => {
|
|
97
|
-
if (--stopAt <= 0) state.running = false;
|
|
98
|
-
throw new Error("CDP not ready");
|
|
99
|
-
},
|
|
100
|
-
sleep: () => Promise.resolve(),
|
|
101
|
-
},
|
|
102
|
-
state,
|
|
103
|
-
);
|
|
104
|
-
// We don't count ticks on throw, but the loop kept running until
|
|
105
|
-
// state.running flipped — so it exited cleanly.
|
|
106
|
-
expect(state.running).toBe(false);
|
|
107
|
-
expect(state.ticks).toBe(0);
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
describe("driveWarmupTicks — locked", () => {
|
|
112
|
-
it("runs exactly LOCKED_WARMUP_TICKS iterations regardless of state.running", async () => {
|
|
113
|
-
// Simulate a fast page load: state.running flips false after just 5 ticks.
|
|
114
|
-
// The locked loop must still run to LOCKED_WARMUP_TICKS.
|
|
115
|
-
const state = await runWithSimulatedPageLoad(true, 5);
|
|
116
|
-
expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("runs exactly LOCKED_WARMUP_TICKS iterations on slow simulated load", async () => {
|
|
120
|
-
// state.running stays true past the locked count — loop still stops at
|
|
121
|
-
// LOCKED_WARMUP_TICKS.
|
|
122
|
-
const state = await runWithSimulatedPageLoad(true, 9999);
|
|
123
|
-
expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("yields IDENTICAL tick counts across simulated fast and slow loads", async () => {
|
|
127
|
-
// THE determinism property the locked mode exists for.
|
|
128
|
-
const fast = await runWithSimulatedPageLoad(true, 5);
|
|
129
|
-
const slow = await runWithSimulatedPageLoad(true, 200);
|
|
130
|
-
expect(fast.ticks).toBe(slow.ticks);
|
|
131
|
-
expect(fast.ticks).toBe(LOCKED_WARMUP_TICKS);
|
|
132
|
-
expect(warmupFrameTimeTicks(fast, 33)).toBe(warmupFrameTimeTicks(slow, 33));
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("yields LOCKED_WARMUP_TICKS even when state.running starts false", async () => {
|
|
136
|
-
// Guards the locked contract independently of the caller's running flag.
|
|
137
|
-
const state: WarmupTickState = { running: false, ticks: 0 };
|
|
138
|
-
await driveWarmupTicks(
|
|
139
|
-
{
|
|
140
|
-
intervalMs: 33,
|
|
141
|
-
lockWarmupTicks: true,
|
|
142
|
-
tick: async () => {},
|
|
143
|
-
sleep: () => Promise.resolve(),
|
|
144
|
-
},
|
|
145
|
-
state,
|
|
146
|
-
);
|
|
147
|
-
expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("does not exceed LOCKED_WARMUP_TICKS even if the caller never stops it", async () => {
|
|
151
|
-
const state = makeState();
|
|
152
|
-
let observedMax = 0;
|
|
153
|
-
await driveWarmupTicks(
|
|
154
|
-
{
|
|
155
|
-
intervalMs: 33,
|
|
156
|
-
lockWarmupTicks: true,
|
|
157
|
-
tick: async () => {
|
|
158
|
-
observedMax = Math.max(observedMax, state.ticks + 1);
|
|
159
|
-
},
|
|
160
|
-
sleep: () => Promise.resolve(),
|
|
161
|
-
},
|
|
162
|
-
state,
|
|
163
|
-
);
|
|
164
|
-
expect(observedMax).toBe(LOCKED_WARMUP_TICKS);
|
|
165
|
-
expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it("baseline frame time matches (LOCKED_WARMUP_TICKS * intervalMs)", async () => {
|
|
169
|
-
// initializeSession derives session.beginFrameTimeTicks from the final
|
|
170
|
-
// tick count — locked mode pins this to a host-independent value.
|
|
171
|
-
const state = await runWithSimulatedPageLoad(true, 5);
|
|
172
|
-
expect(warmupFrameTimeTicks(state, 33)).toBe(LOCKED_WARMUP_TICKS * 33);
|
|
173
|
-
});
|
|
174
|
-
});
|