diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d986f278d..4665a42c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,10 @@ jobs: working-directory: packages/webgpu run: yarn test:plugin + - name: Run the component tests + working-directory: packages/webgpu + run: yarn test:unit + # Validate the semantic-release config without publishing, so a broken # .releaserc or publish script is caught on PRs rather than at release time. - name: Release dry run diff --git a/apps/example/src/Tests.tsx b/apps/example/src/Tests.tsx index e62dfd551..6919e7e18 100644 --- a/apps/example/src/Tests.tsx +++ b/apps/example/src/Tests.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Dimensions, Text, @@ -8,6 +8,7 @@ import { findNodeHandle, } from "react-native"; import { + Canvas, GPUOffscreenCanvas, // The flags as the package exports them, not the globals of the same name // (see the `exportedFlags` spec in Constants.spec.ts). @@ -17,6 +18,7 @@ import { GPUShaderStage as exportedShaderStage, GPUTextureUsage as exportedTextureUsage, } from "react-native-webgpu"; +import type { CanvasRef } from "react-native-webgpu"; import { mat4, vec3, mat3 } from "wgpu-matrix"; import { useClient } from "./useClient"; @@ -33,6 +35,32 @@ const { width } = Dimensions.get("window"); const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); const PROBE_SIZE = 64; const QUADRANT = PROBE_SIZE / 2; +const SCALED_CANVAS_SIZE = { width: 64, height: 32 }; + +// Re-renders the screen; each request resolves once a commit has rendered it, +// so requests that share a commit all resolve. +const useRerender = () => { + const [renderedRequest, setRenderedRequest] = useState(0); + const lastRequest = useRef(0); + const pendingRequests = useRef(new Map void>()); + useEffect(() => { + pendingRequests.current.forEach((resolve, request) => { + if (request <= renderedRequest) { + pendingRequests.current.delete(request); + resolve(); + } + }); + }, [renderedRequest]); + return useCallback( + () => + new Promise((resolve) => { + lastRequest.current += 1; + pendingRequests.current.set(lastRequest.current, resolve); + setRenderedRequest(lastRequest.current); + }), + [], + ); +}; export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => { const [texture, setTexture] = useState(null); @@ -42,6 +70,10 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => { // A fixed native view the drawElementImageToTexture spec snapshots: four // 32x32pt solid quadrants (red, lime, blue, white), 64x64pt in total. const probeRef = useRef(null); + // The Canvas spec measures this canvas, drawn at half its layout size, and + // re-renders the screen to check that it keeps its ref handle. + const scaledCanvasRef = useRef(null); + const rerender = useRerender(); useEffect(() => { let cancelled = false; (async () => { @@ -129,6 +161,9 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => { probe: findNodeHandle(probeRef.current), probeSize: PROBE_SIZE, pixelRatio: PixelRatio.get(), + scaledCanvas: () => scaledCanvasRef.current, + scaledCanvasSize: SCALED_CANVAS_SIZE, + rerender, }, workletDeviceStress, workletSerialization, @@ -178,7 +213,7 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => { }; } return; - }, [adapter, client, device, di3D, moon, saturn]); + }, [adapter, client, device, di3D, moon, rerender, saturn]); if (setupError) { return ( @@ -229,6 +264,10 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => { }} /> + /lib/', '/plugin/'], + // (plugin/jest.config.js, run via `yarn test:plugin`), and so do the + // component tests (jest.unit.config.js, `yarn test:unit`), so neither goes + // through the e2e globalSetup below. + testPathIgnorePatterns: [ + '/node_modules/', + '/lib/', + '/plugin/', + '/src/__tests__/unit/', + ], globalSetup: './src/__tests__/globalSetup.ts', globalTeardown: './src/__tests__/globalTeardown.ts', transform: { diff --git a/packages/webgpu/jest.unit.config.js b/packages/webgpu/jest.unit.config.js new file mode 100644 index 000000000..c4cafa07a --- /dev/null +++ b/packages/webgpu/jest.unit.config.js @@ -0,0 +1,14 @@ +// Component tests rendered under Jest with React Native's mocks and +// React Native Testing Library. The package's main jest config drives the e2e +// harness, which waits for the example app; these need no device. +module.exports = { + preset: 'react-native', + rootDir: __dirname, + testMatch: ['/src/__tests__/unit/**/*.test.ts?(x)'], + transform: { + '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', + }, + transformIgnorePatterns: [ + 'node_modules/(?!(wgpu-matrix|react-native|@react-native|react-native-.*)/)', + ], +}; diff --git a/packages/webgpu/package.json b/packages/webgpu/package.json index bb856e47e..e62fea28c 100644 --- a/packages/webgpu/package.json +++ b/packages/webgpu/package.json @@ -31,6 +31,7 @@ "test:ref": "REFERENCE=true NODE_OPTIONS='--experimental-require-module' jest -i", "test:node": "NODE_WEBGPU=true NODE_OPTIONS='--experimental-require-module' jest -i", "test:plugin": "jest -c plugin/jest.config.js", + "test:unit": "jest -c jest.unit.config.js", "lint": "eslint . --ext .ts,.tsx --max-warnings 0 --cache --fix", "tsc": "tsc --noEmit", "build": "bob build && yarn build:plugin", @@ -71,6 +72,7 @@ "@semantic-release/exec": "^7.0.3", "@semantic-release/github": "^12.0.9", "@semantic-release/release-notes-generator": "^14.1.1", + "@testing-library/react-native": "^14.1.0", "@types/jest": "^29.5.12", "@types/node": "^20.14.7", "@types/pixelmatch": "5.2.4", @@ -103,6 +105,7 @@ "semantic-release": "^25.0.9", "semantic-release-yarn": "^3.0.2", "teapot": "^1.0.0", + "test-renderer": "~1.1.0", "tsx": "^4.20.5", "typescript": "^5.2.2", "webgpu": "0.4.0", diff --git a/packages/webgpu/src/__tests__/Canvas.spec.ts b/packages/webgpu/src/__tests__/Canvas.spec.ts new file mode 100644 index 000000000..10a45d7a9 --- /dev/null +++ b/packages/webgpu/src/__tests__/Canvas.spec.ts @@ -0,0 +1,46 @@ +import { client, isNativeApp } from "./setup"; + +// The harness mounts a canvas laid out at a known size and drawn at half that +// size under a scale transform (apps/example/src/Tests.tsx). + +describe("Canvas", () => { + it("sizes the context's canvas from its layout, not its transformed box", async () => { + if (!isNativeApp()) { + return; + } + const result = await client.eval(({ views }) => { + const context = views?.scaledCanvas()?.getContext("webgpu"); + if (!views || !context) { + throw new Error("The Tests screen has no scaled canvas"); + } + const canvas = context.canvas as HTMLCanvasElement; + return { + layoutSize: views.scaledCanvasSize, + size: { width: canvas.width, height: canvas.height }, + clientSize: { width: canvas.clientWidth, height: canvas.clientHeight }, + }; + }); + expect(result.size).toEqual(result.layoutSize); + expect(result.clientSize).toEqual(result.layoutSize); + }); + + it("keeps one ref handle across re-renders", async () => { + if (!isNativeApp()) { + return; + } + const result = await client.eval(({ views }) => { + if (!views) { + throw new Error("The Tests screen has no scaled canvas"); + } + const { scaledCanvas, rerender } = views; + const handle = scaledCanvas(); + return Promise.all([rerender(), rerender()]) + .then(() => rerender()) + .then(() => ({ + attached: handle !== null, + kept: scaledCanvas() === handle, + })); + }); + expect(result).toEqual({ attached: true, kept: true }); + }); +}); diff --git a/packages/webgpu/src/__tests__/CanvasContext.spec.ts b/packages/webgpu/src/__tests__/CanvasContext.spec.ts index 5277e5422..560665c91 100644 --- a/packages/webgpu/src/__tests__/CanvasContext.spec.ts +++ b/packages/webgpu/src/__tests__/CanvasContext.spec.ts @@ -1,8 +1,8 @@ -import { client } from "./setup"; +import { client, isNativeApp } from "./setup"; describe("Native canvas context", () => { it("returns the same texture within a frame, even after destroy", async () => { - if (client.OS !== "ios" && client.OS !== "android") { + if (!isNativeApp()) { return; } const result = await client.eval(({ device, gpu }) => { @@ -31,7 +31,7 @@ describe("Native canvas context", () => { // per SurfaceInfo, so presenting through one context ends the other's frame // only when they share it. it("keeps the surface of a destroyed context for its id while the context lives", async () => { - if (client.OS !== "ios" && client.OS !== "android") { + if (!isNativeApp()) { return; } const result = await client.eval(({ device, gpu }) => { @@ -57,7 +57,7 @@ describe("Native canvas context", () => { it.each(["present", "configure", "unconfigure", "resize"] as const)( "returns a new texture after %s", async (operation) => { - if (client.OS !== "ios" && client.OS !== "android") { + if (!isNativeApp()) { return; } const result = await client.eval( diff --git a/packages/webgpu/src/__tests__/Constants.spec.ts b/packages/webgpu/src/__tests__/Constants.spec.ts index 6e3bd8a6f..c92bda663 100644 --- a/packages/webgpu/src/__tests__/Constants.spec.ts +++ b/packages/webgpu/src/__tests__/Constants.spec.ts @@ -1,4 +1,4 @@ -import { client } from "./setup"; +import { client, isNativeApp } from "./setup"; describe("WebGPUConstants", () => { it("GPUBufferUsage", async () => { @@ -67,7 +67,7 @@ describe("WebGPUConstants", () => { // the web and node runners read the flags of the browser and of the `webgpu` // package. it("exports the flags installed by the native module", async () => { - if (client.OS !== "ios" && client.OS !== "android") { + if (!isNativeApp()) { return; } const result = await client.eval(({ exportedFlags }) => { diff --git a/packages/webgpu/src/__tests__/ImageBitmapOptions.spec.ts b/packages/webgpu/src/__tests__/ImageBitmapOptions.spec.ts index e522ee595..edb94848e 100644 --- a/packages/webgpu/src/__tests__/ImageBitmapOptions.spec.ts +++ b/packages/webgpu/src/__tests__/ImageBitmapOptions.spec.ts @@ -1,4 +1,10 @@ -import { checkImage, client, encodeImage, fixtureUrl } from "./setup"; +import { + checkImage, + client, + encodeImage, + fixtureUrl, + isNativeApp, +} from "./setup"; // createImageBitmap options beyond premultiplyAlpha (which is covered by // ImageBitmapAlpha.spec.ts): the crop-rect overload, resizeWidth/resizeHeight, @@ -138,10 +144,9 @@ describe("createImageBitmap options", () => { cropRect ?? null, options ?? null, ); - const isReference = client.OS === "web" || client.OS === "node"; checkImage( encodeImage(result), - isReference ? referenceSnapshot : identitySnapshot, + isNativeApp() ? identitySnapshot : referenceSnapshot, ); }); @@ -155,7 +160,7 @@ describe("createImageBitmap options", () => { // (CoreGraphics, device RGB) decode paths; these cases are skipped there // until that behavior is measured on-device and pinned down. it("colorSpaceConversion none returns raw pixel values", async () => { - if (client.OS !== "web" && client.OS !== "node") { + if (isNativeApp()) { return; } const result = await runCase("p3-gradient.png", null, { diff --git a/packages/webgpu/src/__tests__/ViewSnapshot.spec.ts b/packages/webgpu/src/__tests__/ViewSnapshot.spec.ts index 65c218b6e..a38d8e2c2 100644 --- a/packages/webgpu/src/__tests__/ViewSnapshot.spec.ts +++ b/packages/webgpu/src/__tests__/ViewSnapshot.spec.ts @@ -1,4 +1,4 @@ -import { client } from "./setup"; +import { client, isNativeApp } from "./setup"; // The harness mounts a 64x64pt probe view made of four solid quadrants // (apps/example/src/Tests.tsx): red top-left, lime top-right, blue @@ -18,11 +18,9 @@ const expectColor = (actual: number[], expected: RGBA, tolerance = 3) => { }); }; -const nativeOnly = () => client.OS === "ios" || client.OS === "android"; - describe("drawElementImageToTexture", () => { it("draws a native view at its natural pixel size", async () => { - if (!nativeOnly()) { + if (!isNativeApp()) { return; } const result = await client.eval(({ device, views }) => { @@ -75,7 +73,7 @@ describe("drawElementImageToTexture", () => { }); it("scales the view to destination.size and honours origin", async () => { - if (!nativeOnly()) { + if (!isNativeApp()) { return; } const result = await client.eval(({ device, views }) => { @@ -143,7 +141,7 @@ describe("drawElementImageToTexture", () => { }); it("crops to the source rectangle and swizzles into bgra8unorm", async () => { - if (!nativeOnly()) { + if (!isNativeApp()) { return; } const result = await client.eval(({ device, views }) => { @@ -208,7 +206,7 @@ describe("drawElementImageToTexture", () => { // into the texture on the GPU instead of reading the pixels back; the result // must be the same. it("draws into a renderable texture", async () => { - if (!nativeOnly()) { + if (!isNativeApp()) { return; } const result = await client.eval(({ device, views }) => { @@ -264,7 +262,7 @@ describe("drawElementImageToTexture", () => { }); it("draws a region of a renderable bgra8unorm texture and leaves the rest", async () => { - if (!nativeOnly()) { + if (!isNativeApp()) { return; } const result = await client.eval(({ device, views }) => { @@ -336,7 +334,7 @@ describe("drawElementImageToTexture", () => { // afterScreenUpdates: false draws the last committed frame on iOS (it is // ignored on Android); the probe is static, so the pixels are the same. it("draws the last committed frame when afterScreenUpdates is false", async () => { - if (!nativeOnly()) { + if (!isNativeApp()) { return; } const result = await client.eval(({ device, views }) => { @@ -395,7 +393,7 @@ describe("drawElementImageToTexture", () => { }); it("rejects when the natural size does not fit the texture", async () => { - if (!nativeOnly()) { + if (!isNativeApp()) { return; } const result = await client.eval(({ device, views }) => { @@ -415,7 +413,7 @@ describe("drawElementImageToTexture", () => { }); it("rejects an unknown view and an unsupported format", async () => { - if (!nativeOnly()) { + if (!isNativeApp()) { return; } const result = await client.eval(({ device }) => { diff --git a/packages/webgpu/src/__tests__/WebPolyfill.spec.ts b/packages/webgpu/src/__tests__/WebPolyfill.spec.ts index 92d2e539a..85da94297 100644 --- a/packages/webgpu/src/__tests__/WebPolyfill.spec.ts +++ b/packages/webgpu/src/__tests__/WebPolyfill.spec.ts @@ -1,34 +1,68 @@ +import type { RNCanvasContext } from "../Canvas"; + // The web polyfill for the RNWebGPU native module runs against the DOM, so it // is exercised here with a minimal stand-in for `window` and `document`. interface FakeCanvas { context: object | null; attributes: Record; + attributeWrites: number; + calls: unknown[][]; + clientWidth: number; + clientHeight: number; getAttribute(name: string): string | null; setAttribute(name: string, value: string): void; getContext(contextId: string): object | null; + getBoundingClientRect(): { width: number; height: number }; + addEventListener(...args: unknown[]): void; + removeEventListener(...args: unknown[]): void; + dispatchEvent(...args: unknown[]): void; + setPointerCapture(...args: unknown[]): void; + releasePointerCapture(...args: unknown[]): void; } +// Laid out at 100 x 50 CSS pixels and drawn at half that size under a CSS +// scale transform, which getBoundingClientRect() includes. const makeCanvas = (context: object | null): FakeCanvas => ({ context, attributes: {}, + attributeWrites: 0, + calls: [], + clientWidth: 100, + clientHeight: 50, getAttribute(name) { return this.attributes[name] ?? null; }, setAttribute(name, value) { this.attributes[name] = value; + this.attributeWrites += 1; }, getContext() { return this.context; }, + getBoundingClientRect: () => ({ width: 50, height: 25 }), + addEventListener(...args) { + this.calls.push(["addEventListener", ...args]); + }, + removeEventListener(...args) { + this.calls.push(["removeEventListener", ...args]); + }, + dispatchEvent(...args) { + this.calls.push(["dispatchEvent", ...args]); + }, + setPointerCapture(...args) { + this.calls.push(["setPointerCapture", ...args]); + }, + releasePointerCapture(...args) { + this.calls.push(["releasePointerCapture", ...args]); + }, }); -type Polyfill = { +type Polyfill = Pick & { + // Unlike native, the web polyfill returns null without WebGPU support. MakeWebGPUCanvasContext: ( - contextId: number, - width: number, - height: number, - ) => (object & { present: () => void }) | null; + ...args: Parameters + ) => RNCanvasContext | null; }; const loadPolyfill = (canvas: FakeCanvas): Polyfill => { @@ -54,10 +88,60 @@ describe("Web polyfill", () => { const RNWebGPU = loadPolyfill(canvas); const result = RNWebGPU.MakeWebGPUCanvasContext(1, 100, 50); expect(result).toBe(context); - expect(typeof result!.present).toBe("function"); + expect(result!.present()).toBeUndefined(); expect(canvas.attributes).toEqual({ width: "200", height: "100" }); }); + // Writing a canvas's width or height clears its drawing buffer. + it("leaves a drawing buffer that already has its size untouched", () => { + const canvas = makeCanvas({}); + const RNWebGPU = loadPolyfill(canvas); + RNWebGPU.MakeWebGPUCanvasContext(1, 100, 50); + const writes = canvas.attributeWrites; + RNWebGPU.MakeWebGPUCanvasContext(1, 100, 50); + expect({ writes, rewrites: canvas.attributeWrites - writes }).toEqual({ + writes: 2, + rewrites: 0, + }); + }); + + it("reports the canvas's layout size as its native surface size", () => { + const RNWebGPU = loadPolyfill(makeCanvas({})); + const { width, height, clientWidth, clientHeight } = + RNWebGPU.getNativeSurface(1); + expect({ width, height, clientWidth, clientHeight }).toEqual({ + width: 100, + height: 50, + clientWidth: 100, + clientHeight: 50, + }); + }); + + it("reads the native surface without touching the drawing buffer", () => { + const canvas = makeCanvas({}); + loadPolyfill(canvas).getNativeSurface(1); + expect(canvas.attributeWrites).toBe(0); + }); + + it("delegates the native surface's DOM methods to the canvas element", () => { + const canvas = makeCanvas({}); + const surface = loadPolyfill(canvas).getNativeSurface(1); + const listener = () => {}; + const event = new Event("pointerdown"); + surface.addEventListener("pointerdown", listener); + surface.removeEventListener("pointerdown", listener); + surface.dispatchEvent(event); + surface.setPointerCapture(7); + surface.releasePointerCapture(7); + expect(canvas.calls).toEqual([ + ["addEventListener", "pointerdown", listener], + ["removeEventListener", "pointerdown", listener], + ["dispatchEvent", event], + ["setPointerCapture", 7], + ["releasePointerCapture", 7], + ]); + }); + it("returns null when the browser has no WebGPU context", () => { // canvas.getContext("webgpu") returns null when WebGPU is unavailable // (e.g. Safari with the WebGPU feature flag turned off). diff --git a/packages/webgpu/src/__tests__/setup.ts b/packages/webgpu/src/__tests__/setup.ts index c639e139f..97d17ff30 100644 --- a/packages/webgpu/src/__tests__/setup.ts +++ b/packages/webgpu/src/__tests__/setup.ts @@ -11,6 +11,7 @@ import { mat4, vec3, mat3 } from "wgpu-matrix"; import type { Server, WebSocket } from "ws"; import type { Browser, Page } from "puppeteer"; +import type { CanvasRef } from "../Canvas"; import type { GPUOffscreenCanvas } from "../Offscreen"; import { cubeVertexArray } from "./components/cube"; @@ -88,11 +89,16 @@ interface GPUTestingContext { }; // Native app harness only: a mounted probe view for the // drawElementImageToTexture spec (its React tag, its size in points and the - // device pixel ratio). See apps/example/src/Tests.tsx. + // device pixel ratio), and for the Canvas spec a canvas drawn at half its + // layout size, that layout size, and a re-render of the screen that + // resolves once a commit has rendered it. See apps/example/src/Tests.tsx. views?: { probe: number | null; probeSize: number; pixelRatio: number; + scaledCanvas: () => CanvasRef | null; + scaledCanvasSize: { width: number; height: number }; + rerender: () => Promise; }; mat4: typeof mat4; vec3: typeof vec3; @@ -919,6 +925,15 @@ export const checkImage = ( return 0; }; +const NATIVE_APP: Record = { + ios: true, + android: true, + web: false, + node: false, +}; + +export const isNativeApp = () => NATIVE_APP[client.OS]; + export const itSkipsOnWeb = (name: string, fn: () => Promise) => { it(name, async () => { if (client.OS === "web") { @@ -939,7 +954,7 @@ export const itSkipsOnWeb = (name: string, fn: () => Promise) => { // keep the inline form, which leaves the reference clients independent of that // server. export const fixtureUrl = (fileName: string): string => { - if (client.OS === "ios" || client.OS === "android") { + if (isNativeApp()) { return `http://${global.testHost}:${TEST_SERVER_PORT}/${fileName}`; } const p = path.resolve(__dirname, "assets", fileName); diff --git a/packages/webgpu/src/__tests__/unit/Canvas.test.tsx b/packages/webgpu/src/__tests__/unit/Canvas.test.tsx new file mode 100644 index 000000000..7f3917cca --- /dev/null +++ b/packages/webgpu/src/__tests__/unit/Canvas.test.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { View } from "react-native"; +import { render, screen } from "@testing-library/react-native"; + +import { Canvas } from "../../Canvas"; +import type { CanvasRef, RNCanvasContext } from "../../Canvas"; + +const LAYOUT_SIZE = { width: 64, height: 32 }; + +const context = {} as RNCanvasContext; +const nativeModule: Pick< + typeof RNWebGPU, + "MakeWebGPUCanvasContext" | "getNativeSurface" | "destroyContext" +> = { + MakeWebGPUCanvasContext: jest.fn(() => context), + getNativeSurface: jest.fn(), + destroyContext: jest.fn(), +}; +Object.assign(globalThis, { RNWebGPU: nativeModule }); + +const measureLayout = jest.spyOn(View.prototype, "measureLayout"); + +beforeEach(() => { + jest.clearAllMocks(); + measureLayout.mockImplementation((_relativeTo, onSuccess) => + onSuccess(0, 0, LAYOUT_SIZE.width, LAYOUT_SIZE.height), + ); +}); + +const renderCanvas = async () => { + const handles: (CanvasRef | null)[] = []; + const ref = (handle: CanvasRef | null) => { + handles.push(handle); + }; + await render(); + const [handle] = handles; + if (!handle) { + throw new Error("Canvas did not attach its ref"); + } + return { handle, handles, ref }; +}; + +describe("Canvas", () => { + it("creates the context at the layout size the view measures relative to itself", async () => { + const { handle } = await renderCanvas(); + expect(handle.getContext("webgpu")).toBe(context); + expect(nativeModule.MakeWebGPUCanvasContext).toHaveBeenCalledWith( + handle.getContextId(), + LAYOUT_SIZE.width, + LAYOUT_SIZE.height, + ); + expect(measureLayout.mock.calls[0][0]).toBe(measureLayout.mock.contexts[0]); + }); + + it("keeps one ref handle across re-renders", async () => { + const { handles, ref } = await renderCanvas(); + await screen.rerender(); + await screen.rerender(); + expect(handles).toHaveLength(1); + }); + + it("settles when a state setter is its ref", async () => { + let renders = 0; + const Owner = () => { + const [, setCanvas] = useState(null); + renders += 1; + return ; + }; + await render(); + expect(renders).toBe(2); + }); + + it("retires its context once, on unmount", async () => { + const { handle } = await renderCanvas(); + expect(nativeModule.destroyContext).not.toHaveBeenCalled(); + await screen.unmount(); + expect(nativeModule.destroyContext).toHaveBeenCalledTimes(1); + expect(nativeModule.destroyContext).toHaveBeenCalledWith( + handle.getContextId(), + ); + }); + + it("rejects a context name other than webgpu", async () => { + const { handle } = await renderCanvas(); + const getContext = handle.getContext as (contextName: string) => unknown; + expect(() => getContext("webgl")).toThrow( + "[WebGPU] Unsupported context: webgl", + ); + }); +}); diff --git a/packages/webgpu/src/__tests__/unit/mock.test.tsx b/packages/webgpu/src/__tests__/unit/mock.test.tsx new file mode 100644 index 000000000..c36b4c24d --- /dev/null +++ b/packages/webgpu/src/__tests__/unit/mock.test.tsx @@ -0,0 +1,53 @@ +import React, { useState } from "react"; +import { render, screen } from "@testing-library/react-native"; + +import { Canvas } from "../../mock"; +import type { CanvasRef } from "../../mock"; + +const renderCanvas = async () => { + const handles: (CanvasRef | null)[] = []; + const ref = (handle: CanvasRef | null) => { + handles.push(handle); + }; + await render(); + const [handle] = handles; + if (!handle) { + throw new Error("The mock Canvas did not attach its ref"); + } + return { handle, handles, ref }; +}; + +describe("The Jest mock's Canvas", () => { + it("keeps one ref handle across re-renders", async () => { + const { handles, ref } = await renderCanvas(); + await screen.rerender(); + await screen.rerender(); + expect(handles).toHaveLength(1); + }); + + it("settles when a state setter is its ref", async () => { + let renders = 0; + const Owner = () => { + const [, setCanvas] = useState(null); + renders += 1; + return ; + }; + await render(); + expect(renders).toBe(2); + }); + + it("has no context, and fails loudly where a GPU is needed", async () => { + const { handle } = await renderCanvas(); + expect(handle.getContextId()).toBe(-1); + expect(handle.getContext("webgpu")).toBeNull(); + expect(() => handle.getNativeSurface()).toThrow( + "Canvas.getNativeSurface() is not available under Jest", + ); + }); + + it("keeps its own props off the View it renders", async () => { + await render(); + const view = screen.getByTestId("canvas"); + expect(view.props).not.toHaveProperty("android"); + }); +}); diff --git a/packages/webgpu/src/mock.ts b/packages/webgpu/src/mock.ts index 6e0b28f6d..79304a1ba 100644 --- a/packages/webgpu/src/mock.ts +++ b/packages/webgpu/src/mock.ts @@ -95,13 +95,19 @@ export type { // Strip the same props the real Canvas keeps off its outer View. export const Canvas = ({ android: _android, ref, ...props }: CanvasProps) => { - React.useImperativeHandle(ref, () => ({ - getContextId: () => -1, - getContext: (_contextName: "webgpu"): RNCanvasContext | null => null, - getNativeSurface: (): NativeCanvas => { - throw new Error(`Canvas.getNativeSurface() ${NOT_AVAILABLE_UNDER_JEST}`); - }, - })); + React.useImperativeHandle( + ref, + () => ({ + getContextId: () => -1, + getContext: (_contextName: "webgpu"): RNCanvasContext | null => null, + getNativeSurface: (): NativeCanvas => { + throw new Error( + `Canvas.getNativeSurface() ${NOT_AVAILABLE_UNDER_JEST}`, + ); + }, + }), + [], + ); return React.createElement(View, props); }; diff --git a/yarn.lock b/yarn.lock index b5dcc136b..20d391f5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3157,6 +3157,13 @@ __metadata: languageName: node linkType: hard +"@jest/diff-sequences@npm:30.5.0": + version: 30.5.0 + resolution: "@jest/diff-sequences@npm:30.5.0" + checksum: f6afafc9245e0326e03fa2136d9023b93ed4f3e491eb740c4ae66e267b2a47690ff61094a84ebc0a2e5fa9e0ae036ca24636176298698907d6848b89d85677e4 + languageName: node + linkType: hard + "@jest/environment@npm:^29.7.0": version: 29.7.0 resolution: "@jest/environment@npm:29.7.0" @@ -3202,6 +3209,13 @@ __metadata: languageName: node linkType: hard +"@jest/get-type@npm:30.5.0": + version: 30.5.0 + resolution: "@jest/get-type@npm:30.5.0" + checksum: 6b67e70f6c1cca6da153814542903b4c385447c7c5e029f8911402ca8a75fbd33a14cc0fff4c6ed7c6c629f6425fade605d6f32eb9516b52146a8640fd899773 + languageName: node + linkType: hard + "@jest/globals@npm:^29.7.0": version: 29.7.0 resolution: "@jest/globals@npm:29.7.0" @@ -3214,6 +3228,20 @@ __metadata: languageName: node linkType: hard +"@jest/react-is-18@npm:react-is@^18.3.1, react-is@npm:^16.12.0 || ^17.0.0 || ^18.0.0, react-is@npm:^18.0.0, react-is@npm:^18.2.0": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21 + languageName: node + linkType: hard + +"@jest/react-is-19@npm:react-is@^19.2.5": + version: 19.3.1 + resolution: "react-is@npm:19.3.1" + checksum: e66f96e15807a8d5948502a026780b53ac95f993c5772f999e4e71ab591d5632fcc7f966abf6a8208ec6a2b880473ad61eb4767c332571422b2852e93f4750dc + languageName: node + linkType: hard + "@jest/reporters@npm:^29.7.0": version: 29.7.0 resolution: "@jest/reporters@npm:29.7.0" @@ -3251,6 +3279,15 @@ __metadata: languageName: node linkType: hard +"@jest/schemas@npm:30.5.0": + version: 30.5.0 + resolution: "@jest/schemas@npm:30.5.0" + dependencies: + "@sinclair/typebox": ^0.34.0 + checksum: 9cce773d1e749e5a0c542321e5a3cd7593d9e29fde4f661996c2fbcdb14c2160a631428e04cd6619827c809c34607d1a388a571df9ee0997026782d098e80735 + languageName: node + linkType: hard + "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -6029,6 +6066,13 @@ __metadata: languageName: node linkType: hard +"@sinclair/typebox@npm:^0.34.0": + version: 0.34.52 + resolution: "@sinclair/typebox@npm:0.34.52" + checksum: 4996f7e16431216d9998700d86645a3341a8036a020466414ec89dbbbbcc60e96d0ed04bd8c7ac4d145ced9aeba991bf2d7334b5b6b579b943e94d208a516c0e + languageName: node + linkType: hard + "@sindresorhus/is@npm:^4.6.0": version: 4.6.0 resolution: "@sindresorhus/is@npm:4.6.0" @@ -6363,6 +6407,26 @@ __metadata: languageName: node linkType: hard +"@testing-library/react-native@npm:^14.1.0": + version: 14.1.0 + resolution: "@testing-library/react-native@npm:14.1.0" + dependencies: + jest-matcher-utils: ^30.4.1 + picocolors: ^1.1.1 + pretty-format: ^30.4.1 + redent: ^3.0.0 + peerDependencies: + jest: ">=29.0.0" + react: ">=19.0.0" + react-native: ">=0.78" + test-renderer: ^1.0.0 + peerDependenciesMeta: + jest: + optional: true + checksum: c00bdf5f5355f619f0bdced365af40bdff614bb0fadd7f31b1ebae51516adbec5be331d5a9a360e63f3932dc2b9c610ded8b49d8d7f605d059f74038b415b382 + languageName: node + linkType: hard + "@tootallnate/quickjs-emscripten@npm:^0.23.0": version: 0.23.0 resolution: "@tootallnate/quickjs-emscripten@npm:0.23.0" @@ -7061,7 +7125,7 @@ __metadata: languageName: node linkType: hard -"@types/react-reconciler@npm:^0.32.0": +"@types/react-reconciler@npm:^0.32.0, @types/react-reconciler@npm:~0.32.0": version: 0.32.3 resolution: "@types/react-reconciler@npm:0.32.3" peerDependencies: @@ -8156,7 +8220,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0": +"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: d7f4e97ce0623aea6bc0d90dcd28881ee04cba06c570b97fd3391bd7a268eedfd9d5e2dd4fdcbdd82b8105df5faf6f24aaedc08eaf3da898e702db5948f63469 @@ -15255,6 +15319,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:30.5.2": + version: 30.5.2 + resolution: "jest-diff@npm:30.5.2" + dependencies: + "@jest/diff-sequences": 30.5.0 + "@jest/get-type": 30.5.0 + chalk: ^4.1.2 + pretty-format: 30.5.1 + checksum: 56d162bd4a37c631f81ea00f5f108a0531496a59c2022d7d97214d77094a79f81e68ea16fbbd9a6115469a57ea403ba37fa135fe3c127123775964df7b1efaf2 + languageName: node + linkType: hard + "jest-diff@npm:^29.7.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" @@ -15355,6 +15431,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:^30.4.1": + version: 30.5.2 + resolution: "jest-matcher-utils@npm:30.5.2" + dependencies: + "@jest/get-type": 30.5.0 + chalk: ^4.1.2 + jest-diff: 30.5.2 + pretty-format: 30.5.1 + checksum: 0f5506fc810bbc43876bc6a15ff1ca11b97e06e6cdeff0ca67edb3d569052ccf479eac95e25ee7daf6ff52f2c03a3d92d33e8f0f237444cd24d94212cd7cf2f8 + languageName: node + linkType: hard + "jest-message-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-message-util@npm:29.7.0" @@ -18444,6 +18532,13 @@ __metadata: languageName: node linkType: hard +"min-indent@npm:^1.0.0": + version: 1.0.1 + resolution: "min-indent@npm:1.0.1" + checksum: bfc6dd03c5eaf623a4963ebd94d087f6f4bbbfd8c41329a7f09706b0cb66969c4ddd336abeb587bc44bc6f08e13bf90f0b374f9d71f9f01e04adc2cd6f083ef1 + languageName: node + linkType: hard + "minimatch@npm:^10.0.1, minimatch@npm:^10.0.3, minimatch@npm:^10.1.1, minimatch@npm:^10.2.2, minimatch@npm:^10.2.5": version: 10.2.6 resolution: "minimatch@npm:10.2.6" @@ -20151,6 +20246,18 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:30.5.1, pretty-format@npm:^30.4.1": + version: 30.5.1 + resolution: "pretty-format@npm:30.5.1" + dependencies: + "@jest/react-is-18": "npm:react-is@^18.3.1" + "@jest/react-is-19": "npm:react-is@^19.2.5" + "@jest/schemas": 30.5.0 + ansi-styles: ^5.2.0 + checksum: 0aee5a8a8318c2df834ec7949da34f914803316272a758722d9b2492f527375a887112aeca6f4f17c9a2f7592ce47c851fa6b281eaac25bb5a775caf826da8c1 + languageName: node + linkType: hard + "pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" @@ -20503,13 +20610,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.12.0 || ^17.0.0 || ^18.0.0, react-is@npm:^18.0.0, react-is@npm:^18.2.0": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21 - languageName: node - linkType: hard - "react-is@npm:^16.13.0, react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -20913,6 +21013,7 @@ __metadata: "@semantic-release/exec": ^7.0.3 "@semantic-release/github": ^12.0.9 "@semantic-release/release-notes-generator": ^14.1.1 + "@testing-library/react-native": ^14.1.0 "@types/jest": ^29.5.12 "@types/node": ^20.14.7 "@types/pixelmatch": 5.2.4 @@ -20946,6 +21047,7 @@ __metadata: semantic-release: ^25.0.9 semantic-release-yarn: ^3.0.2 teapot: ^1.0.0 + test-renderer: ~1.1.0 tsx: ^4.20.5 typescript: ^5.2.2 webgpu: 0.4.0 @@ -21176,6 +21278,17 @@ __metadata: languageName: node linkType: hard +"react-reconciler@npm:~0.32.0": + version: 0.32.0 + resolution: "react-reconciler@npm:0.32.0" + dependencies: + scheduler: ^0.26.0 + peerDependencies: + react: ^19.1.0 + checksum: 47dd7ec683cb48ddb6eb71c04847b3df6a43808e6a81249ef3db5b2603e2eb3095f75272fadf0d50560c0d88c9dac7b5e4ff45fbc5eadaab810c84d13deb247b + languageName: node + linkType: hard + "react-refresh@npm:^0.14.0, react-refresh@npm:^0.14.2": version: 0.14.2 resolution: "react-refresh@npm:0.14.2" @@ -21461,6 +21574,16 @@ __metadata: languageName: node linkType: hard +"redent@npm:^3.0.0": + version: 3.0.0 + resolution: "redent@npm:3.0.0" + dependencies: + indent-string: ^4.0.0 + strip-indent: ^3.0.0 + checksum: fa1ef20404a2d399235e83cc80bd55a956642e37dd197b4b612ba7327bf87fa32745aeb4a1634b2bab25467164ab4ed9c15be2c307923dd08b0fe7c52431ae6b + languageName: node + linkType: hard + "redent@npm:^4.0.0": version: 4.0.0 resolution: "redent@npm:4.0.0" @@ -22001,6 +22124,13 @@ __metadata: languageName: node linkType: hard +"scheduler@npm:^0.26.0": + version: 0.26.0 + resolution: "scheduler@npm:0.26.0" + checksum: c63a9f1c0e5089b537231cff6c11f75455b5c8625ae09535c1d7cd0a1b0c77ceecdd9f1074e5e063da5d8dc11e73e8033dcac3361791088be08a6e60c0283ed9 + languageName: node + linkType: hard + "scroll-into-view-if-needed@npm:^3.1.0": version: 3.1.0 resolution: "scroll-into-view-if-needed@npm:3.1.0" @@ -22994,6 +23124,15 @@ __metadata: languageName: node linkType: hard +"strip-indent@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-indent@npm:3.0.0" + dependencies: + min-indent: ^1.0.0 + checksum: 18f045d57d9d0d90cd16f72b2313d6364fd2cb4bf85b9f593523ad431c8720011a4d5f08b6591c9d580f446e78855c5334a30fb91aa1560f5d9f95ed1b4a0530 + languageName: node + linkType: hard + "strip-indent@npm:^4.0.0": version: 4.1.1 resolution: "strip-indent@npm:4.1.1" @@ -23302,6 +23441,18 @@ __metadata: languageName: node linkType: hard +"test-renderer@npm:~1.1.0": + version: 1.1.0 + resolution: "test-renderer@npm:1.1.0" + dependencies: + "@types/react-reconciler": ~0.32.0 + react-reconciler: ~0.32.0 + peerDependencies: + react: ^19.0.0 + checksum: 5435b3419d5b13525a407e2a0a554e3f010d8a144bf973a29677b20e2f7a77a8a3483c07f7f954b7147245741432bc9c9c40fb2f12899ff5f44fd970b1f4ef5c + languageName: node + linkType: hard + "text-decoder@npm:^1.1.0": version: 1.2.7 resolution: "text-decoder@npm:1.2.7"