@quake2ts/test-utils 0.0.1

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 (65) hide show
  1. package/README.md +454 -0
  2. package/dist/index.cjs +5432 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +2150 -0
  5. package/dist/index.d.ts +2150 -0
  6. package/dist/index.js +5165 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +82 -0
  9. package/src/client/helpers/hud.ts +114 -0
  10. package/src/client/helpers/prediction.ts +136 -0
  11. package/src/client/helpers/view.ts +201 -0
  12. package/src/client/mocks/console.ts +75 -0
  13. package/src/client/mocks/download.ts +48 -0
  14. package/src/client/mocks/input.ts +246 -0
  15. package/src/client/mocks/network.ts +148 -0
  16. package/src/client/mocks/state.ts +148 -0
  17. package/src/e2e/network.ts +47 -0
  18. package/src/e2e/playwright.ts +90 -0
  19. package/src/e2e/visual.ts +172 -0
  20. package/src/engine/helpers/pipeline-test-template.ts +113 -0
  21. package/src/engine/helpers/webgpu-rendering.ts +251 -0
  22. package/src/engine/mocks/assets.ts +129 -0
  23. package/src/engine/mocks/audio.ts +152 -0
  24. package/src/engine/mocks/buffers.ts +88 -0
  25. package/src/engine/mocks/lighting.ts +64 -0
  26. package/src/engine/mocks/particles.ts +76 -0
  27. package/src/engine/mocks/renderer.ts +218 -0
  28. package/src/engine/mocks/webgl.ts +267 -0
  29. package/src/engine/mocks/webgpu.ts +262 -0
  30. package/src/engine/rendering.ts +103 -0
  31. package/src/game/factories.ts +204 -0
  32. package/src/game/helpers/physics.ts +171 -0
  33. package/src/game/helpers/save.ts +232 -0
  34. package/src/game/helpers.ts +310 -0
  35. package/src/game/mocks/ai.ts +67 -0
  36. package/src/game/mocks/combat.ts +61 -0
  37. package/src/game/mocks/items.ts +166 -0
  38. package/src/game/mocks.ts +105 -0
  39. package/src/index.ts +93 -0
  40. package/src/server/helpers/bandwidth.ts +127 -0
  41. package/src/server/helpers/multiplayer.ts +158 -0
  42. package/src/server/helpers/snapshot.ts +241 -0
  43. package/src/server/mockNetDriver.ts +106 -0
  44. package/src/server/mockTransport.ts +50 -0
  45. package/src/server/mocks/commands.ts +93 -0
  46. package/src/server/mocks/connection.ts +139 -0
  47. package/src/server/mocks/master.ts +97 -0
  48. package/src/server/mocks/physics.ts +32 -0
  49. package/src/server/mocks/state.ts +162 -0
  50. package/src/server/mocks/transport.ts +161 -0
  51. package/src/setup/audio.ts +118 -0
  52. package/src/setup/browser.ts +249 -0
  53. package/src/setup/canvas.ts +142 -0
  54. package/src/setup/node.ts +21 -0
  55. package/src/setup/storage.ts +60 -0
  56. package/src/setup/timing.ts +142 -0
  57. package/src/setup/webgl.ts +8 -0
  58. package/src/setup/webgpu.ts +113 -0
  59. package/src/shared/bsp.ts +145 -0
  60. package/src/shared/collision.ts +64 -0
  61. package/src/shared/factories.ts +88 -0
  62. package/src/shared/math.ts +65 -0
  63. package/src/shared/mocks.ts +243 -0
  64. package/src/shared/pak-loader.ts +45 -0
  65. package/src/visual/snapshots.ts +292 -0
