jest-environment-webgpu-node 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [jest-environment-webgpu-node-v0.1.1](https://github.com/bhouston/jest-gpu/compare/jest-environment-webgpu-node-v0.1.0...jest-environment-webgpu-node-v0.1.1) (2026-09-21)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **webgpu:** correct sandbox shims and pixel reads ([92c4118](https://github.com/bhouston/jest-gpu/commit/92c4118be3b36f30bb7a8b397de4e09d02035ab6)), closes [#19](https://github.com/bhouston/jest-gpu/issues/19)
package/README.md CHANGED
@@ -57,7 +57,8 @@ const { width, height, data } = await canvas.readPixels();
57
57
  // data is RGBA8, top row first - feed it to a screenshot matcher such as jest-image-snapshot
58
58
  ```
59
59
 
60
- `readPixels()` returns `{ width, height, data }` RGBA8 pixels. `asElement()` is the same object typed as an
60
+ `readPixels()` returns `{ width, height, data }` RGBA8 pixels for `rgba8unorm`, `rgba8unorm-srgb`,
61
+ `bgra8unorm`, and `bgra8unorm-srgb` canvas formats. Other formats are rejected explicitly. `asElement()` is the same object typed as an
61
62
  `HTMLCanvasElement` for library signatures that demand one.
62
63
 
63
64
  Import `jest-environment-webgpu-node/globals` in a `.d.ts` (or add it to `types` in `tsconfig.json`) to get
@@ -70,6 +71,12 @@ works from a plain CommonJS test file, and `testEnvironment: 'jest-environment-w
70
71
  resolves the same way either way. `NODE_OPTIONS=--experimental-vm-modules` is only needed when a
71
72
  test file itself imports an ESM-only library, such as `three/webgpu`.
72
73
 
74
+ ## Using Vitest?
75
+
76
+ The equivalent package for Vitest is
77
+ [`vitest-environment-webgpu-node`](https://github.com/bhouston/vitest-gpu/tree/main/packages/vitest-environment-webgpu-node)
78
+ from [vitest-gpu](https://github.com/bhouston/vitest-gpu).
79
+
73
80
  ## Author
74
81
 
75
82
  Created by [Ben Houston](https://ben3d.ca) and sponsored by [Land of Assets](https://landofassets.com).
package/dist/index.cjs CHANGED
@@ -6,6 +6,12 @@ let jest_environment_node = require("jest-environment-node");
6
6
  //#region src/canvas.ts
7
7
  const RENDER_ATTACHMENT = 16;
8
8
  const MAP_READ = 1;
9
+ const READABLE_FORMATS = /* @__PURE__ */ new Set([
10
+ "rgba8unorm",
11
+ "rgba8unorm-srgb",
12
+ "bgra8unorm",
13
+ "bgra8unorm-srgb"
14
+ ]);
9
15
  /** Minimal `GPUCanvasContext`: `getCurrentTexture()` is a texture the size of the canvas, kept until reconfigured or resized. */
10
16
  var HeadlessCanvasContext = class {
11
17
  canvas;
@@ -40,32 +46,38 @@ var HeadlessCanvasContext = class {
40
46
  }
41
47
  /** Copies the current texture to the CPU. BGRA formats are swizzled to RGBA. */
42
48
  async readPixels() {
49
+ const config = this.#config;
50
+ if (!config) throw new Error("readPixels() called before configure()");
51
+ const { device, format } = config;
52
+ if (!READABLE_FORMATS.has(format)) throw new Error(`readPixels() supports only 8-bit RGBA/BGRA formats; received ${format}`);
43
53
  const texture = this.getCurrentTexture();
44
- const { device, format } = this.#config;
45
54
  const { width, height } = texture;
46
55
  const bytesPerRow = Math.ceil(width * 4 / 256) * 256;
47
56
  const buffer = device.createBuffer({
48
57
  size: bytesPerRow * height,
49
58
  usage: 9
50
59
  });
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
- };
60
+ try {
61
+ const encoder = device.createCommandEncoder();
62
+ encoder.copyTextureToBuffer({ texture }, {
63
+ buffer,
64
+ bytesPerRow
65
+ }, [width, height]);
66
+ device.queue.submit([encoder.finish()]);
67
+ await buffer.mapAsync(MAP_READ);
68
+ const padded = new Uint8Array(buffer.getMappedRange());
69
+ const data = new Uint8Array(width * height * 4);
70
+ for (let y = 0; y < height; y++) data.set(padded.subarray(y * bytesPerRow, y * bytesPerRow + width * 4), y * width * 4);
71
+ if (format.startsWith("bgra")) for (let i = 0; i < data.length; i += 4) [data[i], data[i + 2]] = [data[i + 2], data[i]];
72
+ return {
73
+ width,
74
+ height,
75
+ data
76
+ };
77
+ } finally {
78
+ if (buffer.mapState === "mapped") buffer.unmap();
79
+ buffer.destroy();
80
+ }
69
81
  }
70
82
  #drop() {
71
83
  this.#texture?.destroy();
@@ -124,28 +136,36 @@ var HeadlessCanvas = class {
124
136
  const createCanvas = (width, height) => new HeadlessCanvas(width, height);
125
137
  //#endregion
126
138
  //#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() {}
139
+ const domShims = (global, animationFrames) => {
140
+ const requestAnimationFrame = (callback) => {
141
+ const handle = global.setTimeout(() => {
142
+ animationFrames.delete(handle);
143
+ callback(global.performance.now());
144
+ }, 16);
145
+ animationFrames.set(handle, global.clearTimeout);
146
+ handle.unref?.();
147
+ return handle;
148
+ };
149
+ const cancelAnimationFrame = (handle) => {
150
+ const clear = animationFrames.get(handle) ?? global.clearTimeout;
151
+ animationFrames.delete(handle);
152
+ clear(handle);
136
153
  };
137
154
  return {
138
- HTMLCanvasElement: HeadlessCanvas,
139
155
  document: {
140
156
  createElement: (tag) => tag === "canvas" ? new HeadlessCanvas() : {},
141
157
  createElementNS: (_ns, tag) => tag === "canvas" ? new HeadlessCanvas() : {},
142
158
  addEventListener() {},
143
159
  removeEventListener() {}
144
160
  },
145
- window,
146
- self: window,
161
+ devicePixelRatio: 1,
147
162
  requestAnimationFrame,
148
- cancelAnimationFrame: clearTimeout
163
+ cancelAnimationFrame,
164
+ addEventListener() {},
165
+ removeEventListener() {},
166
+ HTMLCanvasElement: HeadlessCanvas,
167
+ window: global,
168
+ self: global
149
169
  };
150
170
  };
151
171
  /**
@@ -158,6 +178,7 @@ var WebgpuEnvironment = class extends jest_environment_node.TestEnvironment {
158
178
  #navigator = {};
159
179
  #nativeAdded;
160
180
  #dawnOptions;
181
+ #animationFrames = /* @__PURE__ */ new Map();
161
182
  constructor(config, context) {
162
183
  super(config, context);
163
184
  this.#nativeAdded = !Object.getOwnPropertyNames(this.global).includes("_native");
@@ -175,7 +196,7 @@ var WebgpuEnvironment = class extends jest_environment_node.TestEnvironment {
175
196
  const global = this.global;
176
197
  const shims = {
177
198
  ...globals,
178
- ...domShims(),
199
+ ...domShims(global, this.#animationFrames),
179
200
  createCanvas
180
201
  };
181
202
  this.#added = Object.keys(shims).filter((key) => !(key in global));
@@ -188,8 +209,10 @@ var WebgpuEnvironment = class extends jest_environment_node.TestEnvironment {
188
209
  });
189
210
  }
190
211
  async teardown() {
191
- delete this.#navigator.gpu;
192
212
  const global = this.global;
213
+ for (const [handle, clear] of this.#animationFrames) clear(handle);
214
+ this.#animationFrames.clear();
215
+ delete this.#navigator.gpu;
193
216
  for (const key of this.#added) delete global[key];
194
217
  await super.teardown();
195
218
  }
@@ -1 +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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"}
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;\nconst READABLE_FORMATS = new Set<GPUTextureFormat>(['rgba8unorm', 'rgba8unorm-srgb', 'bgra8unorm', 'bgra8unorm-srgb']);\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 config = this.#config;\n if (!config) throw new Error('readPixels() called before configure()');\n const { device, format } = config;\n if (!READABLE_FORMATS.has(format)) {\n throw new Error(`readPixels() supports only 8-bit RGBA/BGRA formats; received ${format}`);\n }\n const texture = this.getCurrentTexture();\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 try {\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 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 } finally {\n if (buffer.mapState === 'mapped') buffer.unmap();\n buffer.destroy();\n }\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. */\ntype TimerHandle = ReturnType<typeof setTimeout>;\ntype SandboxGlobal = Record<string, unknown> & {\n clearTimeout: typeof clearTimeout;\n performance: Performance;\n setTimeout: typeof setTimeout;\n};\n\nconst domShims = (\n global: SandboxGlobal,\n animationFrames: Map<TimerHandle, typeof clearTimeout>,\n): Record<string, unknown> => {\n const requestAnimationFrame = (callback: (time: number) => void): TimerHandle => {\n const handle = global.setTimeout(() => {\n animationFrames.delete(handle);\n callback(global.performance.now());\n }, 16);\n animationFrames.set(handle, global.clearTimeout);\n (handle as NodeJS.Timeout).unref?.();\n return handle;\n };\n const cancelAnimationFrame = (handle: TimerHandle): void => {\n const clear = animationFrames.get(handle) ?? global.clearTimeout;\n animationFrames.delete(handle);\n clear(handle);\n };\n const 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 return {\n document,\n devicePixelRatio: 1,\n requestAnimationFrame,\n cancelAnimationFrame,\n addEventListener() {},\n removeEventListener() {},\n HTMLCanvasElement: HeadlessCanvas,\n window: global,\n self: global,\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 #animationFrames = new Map<TimerHandle, typeof clearTimeout>();\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 SandboxGlobal;\n const shims = { ...globals, ...domShims(global, this.#animationFrames), 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 const global = this.global as unknown as Record<string, unknown>;\n for (const [handle, clear] of this.#animationFrames) clear(handle);\n this.#animationFrames.clear();\n delete this.#navigator.gpu;\n for (const key of this.#added) delete global[key];\n await super.teardown();\n }\n}\n"],"mappings":"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"}
@@ -1 +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"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../src/canvas.ts","../src/index.ts"],"mappings":";;;KACY;EAAc;EAAe;EAAgB,MAAM;;;qBASlD;;WAIU,QAAQ;EAA7B,YAAqB,QAAQ;EAE7B,UAAU,QAAQ;EAKlB;EAKA,oBAAoB;EAIpB,qBAAqB;;EAef,cAAc,QAAQ;;;qBAqCjB;;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;;;KCnIxD,wBAAwB,6BAA6B;KACrD,qBAAqB,6BAA6B;;YAK3C;;EAEV;;;;;;;qBAqDmB,0BAA0B;;EAO7C,YAAY,QAAQ,uBAAuB,SAAS;EA2BrC,SAAS;EAYT,YAAY"}
@@ -1 +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"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/canvas.ts","../src/index.ts"],"mappings":";;;KACY;EAAc;EAAe;EAAgB,MAAM;;;qBASlD;;WAIU,QAAQ;EAA7B,YAAqB,QAAQ;EAE7B,UAAU,QAAQ;EAKlB;EAKA,oBAAoB;EAIpB,qBAAqB;;EAef,cAAc,QAAQ;;;qBAqCjB;;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;;;KCnIxD,wBAAwB,6BAA6B;KACrD,qBAAqB,6BAA6B;;YAK3C;;EAEV;;;;;;;qBAqDmB,0BAA0B;;EAO7C,YAAY,QAAQ,uBAAuB,SAAS;EA2BrC,SAAS;EAYT,YAAY"}
package/dist/index.mjs CHANGED
@@ -2,6 +2,12 @@ import { TestEnvironment } from "jest-environment-node";
2
2
  //#region src/canvas.ts
3
3
  const RENDER_ATTACHMENT = 16;
4
4
  const MAP_READ = 1;
5
+ const READABLE_FORMATS = /* @__PURE__ */ new Set([
6
+ "rgba8unorm",
7
+ "rgba8unorm-srgb",
8
+ "bgra8unorm",
9
+ "bgra8unorm-srgb"
10
+ ]);
5
11
  /** Minimal `GPUCanvasContext`: `getCurrentTexture()` is a texture the size of the canvas, kept until reconfigured or resized. */
6
12
  var HeadlessCanvasContext = class {
7
13
  canvas;
@@ -36,32 +42,38 @@ var HeadlessCanvasContext = class {
36
42
  }
37
43
  /** Copies the current texture to the CPU. BGRA formats are swizzled to RGBA. */
38
44
  async readPixels() {
45
+ const config = this.#config;
46
+ if (!config) throw new Error("readPixels() called before configure()");
47
+ const { device, format } = config;
48
+ if (!READABLE_FORMATS.has(format)) throw new Error(`readPixels() supports only 8-bit RGBA/BGRA formats; received ${format}`);
39
49
  const texture = this.getCurrentTexture();
40
- const { device, format } = this.#config;
41
50
  const { width, height } = texture;
42
51
  const bytesPerRow = Math.ceil(width * 4 / 256) * 256;
43
52
  const buffer = device.createBuffer({
44
53
  size: bytesPerRow * height,
45
54
  usage: 9
46
55
  });
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
- };
56
+ try {
57
+ const encoder = device.createCommandEncoder();
58
+ encoder.copyTextureToBuffer({ texture }, {
59
+ buffer,
60
+ bytesPerRow
61
+ }, [width, height]);
62
+ device.queue.submit([encoder.finish()]);
63
+ await buffer.mapAsync(MAP_READ);
64
+ const padded = new Uint8Array(buffer.getMappedRange());
65
+ const data = new Uint8Array(width * height * 4);
66
+ for (let y = 0; y < height; y++) data.set(padded.subarray(y * bytesPerRow, y * bytesPerRow + width * 4), y * width * 4);
67
+ if (format.startsWith("bgra")) for (let i = 0; i < data.length; i += 4) [data[i], data[i + 2]] = [data[i + 2], data[i]];
68
+ return {
69
+ width,
70
+ height,
71
+ data
72
+ };
73
+ } finally {
74
+ if (buffer.mapState === "mapped") buffer.unmap();
75
+ buffer.destroy();
76
+ }
65
77
  }
66
78
  #drop() {
67
79
  this.#texture?.destroy();
@@ -120,28 +132,36 @@ var HeadlessCanvas = class {
120
132
  const createCanvas = (width, height) => new HeadlessCanvas(width, height);
121
133
  //#endregion
122
134
  //#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() {}
135
+ const domShims = (global, animationFrames) => {
136
+ const requestAnimationFrame = (callback) => {
137
+ const handle = global.setTimeout(() => {
138
+ animationFrames.delete(handle);
139
+ callback(global.performance.now());
140
+ }, 16);
141
+ animationFrames.set(handle, global.clearTimeout);
142
+ handle.unref?.();
143
+ return handle;
144
+ };
145
+ const cancelAnimationFrame = (handle) => {
146
+ const clear = animationFrames.get(handle) ?? global.clearTimeout;
147
+ animationFrames.delete(handle);
148
+ clear(handle);
132
149
  };
133
150
  return {
134
- HTMLCanvasElement: HeadlessCanvas,
135
151
  document: {
136
152
  createElement: (tag) => tag === "canvas" ? new HeadlessCanvas() : {},
137
153
  createElementNS: (_ns, tag) => tag === "canvas" ? new HeadlessCanvas() : {},
138
154
  addEventListener() {},
139
155
  removeEventListener() {}
140
156
  },
141
- window,
142
- self: window,
157
+ devicePixelRatio: 1,
143
158
  requestAnimationFrame,
144
- cancelAnimationFrame: clearTimeout
159
+ cancelAnimationFrame,
160
+ addEventListener() {},
161
+ removeEventListener() {},
162
+ HTMLCanvasElement: HeadlessCanvas,
163
+ window: global,
164
+ self: global
145
165
  };
146
166
  };
147
167
  /**
@@ -154,6 +174,7 @@ var WebgpuEnvironment = class extends TestEnvironment {
154
174
  #navigator = {};
155
175
  #nativeAdded;
156
176
  #dawnOptions;
177
+ #animationFrames = /* @__PURE__ */ new Map();
157
178
  constructor(config, context) {
158
179
  super(config, context);
159
180
  this.#nativeAdded = !Object.getOwnPropertyNames(this.global).includes("_native");
@@ -171,7 +192,7 @@ var WebgpuEnvironment = class extends TestEnvironment {
171
192
  const global = this.global;
172
193
  const shims = {
173
194
  ...globals,
174
- ...domShims(),
195
+ ...domShims(global, this.#animationFrames),
175
196
  createCanvas
176
197
  };
177
198
  this.#added = Object.keys(shims).filter((key) => !(key in global));
@@ -184,8 +205,10 @@ var WebgpuEnvironment = class extends TestEnvironment {
184
205
  });
185
206
  }
186
207
  async teardown() {
187
- delete this.#navigator.gpu;
188
208
  const global = this.global;
209
+ for (const [handle, clear] of this.#animationFrames) clear(handle);
210
+ this.#animationFrames.clear();
211
+ delete this.#navigator.gpu;
189
212
  for (const key of this.#added) delete global[key];
190
213
  await super.teardown();
191
214
  }
@@ -1 +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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"}
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;\nconst READABLE_FORMATS = new Set<GPUTextureFormat>(['rgba8unorm', 'rgba8unorm-srgb', 'bgra8unorm', 'bgra8unorm-srgb']);\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 config = this.#config;\n if (!config) throw new Error('readPixels() called before configure()');\n const { device, format } = config;\n if (!READABLE_FORMATS.has(format)) {\n throw new Error(`readPixels() supports only 8-bit RGBA/BGRA formats; received ${format}`);\n }\n const texture = this.getCurrentTexture();\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 try {\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 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 } finally {\n if (buffer.mapState === 'mapped') buffer.unmap();\n buffer.destroy();\n }\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. */\ntype TimerHandle = ReturnType<typeof setTimeout>;\ntype SandboxGlobal = Record<string, unknown> & {\n clearTimeout: typeof clearTimeout;\n performance: Performance;\n setTimeout: typeof setTimeout;\n};\n\nconst domShims = (\n global: SandboxGlobal,\n animationFrames: Map<TimerHandle, typeof clearTimeout>,\n): Record<string, unknown> => {\n const requestAnimationFrame = (callback: (time: number) => void): TimerHandle => {\n const handle = global.setTimeout(() => {\n animationFrames.delete(handle);\n callback(global.performance.now());\n }, 16);\n animationFrames.set(handle, global.clearTimeout);\n (handle as NodeJS.Timeout).unref?.();\n return handle;\n };\n const cancelAnimationFrame = (handle: TimerHandle): void => {\n const clear = animationFrames.get(handle) ?? global.clearTimeout;\n animationFrames.delete(handle);\n clear(handle);\n };\n const 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 return {\n document,\n devicePixelRatio: 1,\n requestAnimationFrame,\n cancelAnimationFrame,\n addEventListener() {},\n removeEventListener() {},\n HTMLCanvasElement: HeadlessCanvas,\n window: global,\n self: global,\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 #animationFrames = new Map<TimerHandle, typeof clearTimeout>();\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 SandboxGlobal;\n const shims = { ...globals, ...domShims(global, this.#animationFrames), 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 const global = this.global as unknown as Record<string, unknown>;\n for (const [handle, clear] of this.#animationFrames) clear(handle);\n this.#animationFrames.clear();\n delete this.#navigator.gpu;\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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jest-environment-webgpu-node",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Jest environment that provides real headless WebGPU (Dawn via the webgpu package) in Node",
5
5
  "keywords": [
6
6
  "dawn",
package/src/globals.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  // Ambient typing for the `createCanvas` global this environment installs on `globalThis`, for
2
2
  // CommonJS test files that use the global instead of importing this ESM package.
3
- declare const createCanvas: typeof import('./canvas.js').createCanvas;
3
+ declare const createCanvas: typeof import('jest-environment-webgpu-node').createCanvas;