Skip to content
Merged
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
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,42 @@ jobs:
- name: Test
run: yarn test

test-web:
if: ${{ github.repository == 'shopify/react-native-skia' }}
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v5.0.0
with:
submodules: recursive

- name: Setup
uses: ./.github/actions/setup
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

- name: Install Playwright Chromium
run: yarn playwright install chromium

- name: Run web example tests
run: yarn test:web

- name: Upload screenshots
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v6.0.0
with:
name: web-example-screenshots
path: apps/example/e2e/screenshots/
if-no-files-found: ignore

- name: Upload test results
if: failure()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v6.0.0
with:
name: web-example-test-results
path: apps/example/test-results/
if-no-files-found: ignore

build-library:
if: ${{ github.repository == 'shopify/react-native-skia' }}
runs-on: ubuntu-latest
Expand Down
39 changes: 39 additions & 0 deletions .github/workflows/wcandillon-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,45 @@ jobs:
- name: Test
run: yarn test

test-web:
if: ${{ github.repository == 'wcandillon/react-native-skia' }}
runs-on: [self-hosted, macOS]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v5.0.0
with:
submodules: recursive

- name: Setup
uses: ./.github/actions/setup
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

- name: Install Playwright Chromium
run: yarn playwright install chromium

- name: Free Metro port
run: lsof -ti :8081 | xargs kill -9 || true

- name: Run web example tests
run: yarn test:web

- name: Upload screenshots
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v6.0.0
with:
name: web-example-screenshots
path: apps/example/e2e/screenshots/
if-no-files-found: ignore

- name: Upload test results
if: failure()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v6.0.0
with:
name: web-example-test-results
path: apps/example/test-results/
if-no-files-found: ignore

