@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.
- package/README.md +454 -0
- package/dist/index.cjs +5432 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2150 -0
- package/dist/index.d.ts +2150 -0
- package/dist/index.js +5165 -0
- package/dist/index.js.map +1 -0
- package/package.json +82 -0
- package/src/client/helpers/hud.ts +114 -0
- package/src/client/helpers/prediction.ts +136 -0
- package/src/client/helpers/view.ts +201 -0
- package/src/client/mocks/console.ts +75 -0
- package/src/client/mocks/download.ts +48 -0
- package/src/client/mocks/input.ts +246 -0
- package/src/client/mocks/network.ts +148 -0
- package/src/client/mocks/state.ts +148 -0
- package/src/e2e/network.ts +47 -0
- package/src/e2e/playwright.ts +90 -0
- package/src/e2e/visual.ts +172 -0
- package/src/engine/helpers/pipeline-test-template.ts +113 -0
- package/src/engine/helpers/webgpu-rendering.ts +251 -0
- package/src/engine/mocks/assets.ts +129 -0
- package/src/engine/mocks/audio.ts +152 -0
- package/src/engine/mocks/buffers.ts +88 -0
- package/src/engine/mocks/lighting.ts +64 -0
- package/src/engine/mocks/particles.ts +76 -0
- package/src/engine/mocks/renderer.ts +218 -0
- package/src/engine/mocks/webgl.ts +267 -0
- package/src/engine/mocks/webgpu.ts +262 -0
- package/src/engine/rendering.ts +103 -0
- package/src/game/factories.ts +204 -0
- package/src/game/helpers/physics.ts +171 -0
- package/src/game/helpers/save.ts +232 -0
- package/src/game/helpers.ts +310 -0
- package/src/game/mocks/ai.ts +67 -0
- package/src/game/mocks/combat.ts +61 -0
- package/src/game/mocks/items.ts +166 -0
- package/src/game/mocks.ts +105 -0
- package/src/index.ts +93 -0
- package/src/server/helpers/bandwidth.ts +127 -0
- package/src/server/helpers/multiplayer.ts +158 -0
- package/src/server/helpers/snapshot.ts +241 -0
- package/src/server/mockNetDriver.ts +106 -0
- package/src/server/mockTransport.ts +50 -0
- package/src/server/mocks/commands.ts +93 -0
- package/src/server/mocks/connection.ts +139 -0
- package/src/server/mocks/master.ts +97 -0
- package/src/server/mocks/physics.ts +32 -0
- package/src/server/mocks/state.ts +162 -0
- package/src/server/mocks/transport.ts +161 -0
- package/src/setup/audio.ts +118 -0
- package/src/setup/browser.ts +249 -0
- package/src/setup/canvas.ts +142 -0
- package/src/setup/node.ts +21 -0
- package/src/setup/storage.ts +60 -0
- package/src/setup/timing.ts +142 -0
- package/src/setup/webgl.ts +8 -0
- package/src/setup/webgpu.ts +113 -0
- package/src/shared/bsp.ts +145 -0
- package/src/shared/collision.ts +64 -0
- package/src/shared/factories.ts +88 -0
- package/src/shared/math.ts +65 -0
- package/src/shared/mocks.ts +243 -0
- package/src/shared/pak-loader.ts +45 -0
- 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
|
+
}
|