wgsl-test 0.2.5 → 0.2.6
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 +38 -16
- package/dist/index.d.ts +72 -9
- package/dist/index.js +258 -64
- package/dist/index.js.map +1 -1
- package/dist/weslBundle.d.ts +20 -0
- package/dist/weslBundle.js +12 -0
- package/package.json +10 -5
- package/src/CompileShader.ts +41 -24
- package/src/TestComputeShader.ts +19 -19
- package/src/TestDiscovery.ts +50 -0
- package/src/TestFragmentShader.ts +13 -10
- package/src/TestVirtualLib.ts +18 -0
- package/src/TestWesl.ts +183 -0
- package/src/VitestImport.ts +23 -0
- package/src/WebGPUTestSetup.ts +28 -13
- package/src/index.ts +3 -0
- package/src/test/BackendCheck.test.ts +12 -0
- package/src/test/TestDiscovery.test.ts +77 -0
- package/src/test/TestWesl.test.ts +111 -0
- package/src/test/fixtures/wesl_test_pkg/package.json +4 -0
- package/src/test/fixtures/wesl_test_pkg/shaders/expect_eq.wesl +9 -0
- package/src/test/fixtures/wesl_test_pkg/shaders/failing_expect.wesl +5 -0
- package/src/test/fixtures/wesl_test_pkg/shaders/failing_near.wesl +5 -0
- package/src/test/fixtures/wesl_test_pkg/shaders/failing_ulp.wesl +8 -0
- package/src/test/fixtures/wesl_test_pkg/shaders/multiple_tests.wesl +7 -0
- package/src/test/fixtures/wesl_test_pkg/shaders/no_tests.wesl +1 -0
- package/src/test/fixtures/wesl_test_pkg/shaders/passing_expect.wesl +5 -0
- package/src/test/fixtures/wesl_test_pkg/shaders/passing_near.wesl +5 -0
- package/src/test/fixtures/wesl_test_pkg/shaders/passing_ulp.wesl +12 -0
- package/src/test/fixtures/wesl_test_pkg/wesl.toml +3 -0
- package/src/test_lib.wesl +89 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface WeslBundle {
|
|
2
|
+
/** npm package name sanitized to be a valid WESL identifier
|
|
3
|
+
* (@ removed, / ==> __, - ==> _) */
|
|
4
|
+
name: string;
|
|
5
|
+
|
|
6
|
+
/** WESL edition of the code e.g. unstable_2025_1 */
|
|
7
|
+
edition: string;
|
|
8
|
+
|
|
9
|
+
/** map of WESL/WGSL modules:
|
|
10
|
+
* keys are file paths, relative to package root (e.g. "./lib.wgsl")
|
|
11
|
+
* values are WESL/WGSL code strings
|
|
12
|
+
*/
|
|
13
|
+
modules: Record<string, string>;
|
|
14
|
+
|
|
15
|
+
/** packages referenced by this package */
|
|
16
|
+
dependencies?: WeslBundle[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export declare const weslBundle: WeslBundle;
|
|
20
|
+
export default weslBundle;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const weslBundle = {
|
|
2
|
+
name: "wgsl_test",
|
|
3
|
+
edition: "unstable_2025_1",
|
|
4
|
+
modules: {
|
|
5
|
+
"lib.wesl":
|
|
6
|
+
"import package::TestResult::testResult;\n\nconst relTol: f32 = 1e-3;\nconst absTol: f32 = 1e-6;\n\nfn expect(condition: bool) {\n if (!condition && testResult.failCount == 0u) {\n testResult.passed = 0u;\n testResult.failCount = 1u;\n }\n}\n\nfn expectEq(actual: u32, expected: u32) {\n testResult.actual = vec4f(f32(actual), 0.0, 0.0, 0.0);\n testResult.expected = vec4f(f32(expected), 0.0, 0.0, 0.0);\n if (actual != expected && testResult.failCount == 0u) {\n testResult.passed = 0u;\n testResult.failCount = 1u;\n }\n}\n\nfn expectWithin(actual: f32, expected: f32, relTol: f32, absTol: f32) {\n testResult.actual = vec4f(actual, 0.0, 0.0, 0.0);\n testResult.expected = vec4f(expected, 0.0, 0.0, 0.0);\n let diff = abs(actual - expected);\n let tolerance = max(absTol, relTol * max(abs(actual), abs(expected)));\n if (diff > tolerance && testResult.failCount == 0u) {\n testResult.passed = 0u;\n testResult.failCount = 1u;\n }\n}\n\nfn expectNear(actual: f32, expected: f32) {\n expectWithin(actual, expected, relTol, absTol);\n}\n\nfn expectWithinVec2(actual: vec2f, expected: vec2f, relTol: f32, absTol: f32) {\n testResult.actual = vec4f(actual, 0.0, 0.0);\n testResult.expected = vec4f(expected, 0.0, 0.0);\n let diff = abs(actual - expected);\n let tolerance = max(vec2f(absTol), relTol * max(abs(actual), abs(expected)));\n if (any(diff > tolerance) && testResult.failCount == 0u) {\n testResult.passed = 0u;\n testResult.failCount = 1u;\n }\n}\n\nfn expectNearVec2(actual: vec2f, expected: vec2f) {\n expectWithinVec2(actual, expected, relTol, absTol);\n}\n\nfn expectWithinVec3(actual: vec3f, expected: vec3f, relTol: f32, absTol: f32) {\n testResult.actual = vec4f(actual, 0.0);\n testResult.expected = vec4f(expected, 0.0);\n let diff = abs(actual - expected);\n let tolerance = max(vec3f(absTol), relTol * max(abs(actual), abs(expected)));\n if (any(diff > tolerance) && testResult.failCount == 0u) {\n testResult.passed = 0u;\n testResult.failCount = 1u;\n }\n}\n\nfn expectNearVec3(actual: vec3f, expected: vec3f) {\n expectWithinVec3(actual, expected, relTol, absTol);\n}\n\nfn expectWithinVec4(actual: vec4f, expected: vec4f, relTol: f32, absTol: f32) {\n testResult.actual = actual;\n testResult.expected = expected;\n let diff = abs(actual - expected);\n let tolerance = max(vec4f(absTol), relTol * max(abs(actual), abs(expected)));\n if (any(diff > tolerance) && testResult.failCount == 0u) {\n testResult.passed = 0u;\n testResult.failCount = 1u;\n }\n}\n\nfn expectNearVec4(actual: vec4f, expected: vec4f) {\n expectWithinVec4(actual, expected, relTol, absTol);\n}\n\n// ULP (Units in Last Place) comparison - measures how many representable floats apart\nfn ulpDiff(actual: f32, expected: f32) -> u32 {\n let aBits = bitcast<i32>(actual);\n let bBits = bitcast<i32>(expected);\n return u32(abs(aBits - bBits));\n}\n\nfn expectUlp(actual: f32, expected: f32, maxUlp: u32) {\n testResult.actual = vec4f(actual, 0.0, 0.0, 0.0);\n testResult.expected = vec4f(expected, 0.0, 0.0, 0.0);\n if (actual == expected) { return; } // handles +0 == -0, exact match\n let diff = ulpDiff(actual, expected);\n if (diff > maxUlp && testResult.failCount == 0u) {\n testResult.passed = 0u;\n testResult.failCount = 1u;\n }\n}\n\nfn expectUlpVec2(actual: vec2f, expected: vec2f, maxUlp: u32) {\n testResult.actual = vec4f(actual, 0.0, 0.0);\n testResult.expected = vec4f(expected, 0.0, 0.0);\n if (all(actual == expected)) { return; }\n let d0 = ulpDiff(actual.x, expected.x);\n let d1 = ulpDiff(actual.y, expected.y);\n if ((d0 > maxUlp || d1 > maxUlp) && testResult.failCount == 0u) {\n testResult.passed = 0u;\n testResult.failCount = 1u;\n }\n}\n\nfn expectUlpVec3(actual: vec3f, expected: vec3f, maxUlp: u32) {\n testResult.actual = vec4f(actual, 0.0);\n testResult.expected = vec4f(expected, 0.0);\n if (all(actual == expected)) { return; }\n let d0 = ulpDiff(actual.x, expected.x);\n let d1 = ulpDiff(actual.y, expected.y);\n let d2 = ulpDiff(actual.z, expected.z);\n if ((d0 > maxUlp || d1 > maxUlp || d2 > maxUlp) && testResult.failCount == 0u) {\n testResult.passed = 0u;\n testResult.failCount = 1u;\n }\n}\n\nfn expectUlpVec4(actual: vec4f, expected: vec4f, maxUlp: u32) {\n testResult.actual = actual;\n testResult.expected = expected;\n if (all(actual == expected)) { return; }\n let d0 = ulpDiff(actual.x, expected.x);\n let d1 = ulpDiff(actual.y, expected.y);\n let d2 = ulpDiff(actual.z, expected.z);\n let d3 = ulpDiff(actual.w, expected.w);\n if ((d0 > maxUlp || d1 > maxUlp || d2 > maxUlp || d3 > maxUlp) && testResult.failCount == 0u) {\n testResult.passed = 0u;\n testResult.failCount = 1u;\n }\n}\n",
|
|
7
|
+
"TestResult.wesl":
|
|
8
|
+
"struct TestResult {\n passed: u32,\n failCount: u32,\n actual: vec4f,\n expected: vec4f,\n}\n\n@group(0) @binding(0) var<storage, read_write> testResult: TestResult;\n\nfn initTestResult() {\n testResult.passed = 1u;\n testResult.failCount = 0u;\n}\n",
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default weslBundle;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wgsl-test",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -12,24 +12,28 @@
|
|
|
12
12
|
".": {
|
|
13
13
|
"types": "./dist/index.d.ts",
|
|
14
14
|
"import": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./shaders": {
|
|
17
|
+
"import": "./dist/weslBundle.js"
|
|
15
18
|
}
|
|
16
19
|
},
|
|
17
20
|
"dependencies": {
|
|
18
21
|
"pngjs": "^7.0.0",
|
|
19
22
|
"thimbleberry": "^0.2.10",
|
|
20
23
|
"webgpu": "^0.3.8",
|
|
21
|
-
"wesl": "0.7.
|
|
22
|
-
"wesl-gpu": "0.1.
|
|
24
|
+
"wesl": "0.7.2",
|
|
25
|
+
"wesl-gpu": "0.1.5"
|
|
23
26
|
},
|
|
24
27
|
"devDependencies": {
|
|
25
28
|
"@types/pngjs": "^6.0.0",
|
|
26
29
|
"@webgpu/types": "^0.1.68",
|
|
27
30
|
"dependent_package": "x",
|
|
31
|
+
"wesl-packager": "x",
|
|
28
32
|
"wesl-tooling": "x"
|
|
29
33
|
},
|
|
30
34
|
"peerDependencies": {
|
|
31
35
|
"vitest": "^4.0.16",
|
|
32
|
-
"vitest-image-snapshot": "^0.6.
|
|
36
|
+
"vitest-image-snapshot": "^0.6.40"
|
|
33
37
|
},
|
|
34
38
|
"peerDependenciesMeta": {
|
|
35
39
|
"vitest": {
|
|
@@ -51,9 +55,10 @@
|
|
|
51
55
|
"WGSL"
|
|
52
56
|
],
|
|
53
57
|
"scripts": {
|
|
54
|
-
"build": "tsdown",
|
|
58
|
+
"build": "tsdown && wesl-packager",
|
|
55
59
|
"dev": "tsdown --watch",
|
|
56
60
|
"test": "cross-env FORCE_COLOR=1 vitest",
|
|
61
|
+
"test:deno": "deno run -A --unstable-webgpu npm:vitest run",
|
|
57
62
|
"test:once": "vitest run",
|
|
58
63
|
"typecheck": "tsgo"
|
|
59
64
|
},
|
package/src/CompileShader.ts
CHANGED
|
@@ -30,6 +30,9 @@ export interface ResolveContextParams {
|
|
|
30
30
|
|
|
31
31
|
/** Use source shaders instead of built bundles. Default: true. */
|
|
32
32
|
useSourceShaders?: boolean;
|
|
33
|
+
|
|
34
|
+
/** Virtual lib names to exclude from dependency resolution. */
|
|
35
|
+
virtualLibNames?: string[];
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export interface CompileShaderParams {
|
|
@@ -56,6 +59,14 @@ export interface CompileShaderParams {
|
|
|
56
59
|
* Allows dynamic generation of shader code at runtime. */
|
|
57
60
|
virtualLibs?: LinkParams["virtualLibs"];
|
|
58
61
|
|
|
62
|
+
/** Additional WESL bundles to include.
|
|
63
|
+
* These are merged with auto-discovered dependencies. */
|
|
64
|
+
libs?: WeslBundle[];
|
|
65
|
+
|
|
66
|
+
/** Override the package name for module resolution.
|
|
67
|
+
* Used to ensure package:: references resolve correctly. */
|
|
68
|
+
packageName?: string;
|
|
69
|
+
|
|
59
70
|
/** Use source shaders from current package instead of built bundles.
|
|
60
71
|
* Default: true for faster iteration during development.
|
|
61
72
|
* Set to false or use TEST_BUNDLES=true environment variable to test built bundles.
|
|
@@ -78,18 +89,22 @@ export interface CompileShaderParams {
|
|
|
78
89
|
export async function compileShader(
|
|
79
90
|
params: CompileShaderParams,
|
|
80
91
|
): Promise<GPUShaderModule> {
|
|
81
|
-
const { device, src, conditions, constants, virtualLibs } = params;
|
|
92
|
+
const { device, src, conditions, constants, virtualLibs, libs = [] } = params;
|
|
82
93
|
const ctx = await resolveShaderContext({
|
|
83
94
|
src,
|
|
84
95
|
projectDir: params.projectDir,
|
|
85
96
|
useSourceShaders: params.useSourceShaders,
|
|
97
|
+
virtualLibNames: virtualLibs ? Object.keys(virtualLibs) : [],
|
|
86
98
|
});
|
|
87
99
|
|
|
100
|
+
// Filter out undefined values that can occur when auto-discovery finds packages
|
|
101
|
+
// that aren't resolvable (e.g., wgsl_test when running tests within wgsl-test itself)
|
|
102
|
+
const allLibs = [...ctx.libs, ...libs].filter(Boolean) as WeslBundle[];
|
|
88
103
|
let linkParams: Pick<LinkParams, "resolver" | "libs" | "weslSrc">;
|
|
89
104
|
if (ctx.resolver) {
|
|
90
|
-
linkParams = { resolver: ctx.resolver, libs:
|
|
105
|
+
linkParams = { resolver: ctx.resolver, libs: allLibs };
|
|
91
106
|
} else {
|
|
92
|
-
linkParams = { weslSrc: { main: src }, libs:
|
|
107
|
+
linkParams = { weslSrc: { main: src }, libs: allLibs };
|
|
93
108
|
}
|
|
94
109
|
|
|
95
110
|
const linked = await link({
|
|
@@ -98,7 +113,7 @@ export async function compileShader(
|
|
|
98
113
|
virtualLibs,
|
|
99
114
|
conditions,
|
|
100
115
|
constants,
|
|
101
|
-
packageName: ctx.packageName,
|
|
116
|
+
packageName: params.packageName ?? ctx.packageName,
|
|
102
117
|
});
|
|
103
118
|
const module = linked.createShaderModule(device);
|
|
104
119
|
|
|
@@ -111,6 +126,7 @@ export async function resolveShaderContext(
|
|
|
111
126
|
params: ResolveContextParams,
|
|
112
127
|
): Promise<ShaderContext> {
|
|
113
128
|
const { src, useSourceShaders = !process.env.TEST_BUNDLES } = params;
|
|
129
|
+
const { virtualLibNames = [] } = params;
|
|
114
130
|
const projectDir = await resolveProjectDir(params.projectDir);
|
|
115
131
|
const packageName = await getPackageName(projectDir);
|
|
116
132
|
|
|
@@ -119,6 +135,7 @@ export async function resolveShaderContext(
|
|
|
119
135
|
projectDir,
|
|
120
136
|
packageName,
|
|
121
137
|
!useSourceShaders, // include current package when testing bundles
|
|
138
|
+
virtualLibNames,
|
|
122
139
|
);
|
|
123
140
|
|
|
124
141
|
const resolver = useSourceShaders
|
|
@@ -149,16 +166,16 @@ export async function createProjectResolver(
|
|
|
149
166
|
return new FileModuleResolver(baseDir, packageName);
|
|
150
167
|
}
|
|
151
168
|
|
|
152
|
-
/**
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
/** Verify shader compilation succeeded, throw on errors. */
|
|
170
|
+
async function verifyCompilation(module: GPUShaderModule): Promise<void> {
|
|
171
|
+
const info = await module.getCompilationInfo();
|
|
172
|
+
const errors = info.messages.filter(msg => msg.type === "error");
|
|
173
|
+
if (errors.length > 0) {
|
|
174
|
+
const messages = errors
|
|
175
|
+
.map(e => `${e.lineNum}:${e.linePos} ${e.message}`)
|
|
176
|
+
.join("\n");
|
|
177
|
+
throw new Error(`Shader compilation failed:\n${messages}`);
|
|
178
|
+
}
|
|
162
179
|
}
|
|
163
180
|
|
|
164
181
|
/** Read package name from package.json, normalized for WGSL identifiers. */
|
|
@@ -172,14 +189,14 @@ async function getPackageName(projectDir: string): Promise<string | undefined> {
|
|
|
172
189
|
}
|
|
173
190
|
}
|
|
174
191
|
|
|
175
|
-
/**
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
192
|
+
/** Create a lazy resolver that loads local shaders on-demand from the filesystem.
|
|
193
|
+
* Laziness allows testing without rebuilding the current package after edits. */
|
|
194
|
+
async function lazyFileResolver(
|
|
195
|
+
projectDir: string,
|
|
196
|
+
mainSrc: string,
|
|
197
|
+
packageName: string | undefined,
|
|
198
|
+
): Promise<CompositeResolver> {
|
|
199
|
+
const mainResolver = new RecordResolver({ main: mainSrc }, { packageName });
|
|
200
|
+
const fileResolver = await createProjectResolver(projectDir, packageName);
|
|
201
|
+
return new CompositeResolver([mainResolver, fileResolver]);
|
|
185
202
|
}
|
package/src/TestComputeShader.ts
CHANGED
|
@@ -2,9 +2,7 @@ import { copyBuffer, elementStride, type WgslElementType } from "thimbleberry";
|
|
|
2
2
|
import type { LinkParams } from "wesl";
|
|
3
3
|
import { withErrorScopes } from "wesl-gpu";
|
|
4
4
|
import { compileShader } from "./CompileShader.ts";
|
|
5
|
-
import { resolveShaderSource } from "./ShaderModuleLoader.ts";
|
|
6
|
-
|
|
7
|
-
const defaultResultSize = 4; // 4 elements
|
|
5
|
+
import { resolveShaderSource } from "./ShaderModuleLoader.ts"; // 4 elements
|
|
8
6
|
|
|
9
7
|
export interface ComputeTestParams {
|
|
10
8
|
/** WESL/WGSL source code for the compute shader to test.
|
|
@@ -49,6 +47,17 @@ export interface ComputeTestParams {
|
|
|
49
47
|
dispatchWorkgroups?: number | [number, number, number];
|
|
50
48
|
}
|
|
51
49
|
|
|
50
|
+
export interface RunComputeParams {
|
|
51
|
+
device: GPUDevice;
|
|
52
|
+
module: GPUShaderModule;
|
|
53
|
+
resultFormat?: WgslElementType;
|
|
54
|
+
size?: number;
|
|
55
|
+
dispatchWorkgroups?: number | [number, number, number];
|
|
56
|
+
entryPoint?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const defaultResultSize = 4;
|
|
60
|
+
|
|
52
61
|
/**
|
|
53
62
|
* Compiles and runs a compute shader on the GPU for testing.
|
|
54
63
|
*
|
|
@@ -94,13 +103,13 @@ export async function testCompute(
|
|
|
94
103
|
useSourceShaders,
|
|
95
104
|
});
|
|
96
105
|
|
|
97
|
-
return await runCompute(
|
|
106
|
+
return await runCompute({
|
|
98
107
|
device,
|
|
99
108
|
module,
|
|
100
109
|
resultFormat,
|
|
101
110
|
size,
|
|
102
111
|
dispatchWorkgroups,
|
|
103
|
-
);
|
|
112
|
+
});
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
/**
|
|
@@ -109,20 +118,11 @@ export async function testCompute(
|
|
|
109
118
|
* Creates a storage buffer at @group(0) @binding(0) where the shader can
|
|
110
119
|
* write output. The shader is invoked once, then the buffer is copied back
|
|
111
120
|
* to the CPU for reading.
|
|
112
|
-
*
|
|
113
|
-
* @param module - The compiled GPUShaderModule containing the compute shader
|
|
114
|
-
* @param resultFormat - Format for interpreting result buffer data (default: u32)
|
|
115
|
-
* @param size - Size of result buffer in bytes (default: 16)
|
|
116
|
-
* @param dispatchWorkgroups - Number of workgroups to dispatch (default: 1)
|
|
117
|
-
* @returns Array containing the shader's output from the storage buffer
|
|
118
121
|
*/
|
|
119
|
-
export async function runCompute(
|
|
120
|
-
device
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
size = defaultResultSize,
|
|
124
|
-
dispatchWorkgroups: number | [number, number, number] = 1,
|
|
125
|
-
): Promise<number[]> {
|
|
122
|
+
export async function runCompute(params: RunComputeParams): Promise<number[]> {
|
|
123
|
+
const { device, module, entryPoint } = params;
|
|
124
|
+
const { resultFormat = "u32", size = defaultResultSize } = params;
|
|
125
|
+
const { dispatchWorkgroups = 1 } = params;
|
|
126
126
|
return await withErrorScopes(device, async () => {
|
|
127
127
|
const bgLayout = device.createBindGroupLayout({
|
|
128
128
|
entries: [
|
|
@@ -136,7 +136,7 @@ export async function runCompute(
|
|
|
136
136
|
|
|
137
137
|
const pipeline = device.createComputePipeline({
|
|
138
138
|
layout: device.createPipelineLayout({ bindGroupLayouts: [bgLayout] }),
|
|
139
|
-
compute: { module },
|
|
139
|
+
compute: { module, entryPoint },
|
|
140
140
|
});
|
|
141
141
|
|
|
142
142
|
const storageBuffer = createStorageBuffer(
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { FnElem, StandardAttribute, WeslAST } from "wesl";
|
|
2
|
+
|
|
3
|
+
export interface TestFunctionInfo {
|
|
4
|
+
name: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
fn: FnElem;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Find all functions marked with @test attribute in a parsed WESL module. */
|
|
10
|
+
export function findTestFunctions(ast: WeslAST): TestFunctionInfo[] {
|
|
11
|
+
return ast.moduleElem.contents
|
|
12
|
+
.filter((e): e is FnElem => e.kind === "fn")
|
|
13
|
+
.filter(hasTestAttribute)
|
|
14
|
+
.filter(fn => {
|
|
15
|
+
if (fn.params.length > 0) {
|
|
16
|
+
const name = fn.name.ident.originalName;
|
|
17
|
+
console.warn(
|
|
18
|
+
`@test function '${name}' has parameters and will be skipped`,
|
|
19
|
+
);
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
return true;
|
|
23
|
+
})
|
|
24
|
+
.map(fn => ({
|
|
25
|
+
name: fn.name.ident.originalName,
|
|
26
|
+
description: getTestDescription(fn),
|
|
27
|
+
fn,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasTestAttribute(fn: FnElem): boolean {
|
|
32
|
+
return !!getTestAttribute(fn);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Extract description from @test(description) attribute. */
|
|
36
|
+
function getTestDescription(fn: FnElem): string | undefined {
|
|
37
|
+
const testAttr = getTestAttribute(fn);
|
|
38
|
+
const param = testAttr?.params?.[0];
|
|
39
|
+
if (!param) return undefined;
|
|
40
|
+
// Extract the identifier text from the expression contents
|
|
41
|
+
const text = param.contents.find(c => c.kind === "ref");
|
|
42
|
+
return text?.kind === "ref" ? text.ident.originalName : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getTestAttribute(fn: FnElem): StandardAttribute | undefined {
|
|
46
|
+
for (const e of fn.attributes ?? []) {
|
|
47
|
+
const attr = e.attribute;
|
|
48
|
+
if (attr.kind === "@attribute" && attr.name === "test") return attr;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "wesl-gpu";
|
|
9
9
|
import { resolveShaderContext } from "./CompileShader.ts";
|
|
10
10
|
import { resolveShaderSource } from "./ShaderModuleLoader.ts";
|
|
11
|
+
import { importImageSnapshot, importVitest } from "./VitestImport.ts";
|
|
11
12
|
|
|
12
13
|
export interface FragmentTestParams extends WeslOptions, FragmentRenderParams {
|
|
13
14
|
/** WESL/WGSL source code for the fragment shader to test.
|
|
@@ -62,9 +63,9 @@ export async function expectFragmentImage(
|
|
|
62
63
|
|
|
63
64
|
const snapshotName =
|
|
64
65
|
opts.snapshotName ?? moduleNameToSnapshotName(moduleName);
|
|
65
|
-
const { imageMatcher } = await
|
|
66
|
+
const { imageMatcher } = await importImageSnapshot();
|
|
66
67
|
imageMatcher();
|
|
67
|
-
const { expect } = await
|
|
68
|
+
const { expect } = await importVitest();
|
|
68
69
|
await expect(imageData).toMatchImage(snapshotName);
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -106,6 +107,14 @@ export async function testFragmentImage(
|
|
|
106
107
|
} as ImageData;
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/** Convert module path to snapshot name (e.g., "package::effects::blur" → "effects-blur") */
|
|
111
|
+
function moduleNameToSnapshotName(moduleName: string): string {
|
|
112
|
+
const normalized = normalizeModuleName(moduleName);
|
|
113
|
+
return normalized
|
|
114
|
+
.replace(/^package::/, "") // Strip "package::" prefix
|
|
115
|
+
.replaceAll("::", "-"); // Replace :: with -
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
async function runFragment(params: FragmentTestParams): Promise<number[]> {
|
|
110
119
|
const { projectDir, src, moduleName, useSourceShaders } = params;
|
|
111
120
|
|
|
@@ -113,10 +122,12 @@ async function runFragment(params: FragmentTestParams): Promise<number[]> {
|
|
|
113
122
|
const fragmentSrc = await resolveShaderSource(src, moduleName, projectDir);
|
|
114
123
|
|
|
115
124
|
// Resolve context (libs, resolver, packageName) from project
|
|
125
|
+
// Note: "test" virtualLib is provided by wesl-gpu for test::Uniforms
|
|
116
126
|
const ctx = await resolveShaderContext({
|
|
117
127
|
src: fragmentSrc,
|
|
118
128
|
projectDir,
|
|
119
129
|
useSourceShaders,
|
|
130
|
+
virtualLibNames: ["test"],
|
|
120
131
|
});
|
|
121
132
|
|
|
122
133
|
// Use shared runFragment with resolved source and context
|
|
@@ -157,11 +168,3 @@ function imageToUint8(
|
|
|
157
168
|
|
|
158
169
|
throw new Error(`Unsupported texture format for image export: ${format}`);
|
|
159
170
|
}
|
|
160
|
-
|
|
161
|
-
/** Convert module path to snapshot name (e.g., "package::effects::blur" → "effects-blur") */
|
|
162
|
-
function moduleNameToSnapshotName(moduleName: string): string {
|
|
163
|
-
const normalized = normalizeModuleName(moduleName);
|
|
164
|
-
return normalized
|
|
165
|
-
.replace(/^package::/, "") // Strip "package::" prefix
|
|
166
|
-
.replaceAll("::", "-"); // Replace :: with -
|
|
167
|
-
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** Size of TestResult struct in bytes.
|
|
4
|
+
* Layout: u32 passed (4) + u32 failCount (4) + padding (8) + vec4f actual (16) + vec4f expected (16) = 48 */
|
|
5
|
+
export const testResultSize = 48;
|
|
6
|
+
|
|
7
|
+
let cachedTestLibSrc: string | undefined;
|
|
8
|
+
|
|
9
|
+
/** Virtual library for test:: namespace providing assertions and result reporting. */
|
|
10
|
+
export function testVirtualLib(): string {
|
|
11
|
+
if (!cachedTestLibSrc) {
|
|
12
|
+
cachedTestLibSrc = readFileSync(
|
|
13
|
+
new URL("./test_lib.wesl", import.meta.url),
|
|
14
|
+
"utf-8",
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return cachedTestLibSrc;
|
|
18
|
+
}
|
package/src/TestWesl.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { type LinkParams, parseSrcModule, type WeslBundle } from "wesl";
|
|
2
|
+
import { compileShader } from "./CompileShader.ts";
|
|
3
|
+
import { resolveShaderSource } from "./ShaderModuleLoader.ts";
|
|
4
|
+
import { type ComputeTestParams, runCompute } from "./TestComputeShader.ts";
|
|
5
|
+
import { findTestFunctions, type TestFunctionInfo } from "./TestDiscovery.ts";
|
|
6
|
+
import { testResultSize } from "./TestVirtualLib.ts";
|
|
7
|
+
import { importVitest } from "./VitestImport.ts";
|
|
8
|
+
|
|
9
|
+
/** Parameters for running @test functions in a WESL module. */
|
|
10
|
+
export type RunWeslParams = Omit<
|
|
11
|
+
ComputeTestParams,
|
|
12
|
+
"resultFormat" | "size" | "dispatchWorkgroups"
|
|
13
|
+
> & {
|
|
14
|
+
/** Run only the @test function with this name */
|
|
15
|
+
testName?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Result from running a single @test function on the GPU. */
|
|
19
|
+
export interface TestResult {
|
|
20
|
+
name: string;
|
|
21
|
+
passed: boolean;
|
|
22
|
+
actual: number[];
|
|
23
|
+
expected: number[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Parameters for testWesl() which registers all @test functions with vitest. */
|
|
27
|
+
export type TestWeslParams = Omit<RunWeslParams, "testName">;
|
|
28
|
+
|
|
29
|
+
/** Internal params for executing one @test function as a compute shader. */
|
|
30
|
+
interface RunSingleTestParams {
|
|
31
|
+
testFn: TestFunctionInfo;
|
|
32
|
+
shaderSrc: string;
|
|
33
|
+
projectDir?: string;
|
|
34
|
+
device: GPUDevice;
|
|
35
|
+
conditions?: LinkParams["conditions"];
|
|
36
|
+
constants?: LinkParams["constants"];
|
|
37
|
+
useSourceShaders?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Parsed WESL source with its AST for test discovery. */
|
|
41
|
+
interface ParsedTestModule {
|
|
42
|
+
shaderSrc: string;
|
|
43
|
+
ast: ReturnType<typeof parseSrcModule>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Auto-inject the wgsl-test shader library bundle
|
|
47
|
+
let cachedWeslBundle: WeslBundle;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Discovers @test functions in a WESL module and registers each as a vitest test.
|
|
51
|
+
* Use top-level await in your test file to call this function.
|
|
52
|
+
*/
|
|
53
|
+
export async function testWesl(params: TestWeslParams): Promise<void> {
|
|
54
|
+
const { test } = await importVitest();
|
|
55
|
+
const { ast } = await parseTestModule(params);
|
|
56
|
+
const testFns = findTestFunctions(ast);
|
|
57
|
+
for (const fn of testFns) {
|
|
58
|
+
const testLabel = fn.description ?? fn.name;
|
|
59
|
+
test(testLabel, async () => {
|
|
60
|
+
await expectWesl({ ...params, testName: fn.name });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Runs all @test functions and asserts they pass.
|
|
67
|
+
* Throws descriptive error on failure showing test name and actual/expected values.
|
|
68
|
+
*/
|
|
69
|
+
export async function expectWesl(params: RunWeslParams): Promise<void> {
|
|
70
|
+
const results = await runWesl(params);
|
|
71
|
+
const failures = results.filter(r => !r.passed);
|
|
72
|
+
|
|
73
|
+
if (failures.length > 0) {
|
|
74
|
+
const messages = failures.map(f => {
|
|
75
|
+
let msg = ` ${f.name}: FAILED`;
|
|
76
|
+
msg += `\n actual: [${f.actual.join(", ")}]`;
|
|
77
|
+
msg += `\n expected: [${f.expected.join(", ")}]`;
|
|
78
|
+
return msg;
|
|
79
|
+
});
|
|
80
|
+
throw new Error(`WESL tests failed:\n${messages.join("\n")}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Runs all @test functions in a WESL module.
|
|
86
|
+
* Each test function is wrapped in a compute shader and dispatched.
|
|
87
|
+
* Returns results for all tests.
|
|
88
|
+
*/
|
|
89
|
+
export async function runWesl(params: RunWeslParams): Promise<TestResult[]> {
|
|
90
|
+
const { testName } = params;
|
|
91
|
+
const { shaderSrc, ast } = await parseTestModule(params);
|
|
92
|
+
let testFns = findTestFunctions(ast);
|
|
93
|
+
if (testName) {
|
|
94
|
+
testFns = testFns.filter(t => t.name === testName);
|
|
95
|
+
}
|
|
96
|
+
const results: TestResult[] = [];
|
|
97
|
+
for (const testFn of testFns) {
|
|
98
|
+
results.push(await runSingleTest({ testFn, shaderSrc, ...params }));
|
|
99
|
+
}
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Load and parse a WESL module to extract @test functions. */
|
|
104
|
+
async function parseTestModule(params: {
|
|
105
|
+
src?: string;
|
|
106
|
+
moduleName?: string;
|
|
107
|
+
projectDir?: string;
|
|
108
|
+
}): Promise<ParsedTestModule> {
|
|
109
|
+
const { projectDir, src, moduleName } = params;
|
|
110
|
+
const shaderSrc = await resolveShaderSource(src, moduleName, projectDir);
|
|
111
|
+
const modPath = moduleName || "test";
|
|
112
|
+
const ast = parseSrcModule({
|
|
113
|
+
modulePath: modPath,
|
|
114
|
+
debugFilePath: modPath + ".wesl",
|
|
115
|
+
src: shaderSrc,
|
|
116
|
+
});
|
|
117
|
+
return { shaderSrc, ast };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Wrap a @test function in a compute shader, dispatch it, and return results. */
|
|
121
|
+
async function runSingleTest(params: RunSingleTestParams): Promise<TestResult> {
|
|
122
|
+
const { testFn, shaderSrc, device, ...rest } = params;
|
|
123
|
+
// Generate wrapper that calls the test function
|
|
124
|
+
// Call initTestResult() function to initialize the result buffer
|
|
125
|
+
const wrapper = `
|
|
126
|
+
import wgsl_test::TestResult::initTestResult;
|
|
127
|
+
|
|
128
|
+
${shaderSrc}
|
|
129
|
+
|
|
130
|
+
@compute @workgroup_size(1)
|
|
131
|
+
fn _weslTestEntry() {
|
|
132
|
+
initTestResult();
|
|
133
|
+
${testFn.name}();
|
|
134
|
+
}
|
|
135
|
+
`;
|
|
136
|
+
const weslTestBundle = await getWeslTestBundle();
|
|
137
|
+
const module = await compileShader({
|
|
138
|
+
...rest,
|
|
139
|
+
device,
|
|
140
|
+
src: wrapper,
|
|
141
|
+
libs: [weslTestBundle],
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const resultElems = testResultSize / 4; // 48 bytes / 4 bytes per u32 = 12
|
|
145
|
+
const gpuResult = await runCompute({
|
|
146
|
+
device,
|
|
147
|
+
module,
|
|
148
|
+
resultFormat: "u32",
|
|
149
|
+
size: resultElems,
|
|
150
|
+
entryPoint: "_weslTestEntry",
|
|
151
|
+
});
|
|
152
|
+
const testLabel = testFn.description ?? testFn.name;
|
|
153
|
+
return parseTestResult(testLabel, gpuResult);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Lazy-load and cache the wgsl-test shader library bundle. */
|
|
157
|
+
async function getWeslTestBundle(): Promise<WeslBundle> {
|
|
158
|
+
if (cachedWeslBundle) return cachedWeslBundle;
|
|
159
|
+
// Use import.meta.url to resolve the path correctly regardless of cwd
|
|
160
|
+
const bundlePath = new URL("../dist/weslBundle.js", import.meta.url).href;
|
|
161
|
+
const mod = await import(bundlePath);
|
|
162
|
+
cachedWeslBundle = mod.default;
|
|
163
|
+
return cachedWeslBundle;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Decode TestResult struct from GPU buffer (passed flag + actual/expected vec4f). */
|
|
167
|
+
function parseTestResult(name: string, gpuResult: number[]): TestResult {
|
|
168
|
+
// TestResult struct layout (with vec4f 16-byte alignment):
|
|
169
|
+
// [0] passed (u32)
|
|
170
|
+
// [1] failCount (u32)
|
|
171
|
+
// [2-3] padding (8 bytes to align vec4f)
|
|
172
|
+
// [4-7] actual (vec4f)
|
|
173
|
+
// [8-11] expected (vec4f)
|
|
174
|
+
const passed = gpuResult[0] === 1;
|
|
175
|
+
|
|
176
|
+
// Reinterpret u32 bits as f32 for actual/expected (always captured now)
|
|
177
|
+
const u32Array = new Uint32Array(gpuResult.slice(4, 12));
|
|
178
|
+
const f32Array = new Float32Array(u32Array.buffer);
|
|
179
|
+
const actual = Array.from(f32Array.slice(0, 4));
|
|
180
|
+
const expected = Array.from(f32Array.slice(4, 8));
|
|
181
|
+
|
|
182
|
+
return { name, passed, actual, expected };
|
|
183
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Dynamically import vitest with helpful error if not installed. */
|
|
2
|
+
export async function importVitest(): Promise<typeof import("vitest")> {
|
|
3
|
+
try {
|
|
4
|
+
return await import("vitest");
|
|
5
|
+
} catch {
|
|
6
|
+
throw new Error(
|
|
7
|
+
"This function requires vitest. Use framework-agnostic APIs like testWesl() or testFragment() instead.",
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Dynamically import vitest-image-snapshot with helpful error if not installed. */
|
|
13
|
+
export async function importImageSnapshot(): Promise<
|
|
14
|
+
typeof import("vitest-image-snapshot")
|
|
15
|
+
> {
|
|
16
|
+
try {
|
|
17
|
+
return await import("vitest-image-snapshot");
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error(
|
|
20
|
+
"This function requires vitest-image-snapshot. Use testFragmentImage() for framework-agnostic testing.",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|