Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 41 additions & 2 deletions apps/example/src/Tests.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Dimensions,
Text,
Expand All @@ -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).
Expand All @@ -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";
Expand All @@ -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<number, () => void>());
useEffect(() => {
pendingRequests.current.forEach((resolve, request) => {
if (request <= renderedRequest) {
pendingRequests.current.delete(request);
resolve();
}
});
}, [renderedRequest]);
return useCallback(
() =>
new Promise<void>((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<GPUTexture | null>(null);
Expand All @@ -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<View>(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<CanvasRef>(null);
const rerender = useRerender();
useEffect(() => {
let cancelled = false;
(async () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<View style={{ flex: 1, padding: 16, backgroundColor: "white" }}>
Expand Down Expand Up @@ -229,6 +264,10 @@ export const Tests = ({ assets: { di3D, saturn, moon } }: AssetProps) => {
}}
/>
</View>
<Canvas
ref={scaledCanvasRef}
style={{ ...SCALED_CANVAS_SIZE, transform: [{ scale: 0.5 }] }}
/>
<Texture
texture={texture}
device={device}
Expand Down
2 changes: 1 addition & 1 deletion packages/webgpu/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Example app (from `apps/example/`): `yarn start` · `yarn ios` · `yarn android`

From the repo root: `yarn lint` · `yarn tsc` · `yarn build:docs`

Tests (from `packages/webgpu/`): `yarn test:ref` (Chrome reference) · `yarn test` (E2E) · `yarn test:plugin` (Expo config plugin unit tests)
Tests (from `packages/webgpu/`): `yarn test:ref` (Chrome reference) · `yarn test` (E2E) · `yarn test:plugin` (Expo config plugin unit tests) · `yarn test:unit` (`Canvas` and the Jest mock rendered under React Native Testing Library, `src/__tests__/unit/`)

`yarn test` needs the example app open on its "Tests" screen and connected to Metro first: start Metro with `CI=true yarn start` (from `apps/example/`) — `initialRouteName` in `apps/example/src/App.tsx` is `"Tests"` when `process.env.CI === "true"`, inlined at bundle time via the `transform-inline-environment-variables` babel plugin — then launch the app (`yarn ios` / `yarn android`, or install+launch an existing build). The screen shows "Connecting to localhost..." once it's ready; `yarn test` then picks it up automatically. If a run fails mid-suite, reload the app (`curl -X POST http://localhost:8081/reload`) before the next run — a broken `GPUDevice` from the failed run makes unrelated later tests fail too. The app and the test server talk over port 4242 by default; set `E2E_PORT` for both Metro (it is inlined into the bundle, so start Metro with `--reset-cache` after changing it) and `yarn test` to use another one, as CI does to keep clear of the other projects sharing its runner.

Expand Down
12 changes: 9 additions & 3 deletions packages/webgpu/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@ module.exports = {
// discovery. Without this, the .d.ts files emitted into lib/ by `bob build`
// are picked up as test suites and fail with "must contain at least one test".
// The Expo config plugin has its own standalone jest config
// (plugin/jest.config.js, run via `yarn test:plugin`) so its unit tests do
// not go through the e2e globalSetup below.
testPathIgnorePatterns: ['/node_modules/', '<rootDir>/lib/', '<rootDir>/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/',
'<rootDir>/lib/',
'<rootDir>/plugin/',
'<rootDir>/src/__tests__/unit/',
],
globalSetup: './src/__tests__/globalSetup.ts',
globalTeardown: './src/__tests__/globalTeardown.ts',
transform: {
Expand Down
14 changes: 14 additions & 0 deletions packages/webgpu/jest.unit.config.js
Original file line number Diff line number Diff line change
@@ -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: ['<rootDir>/src/__tests__/unit/**/*.test.ts?(x)'],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
},
transformIgnorePatterns: [
'node_modules/(?!(wgpu-matrix|react-native|@react-native|react-native-.*)/)',
],
};
3 changes: 3 additions & 0 deletions packages/webgpu/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions packages/webgpu/src/__tests__/Canvas.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
8 changes: 4 additions & 4 deletions packages/webgpu/src/__tests__/CanvasContext.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions packages/webgpu/src/__tests__/Constants.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { client } from "./setup";
import { client, isNativeApp } from "./setup";

describe("WebGPUConstants", () => {
it("GPUBufferUsage", async () => {
Expand Down Expand Up @@ -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 }) => {
Expand Down
13 changes: 9 additions & 4 deletions packages/webgpu/src/__tests__/ImageBitmapOptions.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
);
});

Expand All @@ -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, {
Expand Down
20 changes: 9 additions & 11 deletions packages/webgpu/src/__tests__/ViewSnapshot.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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 }) => {
Expand Down
Loading