@hyperframes/engine 0.6.119 → 0.6.121

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 (73) hide show
  1. package/package.json +24 -7
  2. package/scripts/generate-lut-reference.py +0 -168
  3. package/scripts/test-fitTextFontSize-browser.ts +0 -135
  4. package/src/cdp-headless-experimental.d.ts +0 -54
  5. package/src/config.test.ts +0 -213
  6. package/src/config.ts +0 -417
  7. package/src/index.ts +0 -273
  8. package/src/services/audioMixer.test.ts +0 -326
  9. package/src/services/audioMixer.ts +0 -604
  10. package/src/services/audioMixer.types.ts +0 -35
  11. package/src/services/audioVolumeEnvelope.test.ts +0 -176
  12. package/src/services/audioVolumeEnvelope.ts +0 -138
  13. package/src/services/browserManager.test.ts +0 -330
  14. package/src/services/browserManager.ts +0 -670
  15. package/src/services/chunkEncoder.test.ts +0 -1415
  16. package/src/services/chunkEncoder.ts +0 -831
  17. package/src/services/chunkEncoder.types.ts +0 -60
  18. package/src/services/extractionCache.test.ts +0 -199
  19. package/src/services/extractionCache.ts +0 -216
  20. package/src/services/fileServer.ts +0 -110
  21. package/src/services/frameCapture-discardWarmup.test.ts +0 -183
  22. package/src/services/frameCapture-namePolyfill.test.ts +0 -78
  23. package/src/services/frameCapture-pollImagesReady.test.ts +0 -153
  24. package/src/services/frameCapture-staticDedupIndex.test.ts +0 -76
  25. package/src/services/frameCapture-warmupTicks.test.ts +0 -174
  26. package/src/services/frameCapture.test.ts +0 -192
  27. package/src/services/frameCapture.ts +0 -1934
  28. package/src/services/hdrCapture.test.ts +0 -159
  29. package/src/services/hdrCapture.ts +0 -315
  30. package/src/services/parallelCoordinator.test.ts +0 -139
  31. package/src/services/parallelCoordinator.ts +0 -437
  32. package/src/services/screenshotService.test.ts +0 -510
  33. package/src/services/screenshotService.ts +0 -615
  34. package/src/services/streamingEncoder.test.ts +0 -832
  35. package/src/services/streamingEncoder.ts +0 -594
  36. package/src/services/systemMemory.test.ts +0 -324
  37. package/src/services/systemMemory.ts +0 -180
  38. package/src/services/videoFrameExtractor.test.ts +0 -1062
  39. package/src/services/videoFrameExtractor.ts +0 -1139
  40. package/src/services/videoFrameInjector.test.ts +0 -300
  41. package/src/services/videoFrameInjector.ts +0 -687
  42. package/src/services/vp9Options.ts +0 -13
  43. package/src/types.ts +0 -191
  44. package/src/utils/alphaBlit.test.ts +0 -1349
  45. package/src/utils/alphaBlit.ts +0 -1015
  46. package/src/utils/assertSwiftShader.test.ts +0 -130
  47. package/src/utils/assertSwiftShader.ts +0 -126
  48. package/src/utils/ffmpegBinaries.test.ts +0 -43
  49. package/src/utils/ffmpegBinaries.ts +0 -63
  50. package/src/utils/ffprobe.test.ts +0 -342
  51. package/src/utils/ffprobe.ts +0 -457
  52. package/src/utils/gpuEncoder.test.ts +0 -140
  53. package/src/utils/gpuEncoder.ts +0 -268
  54. package/src/utils/hdr.test.ts +0 -191
  55. package/src/utils/hdr.ts +0 -137
  56. package/src/utils/hdrCompositing.test.ts +0 -130
  57. package/src/utils/htmlTemplate.test.ts +0 -42
  58. package/src/utils/htmlTemplate.ts +0 -42
  59. package/src/utils/layerCompositor.test.ts +0 -150
  60. package/src/utils/layerCompositor.ts +0 -58
  61. package/src/utils/parityContract.ts +0 -1
  62. package/src/utils/processTracker.test.ts +0 -74
  63. package/src/utils/processTracker.ts +0 -41
  64. package/src/utils/readWebGlVendorInfoFromCanvas.ts +0 -52
  65. package/src/utils/runFfmpeg.test.ts +0 -102
  66. package/src/utils/runFfmpeg.ts +0 -136
  67. package/src/utils/shaderTransitions.test.ts +0 -738
  68. package/src/utils/shaderTransitions.ts +0 -1130
  69. package/src/utils/uint16-alignment-audit.test.ts +0 -125
  70. package/src/utils/urlDownloader.test.ts +0 -65
  71. package/src/utils/urlDownloader.ts +0 -143
  72. package/tsconfig.json +0 -19
  73. package/vitest.config.ts +0 -7
