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 +5 -0
- package/README.md +8 -1
- package/dist/index.cjs +57 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +57 -34
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/globals.d.ts +1 -1
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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
128
|
-
const requestAnimationFrame = (callback) =>
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
146
|
-
self: window,
|
|
161
|
+
devicePixelRatio: 1,
|
|
147
162
|
requestAnimationFrame,
|
|
148
|
-
cancelAnimationFrame
|
|
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
|
}
|
package/dist/index.cjs.map
CHANGED
|
@@ -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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"}
|
package/dist/index.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.cts","names":[],"sources":["../src/canvas.ts","../src/index.ts"],"mappings":";;;KACY;EAAc;EAAe;EAAgB,MAAM;;;
|
|
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"}
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/canvas.ts","../src/index.ts"],"mappings":";;;KACY;EAAc;EAAe;EAAgB,MAAM;;;
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
124
|
-
const requestAnimationFrame = (callback) =>
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
142
|
-
self: window,
|
|
157
|
+
devicePixelRatio: 1,
|
|
143
158
|
requestAnimationFrame,
|
|
144
|
-
cancelAnimationFrame
|
|
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
|
}
|
package/dist/index.mjs.map
CHANGED
|
@@ -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
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('
|
|
3
|
+
declare const createCanvas: typeof import('jest-environment-webgpu-node').createCanvas;
|