From 69ca6e857ba998b2151a98a4c31c0581c7eb2893 Mon Sep 17 00:00:00 2001 From: Anders Hafreager Date: Mon, 7 Sep 2026 14:19:45 +0200 Subject: [PATCH 1/2] Fix Safari engine isolation and surface startup failures --- e2e/engine-isolation.spec.ts | 64 ++++++++++++++++++++++++++++ e2e/helpers.ts | 1 + e2e/static-server.mjs | 36 ++++++++++++++++ index.html | 29 +++++++++++-- playwright.config.ts | 1 + playwright.safari.config.ts | 21 +++++++++ src/components/Simulation.tsx | 35 ++++++++++----- src/shell/ExamplesScreen.tsx | 15 +++++-- src/shell/HomeScreen.tsx | 18 ++++++-- src/shell/Sidebar.tsx | 31 +++++++++++--- src/shell/modals/NewProjectModal.tsx | 15 +++++-- src/store/app.ts | 5 +++ src/wasm/LammpsWorkerProxy.test.ts | 41 ++++++++++++++++++ src/wasm/LammpsWorkerProxy.ts | 26 +++++++++-- src/wasm/lammps.worker.ts | 2 + vite.config.ts | 9 ++-- 16 files changed, 311 insertions(+), 38 deletions(-) create mode 100644 e2e/engine-isolation.spec.ts create mode 100644 e2e/static-server.mjs create mode 100644 playwright.safari.config.ts create mode 100644 src/wasm/LammpsWorkerProxy.test.ts diff --git a/e2e/engine-isolation.spec.ts b/e2e/engine-isolation.spec.ts new file mode 100644 index 00000000..12879f65 --- /dev/null +++ b/e2e/engine-isolation.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from "@playwright/test"; +import { gotoApp, waitForEngine, waitRunCompleted } from "./helpers"; + +test("static hosting isolates a fresh visit, loads images and runs the engine", async ({ + page, +}) => { + await gotoApp(page); + await expect + .poll(() => page.evaluate(() => window.crossOriginIsolated)) + .toBe(true); + await waitForEngine(page); + await page.getByTestId("nav-examples").click(); + const card = page.getByTestId("library-example-2D-lj-fluid"); + await expect(card).toBeVisible(); + // Use a second origin with CORS but no CORP to exercise external thumbnails. + const img = card.locator("img"); + await img.evaluate((element: HTMLImageElement) => { + element.src = element.src.replace("localhost", "127.0.0.1"); + }); + await expect + .poll(() => + img.evaluate( + (element: HTMLImageElement) => + element.complete && element.naturalWidth > 0, + ), + ) + .toBe(true); + await page.getByTestId("library-quick-2D-lj-fluid").click(); + await waitRunCompleted(page); + // Simulate a client still controlled by the previous credentialless policy. + await page.evaluate(async () => { + navigator.serviceWorker.controller!.postMessage({ + type: "coepCredentialless", + value: true, + }); + // Wait until the controller has applied the message before navigating. + for (let attempt = 0; attempt < 20; attempt++) { + const response = await fetch(location.href); + if ( + response.headers.get("Cross-Origin-Embedder-Policy") === + "credentialless" + ) + return; + } + throw new Error("Could not seed the old service-worker policy"); + }); + await page.reload(); + await expect(page.getByTestId("shell-root")).toBeVisible(); + await waitForEngine(page); + expect(await page.evaluate(() => window.crossOriginIsolated)).toBe(true); +}); + +test("unavailable isolation shows an error instead of an endless loading chip", async ({ + browser, +}) => { + const context = await browser.newContext({ serviceWorkers: "block" }); + const page = await context.newPage(); + await page.goto("http://localhost:5200/atomify/"); + await expect(page.getByTestId("engine-error-chip")).toContainText( + "requires cross-origin isolation", + ); + await expect(page.getByTestId("engine-loading-chip")).toHaveCount(0); + await context.close(); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 6dbc9c6f..235009a0 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -46,6 +46,7 @@ export async function waitForEngine(page: Page): Promise { await expect(page.getByTestId("engine-loading-chip")).toHaveCount(0, { timeout: ENGINE_TIMEOUT, }); + await expect(page.getByTestId("engine-error-chip")).toHaveCount(0); } /** Create a blank project through the New Project modal. */ diff --git a/e2e/static-server.mjs b/e2e/static-server.mjs new file mode 100644 index 00000000..710294c6 --- /dev/null +++ b/e2e/static-server.mjs @@ -0,0 +1,36 @@ +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { resolve, extname } from "node:path"; + +const root = resolve("dist"); +const types = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".png": "image/png", + ".svg": "image/svg+xml", + ".wasm": "application/wasm", +}; +createServer(async (req, res) => { + const pathname = new URL(req.url, "http://localhost").pathname; + const path = resolve( + root, + pathname.replace(/^\/atomify\//, "") || "index.html", + ); + if (!path.startsWith(root + "/")) { + res.writeHead(403).end(); + return; + } + try { + const body = await readFile(path); + res.writeHead(200, { + "Content-Type": types[extname(path)] || "application/octet-stream", + "Cache-Control": "no-store", + "Access-Control-Allow-Origin": "*", + }); + res.end(body); + } catch { + res.writeHead(404).end(); + } +}).listen(5200); diff --git a/index.html b/index.html index 9ebf635b..ea5df0e4 100644 --- a/index.html +++ b/index.html @@ -20,9 +20,9 @@ Cross-origin isolation for SharedArrayBuffer, which the atomify (KOKKOS/pthreads) wasm build requires. GitHub Pages can't set COOP/COEP response headers, so this service-worker shim injects them on the client - (the page reloads once on first visit). COEP credentialless keeps - cross-origin subresources (imported example files, etc.) working without - requiring CORP headers. In dev, vite sets the same headers directly. + (the page reloads once on first visit). COEP require-corp works in + Safari, Chromium and Firefox. Cross-origin resources opt in via CORS or + CORP. In dev, vite sets the same headers directly. Must load before the app module. -->