@@ -1,176 +0,0 @@
1
- import { afterEach, describe, expect, it } from "vitest";
2
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { tmpdir } from "node:os";
5
- import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js";
6
-
7
- const SAMPLE_RATE = 48000;
8
- const CHANNELS = 2;
9
-
10
- /** Build a PCM s16le stereo WAV whose every sample equals `value`. */
11
- function writeConstantWav(path: string, frames: number, value: number): void {
12
- const bytesPerSample = 2;
13
- const dataSize = frames * CHANNELS * bytesPerSample;
14
- const buffer = Buffer.alloc(44 + dataSize);
15
- buffer.write("RIFF", 0, "ascii");
16
- buffer.writeUInt32LE(36 + dataSize, 4);
17
- buffer.write("WAVE", 8, "ascii");
18
- buffer.write("fmt ", 12, "ascii");
19
- buffer.writeUInt32LE(16, 16);
20
- buffer.writeUInt16LE(1, 20); // PCM
21
- buffer.writeUInt16LE(CHANNELS, 22);
22
- buffer.writeUInt32LE(SAMPLE_RATE, 24);
23
- buffer.writeUInt32LE(SAMPLE_RATE * CHANNELS * bytesPerSample, 28);
24
- buffer.writeUInt16LE(CHANNELS * bytesPerSample, 32);
25
- buffer.writeUInt16LE(16, 34);
26
- buffer.write("data", 36, "ascii");
27
- buffer.writeUInt32LE(dataSize, 40);
28
- for (let i = 0; i < frames * CHANNELS; i += 1) buffer.writeInt16LE(value, 44 + i * 2);
29
- writeFileSync(path, buffer);
30
- }
31
-
32
- function sampleAt(path: string, frame: number, channel = 0): number {
33
- const buffer = readFileSync(path);
34
- return buffer.readInt16LE(44 + (frame * CHANNELS + channel) * 2);
35
- }
36
-
37
- describe("applyVolumeEnvelopeToWav", () => {
38
- const dirs: string[] = [];
39
- const tmp = () => {
40
- const d = mkdtempSync(join(tmpdir(), "hf-env-"));
41
- dirs.push(d);
42
- return d;
43
- };
44
- afterEach(() => {
45
- for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
46
- });
47
-
48
- it("applies a linear fade sample-accurately", () => {
49
- const path = join(tmp(), "a.wav");
50
- const frames = SAMPLE_RATE; // 1 second
51
- writeConstantWav(path, frames, 10000);
52
-
53
- // Fade 0 -> 1 over the full second.
54
- const applied = applyVolumeEnvelopeToWav(
55
- path,
56
- [
57
- { time: 0, volume: 0 },
58
- { time: 1, volume: 1 },
59
- ],
60
- 0,
61
- 0,
62
- );
63
- expect(applied).toBe(true);
64
-
65
- expect(sampleAt(path, 0)).toBe(0); // gain 0
66
- expect(sampleAt(path, frames / 2)).toBeCloseTo(5000, -2); // gain ~0.5
67
- expect(sampleAt(path, frames - 1)).toBeGreaterThan(9900); // gain ~1
68
- });
69
-
70
- it("offsets keyframes by the track start (composition time -> track-relative)", () => {
71
- const path = join(tmp(), "b.wav");
72
- const frames = SAMPLE_RATE;
73
- writeConstantWav(path, frames, 10000);
74
-
75
- // Track starts at 5s; the fade runs from comp-time 5s..6s -> wav 0s..1s.
76
- applyVolumeEnvelopeToWav(
77
- path,
78
- [
79
- { time: 5, volume: 0 },
80
- { time: 6, volume: 1 },
81
- ],
82
- 5,
83
- 0,
84
- );
85
-
86
- expect(sampleAt(path, 0)).toBe(0);
87
- expect(sampleAt(path, frames / 2)).toBeCloseTo(5000, -2);
88
- });
89
-
90
- it("holds base volume before the first keyframe and the last value after", () => {
91
- const path = join(tmp(), "c.wav");
92
- const frames = SAMPLE_RATE * 3; // 3 seconds
93
- writeConstantWav(path, frames, 10000);
94
-
95
- // Base 0.8 held until a fade-out begins at 2s.
96
- applyVolumeEnvelopeToWav(
97
- path,
98
- [
99
- { time: 2, volume: 0.8 },
100
- { time: 3, volume: 0 },
101
- ],
102
- 0,
103
- 0.8,
104
- );
105
-
106
- expect(sampleAt(path, SAMPLE_RATE)).toBeCloseTo(8000, -2); // 1s: base 0.8
107
- expect(sampleAt(path, frames - 1)).toBeLessThan(200); // 3s: faded to ~0
108
- });
109
-
110
- it("handles thousands of keyframes without failing (no expression ceiling)", () => {
111
- const path = join(tmp(), "d.wav");
112
- const frames = SAMPLE_RATE * 2;
113
- writeConstantWav(path, frames, 10000);
114
-
115
- const keyframes = Array.from({ length: 5000 }, (_, i) => ({
116
- time: (i / 4999) * 2,
117
- volume: Math.abs(Math.sin(i / 50)),
118
- }));
119
- expect(applyVolumeEnvelopeToWav(path, keyframes, 0, 0)).toBe(true);
120
- });
121
-
122
- it("parses chunks in any order (data before fmt)", () => {
123
- const path = join(tmp(), "order.wav");
124
- const frames = 4;
125
- const dataSize = frames * CHANNELS * 2;
126
- // Lay the data chunk before fmt to exercise order-independent scanning.
127
- const buffer = Buffer.alloc(12 + (8 + dataSize) + (8 + 16));
128
- buffer.write("RIFF", 0, "ascii");
129
- buffer.writeUInt32LE(buffer.length - 8, 4);
130
- buffer.write("WAVE", 8, "ascii");
131
- let o = 12;
132
- buffer.write("data", o, "ascii");
133
- buffer.writeUInt32LE(dataSize, o + 4);
134
- for (let i = 0; i < frames * CHANNELS; i += 1) buffer.writeInt16LE(10000, o + 8 + i * 2);
135
- o += 8 + dataSize;
136
- buffer.write("fmt ", o, "ascii");
137
- buffer.writeUInt32LE(16, o + 4);
138
- buffer.writeUInt16LE(1, o + 8);
139
- buffer.writeUInt16LE(CHANNELS, o + 10);
140
- buffer.writeUInt32LE(SAMPLE_RATE, o + 12);
141
- buffer.writeUInt16LE(16, o + 22);
142
- writeFileSync(path, buffer);
143
-
144
- expect(applyVolumeEnvelopeToWav(path, [{ time: 0, volume: 0 }], 0, 0)).toBe(true);
145
- expect(readFileSync(path).readInt16LE(12 + 8)).toBe(0); // first sample muted
146
- });
147
-
148
- it("rejects non-16-bit PCM so the caller can fall back", () => {
149
- const path = join(tmp(), "e.wav");
150
- // 24-bit PCM header (bitsPerSample = 24); body contents are irrelevant.
151
- const buffer = Buffer.alloc(44);
152
- buffer.write("RIFF", 0, "ascii");
153
- buffer.write("WAVE", 8, "ascii");
154
- buffer.write("fmt ", 12, "ascii");
155
- buffer.writeUInt32LE(16, 16);
156
- buffer.writeUInt16LE(1, 20);
157
- buffer.writeUInt16LE(CHANNELS, 22);
158
- buffer.writeUInt32LE(SAMPLE_RATE, 24);
159
- buffer.writeUInt16LE(24, 34);
160
- buffer.write("data", 36, "ascii");
161
- buffer.writeUInt32LE(0, 40);
162
- writeFileSync(path, buffer);
163
-
164
- expect(
165
- applyVolumeEnvelopeToWav(
166
- path,
167
- [
168
- { time: 0, volume: 0 },
169
- { time: 1, volume: 1 },
170
- ],
171
- 0,
172
- 0,
173
- ),
174
- ).toBe(false);
175
- });
176
- });
@@ -1,138 +0,0 @@
1
- /**
2
- * Sample-accurate volume automation.
3
- *
4
- * The audio mixer's primary path for time-varying volume bakes the envelope
5
- * directly into the prepared PCM rather than encoding it as an FFmpeg `volume`
6
- * expression. The expression approach nests one `if(lt(t,...))` per keyframe and
7
- * overflows FFmpeg's expression evaluator past ~95 levels (a dense GSAP fade
8
- * emits hundreds of keyframes), which fails the whole mix and drops the audio
9
- * track. Multiplying the samples in-house has no such ceiling, is exact at every
10
- * sample, and keeps the downstream ffmpeg `amix`/AAC encode untouched — so the
11
- * output (and the golden baselines) only change where a fade is actually applied.
12
- *
13
- * The prepared tracks are always `pcm_s16le`, 48 kHz, stereo (see
14
- * `prepareAudioTrack` / `extractAudioFromVideo`). Anything else is rejected so
15
- * the caller can fall back to the expression path rather than corrupting audio.
16
- */
17
-
18
- import { readFileSync, renameSync, writeFileSync } from "fs";
19
- import { randomBytes } from "crypto";
20
- import type { AudioVolumeKeyframe } from "./audioMixer.types.js";
21
- import { normaliseEnvelope } from "@hyperframes/core/media-volume-envelope";
22
-
23
- const PCM_FORMAT = 1; // WAVE_FORMAT_PCM
24
- const SUPPORTED_BITS = 16;
25
-
26
- interface WavLayout {
27
- numChannels: number;
28
- sampleRate: number;
29
- dataOffset: number;
30
- dataSize: number;
31
- }
32
-
33
- /**
34
- * Locate the `fmt ` and `data` chunks and validate the format we know how to edit.
35
- *
36
- * Scans every chunk rather than assuming an ordering: the loop always advances
37
- * past a chunk's body (using its declared size), so `data` may precede `fmt `
38
- * and trailing chunks (LIST/fact/etc.) are skipped harmlessly. Returns null on
39
- * anything unexpected so the caller falls back to the expression path.
40
- */
41
- function parseWavLayout(buffer: Buffer): WavLayout | null {
42
- if (buffer.length < 12 || buffer.toString("ascii", 0, 4) !== "RIFF") return null;
43
- if (buffer.toString("ascii", 8, 12) !== "WAVE") return null;
44
-
45
- let offset = 12;
46
- let fmt: { numChannels: number; sampleRate: number; bitsPerSample: number } | null = null;
47
- let data: { offset: number; size: number } | null = null;
48
-
49
- while (offset + 8 <= buffer.length) {
50
- const chunkId = buffer.toString("ascii", offset, offset + 4);
51
- const chunkSize = buffer.readUInt32LE(offset + 4);
52
- const body = offset + 8;
53
- if (chunkId === "fmt " && body + 16 <= buffer.length) {
54
- if (buffer.readUInt16LE(body) !== PCM_FORMAT) return null;
55
- fmt = {
56
- numChannels: buffer.readUInt16LE(body + 2),
57
- sampleRate: buffer.readUInt32LE(body + 4),
58
- bitsPerSample: buffer.readUInt16LE(body + 14),
59
- };
60
- } else if (chunkId === "data") {
61
- data = { offset: body, size: Math.min(chunkSize, buffer.length - body) };
62
- }
63
- // Chunks are word-aligned: an odd size carries a trailing pad byte.
64
- offset = body + chunkSize + (chunkSize % 2);
65
- }
66
-
67
- if (!fmt || !data) return null;
68
- if (fmt.bitsPerSample !== SUPPORTED_BITS || fmt.numChannels < 1) return null;
69
- return {
70
- numChannels: fmt.numChannels,
71
- sampleRate: fmt.sampleRate,
72
- dataOffset: data.offset,
73
- dataSize: data.size,
74
- };
75
- }
76
-
77
- /**
78
- * Multiply a prepared WAV's samples by a time-varying gain envelope in place.
79
- *
80
- * @returns `true` if the envelope was applied; `false` if the file isn't the
81
- * expected 16-bit PCM (caller should fall back to the expression path).
82
- */
83
- export function applyVolumeEnvelopeToWav(
84
- wavPath: string,
85
- keyframes: AudioVolumeKeyframe[],
86
- trackStart: number,
87
- baseVolume: number,
88
- ): boolean {
89
- const envelope = normaliseEnvelope(keyframes, trackStart, baseVolume);
90
- if (envelope.length === 0) return false;
91
-
92
- try {
93
- const buffer = readFileSync(wavPath);
94
- const layout = parseWavLayout(buffer);
95
- if (!layout) return false;
96
-
97
- const { numChannels, sampleRate, dataOffset, dataSize } = layout;
98
- const bytesPerSample = SUPPORTED_BITS / 8;
99
- const frameBytes = numChannels * bytesPerSample;
100
- const frameCount = Math.floor(dataSize / frameBytes);
101
-
102
- // Maintain an incremental segment cursor so the per-frame envelope lookup
103
- // is O(N+M) overall, not O(N×M). interpolateVolumeGain restarts from 0 on
104
- // each call — fine for the preview path (one call per RAF tick) but not for
105
- // the PCM path (one call per sample, 48k×duration frames total).
106
- let segment = 0;
107
- for (let frame = 0; frame < frameCount; frame += 1) {
108
- const time = frame / sampleRate;
109
- while (segment < envelope.length - 2 && time >= envelope[segment + 1]!.time) segment += 1;
110
-
111
- const a = envelope[segment]!;
112
- const b = envelope[segment + 1] ?? a;
113
- const span = b.time - a.time;
114
- const progress = span <= 0 ? 0 : Math.min(1, Math.max(0, (time - a.time) / span));
115
- const gain = a.volume + (b.volume - a.volume) * progress;
116
-
117
- const base = dataOffset + frame * frameBytes;
118
- for (let channel = 0; channel < numChannels; channel += 1) {
119
- const at = base + channel * bytesPerSample;
120
- const scaled = Math.round(buffer.readInt16LE(at) * gain);
121
- buffer.writeInt16LE(scaled < -32768 ? -32768 : scaled > 32767 ? 32767 : scaled, at);
122
- }
123
- }
124
-
125
- // Write to a uniquely-named sibling then atomically rename over the
126
- // original. The random name avoids following a pre-planted symlink at a
127
- // predictable path, and the rename means a crash mid-write can't leave a
128
- // truncated WAV for the downstream mix.
129
- const tempPath = `${wavPath}.${randomBytes(6).toString("hex")}.tmp`;
130
- writeFileSync(tempPath, buffer);
131
- renameSync(tempPath, wavPath);
132
- return true;
133
- } catch {
134
- // Any read/parse/write failure → leave the file untouched and let the
135
- // caller fall back to the ffmpeg expression path rather than losing audio.
136
- return false;
137
- }
138
- }
@@ -1,330 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
-
3
- import type { Browser, PuppeteerNode } from "puppeteer-core";
4
-
5
- import {
6
- _resetAutoBrowserGpuModeCacheForTests,
7
- _resetBrowserPoolForTests,
8
- _setPuppeteerForTests,
9
- acquireBrowser,
10
- buildChromeArgs,
11
- drainBrowserPool,
12
- forceReleaseBrowser,
13
- releaseBrowser,
14
- resolveHeadlessShellPath,
15
- resolveBrowserGpuMode,
16
- } from "./browserManager.js";
17
-
18
- describe("buildChromeArgs browser GPU mode", () => {
19
- const base = { width: 1920, height: 1080 };
20
-
21
- it("uses SwiftShader software GL by default for reproducible local renders", () => {
22
- const args = buildChromeArgs(base);
23
- expect(args).toContain("--enable-features=CanvasDrawElement");
24
- expect(args).not.toContain("--enable-unsafe-webgpu");
25
- expect(args).toContain("--use-gl=angle");
26
- expect(args).toContain("--use-angle=swiftshader");
27
- expect(args).toContain("--enable-unsafe-swiftshader");
28
- expect(args).not.toContain("--enable-gpu-rasterization");
29
- });
30
-
31
- it("uses Metal-backed ANGLE for hardware browser GPU mode on macOS", () => {
32
- const args = buildChromeArgs({ ...base, platform: "darwin" }, { browserGpuMode: "hardware" });
33
- expect(args).toContain("--enable-unsafe-webgpu");
34
- expect(args).toContain("--use-gl=angle");
35
- expect(args).toContain("--use-angle=metal");
36
- expect(args).toContain("--enable-gpu-rasterization");
37
- expect(args).not.toContain("--use-angle=swiftshader");
38
- });
39
-
40
- it("uses D3D11-backed ANGLE for hardware browser GPU mode on Windows", () => {
41
- const args = buildChromeArgs({ ...base, platform: "win32" }, { browserGpuMode: "hardware" });
42
- expect(args).toContain("--use-gl=angle");
43
- expect(args).toContain("--use-angle=d3d11");
44
- expect(args).toContain("--enable-gpu-rasterization");
45
- expect(args).not.toContain("--use-angle=swiftshader");
46
- });
47
-
48
- it("uses ANGLE-EGL for hardware browser GPU mode on Linux", () => {
49
- const args = buildChromeArgs({ ...base, platform: "linux" }, { browserGpuMode: "hardware" });
50
- expect(args).toContain("--use-gl=angle");
51
- expect(args).toContain("--use-angle=gl-egl");
52
- expect(args).toContain("--enable-gpu-rasterization");
53
- expect(args).toContain("--ignore-gpu-blocklist");
54
- expect(args).toContain("--disable-software-rasterizer");
55
- expect(args).not.toContain("--use-angle=swiftshader");
56
- });
57
-
58
- it("keeps --disable-gpu authoritative when requested", () => {
59
- const args = buildChromeArgs(
60
- { ...base, platform: "darwin" },
61
- { browserGpuMode: "hardware", disableGpu: true },
62
- );
63
- expect(args).toContain("--disable-gpu");
64
- expect(args).toContain("--use-angle=swiftshader");
65
- expect(args).not.toContain("--use-angle=metal");
66
- });
67
- });
68
-
69
- describe("resolveBrowserGpuMode", () => {
70
- beforeEach(() => {
71
- _resetAutoBrowserGpuModeCacheForTests();
72
- });
73
-
74
- afterEach(() => {
75
- vi.restoreAllMocks();
76
- _resetAutoBrowserGpuModeCacheForTests();
77
- });
78
-
79
- it("passes 'software' through unchanged without probing", async () => {
80
- const mode = await resolveBrowserGpuMode("software");
81
- expect(mode).toBe("software");
82
- });
83
-
84
- it("passes 'hardware' through unchanged without probing", async () => {
85
- const mode = await resolveBrowserGpuMode("hardware");
86
- expect(mode).toBe("hardware");
87
- });
88
-
89
- it("falls back to 'software' when the probe browser cannot launch", async () => {
90
- // No chromePath, env unset, and (in the test env) no system Chrome to find
91
- // → puppeteer.launch will throw → caller catches → software fallback.
92
- // Force a definitely-missing chrome binary so the launch path errors fast.
93
- const mode = await resolveBrowserGpuMode("auto", {
94
- chromePath: "/definitely/not/a/real/chrome/binary",
95
- browserTimeout: 2000,
96
- });
97
- expect(mode).toBe("software");
98
- });
99
-
100
- it("caches the probe result across calls", async () => {
101
- const first = await resolveBrowserGpuMode("auto", {
102
- chromePath: "/definitely/not/a/real/chrome/binary",
103
- browserTimeout: 2000,
104
- });
105
- // Second call uses cache — no new launch. Assert the same answer comes back
106
- // even with a different chromePath that would have a different probe outcome.
107
- const second = await resolveBrowserGpuMode("auto", {
108
- chromePath: "/another/definitely/missing/path",
109
- browserTimeout: 2000,
110
- });
111
- expect(first).toBe("software");
112
- expect(second).toBe("software");
113
- // Reset and re-probe to confirm the test-only reset works.
114
- _resetAutoBrowserGpuModeCacheForTests();
115
- const third = await resolveBrowserGpuMode("hardware");
116
- expect(third).toBe("hardware");
117
- });
118
-
119
- it("deduplicates concurrent auto-mode probes by caching the in-flight Promise", async () => {
120
- // Parallel coordinator fires N workers via Promise.all — without Promise-
121
- // level caching, a `--workers 4` render against a no-GPU host would launch
122
- // 4 simultaneous probe Chromes. Verify all concurrent callers get the
123
- // exact same Promise reference (proving the probe runs once, not N times).
124
- const p1 = resolveBrowserGpuMode("auto", {
125
- chromePath: "/definitely/not/a/real/chrome/binary",
126
- browserTimeout: 2000,
127
- });
128
- const p2 = resolveBrowserGpuMode("auto", {
129
- chromePath: "/definitely/not/a/real/chrome/binary",
130
- browserTimeout: 2000,
131
- });
132
- const p3 = resolveBrowserGpuMode("auto", {
133
- chromePath: "/definitely/not/a/real/chrome/binary",
134
- browserTimeout: 2000,
135
- });
136
- expect(p1).toBe(p2);
137
- expect(p2).toBe(p3);
138
- const results = await Promise.all([p1, p2, p3]);
139
- expect(results).toEqual(["software", "software", "software"]);
140
- });
141
- });
142
-
143
- describe("resolveHeadlessShellPath", () => {
144
- const originalHeadlessShellPath = process.env.PRODUCER_HEADLESS_SHELL_PATH;
145
-
146
- afterEach(() => {
147
- if (originalHeadlessShellPath === undefined) delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
148
- else process.env.PRODUCER_HEADLESS_SHELL_PATH = originalHeadlessShellPath;
149
- });
150
-
151
- it("throws a clear error when PRODUCER_HEADLESS_SHELL_PATH points at a missing binary", () => {
152
- process.env.PRODUCER_HEADLESS_SHELL_PATH = "/missing/chrome-headless-shell.exe";
153
-
154
- expect(() => resolveHeadlessShellPath({})).toThrow(
155
- /Chrome binary not found at PRODUCER_HEADLESS_SHELL_PATH/,
156
- );
157
- });
158
- });
159
-
160
- describe("forceReleaseBrowser", () => {
161
- it("kills the browser process and disconnects", () => {
162
- const killFn = vi.fn(() => true);
163
- const disconnectFn = vi.fn();
164
- const mockBrowser = {
165
- process: () => ({ kill: killFn, killed: false }),
166
- disconnect: disconnectFn,
167
- } as any;
168
-
169
- forceReleaseBrowser(mockBrowser);
170
-
171
- expect(killFn).toHaveBeenCalledWith("SIGKILL");
172
- expect(disconnectFn).toHaveBeenCalled();
173
- });
174
-
175
- it("tolerates an already-killed process", () => {
176
- const killFn = vi.fn();
177
- const disconnectFn = vi.fn();
178
- const mockBrowser = {
179
- process: () => ({ kill: killFn, killed: true }),
180
- disconnect: disconnectFn,
181
- } as any;
182
-
183
- forceReleaseBrowser(mockBrowser);
184
-
185
- expect(killFn).not.toHaveBeenCalled();
186
- expect(disconnectFn).toHaveBeenCalled();
187
- });
188
- });
189
-
190
- describe("browser pool", () => {
191
- function makeMockBrowser(): Browser {
192
- return {
193
- connected: true,
194
- newPage: vi.fn(),
195
- version: vi.fn().mockResolvedValue("HeadlessChrome/131.0.0.0"),
196
- close: vi.fn().mockResolvedValue(undefined),
197
- disconnect: vi.fn(),
198
- process: () => ({ kill: vi.fn(), killed: false }),
199
- } as unknown as Browser;
200
- }
201
-
202
- // forceScreenshot: true bypasses the BeginFrame probe path, which on Linux
203
- // CI would trigger a second ppt.launch() when the mock's newPage() doesn't
204
- // return a real page and the probe falls back to screenshot mode.
205
- const poolCfg = { enableBrowserPool: true, forceScreenshot: true } as const;
206
-
207
- let launchFn: ReturnType<typeof vi.fn>;
208
-
209
- beforeEach(() => {
210
- _resetBrowserPoolForTests();
211
- const mockBrowser = makeMockBrowser();
212
- launchFn = vi.fn().mockResolvedValue(mockBrowser);
213
- _setPuppeteerForTests({ launch: launchFn } as unknown as PuppeteerNode);
214
- });
215
-
216
- afterEach(async () => {
217
- await drainBrowserPool();
218
- _setPuppeteerForTests(undefined);
219
- });
220
-
221
- it("sequential acquires with pool enabled return the same browser", async () => {
222
- const first = await acquireBrowser(["--no-sandbox"], poolCfg);
223
- const second = await acquireBrowser(["--no-sandbox"], poolCfg);
224
-
225
- expect(first.browser).toBe(second.browser);
226
- expect(launchFn).toHaveBeenCalledTimes(1);
227
-
228
- await releaseBrowser(first.browser, poolCfg);
229
- await releaseBrowser(second.browser, poolCfg);
230
- });
231
-
232
- it("concurrent acquires via Promise.all trigger exactly one launch", async () => {
233
- const [a, b, c] = await Promise.all([
234
- acquireBrowser(["--no-sandbox"], poolCfg),
235
- acquireBrowser(["--no-sandbox"], poolCfg),
236
- acquireBrowser(["--no-sandbox"], poolCfg),
237
- ]);
238
-
239
- expect(launchFn).toHaveBeenCalledTimes(1);
240
- expect(a.browser).toBe(b.browser);
241
- expect(b.browser).toBe(c.browser);
242
-
243
- await releaseBrowser(a.browser, poolCfg);
244
- await releaseBrowser(b.browser, poolCfg);
245
- await releaseBrowser(c.browser, poolCfg);
246
- });
247
-
248
- it("pool recovers from a disconnected browser", async () => {
249
- const first = await acquireBrowser(["--no-sandbox"], poolCfg);
250
- await releaseBrowser(first.browser, poolCfg);
251
-
252
- // Simulate Chrome crash
253
- (first.browser as unknown as { connected: boolean }).connected = false;
254
-
255
- const freshBrowser = makeMockBrowser();
256
- launchFn.mockResolvedValue(freshBrowser);
257
-
258
- const second = await acquireBrowser(["--no-sandbox"], poolCfg);
259
- expect(second.browser).toBe(freshBrowser);
260
- expect(second.browser).not.toBe(first.browser);
261
- expect(launchFn).toHaveBeenCalledTimes(2);
262
-
263
- await releaseBrowser(second.browser, poolCfg);
264
- });
265
-
266
- it("release at refCount 0 closes the browser", async () => {
267
- const result = await acquireBrowser(["--no-sandbox"], poolCfg);
268
- const closeFn = result.browser.close as ReturnType<typeof vi.fn>;
269
-
270
- await releaseBrowser(result.browser, poolCfg);
271
- expect(closeFn).toHaveBeenCalledTimes(1);
272
- });
273
-
274
- it("pool returns a separate browser when forceScreenshot mismatches pooled mode", async () => {
275
- const first = await acquireBrowser(["--no-sandbox"], poolCfg);
276
- expect(first.captureMode).toBe("screenshot");
277
-
278
- // Second acquire with same forceScreenshot — same mode, should reuse
279
- const second = await acquireBrowser(["--no-sandbox"], poolCfg);
280
- expect(second.browser).toBe(first.browser);
281
- expect(launchFn).toHaveBeenCalledTimes(1);
282
-
283
- await releaseBrowser(first.browser, poolCfg);
284
- await releaseBrowser(second.browser, poolCfg);
285
- });
286
-
287
- it("forceReleaseBrowser does not kill Chrome when other sessions hold refs", async () => {
288
- const result = await acquireBrowser(["--no-sandbox"], poolCfg);
289
- // Acquire a second ref
290
- const second = await acquireBrowser(["--no-sandbox"], poolCfg);
291
-
292
- const disconnectFn = result.browser.disconnect as ReturnType<typeof vi.fn>;
293
- forceReleaseBrowser(result.browser);
294
-
295
- // Should NOT have disconnected — other session still holds a ref
296
- expect(disconnectFn).not.toHaveBeenCalled();
297
-
298
- // Release the remaining ref normally
299
- await releaseBrowser(second.browser, poolCfg);
300
- });
301
-
302
- it("drainBrowserPool is safe to call when no browser is pooled", async () => {
303
- await drainBrowserPool();
304
- });
305
-
306
- it("drainBrowserPool awaits in-flight launch before closing", async () => {
307
- let resolveDeferred!: (browser: Browser) => void;
308
- const deferred = new Promise<Browser>((resolve) => {
309
- resolveDeferred = resolve;
310
- });
311
- launchFn.mockReturnValue(deferred);
312
-
313
- // Start acquire — it will be pending
314
- const acquirePromise = acquireBrowser(["--no-sandbox"], poolCfg);
315
-
316
- // Drain while launch is in-flight
317
- const drainPromise = drainBrowserPool();
318
-
319
- // Resolve the pending launch
320
- const mockBrowser = makeMockBrowser();
321
- resolveDeferred(mockBrowser);
322
-
323
- await drainPromise;
324
- const closeFn = mockBrowser.close as ReturnType<typeof vi.fn>;
325
- expect(closeFn).toHaveBeenCalled();
326
-
327
- // The acquire should still resolve (the launch completed before drain closed it)
328
- await acquirePromise.catch(() => {});
329
- });
330
- });