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.
Files changed (31) hide show
  1. package/README.md +38 -16
  2. package/dist/index.d.ts +72 -9
  3. package/dist/index.js +258 -64
  4. package/dist/index.js.map +1 -1
  5. package/dist/weslBundle.d.ts +20 -0
  6. package/dist/weslBundle.js +12 -0
  7. package/package.json +10 -5
  8. package/src/CompileShader.ts +41 -24
  9. package/src/TestComputeShader.ts +19 -19
  10. package/src/TestDiscovery.ts +50 -0
  11. package/src/TestFragmentShader.ts +13 -10
  12. package/src/TestVirtualLib.ts +18 -0
  13. package/src/TestWesl.ts +183 -0
  14. package/src/VitestImport.ts +23 -0
  15. package/src/WebGPUTestSetup.ts +28 -13
  16. package/src/index.ts +3 -0
  17. package/src/test/BackendCheck.test.ts +12 -0
  18. package/src/test/TestDiscovery.test.ts +77 -0
  19. package/src/test/TestWesl.test.ts +111 -0
  20. package/src/test/fixtures/wesl_test_pkg/package.json +4 -0
  21. package/src/test/fixtures/wesl_test_pkg/shaders/expect_eq.wesl +9 -0
  22. package/src/test/fixtures/wesl_test_pkg/shaders/failing_expect.wesl +5 -0
  23. package/src/test/fixtures/wesl_test_pkg/shaders/failing_near.wesl +5 -0
  24. package/src/test/fixtures/wesl_test_pkg/shaders/failing_ulp.wesl +8 -0
  25. package/src/test/fixtures/wesl_test_pkg/shaders/multiple_tests.wesl +7 -0
  26. package/src/test/fixtures/wesl_test_pkg/shaders/no_tests.wesl +1 -0
  27. package/src/test/fixtures/wesl_test_pkg/shaders/passing_expect.wesl +5 -0
  28. package/src/test/fixtures/wesl_test_pkg/shaders/passing_near.wesl +5 -0
  29. package/src/test/fixtures/wesl_test_pkg/shaders/passing_ulp.wesl +12 -0
  30. package/src/test/fixtures/wesl_test_pkg/wesl.toml +3 -0
  31. 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.5",
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.1",
22
- "wesl-gpu": "0.1.4"
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.39"
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
  },
@@ -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: ctx.libs };
105
+ linkParams = { resolver: ctx.resolver, libs: allLibs };
91
106
  } else {
92
- linkParams = { weslSrc: { main: src }, libs: ctx.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
- /** Create a lazy resolver that loads local shaders on-demand from the filesystem.
153
- * Laziness allows testing without rebuilding the current package after edits. */
154
- async function lazyFileResolver(
155
- projectDir: string,
156
- mainSrc: string,
157
- packageName: string | undefined,
158
- ): Promise<CompositeResolver> {
159
- const mainResolver = new RecordResolver({ main: mainSrc }, { packageName });
160
- const fileResolver = await createProjectResolver(projectDir, packageName);
161
- return new CompositeResolver([mainResolver, fileResolver]);
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
- /** Verify shader compilation succeeded, throw on errors. */
176
- async function verifyCompilation(module: GPUShaderModule): Promise<void> {
177
- const info = await module.getCompilationInfo();
178
- const errors = info.messages.filter(msg => msg.type === "error");
179
- if (errors.length > 0) {
180
- const messages = errors
181
- .map(e => `${e.lineNum}:${e.linePos} ${e.message}`)
182
- .join("\n");
183
- throw new Error(`Shader compilation failed:\n${messages}`);
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
  }
@@ -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: GPUDevice,
121
- module: GPUShaderModule,
122
- resultFormat: WgslElementType = "u32",
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 import("vitest-image-snapshot");
66
+ const { imageMatcher } = await importImageSnapshot();
66
67
  imageMatcher();
67
- const { expect } = await import("vitest");
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
+ }
@@ -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
+ }