@@ -0,0 +1,243 @@
1
+ import { vi, type Mock } from 'vitest';
2
+
3
+ /**
4
+ * Interface for the BinaryWriter mock.
5
+ */
6
+ export interface BinaryWriterMock {
7
+ writeByte: Mock<[number], void>;
8
+ writeShort: Mock<[number], void>;
9
+ writeLong: Mock<[number], void>;
10
+ writeString: Mock<[string], void>;
11
+ writeBytes: Mock<[Uint8Array], void>;
12
+ getBuffer: Mock<[], Uint8Array>;
13
+ reset: Mock<[], void>;
14
+ writeInt8: Mock<[number], void>;
15
+ writeUint8: Mock<[number], void>;
16
+ writeInt16: Mock<[number], void>;
17
+ writeUint16: Mock<[number], void>;
18
+ writeInt32: Mock<[number], void>;
19
+ writeUint32: Mock<[number], void>;
20
+ writeFloat: Mock<[number], void>;
21
+ getData: Mock<[], Uint8Array>;
22
+ writePos: Mock<[any], void>;
23
+ writeDir: Mock<[any], void>;
24
+ }
25
+
26
+ /**
27
+ * Creates a mock BinaryWriter for testing binary data writing.
28
+ *
29
+ * @returns A BinaryWriterMock object with all methods mocked using vi.fn().
30
+ */
31
+ export const createBinaryWriterMock = (): BinaryWriterMock => ({
32
+ writeByte: vi.fn(),
33
+ writeShort: vi.fn(),
34
+ writeLong: vi.fn(),
35
+ writeString: vi.fn(),
36
+ writeBytes: vi.fn(),
37
+ getBuffer: vi.fn<[], Uint8Array>(() => new Uint8Array(0)),
38
+ reset: vi.fn(),
39
+ // Legacy methods (if any)
40
+ writeInt8: vi.fn(),
41
+ writeUint8: vi.fn(),
42
+ writeInt16: vi.fn(),
43
+ writeUint16: vi.fn(),
44
+ writeInt32: vi.fn(),
45
+ writeUint32: vi.fn(),
46
+ writeFloat: vi.fn(),
47
+ getData: vi.fn<[], Uint8Array>(() => new Uint8Array(0)),
48
+ writePos: vi.fn(),
49
+ writeDir: vi.fn(),
50
+ });
51
+
52
+ /**
53
+ * Creates a mock NetChan (Network Channel) for testing network communication.
54
+ * Includes mocks for sequencing, reliable messaging, and fragmentation.
55
+ *
56
+ * @returns A mocked NetChan object.
57
+ */
58
+ export const createNetChanMock = () => ({
59
+ qport: 1234,
60
+
61
+ // Sequencing
62
+ incomingSequence: 0,
63
+ outgoingSequence: 0,
64
+ incomingAcknowledged: 0,
65
+
66
+ // Reliable messaging
67
+ incomingReliableAcknowledged: false,
68
+ incomingReliableSequence: 0,
69
+ outgoingReliableSequence: 0,
70
+ reliableMessage: createBinaryWriterMock(),
71
+ reliableLength: 0,
72
+
73
+ // Fragmentation
74
+ fragmentSendOffset: 0,
75
+ fragmentBuffer: null,
76
+ fragmentLength: 0,
77
+ fragmentReceived: 0,
78
+
79
+ // Timing
80
+ lastReceived: 0,
81
+ lastSent: 0,
82
+
83
+ remoteAddress: { type: 'IP', port: 1234 },
84
+
85
+ // Methods
86
+ setup: vi.fn(),
87
+ reset: vi.fn(),
88
+ transmit: vi.fn(),
89
+ process: vi.fn(),
90
+ canSendReliable: vi.fn(() => true),
91
+ writeReliableByte: vi.fn(),
92
+ writeReliableShort: vi.fn(),
93
+ writeReliableLong: vi.fn(),
94
+ writeReliableString: vi.fn(),
95
+ getReliableData: vi.fn<[], Uint8Array>(() => new Uint8Array(0)),
96
+ needsKeepalive: vi.fn(() => false),
97
+ isTimedOut: vi.fn(() => false),
98
+ });
99
+
100
+ /**
101
+ * Interface for the BinaryStream mock.
102
+ */
103
+ export interface BinaryStreamMock {
104
+ getPosition: Mock<[], number>;
105
+ getReadPosition: Mock<[], number>;
106
+ getLength: Mock<[], number>;
107
+ getRemaining: Mock<[], number>;
108
+ seek: Mock<[number], void>;
109
+ setReadPosition: Mock<[number], void>;
110
+ hasMore: Mock<[], boolean>;
111
+ hasBytes: Mock<[number], boolean>;
112
+
113
+ readChar: Mock<[], number>;
114
+ readByte: Mock<[], number>;
115
+ readShort: Mock<[], number>;
116
+ readUShort: Mock<[], number>;
117
+ readLong: Mock<[], number>;
118
+ readULong: Mock<[], number>;
119
+ readFloat: Mock<[], number>;
120
+
121
+ readString: Mock<[], string>;
122
+ readStringLine: Mock<[], string>;
123
+
124
+ readCoord: Mock<[], number>;
125
+ readAngle: Mock<[], number>;
126
+ readAngle16: Mock<[], number>;
127
+
128
+ readData: Mock<[number], Uint8Array>;
129
+
130
+ readPos: Mock<[], any>; // Use proper type if available, e.g., Vec3
131
+ readDir: Mock<[], any>;
132
+ }
133
+
134
+ /**
135
+ * Creates a mock BinaryStream for testing binary data reading.
136
+ *
137
+ * @returns A BinaryStreamMock object with all methods mocked.
138
+ */
139
+ export const createBinaryStreamMock = (): BinaryStreamMock => ({
140
+ getPosition: vi.fn(() => 0),
141
+ getReadPosition: vi.fn(() => 0),
142
+ getLength: vi.fn(() => 0),
143
+ getRemaining: vi.fn(() => 0),
144
+ seek: vi.fn(),
145
+ setReadPosition: vi.fn(),
146
+ hasMore: vi.fn(() => true),
147
+ hasBytes: vi.fn((amount: number) => true),
148
+
149
+ readChar: vi.fn(() => 0),
150
+ readByte: vi.fn(() => 0),
151
+ readShort: vi.fn(() => 0),
152
+ readUShort: vi.fn(() => 0),
153
+ readLong: vi.fn(() => 0),
154
+ readULong: vi.fn(() => 0),
155
+ readFloat: vi.fn(() => 0),
156
+
157
+ readString: vi.fn(() => ''),
158
+ readStringLine: vi.fn(() => ''),
159
+
160
+ readCoord: vi.fn(() => 0),
161
+ readAngle: vi.fn(() => 0),
162
+ readAngle16: vi.fn(() => 0),
163
+
164
+ readData: vi.fn<[number], Uint8Array>((length: number) => new Uint8Array(length)),
165
+
166
+ readPos: vi.fn(),
167
+ readDir: vi.fn(),
168
+ });
169
+
170
+ /**
171
+ * Interface for MessageWriter mock, extending BinaryWriterMock with additional message-specific methods.
172
+ */
173
+ export interface MessageWriterMock extends BinaryWriterMock {
174
+ writeInt: Mock<[number], void>;
175
+ writeVector: Mock<[any], void>;
176
+ }
177
+
178
+ /**
179
+ * Creates a mock MessageWriter, aliasing writeInt to writeInt32 and writeVector to writePos.
180
+ *
181
+ * @param overrides - Optional overrides for the mock.
182
+ * @returns A MessageWriterMock object.
183
+ */
184
+ export const createMessageWriterMock = (overrides?: Partial<MessageWriterMock>): MessageWriterMock => {
185
+ const mock = createBinaryWriterMock();
186
+ const writer: MessageWriterMock = {
187
+ ...mock,
188
+ writeInt: mock.writeInt32, // Alias writeInt to writeInt32
189
+ writeVector: mock.writePos, // Alias writeVector to writePos
190
+ ...overrides
191
+ };
192
+ return writer;
193
+ };
194
+
195
+ /**
196
+ * Interface for MessageReader mock, extending BinaryStreamMock with additional message-specific methods.
197
+ */
198
+ export interface MessageReaderMock extends BinaryStreamMock {
199
+ readInt: Mock<[], number>;
200
+ readVector: Mock<[], any>;
201
+ }
202
+
203
+ /**
204
+ * Creates a mock MessageReader, aliasing readInt to readLong and readVector to readPos.
205
+ *
206
+ * @param data - Optional initial data for the reader.
207
+ * @returns A MessageReaderMock object.
208
+ */
209
+ export const createMessageReaderMock = (data?: Uint8Array): MessageReaderMock => {
210
+ const mock = createBinaryStreamMock();
211
+ const reader: MessageReaderMock = {
212
+ ...mock,
213
+ readInt: mock.readLong, // Alias readInt to readLong (int32)
214
+ readVector: mock.readPos, // Alias readVector to readPos
215
+ };
216
+ return reader;
217
+ };
218
+
219
+ /**
220
+ * Interface for a generic network packet mock.
221
+ */
222
+ export interface PacketMock {
223
+ type: 'connection' | 'data' | 'ack' | 'disconnect';
224
+ sequence: number;
225
+ ack: number;
226
+ qport: number;
227
+ data: Uint8Array;
228
+ }
229
+
230
+ /**
231
+ * Creates a mock network packet.
232
+ *
233
+ * @param overrides - Optional overrides for packet properties.
234
+ * @returns A PacketMock object.
235
+ */
236
+ export const createPacketMock = (overrides?: Partial<PacketMock>): PacketMock => ({
237
+ type: 'data',
238
+ sequence: 0,
239
+ ack: 0,
240
+ qport: 0,
241
+ data: new Uint8Array(0),
242
+ ...overrides
243
+ });
@@ -0,0 +1,45 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { PakArchive } from '@quake2ts/engine';
4
+
5
+ export function findPakFile(): string | null {
6
+ const possiblePaths = [
7
+ path.resolve(process.cwd(), 'pak.pak'),
8
+ path.resolve(process.cwd(), '../pak.pak'),
9
+ path.resolve(process.cwd(), '../../pak.pak'),
10
+ path.resolve(process.cwd(), '../../../pak.pak'),
11
+ path.resolve(process.cwd(), 'baseq2/pak.pak'),
12
+ path.resolve('/app/quake2ts/pak.pak'), // Common docker/env path
13
+ // Fallback for when running deeply nested tests
14
+ path.resolve(process.cwd(), 'quake2ts/pak.pak'),
15
+ ];
16
+
17
+ return possiblePaths.find(p => fs.existsSync(p)) || null;
18
+ }
19
+
20
+ export function loadMapFromPak(mapName: string): ArrayBuffer | null {
21
+ const pakPath = findPakFile();
22
+ if (!pakPath) {
23
+ console.warn('pak.pak not found');
24
+ return null;
25
+ }
26
+
27
+ try {
28
+ const pakBuffer = fs.readFileSync(pakPath);
29
+ // Cast to ArrayBuffer because node Buffer is compatible but types might mismatch
30
+ const arrayBuffer = pakBuffer.buffer.slice(pakBuffer.byteOffset, pakBuffer.byteOffset + pakBuffer.byteLength) as ArrayBuffer;
31
+
32
+ const pak = PakArchive.fromArrayBuffer('pak.pak', arrayBuffer);
33
+ const entry = pak.getEntry(mapName);
34
+
35
+ if (entry) {
36
+ // PakArchive.readFile returns Uint8Array, we might want ArrayBuffer
37
+ const data = pak.readFile(mapName);
38
+ return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
39
+ }
40
+ return null;
41
+ } catch (e) {
42
+ console.error(`Failed to load map ${mapName} from pak:`, e);
43
+ return null;
44
+ }
45
+ }
@@ -0,0 +1,292 @@
1
+ import { PNG } from 'pngjs';
2
+ import pixelmatch from 'pixelmatch';
3
+ import fs from 'fs/promises';
4
+ import { createReadStream, createWriteStream, existsSync } from 'fs';
5
+ import path from 'path';
6
+ import { RenderTestSetup, renderAndCapture } from '../engine/helpers/webgpu-rendering.js';
7
+
8
+ export interface CaptureOptions {
9
+ width: number;
10
+ height: number;
11
+ format?: GPUTextureFormat;
12
+ }
13
+
14
+ // Helper to pad bytes to 256 byte alignment (WebGPU requirement)
15
+ function getBytesPerRow(width: number): number {
16
+ const bytesPerPixel = 4;
17
+ const unpaddedBytesPerRow = width * bytesPerPixel;
18
+ const align = 256;
19
+ const paddedBytesPerRow = Math.max(
20
+ unpaddedBytesPerRow,
21
+ Math.ceil(unpaddedBytesPerRow / align) * align
22
+ );
23
+ return paddedBytesPerRow;
24
+ }
25
+
26
+ export async function captureFramebufferAsPNG(
27
+ device: GPUDevice,
28
+ texture: GPUTexture,
29
+ options: CaptureOptions
30
+ ): Promise<Buffer> {
31
+ const { width, height, format = 'rgba8unorm' } = options;
32
+
33
+ const bytesPerRow = getBytesPerRow(width);
34
+ const bufferSize = bytesPerRow * height;
35
+
36
+ const outputBuffer = device.createBuffer({
37
+ size: bufferSize,
38
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
39
+ label: 'captureFramebufferAsPNG output buffer'
40
+ });
41
+
42
+ const commandEncoder = device.createCommandEncoder({ label: 'captureFramebufferAsPNG encoder' });
43
+ commandEncoder.copyTextureToBuffer(
44
+ { texture },
45
+ { buffer: outputBuffer, bytesPerRow, rowsPerImage: height },
46
+ { width, height, depthOrArrayLayers: 1 }
47
+ );
48
+ device.queue.submit([commandEncoder.finish()]);
49
+
50
+ await outputBuffer.mapAsync(GPUMapMode.READ);
51
+ const mappedRange = outputBuffer.getMappedRange();
52
+ const rawData = new Uint8Array(mappedRange);
53
+
54
+ // Remove padding to get tight packed RGBA
55
+ const bytesPerPixel = 4;
56
+ const tightData = new Uint8ClampedArray(width * height * bytesPerPixel);
57
+
58
+ for (let y = 0; y < height; y++) {
59
+ const srcOffset = y * bytesPerRow;
60
+ const dstOffset = y * width * bytesPerPixel;
61
+ const rowSize = width * bytesPerPixel;
62
+ // Copy row
63
+ for (let i = 0; i < rowSize; i++) {
64
+ tightData[dstOffset + i] = rawData[srcOffset + i];
65
+ }
66
+ }
67
+
68
+ outputBuffer.unmap();
69
+
70
+ // Handle BGRA to RGBA conversion if needed
71
+ // Note: WebGPU usually prefers bgra8unorm for swapchains, but pngjs expects rgba
72
+ if (format === 'bgra8unorm' || format === 'bgra8unorm-srgb') {
73
+ for (let i = 0; i < tightData.length; i += 4) {
74
+ const b = tightData[i];
75
+ const r = tightData[i + 2];
76
+ tightData[i] = r;
77
+ tightData[i + 2] = b;
78
+ }
79
+ }
80
+
81
+ // Create PNG
82
+ const png = new PNG({ width, height });
83
+ png.data = Buffer.from(tightData);
84
+
85
+ return PNG.sync.write(png);
86
+ }
87
+
88
+ export async function savePNG(
89
+ pixels: Uint8ClampedArray,
90
+ width: number,
91
+ height: number,
92
+ filepath: string
93
+ ): Promise<void> {
94
+ const png = new PNG({ width, height });
95
+ png.data = Buffer.from(pixels);
96
+
97
+ await fs.mkdir(path.dirname(filepath), { recursive: true });
98
+
99
+ return new Promise((resolve, reject) => {
100
+ const stream = createWriteStream(filepath);
101
+ stream.on('error', reject);
102
+ stream.on('finish', resolve);
103
+ png.pack().pipe(stream);
104
+ });
105
+ }
106
+
107
+ export async function loadPNG(
108
+ filepath: string
109
+ ): Promise<{ data: Uint8ClampedArray; width: number; height: number }> {
110
+ return new Promise((resolve, reject) => {
111
+ const stream = createReadStream(filepath);
112
+ stream.on('error', reject);
113
+
114
+ const png = new PNG();
115
+ png.on('error', reject);
116
+ png.on('parsed', (data) => {
117
+ resolve({
118
+ data: new Uint8ClampedArray(data),
119
+ width: png.width,
120
+ height: png.height
121
+ });
122
+ });
123
+
124
+ stream.pipe(png);
125
+ });
126
+ }
127
+
128
+ export interface ComparisonResult {
129
+ pixelsDifferent: number;
130
+ totalPixels: number;
131
+ percentDifferent: number;
132
+ passed: boolean;
133
+ diffImage?: Uint8ClampedArray;
134
+ }
135
+
136
+ export interface ComparisonOptions {
137
+ threshold?: number; // Pixel difference threshold (0-1), default 0.1
138
+ includeAA?: boolean; // Include anti-aliasing detection
139
+ diffColor?: [number, number, number]; // Color for diff pixels
140
+ maxDifferencePercent?: number; // Max % difference to pass, default 0.1%
141
+ }
142
+
143
+ export async function compareSnapshots(
144
+ actual: Uint8ClampedArray,
145
+ expected: Uint8ClampedArray,
146
+ width: number,
147
+ height: number,
148
+ options?: ComparisonOptions
149
+ ): Promise<ComparisonResult> {
150
+ const {
151
+ threshold = 0.1,
152
+ includeAA = false,
153
+ diffColor = [255, 0, 0],
154
+ maxDifferencePercent = 0.1
155
+ } = options || {};
156
+
157
+ if (actual.length !== expected.length) {
158
+ throw new Error(`Size mismatch: actual length ${actual.length} vs expected length ${expected.length}`);
159
+ }
160
+
161
+ const diff = new Uint8ClampedArray(width * height * 4);
162
+ const numDiffPixels = pixelmatch(
163
+ actual,
164
+ expected,
165
+ diff,
166
+ width,
167
+ height,
168
+ {
169
+ threshold,
170
+ includeAA,
171
+ diffColor: diffColor as [number, number, number],
172
+ alpha: 1 // Default alpha
173
+ }
174
+ );
175
+
176
+ const totalPixels = width * height;
177
+ const percentDifferent = (numDiffPixels / totalPixels) * 100;
178
+ const passed = percentDifferent <= (maxDifferencePercent || 0);
179
+
180
+ return {
181
+ pixelsDifferent: numDiffPixels,
182
+ totalPixels,
183
+ percentDifferent,
184
+ passed,
185
+ diffImage: diff
186
+ };
187
+ }
188
+
189
+ export interface SnapshotTestOptions extends ComparisonOptions {
190
+ name: string;
191
+ width?: number;
192
+ height?: number;
193
+ updateBaseline?: boolean;
194
+ snapshotDir?: string; // Root snapshot directory
195
+ }
196
+
197
+ export function getSnapshotPath(name: string, type: 'baseline' | 'actual' | 'diff', snapshotDir: string = '__snapshots__'): string {
198
+ const dirMap = {
199
+ baseline: 'baselines',
200
+ actual: 'actual',
201
+ diff: 'diff'
202
+ };
203
+ return path.join(snapshotDir, dirMap[type], `${name}.png`);
204
+ }
205
+
206
+ export async function expectSnapshot(
207
+ pixels: Uint8ClampedArray,
208
+ options: SnapshotTestOptions
209
+ ): Promise<void> {
210
+ const {
211
+ name,
212
+ width,
213
+ height,
214
+ updateBaseline = false,
215
+ snapshotDir = path.join(process.cwd(), 'tests', '__snapshots__') // Default to current working dir/tests/__snapshots__
216
+ } = options;
217
+
218
+ if (!width || !height) {
219
+ throw new Error('Width and height are required for expectSnapshot');
220
+ }
221
+
222
+ const baselinePath = getSnapshotPath(name, 'baseline', snapshotDir);
223
+ const actualPath = getSnapshotPath(name, 'actual', snapshotDir);
224
+ const diffPath = getSnapshotPath(name, 'diff', snapshotDir);
225
+
226
+ const alwaysSave = process.env.ALWAYS_SAVE_SNAPSHOTS === '1';
227
+
228
+ // If update baseline is requested or baseline doesn't exist, save as baseline and return
229
+ if (updateBaseline || !existsSync(baselinePath)) {
230
+ console.log(`Creating/Updating baseline for ${name} at ${baselinePath}`);
231
+ await savePNG(pixels, width, height, baselinePath);
232
+ return;
233
+ }
234
+
235
+ // Load baseline
236
+ let baseline: { data: Uint8ClampedArray; width: number; height: number };
237
+ try {
238
+ baseline = await loadPNG(baselinePath);
239
+ } catch (e) {
240
+ throw new Error(`Failed to load baseline for ${name} at ${baselinePath}: ${e}`);
241
+ }
242
+
243
+ if (baseline.width !== width || baseline.height !== height) {
244
+ // Save actual for debugging
245
+ await savePNG(pixels, width, height, actualPath);
246
+ throw new Error(`Snapshot dimension mismatch for ${name}: expected ${baseline.width}x${baseline.height}, got ${width}x${height}`);
247
+ }
248
+
249
+ // Compare
250
+ const result = await compareSnapshots(pixels, baseline.data, width, height, options);
251
+
252
+ // Save stats
253
+ const statsPath = path.join(snapshotDir, 'stats', `${name}.json`);
254
+ await fs.mkdir(path.dirname(statsPath), { recursive: true });
255
+ await fs.writeFile(statsPath, JSON.stringify({
256
+ passed: result.passed,
257
+ percentDifferent: result.percentDifferent,
258
+ pixelsDifferent: result.pixelsDifferent,
259
+ totalPixels: result.totalPixels,
260
+ threshold: options.threshold ?? 0.1,
261
+ maxDifferencePercent: options.maxDifferencePercent ?? 0.1
262
+ }, null, 2));
263
+
264
+ if (!result.passed || alwaysSave) {
265
+ // Save actual and diff
266
+ await savePNG(pixels, width, height, actualPath);
267
+ if (result.diffImage) {
268
+ await savePNG(result.diffImage, width, height, diffPath);
269
+ }
270
+ }
271
+
272
+ if (!result.passed) {
273
+ throw new Error(
274
+ `Snapshot comparison failed for ${name}: ${result.percentDifferent.toFixed(2)}% different ` +
275
+ `(${result.pixelsDifferent} pixels). ` +
276
+ `See ${diffPath} for details.`
277
+ );
278
+ }
279
+ }
280
+
281
+ export async function renderAndExpectSnapshot(
282
+ setup: RenderTestSetup,
283
+ renderFn: (pass: GPURenderPassEncoder) => void,
284
+ options: Omit<SnapshotTestOptions, 'width' | 'height'>
285
+ ): Promise<void> {
286
+ const pixels = await renderAndCapture(setup, renderFn);
287
+ await expectSnapshot(pixels, {
288
+ ...options,
289
+ width: setup.width,
290
+ height: setup.height
291
+ });
292
+ }