jest-environment-webgpu-node 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ben Houston
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # jest-environment-webgpu-node
2
+
3
+ Real, headless WebGPU inside Jest, with no browser. Powered by Google's Dawn through the
4
+ [`webgpu`](https://github.com/dawn-gpu/node-webgpu) npm package.
5
+
6
+ The environment puts `navigator.gpu` and every `GPU*` class and constant (`GPUBufferUsage`,
7
+ `GPUShaderStage`, ...) on the test's global object before each test file and removes them
8
+ afterwards.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ pnpm add -D jest jest-environment-webgpu-node
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```js
19
+ // jest.config.mjs
20
+ export default {
21
+ testEnvironment: 'jest-environment-webgpu-node',
22
+ testEnvironmentOptions: { dawnOptions: ['backend=vulkan'] },
23
+ };
24
+ ```
25
+
26
+ ```ts
27
+ import { expect, it } from '@jest/globals';
28
+
29
+ it('runs a compute shader', async () => {
30
+ const device = await (await navigator.gpu.requestAdapter())!.requestDevice();
31
+ // ...createShaderModule, dispatch, copy to a MAP_READ buffer, mapAsync, assert
32
+ });
33
+ ```
34
+
35
+ `dawnOptions` are passed straight to Dawn: `backend=<null|d3d11|d3d12|metal|vulkan|opengl|opengles>`,
36
+ `adapter=<name>`, `enable-dawn-features=...`, `disable-dawn-features=...`.
37
+
38
+ ## Canvas
39
+
40
+ Dawn has no `<canvas>`, so the environment ships a headless one whose `getContext('webgpu')` returns a
41
+ `GPUCanvasContext` backed by a texture. `document.createElement('canvas')`, `HTMLCanvasElement`, `window`,
42
+ `self` and `requestAnimationFrame` are installed too (only when missing), which is enough for three.js
43
+ `WebGPURenderer`, Babylon Lite and vgpu to run unchanged.
44
+
45
+ `createCanvas` is installed as a global, so CommonJS test files can use it without an import. It is also
46
+ available as a named export for ESM test files:
47
+
48
+ ```ts
49
+ import * as THREE from 'three/webgpu';
50
+ import { createCanvas } from 'jest-environment-webgpu-node';
51
+
52
+ const canvas = createCanvas(256, 256);
53
+ const renderer = new THREE.WebGPURenderer({ canvas: canvas.asElement() });
54
+ await renderer.init();
55
+ await renderer.renderAsync(scene, camera);
56
+ const { width, height, data } = await canvas.readPixels();
57
+ // data is RGBA8, top row first - feed it to a screenshot matcher such as jest-image-snapshot
58
+ ```
59
+
60
+ `readPixels()` returns `{ width, height, data }` RGBA8 pixels. `asElement()` is the same object typed as an
61
+ `HTMLCanvasElement` for library signatures that demand one.
62
+
63
+ Import `jest-environment-webgpu-node/globals` in a `.d.ts` (or add it to `types` in `tsconfig.json`) to get
64
+ TypeScript types for the global `createCanvas`.
65
+
66
+ ## ESM and CommonJS
67
+
68
+ This package ships both an ESM and a CommonJS build, so `require('jest-environment-webgpu-node')`
69
+ works from a plain CommonJS test file, and `testEnvironment: 'jest-environment-webgpu-node'`
70
+ resolves the same way either way. `NODE_OPTIONS=--experimental-vm-modules` is only needed when a
71
+ test file itself imports an ESM-only library, such as `three/webgpu`.
72
+
73
+ ## Author
74
+
75
+ Created by [Ben Houston](https://ben3d.ca) and sponsored by [Land of Assets](https://landofassets.com).
76
+
77
+ ## License
78
+
79
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,203 @@
1
+ Object.defineProperties(exports, {
2
+ __esModule: { value: true },
3
+ [Symbol.toStringTag]: { value: "Module" }
4
+ });
5
+ let jest_environment_node = require("jest-environment-node");
6
+ //#region src/canvas.ts
7
+ const RENDER_ATTACHMENT = 16;
8
+ const MAP_READ = 1;
9
+ /** Minimal `GPUCanvasContext`: `getCurrentTexture()` is a texture the size of the canvas, kept until reconfigured or resized. */
10
+ var HeadlessCanvasContext = class {
11
+ canvas;
12
+ #config;
13
+ #texture;
14
+ constructor(canvas) {
15
+ this.canvas = canvas;
16
+ }
17
+ configure(config) {
18
+ this.#config = config;
19
+ this.#drop();
20
+ }
21
+ unconfigure() {
22
+ this.#config = void 0;
23
+ this.#drop();
24
+ }
25
+ getConfiguration() {
26
+ return this.#config ?? null;
27
+ }
28
+ getCurrentTexture() {
29
+ const config = this.#config;
30
+ if (!config) throw new Error("getCurrentTexture() called before configure()");
31
+ const { width, height } = this.canvas;
32
+ if (this.#texture && (this.#texture.width !== width || this.#texture.height !== height)) this.#drop();
33
+ this.#texture ??= config.device.createTexture({
34
+ size: [width, height],
35
+ format: config.format,
36
+ usage: (config.usage ?? RENDER_ATTACHMENT) | 17,
37
+ viewFormats: config.viewFormats
38
+ });
39
+ return this.#texture;
40
+ }
41
+ /** Copies the current texture to the CPU. BGRA formats are swizzled to RGBA. */
42
+ async readPixels() {
43
+ const texture = this.getCurrentTexture();
44
+ const { device, format } = this.#config;
45
+ const { width, height } = texture;
46
+ const bytesPerRow = Math.ceil(width * 4 / 256) * 256;
47
+ const buffer = device.createBuffer({
48
+ size: bytesPerRow * height,
49
+ usage: 9
50
+ });
51
+ const encoder = device.createCommandEncoder();
52
+ encoder.copyTextureToBuffer({ texture }, {
53
+ buffer,
54
+ bytesPerRow
55
+ }, [width, height]);
56
+ device.queue.submit([encoder.finish()]);
57
+ await buffer.mapAsync(MAP_READ);
58
+ const padded = new Uint8Array(buffer.getMappedRange());
59
+ const data = new Uint8Array(width * height * 4);
60
+ for (let y = 0; y < height; y++) data.set(padded.subarray(y * bytesPerRow, y * bytesPerRow + width * 4), y * width * 4);
61
+ buffer.unmap();
62
+ buffer.destroy();
63
+ if (format.startsWith("bgra")) for (let i = 0; i < data.length; i += 4) [data[i], data[i + 2]] = [data[i + 2], data[i]];
64
+ return {
65
+ width,
66
+ height,
67
+ data
68
+ };
69
+ }
70
+ #drop() {
71
+ this.#texture?.destroy();
72
+ this.#texture = void 0;
73
+ }
74
+ };
75
+ /** Enough of `HTMLCanvasElement` for three.js `WebGPURenderer`, Babylon Lite and friends. */
76
+ var HeadlessCanvas = class {
77
+ width;
78
+ height;
79
+ #context = new HeadlessCanvasContext(this);
80
+ style = {};
81
+ #attributes = /* @__PURE__ */ new Map();
82
+ constructor(width = 300, height = 150) {
83
+ this.width = width;
84
+ this.height = height;
85
+ }
86
+ get clientWidth() {
87
+ return this.width;
88
+ }
89
+ get clientHeight() {
90
+ return this.height;
91
+ }
92
+ getContext(id) {
93
+ return id === "webgpu" ? this.#context : null;
94
+ }
95
+ readPixels() {
96
+ return this.#context.readPixels();
97
+ }
98
+ /** This same object, typed as an element for libraries whose signatures demand one. */
99
+ asElement() {
100
+ return this;
101
+ }
102
+ getBoundingClientRect() {
103
+ return {
104
+ x: 0,
105
+ y: 0,
106
+ top: 0,
107
+ left: 0,
108
+ width: this.width,
109
+ height: this.height
110
+ };
111
+ }
112
+ setAttribute(name, value) {
113
+ this.#attributes.set(name, value);
114
+ }
115
+ getAttribute(name) {
116
+ return this.#attributes.get(name) ?? null;
117
+ }
118
+ addEventListener() {}
119
+ removeEventListener() {}
120
+ dispatchEvent() {
121
+ return true;
122
+ }
123
+ };
124
+ const createCanvas = (width, height) => new HeadlessCanvas(width, height);
125
+ //#endregion
126
+ //#region src/index.ts
127
+ /** Just enough DOM for libraries that make their own canvas and drive a frame loop. Only installed when missing. */
128
+ const requestAnimationFrame = (callback) => setTimeout(() => callback(performance.now()), 16).unref();
129
+ const domShims = () => {
130
+ const window = {
131
+ devicePixelRatio: 1,
132
+ requestAnimationFrame,
133
+ cancelAnimationFrame: clearTimeout,
134
+ addEventListener() {},
135
+ removeEventListener() {}
136
+ };
137
+ return {
138
+ HTMLCanvasElement: HeadlessCanvas,
139
+ document: {
140
+ createElement: (tag) => tag === "canvas" ? new HeadlessCanvas() : {},
141
+ createElementNS: (_ns, tag) => tag === "canvas" ? new HeadlessCanvas() : {},
142
+ addEventListener() {},
143
+ removeEventListener() {}
144
+ },
145
+ window,
146
+ self: window,
147
+ requestAnimationFrame,
148
+ cancelAnimationFrame: clearTimeout
149
+ };
150
+ };
151
+ /**
152
+ * Jest environment that puts real headless WebGPU (Dawn via the `webgpu` package) on the sandbox
153
+ * global: `navigator.gpu`, every `GPU*` class and constant, and a `HeadlessCanvas` shim. CommonJS
154
+ * tests can rely on `createCanvas` being a global; ESM tests can also `import` it from this package.
155
+ */
156
+ var WebgpuEnvironment = class extends jest_environment_node.TestEnvironment {
157
+ #added = [];
158
+ #navigator = {};
159
+ #nativeAdded;
160
+ #dawnOptions;
161
+ constructor(config, context) {
162
+ super(config, context);
163
+ this.#nativeAdded = !Object.getOwnPropertyNames(this.global).includes("_native");
164
+ if (this.#nativeAdded) Object.defineProperty(this.global, "_native", {
165
+ value: void 0,
166
+ writable: true,
167
+ configurable: true,
168
+ enumerable: false
169
+ });
170
+ this.#dawnOptions = config.projectConfig.testEnvironmentOptions.dawnOptions;
171
+ }
172
+ async setup() {
173
+ await super.setup();
174
+ const { create, globals } = await import("webgpu");
175
+ const global = this.global;
176
+ const shims = {
177
+ ...globals,
178
+ ...domShims(),
179
+ createCanvas
180
+ };
181
+ this.#added = Object.keys(shims).filter((key) => !(key in global));
182
+ for (const key of this.#added) global[key] = shims[key];
183
+ if (this.#nativeAdded) this.#added.push("_native");
184
+ this.#navigator = global.navigator ??= {};
185
+ Object.defineProperty(this.#navigator, "gpu", {
186
+ value: create(this.#dawnOptions ?? []),
187
+ configurable: true
188
+ });
189
+ }
190
+ async teardown() {
191
+ delete this.#navigator.gpu;
192
+ const global = this.global;
193
+ for (const key of this.#added) delete global[key];
194
+ await super.teardown();
195
+ }
196
+ };
197
+ //#endregion
198
+ exports.HeadlessCanvas = HeadlessCanvas;
199
+ exports.HeadlessCanvasContext = HeadlessCanvasContext;
200
+ exports.createCanvas = createCanvas;
201
+ exports.default = WebgpuEnvironment;
202
+
203
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":["TestEnvironment"],"sources":["../src/canvas.ts","../src/index.ts"],"sourcesContent":["/** RGBA8 pixels, top row first. Feed it to a screenshot matcher such as jest-image-snapshot after encoding to PNG. */\nexport type RgbaImage = { width: number; height: number; data: Uint8Array };\n\nconst COPY_SRC = 0x01;\nconst RENDER_ATTACHMENT = 0x10;\nconst MAP_READ = 0x01;\nconst COPY_DST = 0x08;\n\n/** Minimal `GPUCanvasContext`: `getCurrentTexture()` is a texture the size of the canvas, kept until reconfigured or resized. */\nexport class HeadlessCanvasContext {\n #config: GPUCanvasConfiguration | undefined;\n #texture: GPUTexture | undefined;\n\n constructor(readonly canvas: HeadlessCanvas) {}\n\n configure(config: GPUCanvasConfiguration): void {\n this.#config = config;\n this.#drop();\n }\n\n unconfigure(): void {\n this.#config = undefined;\n this.#drop();\n }\n\n getConfiguration(): GPUCanvasConfiguration | null {\n return this.#config ?? null;\n }\n\n getCurrentTexture(): GPUTexture {\n const config = this.#config;\n if (!config) throw new Error('getCurrentTexture() called before configure()');\n const { width, height } = this.canvas;\n if (this.#texture && (this.#texture.width !== width || this.#texture.height !== height)) this.#drop();\n this.#texture ??= config.device.createTexture({\n size: [width, height],\n format: config.format,\n usage: (config.usage ?? RENDER_ATTACHMENT) | RENDER_ATTACHMENT | COPY_SRC,\n viewFormats: config.viewFormats,\n });\n return this.#texture;\n }\n\n /** Copies the current texture to the CPU. BGRA formats are swizzled to RGBA. */\n async readPixels(): Promise<RgbaImage> {\n const texture = this.getCurrentTexture();\n const { device, format } = this.#config!;\n const { width, height } = texture;\n const bytesPerRow = Math.ceil((width * 4) / 256) * 256;\n const buffer = device.createBuffer({ size: bytesPerRow * height, usage: MAP_READ | COPY_DST });\n const encoder = device.createCommandEncoder();\n encoder.copyTextureToBuffer({ texture }, { buffer, bytesPerRow }, [width, height]);\n device.queue.submit([encoder.finish()]);\n await buffer.mapAsync(MAP_READ);\n const padded = new Uint8Array(buffer.getMappedRange());\n const data = new Uint8Array(width * height * 4);\n for (let y = 0; y < height; y++)\n data.set(padded.subarray(y * bytesPerRow, y * bytesPerRow + width * 4), y * width * 4);\n buffer.unmap();\n buffer.destroy();\n if (format.startsWith('bgra')) {\n for (let i = 0; i < data.length; i += 4) [data[i], data[i + 2]] = [data[i + 2]!, data[i]!];\n }\n return { width, height, data };\n }\n\n #drop(): void {\n this.#texture?.destroy();\n this.#texture = undefined;\n }\n}\n\n/** Enough of `HTMLCanvasElement` for three.js `WebGPURenderer`, Babylon Lite and friends. */\nexport class HeadlessCanvas {\n readonly #context = new HeadlessCanvasContext(this);\n readonly style: Record<string, string> = {};\n readonly #attributes = new Map<string, string>();\n\n constructor(\n public width = 300,\n public height = 150,\n ) {}\n\n get clientWidth(): number {\n return this.width;\n }\n\n get clientHeight(): number {\n return this.height;\n }\n\n getContext(id: 'webgpu'): HeadlessCanvasContext;\n getContext(id: string): HeadlessCanvasContext | null;\n getContext(id: string): HeadlessCanvasContext | null {\n return id === 'webgpu' ? this.#context : null;\n }\n\n readPixels(): Promise<RgbaImage> {\n return this.#context.readPixels();\n }\n\n /** This same object, typed as an element for libraries whose signatures demand one. */\n asElement(): HTMLCanvasElement {\n return this as unknown as HTMLCanvasElement;\n }\n\n getBoundingClientRect(): { x: number; y: number; width: number; height: number; top: number; left: number } {\n return { x: 0, y: 0, top: 0, left: 0, width: this.width, height: this.height };\n }\n\n setAttribute(name: string, value: string): void {\n this.#attributes.set(name, value);\n }\n\n getAttribute(name: string): string | null {\n return this.#attributes.get(name) ?? null;\n }\n\n addEventListener(): void {}\n removeEventListener(): void {}\n dispatchEvent(): boolean {\n return true;\n }\n}\n\nexport const createCanvas = (width: number, height: number): HeadlessCanvas => new HeadlessCanvas(width, height);\n","import { TestEnvironment } from 'jest-environment-node';\nimport { createCanvas, HeadlessCanvas } from './canvas.js';\n\ntype JestEnvironmentConfig = ConstructorParameters<typeof TestEnvironment>[0];\ntype EnvironmentContext = ConstructorParameters<typeof TestEnvironment>[1];\n\nexport { createCanvas, HeadlessCanvas, HeadlessCanvasContext, type RgbaImage } from './canvas.js';\n\n/** Set under `testEnvironmentOptions` in your Jest config. */\nexport type WebgpuNodeOptions = {\n /** Dawn options, e.g. `['backend=vulkan', 'enable-dawn-features=allow_unsafe_apis']`. */\n dawnOptions?: string[];\n};\n\n/** Just enough DOM for libraries that make their own canvas and drive a frame loop. Only installed when missing. */\nconst requestAnimationFrame = (callback: (time: number) => void): NodeJS.Timeout =>\n setTimeout(() => callback(performance.now()), 16).unref();\n\nconst domShims = (): Record<string, unknown> => {\n const window = {\n devicePixelRatio: 1,\n requestAnimationFrame,\n cancelAnimationFrame: clearTimeout,\n addEventListener() {},\n removeEventListener() {},\n };\n return {\n HTMLCanvasElement: HeadlessCanvas,\n document: {\n createElement: (tag: string) => (tag === 'canvas' ? new HeadlessCanvas() : {}),\n createElementNS: (_ns: string, tag: string) => (tag === 'canvas' ? new HeadlessCanvas() : {}),\n addEventListener() {},\n removeEventListener() {},\n },\n window,\n self: window,\n requestAnimationFrame,\n cancelAnimationFrame: clearTimeout,\n };\n};\n\n/**\n * Jest environment that puts real headless WebGPU (Dawn via the `webgpu` package) on the sandbox\n * global: `navigator.gpu`, every `GPU*` class and constant, and a `HeadlessCanvas` shim. CommonJS\n * tests can rely on `createCanvas` being a global; ESM tests can also `import` it from this package.\n */\nexport default class WebgpuEnvironment extends TestEnvironment {\n #added: string[] = [];\n #navigator: { gpu?: GPU } = {};\n #nativeAdded: boolean;\n #dawnOptions: string[] | undefined;\n\n constructor(config: JestEnvironmentConfig, context: EnvironmentContext) {\n super(config, context);\n // @babylonjs/core defines a `_native` accessor on `self` at module load; on Jest's proxied\n // sandbox global that defineProperty violates a proxy invariant, so pre-seed the property to\n // satisfy Babylon's hasOwnProperty guard.\n // `Object.hasOwn`/`in` report false negatives here: jest-environment-node's sandbox global is\n // a Proxy over a vm context, and reads of freshly-added properties from outside the sandbox\n // (as this constructor runs) don't go through the fast path those use. getOwnPropertyNames is\n // unaffected, so use that instead.\n this.#nativeAdded = !Object.getOwnPropertyNames(this.global).includes('_native');\n if (this.#nativeAdded) {\n Object.defineProperty(this.global, '_native', {\n value: undefined,\n writable: true,\n configurable: true,\n enumerable: false,\n });\n }\n this.#dawnOptions = config.projectConfig.testEnvironmentOptions.dawnOptions as string[] | undefined;\n }\n\n // `webgpu` is ESM-only. Loading it via a dynamic import here (rather than a static top-level\n // import) keeps it out of this module's own top-level module graph, so a CommonJS test file's\n // `require('jest-environment-webgpu-node')` - which only needs `createCanvas` - never forces\n // Jest's sandboxed CJS loader to load an ESM-only dependency (jestjs/jest#15716). This method\n // itself runs outside that sandbox, through Jest's own environment setup, where Node's native\n // `import()` always works.\n override async setup(): Promise<void> {\n await super.setup();\n const { create, globals } = await import('webgpu');\n const global = this.global as unknown as Record<string, unknown>;\n const shims = { ...globals, ...domShims(), createCanvas } as Record<string, unknown>;\n this.#added = Object.keys(shims).filter((key) => !(key in global));\n for (const key of this.#added) global[key] = shims[key];\n if (this.#nativeAdded) this.#added.push('_native');\n this.#navigator = (global.navigator ??= {}) as { gpu?: GPU };\n Object.defineProperty(this.#navigator, 'gpu', { value: create(this.#dawnOptions ?? []), configurable: true });\n }\n\n override async teardown(): Promise<void> {\n delete this.#navigator.gpu;\n const global = this.global as unknown as Record<string, unknown>;\n for (const key of this.#added) delete global[key];\n await super.teardown();\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,72 @@
1
+ import { TestEnvironment } from "jest-environment-node";
2
+ //#region src/canvas.d.ts
3
+ /** RGBA8 pixels, top row first. Feed it to a screenshot matcher such as jest-image-snapshot after encoding to PNG. */
4
+ type RgbaImage = {
5
+ width: number;
6
+ height: number;
7
+ data: Uint8Array;
8
+ };
9
+ /** Minimal `GPUCanvasContext`: `getCurrentTexture()` is a texture the size of the canvas, kept until reconfigured or resized. */
10
+ export declare class HeadlessCanvasContext {
11
+ #private;
12
+ readonly canvas: HeadlessCanvas;
13
+ constructor(canvas: HeadlessCanvas);
14
+ configure(config: GPUCanvasConfiguration): void;
15
+ unconfigure(): void;
16
+ getConfiguration(): GPUCanvasConfiguration | null;
17
+ getCurrentTexture(): GPUTexture;
18
+ /** Copies the current texture to the CPU. BGRA formats are swizzled to RGBA. */
19
+ readPixels(): Promise<RgbaImage>;
20
+ }
21
+ /** Enough of `HTMLCanvasElement` for three.js `WebGPURenderer`, Babylon Lite and friends. */
22
+ export declare class HeadlessCanvas {
23
+ #private;
24
+ width: number;
25
+ height: number;
26
+ readonly style: Record<string, string>;
27
+ constructor(width?: number, height?: number);
28
+ get clientWidth(): number;
29
+ get clientHeight(): number;
30
+ getContext(id: 'webgpu'): HeadlessCanvasContext;
31
+ getContext(id: string): HeadlessCanvasContext | null;
32
+ readPixels(): Promise<RgbaImage>;
33
+ /** This same object, typed as an element for libraries whose signatures demand one. */
34
+ asElement(): HTMLCanvasElement;
35
+ getBoundingClientRect(): {
36
+ x: number;
37
+ y: number;
38
+ width: number;
39
+ height: number;
40
+ top: number;
41
+ left: number;
42
+ };
43
+ setAttribute(name: string, value: string): void;
44
+ getAttribute(name: string): string | null;
45
+ addEventListener(): void;
46
+ removeEventListener(): void;
47
+ dispatchEvent(): boolean;
48
+ }
49
+ export declare const createCanvas: (width: number, height: number) => HeadlessCanvas;
50
+ //#endregion
51
+ //#region src/index.d.ts
52
+ type JestEnvironmentConfig = ConstructorParameters<typeof TestEnvironment>[0];
53
+ type EnvironmentContext = ConstructorParameters<typeof TestEnvironment>[1];
54
+ /** Set under `testEnvironmentOptions` in your Jest config. */
55
+ export type WebgpuNodeOptions = {
56
+ /** Dawn options, e.g. `['backend=vulkan', 'enable-dawn-features=allow_unsafe_apis']`. */
57
+ dawnOptions?: string[];
58
+ };
59
+ /**
60
+ * Jest environment that puts real headless WebGPU (Dawn via the `webgpu` package) on the sandbox
61
+ * global: `navigator.gpu`, every `GPU*` class and constant, and a `HeadlessCanvas` shim. CommonJS
62
+ * tests can rely on `createCanvas` being a global; ESM tests can also `import` it from this package.
63
+ */
64
+ export default class WebgpuEnvironment extends TestEnvironment {
65
+ #private;
66
+ constructor(config: JestEnvironmentConfig, context: EnvironmentContext);
67
+ setup(): Promise<void>;
68
+ teardown(): Promise<void>;
69
+ }
70
+ //#endregion
71
+ export type { RgbaImage };
72
+ //# sourceMappingURL=index.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../src/canvas.ts","../src/index.ts"],"mappings":";;;KACY;EAAc;EAAe;EAAgB,MAAM;;;qBAQlD;;WAIU,QAAQ;EAA7B,YAAqB,QAAQ;EAE7B,UAAU,QAAQ;EAKlB;EAKA,oBAAoB;EAIpB,qBAAqB;;EAef,cAAc,QAAQ;;;qBA6BjB;;EAMF;EACA;WALA,OAAO;EAGhB,YACS,gBACA;MAGL;MAIA;EAIJ,WAAW,eAAe;EAC1B,WAAW,aAAa;EAKxB,cAAc,QAAQ;;EAKtB,aAAa;EAIb;IAA2B;IAAW;IAAW;IAAe;IAAgB;IAAa;;EAI7F,aAAa,cAAc;EAI3B,aAAa;EAIb;EACA;EACA;;qBAKW,eAAY,eAAiB,mBAAmB;;;KC1HxD,wBAAwB,6BAA6B;KACrD,qBAAqB,6BAA6B;;YAK3C;;EAEV;;;;;;;qBAmCmB,0BAA0B;;EAM7C,YAAY,QAAQ,uBAAuB,SAAS;EA2BrC,SAAS;EAYT,YAAY"}
@@ -0,0 +1,72 @@
1
+ import { TestEnvironment } from "jest-environment-node";
2
+ //#region src/canvas.d.ts
3
+ /** RGBA8 pixels, top row first. Feed it to a screenshot matcher such as jest-image-snapshot after encoding to PNG. */
4
+ type RgbaImage = {
5
+ width: number;
6
+ height: number;
7
+ data: Uint8Array;
8
+ };
9
+ /** Minimal `GPUCanvasContext`: `getCurrentTexture()` is a texture the size of the canvas, kept until reconfigured or resized. */
10
+ export declare class HeadlessCanvasContext {
11
+ #private;
12
+ readonly canvas: HeadlessCanvas;
13
+ constructor(canvas: HeadlessCanvas);
14
+ configure(config: GPUCanvasConfiguration): void;
15
+ unconfigure(): void;
16
+ getConfiguration(): GPUCanvasConfiguration | null;
17
+ getCurrentTexture(): GPUTexture;
18
+ /** Copies the current texture to the CPU. BGRA formats are swizzled to RGBA. */
19
+ readPixels(): Promise<RgbaImage>;
20
+ }
21
+ /** Enough of `HTMLCanvasElement` for three.js `WebGPURenderer`, Babylon Lite and friends. */
22
+ export declare class HeadlessCanvas {
23
+ #private;
24
+ width: number;
25
+ height: number;
26
+ readonly style: Record<string, string>;
27
+ constructor(width?: number, height?: number);
28
+ get clientWidth(): number;
29
+ get clientHeight(): number;
30
+ getContext(id: 'webgpu'): HeadlessCanvasContext;
31
+ getContext(id: string): HeadlessCanvasContext | null;
32
+ readPixels(): Promise<RgbaImage>;
33
+ /** This same object, typed as an element for libraries whose signatures demand one. */
34
+ asElement(): HTMLCanvasElement;
35
+ getBoundingClientRect(): {
36
+ x: number;
37
+ y: number;
38
+ width: number;
39
+ height: number;
40
+ top: number;
41
+ left: number;
42
+ };
43
+ setAttribute(name: string, value: string): void;
44
+ getAttribute(name: string): string | null;
45
+ addEventListener(): void;
46
+ removeEventListener(): void;
47
+ dispatchEvent(): boolean;
48
+ }
49
+ export declare const createCanvas: (width: number, height: number) => HeadlessCanvas;
50
+ //#endregion
51
+ //#region src/index.d.ts
52
+ type JestEnvironmentConfig = ConstructorParameters<typeof TestEnvironment>[0];
53
+ type EnvironmentContext = ConstructorParameters<typeof TestEnvironment>[1];
54
+ /** Set under `testEnvironmentOptions` in your Jest config. */
55
+ export type WebgpuNodeOptions = {
56
+ /** Dawn options, e.g. `['backend=vulkan', 'enable-dawn-features=allow_unsafe_apis']`. */
57
+ dawnOptions?: string[];
58
+ };
59
+ /**
60
+ * Jest environment that puts real headless WebGPU (Dawn via the `webgpu` package) on the sandbox
61
+ * global: `navigator.gpu`, every `GPU*` class and constant, and a `HeadlessCanvas` shim. CommonJS
62
+ * tests can rely on `createCanvas` being a global; ESM tests can also `import` it from this package.
63
+ */
64
+ export default class WebgpuEnvironment extends TestEnvironment {
65
+ #private;
66
+ constructor(config: JestEnvironmentConfig, context: EnvironmentContext);
67
+ setup(): Promise<void>;
68
+ teardown(): Promise<void>;
69
+ }
70
+ //#endregion
71
+ export type { RgbaImage };
72
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/canvas.ts","../src/index.ts"],"mappings":";;;KACY;EAAc;EAAe;EAAgB,MAAM;;;qBAQlD;;WAIU,QAAQ;EAA7B,YAAqB,QAAQ;EAE7B,UAAU,QAAQ;EAKlB;EAKA,oBAAoB;EAIpB,qBAAqB;;EAef,cAAc,QAAQ;;;qBA6BjB;;EAMF;EACA;WALA,OAAO;EAGhB,YACS,gBACA;MAGL;MAIA;EAIJ,WAAW,eAAe;EAC1B,WAAW,aAAa;EAKxB,cAAc,QAAQ;;EAKtB,aAAa;EAIb;IAA2B;IAAW;IAAW;IAAe;IAAgB;IAAa;;EAI7F,aAAa,cAAc;EAI3B,aAAa;EAIb;EACA;EACA;;qBAKW,eAAY,eAAiB,mBAAmB;;;KC1HxD,wBAAwB,6BAA6B;KACrD,qBAAqB,6BAA6B;;YAK3C;;EAEV;;;;;;;qBAmCmB,0BAA0B;;EAM7C,YAAY,QAAQ,uBAAuB,SAAS;EA2BrC,SAAS;EAYT,YAAY"}
package/dist/index.mjs ADDED
@@ -0,0 +1,196 @@
1
+ import { TestEnvironment } from "jest-environment-node";
2
+ //#region src/canvas.ts
3
+ const RENDER_ATTACHMENT = 16;
4
+ const MAP_READ = 1;
5
+ /** Minimal `GPUCanvasContext`: `getCurrentTexture()` is a texture the size of the canvas, kept until reconfigured or resized. */
6
+ var HeadlessCanvasContext = class {
7
+ canvas;
8
+ #config;
9
+ #texture;
10
+ constructor(canvas) {
11
+ this.canvas = canvas;
12
+ }
13
+ configure(config) {
14
+ this.#config = config;
15
+ this.#drop();
16
+ }
17
+ unconfigure() {
18
+ this.#config = void 0;
19
+ this.#drop();
20
+ }
21
+ getConfiguration() {
22
+ return this.#config ?? null;
23
+ }
24
+ getCurrentTexture() {
25
+ const config = this.#config;
26
+ if (!config) throw new Error("getCurrentTexture() called before configure()");
27
+ const { width, height } = this.canvas;
28
+ if (this.#texture && (this.#texture.width !== width || this.#texture.height !== height)) this.#drop();
29
+ this.#texture ??= config.device.createTexture({
30
+ size: [width, height],
31
+ format: config.format,
32
+ usage: (config.usage ?? RENDER_ATTACHMENT) | 17,
33
+ viewFormats: config.viewFormats
34
+ });
35
+ return this.#texture;
36
+ }
37
+ /** Copies the current texture to the CPU. BGRA formats are swizzled to RGBA. */
38
+ async readPixels() {
39
+ const texture = this.getCurrentTexture();
40
+ const { device, format } = this.#config;
41
+ const { width, height } = texture;
42
+ const bytesPerRow = Math.ceil(width * 4 / 256) * 256;
43
+ const buffer = device.createBuffer({
44
+ size: bytesPerRow * height,
45
+ usage: 9
46
+ });
47
+ const encoder = device.createCommandEncoder();
48
+ encoder.copyTextureToBuffer({ texture }, {
49
+ buffer,
50
+ bytesPerRow
51
+ }, [width, height]);
52
+ device.queue.submit([encoder.finish()]);
53
+ await buffer.mapAsync(MAP_READ);
54
+ const padded = new Uint8Array(buffer.getMappedRange());
55
+ const data = new Uint8Array(width * height * 4);
56
+ for (let y = 0; y < height; y++) data.set(padded.subarray(y * bytesPerRow, y * bytesPerRow + width * 4), y * width * 4);
57
+ buffer.unmap();
58
+ buffer.destroy();
59
+ if (format.startsWith("bgra")) for (let i = 0; i < data.length; i += 4) [data[i], data[i + 2]] = [data[i + 2], data[i]];
60
+ return {
61
+ width,
62
+ height,
63
+ data
64
+ };
65
+ }
66
+ #drop() {
67
+ this.#texture?.destroy();
68
+ this.#texture = void 0;
69
+ }
70
+ };
71
+ /** Enough of `HTMLCanvasElement` for three.js `WebGPURenderer`, Babylon Lite and friends. */
72
+ var HeadlessCanvas = class {
73
+ width;
74
+ height;
75
+ #context = new HeadlessCanvasContext(this);
76
+ style = {};
77
+ #attributes = /* @__PURE__ */ new Map();
78
+ constructor(width = 300, height = 150) {
79
+ this.width = width;
80
+ this.height = height;
81
+ }
82
+ get clientWidth() {
83
+ return this.width;
84
+ }
85
+ get clientHeight() {
86
+ return this.height;
87
+ }
88
+ getContext(id) {
89
+ return id === "webgpu" ? this.#context : null;
90
+ }
91
+ readPixels() {
92
+ return this.#context.readPixels();
93
+ }
94
+ /** This same object, typed as an element for libraries whose signatures demand one. */
95
+ asElement() {
96
+ return this;
97
+ }
98
+ getBoundingClientRect() {
99
+ return {
100
+ x: 0,
101
+ y: 0,
102
+ top: 0,
103
+ left: 0,
104
+ width: this.width,
105
+ height: this.height
106
+ };
107
+ }
108
+ setAttribute(name, value) {
109
+ this.#attributes.set(name, value);
110
+ }
111
+ getAttribute(name) {
112
+ return this.#attributes.get(name) ?? null;
113
+ }
114
+ addEventListener() {}
115
+ removeEventListener() {}
116
+ dispatchEvent() {
117
+ return true;
118
+ }
119
+ };
120
+ const createCanvas = (width, height) => new HeadlessCanvas(width, height);
121
+ //#endregion
122
+ //#region src/index.ts
123
+ /** Just enough DOM for libraries that make their own canvas and drive a frame loop. Only installed when missing. */
124
+ const requestAnimationFrame = (callback) => setTimeout(() => callback(performance.now()), 16).unref();
125
+ const domShims = () => {
126
+ const window = {
127
+ devicePixelRatio: 1,
128
+ requestAnimationFrame,
129
+ cancelAnimationFrame: clearTimeout,
130
+ addEventListener() {},
131
+ removeEventListener() {}
132
+ };
133
+ return {
134
+ HTMLCanvasElement: HeadlessCanvas,
135
+ document: {
136
+ createElement: (tag) => tag === "canvas" ? new HeadlessCanvas() : {},
137
+ createElementNS: (_ns, tag) => tag === "canvas" ? new HeadlessCanvas() : {},
138
+ addEventListener() {},
139
+ removeEventListener() {}
140
+ },
141
+ window,
142
+ self: window,
143
+ requestAnimationFrame,
144
+ cancelAnimationFrame: clearTimeout
145
+ };
146
+ };
147
+ /**
148
+ * Jest environment that puts real headless WebGPU (Dawn via the `webgpu` package) on the sandbox
149
+ * global: `navigator.gpu`, every `GPU*` class and constant, and a `HeadlessCanvas` shim. CommonJS
150
+ * tests can rely on `createCanvas` being a global; ESM tests can also `import` it from this package.
151
+ */
152
+ var WebgpuEnvironment = class extends TestEnvironment {
153
+ #added = [];
154
+ #navigator = {};
155
+ #nativeAdded;
156
+ #dawnOptions;
157
+ constructor(config, context) {
158
+ super(config, context);
159
+ this.#nativeAdded = !Object.getOwnPropertyNames(this.global).includes("_native");
160
+ if (this.#nativeAdded) Object.defineProperty(this.global, "_native", {
161
+ value: void 0,
162
+ writable: true,
163
+ configurable: true,
164
+ enumerable: false
165
+ });
166
+ this.#dawnOptions = config.projectConfig.testEnvironmentOptions.dawnOptions;
167
+ }
168
+ async setup() {
169
+ await super.setup();
170
+ const { create, globals } = await import("webgpu");
171
+ const global = this.global;
172
+ const shims = {
173
+ ...globals,
174
+ ...domShims(),
175
+ createCanvas
176
+ };
177
+ this.#added = Object.keys(shims).filter((key) => !(key in global));
178
+ for (const key of this.#added) global[key] = shims[key];
179
+ if (this.#nativeAdded) this.#added.push("_native");
180
+ this.#navigator = global.navigator ??= {};
181
+ Object.defineProperty(this.#navigator, "gpu", {
182
+ value: create(this.#dawnOptions ?? []),
183
+ configurable: true
184
+ });
185
+ }
186
+ async teardown() {
187
+ delete this.#navigator.gpu;
188
+ const global = this.global;
189
+ for (const key of this.#added) delete global[key];
190
+ await super.teardown();
191
+ }
192
+ };
193
+ //#endregion
194
+ export { HeadlessCanvas, HeadlessCanvasContext, createCanvas, WebgpuEnvironment as default };
195
+
196
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/canvas.ts","../src/index.ts"],"sourcesContent":["/** RGBA8 pixels, top row first. Feed it to a screenshot matcher such as jest-image-snapshot after encoding to PNG. */\nexport type RgbaImage = { width: number; height: number; data: Uint8Array };\n\nconst COPY_SRC = 0x01;\nconst RENDER_ATTACHMENT = 0x10;\nconst MAP_READ = 0x01;\nconst COPY_DST = 0x08;\n\n/** Minimal `GPUCanvasContext`: `getCurrentTexture()` is a texture the size of the canvas, kept until reconfigured or resized. */\nexport class HeadlessCanvasContext {\n #config: GPUCanvasConfiguration | undefined;\n #texture: GPUTexture | undefined;\n\n constructor(readonly canvas: HeadlessCanvas) {}\n\n configure(config: GPUCanvasConfiguration): void {\n this.#config = config;\n this.#drop();\n }\n\n unconfigure(): void {\n this.#config = undefined;\n this.#drop();\n }\n\n getConfiguration(): GPUCanvasConfiguration | null {\n return this.#config ?? null;\n }\n\n getCurrentTexture(): GPUTexture {\n const config = this.#config;\n if (!config) throw new Error('getCurrentTexture() called before configure()');\n const { width, height } = this.canvas;\n if (this.#texture && (this.#texture.width !== width || this.#texture.height !== height)) this.#drop();\n this.#texture ??= config.device.createTexture({\n size: [width, height],\n format: config.format,\n usage: (config.usage ?? RENDER_ATTACHMENT) | RENDER_ATTACHMENT | COPY_SRC,\n viewFormats: config.viewFormats,\n });\n return this.#texture;\n }\n\n /** Copies the current texture to the CPU. BGRA formats are swizzled to RGBA. */\n async readPixels(): Promise<RgbaImage> {\n const texture = this.getCurrentTexture();\n const { device, format } = this.#config!;\n const { width, height } = texture;\n const bytesPerRow = Math.ceil((width * 4) / 256) * 256;\n const buffer = device.createBuffer({ size: bytesPerRow * height, usage: MAP_READ | COPY_DST });\n const encoder = device.createCommandEncoder();\n encoder.copyTextureToBuffer({ texture }, { buffer, bytesPerRow }, [width, height]);\n device.queue.submit([encoder.finish()]);\n await buffer.mapAsync(MAP_READ);\n const padded = new Uint8Array(buffer.getMappedRange());\n const data = new Uint8Array(width * height * 4);\n for (let y = 0; y < height; y++)\n data.set(padded.subarray(y * bytesPerRow, y * bytesPerRow + width * 4), y * width * 4);\n buffer.unmap();\n buffer.destroy();\n if (format.startsWith('bgra')) {\n for (let i = 0; i < data.length; i += 4) [data[i], data[i + 2]] = [data[i + 2]!, data[i]!];\n }\n return { width, height, data };\n }\n\n #drop(): void {\n this.#texture?.destroy();\n this.#texture = undefined;\n }\n}\n\n/** Enough of `HTMLCanvasElement` for three.js `WebGPURenderer`, Babylon Lite and friends. */\nexport class HeadlessCanvas {\n readonly #context = new HeadlessCanvasContext(this);\n readonly style: Record<string, string> = {};\n readonly #attributes = new Map<string, string>();\n\n constructor(\n public width = 300,\n public height = 150,\n ) {}\n\n get clientWidth(): number {\n return this.width;\n }\n\n get clientHeight(): number {\n return this.height;\n }\n\n getContext(id: 'webgpu'): HeadlessCanvasContext;\n getContext(id: string): HeadlessCanvasContext | null;\n getContext(id: string): HeadlessCanvasContext | null {\n return id === 'webgpu' ? this.#context : null;\n }\n\n readPixels(): Promise<RgbaImage> {\n return this.#context.readPixels();\n }\n\n /** This same object, typed as an element for libraries whose signatures demand one. */\n asElement(): HTMLCanvasElement {\n return this as unknown as HTMLCanvasElement;\n }\n\n getBoundingClientRect(): { x: number; y: number; width: number; height: number; top: number; left: number } {\n return { x: 0, y: 0, top: 0, left: 0, width: this.width, height: this.height };\n }\n\n setAttribute(name: string, value: string): void {\n this.#attributes.set(name, value);\n }\n\n getAttribute(name: string): string | null {\n return this.#attributes.get(name) ?? null;\n }\n\n addEventListener(): void {}\n removeEventListener(): void {}\n dispatchEvent(): boolean {\n return true;\n }\n}\n\nexport const createCanvas = (width: number, height: number): HeadlessCanvas => new HeadlessCanvas(width, height);\n","import { TestEnvironment } from 'jest-environment-node';\nimport { createCanvas, HeadlessCanvas } from './canvas.js';\n\ntype JestEnvironmentConfig = ConstructorParameters<typeof TestEnvironment>[0];\ntype EnvironmentContext = ConstructorParameters<typeof TestEnvironment>[1];\n\nexport { createCanvas, HeadlessCanvas, HeadlessCanvasContext, type RgbaImage } from './canvas.js';\n\n/** Set under `testEnvironmentOptions` in your Jest config. */\nexport type WebgpuNodeOptions = {\n /** Dawn options, e.g. `['backend=vulkan', 'enable-dawn-features=allow_unsafe_apis']`. */\n dawnOptions?: string[];\n};\n\n/** Just enough DOM for libraries that make their own canvas and drive a frame loop. Only installed when missing. */\nconst requestAnimationFrame = (callback: (time: number) => void): NodeJS.Timeout =>\n setTimeout(() => callback(performance.now()), 16).unref();\n\nconst domShims = (): Record<string, unknown> => {\n const window = {\n devicePixelRatio: 1,\n requestAnimationFrame,\n cancelAnimationFrame: clearTimeout,\n addEventListener() {},\n removeEventListener() {},\n };\n return {\n HTMLCanvasElement: HeadlessCanvas,\n document: {\n createElement: (tag: string) => (tag === 'canvas' ? new HeadlessCanvas() : {}),\n createElementNS: (_ns: string, tag: string) => (tag === 'canvas' ? new HeadlessCanvas() : {}),\n addEventListener() {},\n removeEventListener() {},\n },\n window,\n self: window,\n requestAnimationFrame,\n cancelAnimationFrame: clearTimeout,\n };\n};\n\n/**\n * Jest environment that puts real headless WebGPU (Dawn via the `webgpu` package) on the sandbox\n * global: `navigator.gpu`, every `GPU*` class and constant, and a `HeadlessCanvas` shim. CommonJS\n * tests can rely on `createCanvas` being a global; ESM tests can also `import` it from this package.\n */\nexport default class WebgpuEnvironment extends TestEnvironment {\n #added: string[] = [];\n #navigator: { gpu?: GPU } = {};\n #nativeAdded: boolean;\n #dawnOptions: string[] | undefined;\n\n constructor(config: JestEnvironmentConfig, context: EnvironmentContext) {\n super(config, context);\n // @babylonjs/core defines a `_native` accessor on `self` at module load; on Jest's proxied\n // sandbox global that defineProperty violates a proxy invariant, so pre-seed the property to\n // satisfy Babylon's hasOwnProperty guard.\n // `Object.hasOwn`/`in` report false negatives here: jest-environment-node's sandbox global is\n // a Proxy over a vm context, and reads of freshly-added properties from outside the sandbox\n // (as this constructor runs) don't go through the fast path those use. getOwnPropertyNames is\n // unaffected, so use that instead.\n this.#nativeAdded = !Object.getOwnPropertyNames(this.global).includes('_native');\n if (this.#nativeAdded) {\n Object.defineProperty(this.global, '_native', {\n value: undefined,\n writable: true,\n configurable: true,\n enumerable: false,\n });\n }\n this.#dawnOptions = config.projectConfig.testEnvironmentOptions.dawnOptions as string[] | undefined;\n }\n\n // `webgpu` is ESM-only. Loading it via a dynamic import here (rather than a static top-level\n // import) keeps it out of this module's own top-level module graph, so a CommonJS test file's\n // `require('jest-environment-webgpu-node')` - which only needs `createCanvas` - never forces\n // Jest's sandboxed CJS loader to load an ESM-only dependency (jestjs/jest#15716). This method\n // itself runs outside that sandbox, through Jest's own environment setup, where Node's native\n // `import()` always works.\n override async setup(): Promise<void> {\n await super.setup();\n const { create, globals } = await import('webgpu');\n const global = this.global as unknown as Record<string, unknown>;\n const shims = { ...globals, ...domShims(), createCanvas } as Record<string, unknown>;\n this.#added = Object.keys(shims).filter((key) => !(key in global));\n for (const key of this.#added) global[key] = shims[key];\n if (this.#nativeAdded) this.#added.push('_native');\n this.#navigator = (global.navigator ??= {}) as { gpu?: GPU };\n Object.defineProperty(this.#navigator, 'gpu', { value: create(this.#dawnOptions ?? []), configurable: true });\n }\n\n override async teardown(): Promise<void> {\n delete this.#navigator.gpu;\n const global = this.global as unknown as Record<string, unknown>;\n for (const key of this.#added) delete global[key];\n await super.teardown();\n }\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "jest-environment-webgpu-node",
3
+ "version": "0.1.0",
4
+ "description": "Jest environment that provides real headless WebGPU (Dawn via the webgpu package) in Node",
5
+ "keywords": [
6
+ "dawn",
7
+ "headless",
8
+ "jest",
9
+ "jest-environment",
10
+ "testing",
11
+ "webgpu"
12
+ ],
13
+ "homepage": "https://github.com/bhouston/jest-gpu#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/bhouston/jest-gpu/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Ben Houston <ben@ben3d.ca> (https://ben3d.ca)",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/bhouston/jest-gpu.git",
22
+ "directory": "packages/jest-environment-webgpu-node"
23
+ },
24
+ "files": [
25
+ "dist/**/*.cjs",
26
+ "dist/**/*.cjs.map",
27
+ "dist/**/*.mjs",
28
+ "dist/**/*.mjs.map",
29
+ "dist/**/*.d.cts",
30
+ "dist/**/*.d.cts.map",
31
+ "dist/**/*.d.mts",
32
+ "dist/**/*.d.mts.map",
33
+ "src/globals.d.ts",
34
+ "CHANGELOG.md"
35
+ ],
36
+ "type": "module",
37
+ "main": "./dist/index.cjs",
38
+ "module": "./dist/index.mjs",
39
+ "types": "./dist/index.d.mts",
40
+ "exports": {
41
+ ".": {
42
+ "import": {
43
+ "types": "./dist/index.d.mts",
44
+ "default": "./dist/index.mjs"
45
+ },
46
+ "require": {
47
+ "types": "./dist/index.d.cts",
48
+ "default": "./dist/index.cjs"
49
+ }
50
+ },
51
+ "./globals": {
52
+ "types": "./src/globals.d.ts"
53
+ }
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ },
58
+ "scripts": {
59
+ "build": "tsdown",
60
+ "tsc": "tsc --noEmit"
61
+ },
62
+ "dependencies": {
63
+ "@webgpu/types": "^0.1.72",
64
+ "webgpu": "^0.6.1"
65
+ },
66
+ "peerDependencies": {
67
+ "jest": ">=29 <31",
68
+ "jest-environment-node": ">=29 <31"
69
+ },
70
+ "engines": {
71
+ "node": ">=22.12.0"
72
+ }
73
+ }
@@ -0,0 +1,3 @@
1
+ // Ambient typing for the `createCanvas` global this environment installs on `globalThis`, for
2
+ // CommonJS test files that use the global instead of importing this ESM package.
3
+ declare const createCanvas: typeof import('./canvas.js').createCanvas;