build-library:
if: ${{ github.repository == 'wcandillon/react-native-skia' }}
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions apps/example/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ dist/*
local.properties
msbuild.binlog
node_modules/
e2e/screenshots/
test-results/
playwright-report/
72 changes: 72 additions & 0 deletions apps/example/e2e/web.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import * as fs from "fs";
import * as path from "path";

import { test, expect } from "playwright/test";

import { apiScreenPaths } from "../src/Examples/API/linking";

type Screen = keyof typeof apiScreenPaths;

const screenshotsDir = path.join(__dirname, "screenshots");

// Smoke test: every API example screen must load on web without an uncaught
// exception and render at least one canvas. Screenshots are saved as CI
// artifacts for visual inspection — they are not compared against golden
// images since most examples are animated and rendering is not deterministic
// across GPUs.

// Screens that are currently broken on web — each entry is a TODO.
const skip: Partial<Record<Screen, string>> = {
PathEffect: "PathEffectFactory.MakeCompose is not implemented on web",
ImageFilters: "uses image filter APIs not implemented on web",
FontMgr: "uses font manager APIs not implemented on web",
ColorFilter: "throws 'invalid color matrix' on web",
Path: "PathFactory.Simplify is not implemented on web",
PictureViewCrashTest: "intentional crash reproduction",
WebMemory: "intentionally exhausts WebGL contexts",
};

// Screens that render no canvas by design; they must still load cleanly.
const noCanvas: Screen[] = ["List", "Web", "FirstFrameEmpty"];

// Expected uncaught errors, e.g. from examples that demo error handling.
const allowedErrors: Partial<Record<Screen, RegExp>> = {
Images: /Failed to fetch/, // deliberately loads an invalid image URL
// The svg4 variant references a local asset (test.png), which the web SVG
// implementation cannot resolve, so its backing image never loads.
SVG: /getImageData.*The source width is 0/,
};

const screens = (Object.keys(apiScreenPaths) as Screen[]).filter(
(name) => skip[name] === undefined
);

test.beforeAll(() => {
fs.mkdirSync(screenshotsDir, { recursive: true });
});

for (const name of screens) {
test(name, async ({ page }) => {
const errors: Error[] = [];
page.on("pageerror", (error) => {
if (!allowedErrors[name]?.test(error.message)) {
errors.push(error);
}
});
await page.goto(`/api/${apiScreenPaths[name]}`);
if (noCanvas.includes(name)) {
await expect(page.locator("#root > *").first()).toBeVisible();
} else {
await expect(page.locator("canvas").first()).toBeVisible({
timeout: 60_000,
});
}
// Let the example draw a few frames before taking the screenshot.
await page.waitForTimeout(1_000);
await page.screenshot({ path: path.join(screenshotsDir, `${name}.png`) });
expect(
errors,
errors.map((error) => error.stack ?? error.message).join("\n")
).toEqual([]);
});
}
2 changes: 1 addition & 1 deletion apps/example/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script
src="index.bundle?platform=web&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.routerRoot=app&unstable_transformProfile=hermes-stable"
src="/index.bundle?platform=web&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.routerRoot=app&unstable_transformProfile=hermes-stable"
defer
></script>
</body>
Expand Down
1 change: 1 addition & 0 deletions apps/example/jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const config = {
"^react-native$": "<rootDir>/../../node_modules/react-native",
},
modulePathIgnorePatterns: ["<rootDir>/lib/typescript", "setup.(ts|tsx)$"],
testPathIgnorePatterns: ["<rootDir>/e2e/"],
testEnvironment: "@shopify/react-native-skia/jestEnv.js",
setupFilesAfterEnv: [
"@shopify/react-native-skia/jestSetup.js",
Expand Down
31 changes: 31 additions & 0 deletions apps/example/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,28 @@ const metroConfig = makeMetroConfig({
},
});

// Serve index.html for navigation requests like /api/shapes so that
// react-navigation deep links work on web (history API fallback).
// This runs at the end of the dev server middleware chain, after the static
// file server, so it only sees requests nothing else handled.
function historyApiFallback(middleware) {
const fs = require("fs");
const indexHtml = path.join(__dirname, "index.html");
return (req, res, next) => {
const url = (req.url ?? "").split("?")[0];
const isNavigation =
req.method === "GET" &&
(req.headers.accept ?? "").includes("text/html") &&
!url.includes(".");
if (isNavigation && url !== "/") {
res.setHeader("Content-Type", "text/html; charset=UTF-8");
fs.createReadStream(indexHtml).pipe(res);
return undefined;
}
return middleware(req, res, next);
};
}

function getWebMetroConfig(config) {
config.resolver = config.resolver ?? {};
config.resolver.platforms = ["ios", "android", "web"];
Expand Down Expand Up @@ -49,6 +71,15 @@ function getWebMetroConfig(config) {
config.transformer = config.transformer ?? {};
config.transformer.assetRegistryPath = assetRegistryPath;

config.server = config.server ?? {};
const origEnhanceMiddleware = config.server.enhanceMiddleware;
config.server.enhanceMiddleware = (middleware, server) =>
historyApiFallback(
origEnhanceMiddleware
? origEnhanceMiddleware(middleware, server)
: middleware,
);

return config;
}

Expand Down
1 change: 1 addition & 0 deletions apps/example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"eslint-plugin-import": "2.32.0",
"jest": "^29.6.3",
"jest-transform-stub": "^2.0.0",
"playwright": "1.59.0",
"prettier": "2.8.8",
"react-native-test-app": "4.4.7",
"react-test-renderer": "19.0.0",
Expand Down
28 changes: 28 additions & 0 deletions apps/example/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { defineConfig } from "playwright/test";

const port = 8081;

// Requesting the bundle URL (same query params as index.html) forces Metro to
// build the web bundle before the first test navigates, so individual tests
// don't pay the cold bundling cost.
const bundleURL = `http://localhost:${port}/index.bundle?platform=web&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.routerRoot=app&unstable_transformProfile=hermes-stable`;

// eslint-disable-next-line import/no-default-export
export default defineConfig({
testDir: "./e2e",
timeout: 120_000,
workers: 1,
retries: process.env.CI ? 1 : 0,
reporter: "list",
use: {
baseURL: `http://localhost:${port}`,
viewport: { width: 1024, height: 768 },
},
webServer: {
command: "yarn web",
cwd: __dirname,
url: bundleURL,
reuseExistingServer: !process.env.CI,
timeout: 600_000,
},
});
6 changes: 5 additions & 1 deletion apps/example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,18 @@ import { HomeScreen } from "./Home";
import type { StackParamList } from "./types";
import { useAssets } from "./Tests/useAssets";
import { Chess } from "./Examples/Chess";
import { apiScreenPaths } from "./Examples/API/linking";
import "./resolveAssetSourcePolyfill";

const linking: LinkingOptions<StackParamList> = {
config: {
screens: {
Home: "",
Vertices: "vertices",
API: "api",
API: {
path: "api",
screens: apiScreenPaths,
},
LiquidGlass: "liquid-glass",
Breathe: "breathe",
Filters: "filters",
Expand Down
4 changes: 4 additions & 0 deletions apps/example/src/Examples/API/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ export const examples = [
screen: "Atlas",
title: "🎯 Atlas",
},
{
screen: "Web",
title: "🕸️ Web",
},
] as const;

const styles = StyleSheet.create({
Expand Down
4 changes: 4 additions & 0 deletions apps/example/src/Examples/API/Routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,8 @@ export type Routes = {
FirstFrameEmpty: undefined;
PictureBug: undefined;
Atlas: undefined;
Web: undefined;
WebLayout: undefined;
WebGLContexts: undefined;
WebMemory: undefined;
};
56 changes: 56 additions & 0 deletions apps/example/src/Examples/API/Web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as React from "react";
import { ScrollView, StyleSheet, Text, View, Pressable } from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";

import type { Routes } from "./Routes";

const examples = [
{
screen: "WebLayout",
title: "🕸️ Web Layout",
},
{
screen: "WebGLContexts",
title: "🔥 WebGL Contexts",
},
{
screen: "WebMemory",
title: "💧 WASM Memory",
},
] as const;

const styles = StyleSheet.create({
container: {},
content: {
paddingBottom: 32,
},
thumbnail: {
backgroundColor: "white",
padding: 32,
borderBottomWidth: StyleSheet.hairlineWidth,
},
title: {
color: "black",
},
});

export const Web = () => {
const { navigate } =
useNavigation<NativeStackNavigationProp<Routes, "Web">>();
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{examples.map((thumbnail) => (
<Pressable
key={thumbnail.screen}
onPress={() => navigate(thumbnail.screen)}
testID={thumbnail.screen}
>
<View style={styles.thumbnail}>
<Text style={styles.title}>{thumbnail.title}</Text>
</View>
</Pressable>
))}
</ScrollView>
);
};
Loading
Loading