diff --git a/e2e/engine-isolation.spec.ts b/e2e/engine-isolation.spec.ts new file mode 100644 index 00000000..0fa5044b --- /dev/null +++ b/e2e/engine-isolation.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from "@playwright/test"; +import { gotoApp, waitForEngine, waitRunCompleted } from "./helpers"; + +test("supported browsers isolate a fresh visit and run the engine", async ({ + page, + browserName, +}) => { + test.skip(browserName === "webkit", "Safari is explicitly unsupported"); + 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(); + await page.getByTestId("library-quick-2D-lj-fluid").click(); + await waitRunCompleted(page); + 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, + browserName, +}) => { + test.skip( + browserName === "webkit", + "Safari has a dedicated unsupported message", + ); + 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(); +}); + +test("Safari shows an unsupported message without downloading the engine", async ({ + page, + browserName, +}) => { + test.skip(browserName !== "webkit", "Safari-specific behavior"); + const engineRequests: string[] = []; + page.on("request", (request) => { + if (request.url().includes("lammps-atomify")) + engineRequests.push(request.url()); + }); + await gotoApp(page); + await expect(page.getByTestId("engine-error-chip")).toContainText( + "Safari is not supported", + ); + await expect(page.getByTestId("engine-error-chip")).toContainText( + "Chrome or Firefox on a desktop computer", + ); + await expect(page.getByTestId("engine-loading-chip")).toHaveCount(0); + await page.getByTestId("nav-examples").click(); + await expect(page.getByTestId("library-example-2D-lj-fluid")).toBeVisible(); + await expect(page.getByTestId("library-quick-2D-lj-fluid")).toBeDisabled(); + expect(engineRequests).toEqual([]); +}); 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/playwright.config.ts b/playwright.config.ts index f4937352..88f64e4d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,6 +16,7 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "e2e", + testIgnore: "engine-isolation.spec.ts", // Budget for engine load (~120 s worst case) + the run itself. timeout: 420_000, expect: { timeout: 15_000 }, diff --git a/playwright.safari.config.ts b/playwright.safari.config.ts new file mode 100644 index 00000000..2b10a16b --- /dev/null +++ b/playwright.safari.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Build first: npm run build. No server isolation headers: exercise the +// same service-worker bootstrap used on GitHub Pages, including first visits. +export default defineConfig({ + testDir: "e2e", + testMatch: "engine-isolation.spec.ts", + timeout: 240_000, + expect: { timeout: 15_000 }, + workers: 1, + use: { baseURL: "http://localhost:5200", trace: "retain-on-failure" }, + projects: [ + { name: "webkit", use: { ...devices["Desktop Safari"] } }, + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + ], + webServer: { + command: "node e2e/static-server.mjs", + url: "http://localhost:5200/atomify/", + reuseExistingServer: false, + }, +}); diff --git a/src/components/Simulation.tsx b/src/components/Simulation.tsx index 62f97aa1..12e94ab9 100644 --- a/src/components/Simulation.tsx +++ b/src/components/Simulation.tsx @@ -34,6 +34,9 @@ const SimulationComponent = () => { ); const selectedMenu = useStoreState((state) => state.app.selectedMenu); const setLammps = useStoreActions((actions) => actions.simulation.setLammps); + const setEngineError = useStoreActions( + (actions) => actions.app.setEngineError, + ); const setStatus = useStoreActions((actions) => actions.app.setStatus); const runPostTimestep = useStoreActions( (actions) => actions.processing.runPostTimestep, @@ -168,6 +171,27 @@ const SimulationComponent = () => { return; } loadStartedRef.current = true; + setEngineError(undefined); + // Safari and other WebKit browsers (including iOS browsers) cannot run + // our credentialless / SharedArrayBuffer engine configuration. + const isWebKit = + /AppleWebKit/i.test(navigator.userAgent) && + !/Chrome\/|Chromium\/|Edg\/|OPR\/|Android/i.test(navigator.userAgent); + if (isWebKit) { + setEngineError( + "Safari is not supported for simulations. Use Chrome or Firefox on a desktop computer.", + ); + return; + } + if ( + !window.crossOriginIsolated || + typeof SharedArrayBuffer === "undefined" + ) { + setEngineError( + "The simulation engine requires cross-origin isolation. Reload the page; if this persists, check browser settings or try another browser.", + ); + return; + } setStatus({ title: "Downloading LAMMPS ...", text: "", @@ -182,15 +206,16 @@ const SimulationComponent = () => { // interface; the store and modifier/render pipeline keep reading data // through the usual heap-pointer path, now backed by the streamed bridge // heap instead of a real main-thread module. - const proxy = new LammpsWorkerProxy(); - proxy.onPrint(onPrint); - // Surface worker/LAMMPS errors in the console panel rather than only the - // devtools console. - proxy.onError((message) => onPrint(message)); - proxyRef.current = proxy; - proxy - .load() - .then(() => { + Promise.resolve() + .then(async () => { + const proxy = new LammpsWorkerProxy(); + proxy.onPrint(onPrint); + proxy.onError((message) => onPrint(message)); + proxyRef.current = proxy; + await proxy.load(); + return proxy; + }) + .then((proxy) => { track("WASM.Load"); setStatus({ title: "Downloading LAMMPS ...", @@ -206,6 +231,7 @@ const SimulationComponent = () => { // The module failed to load — don't leave the UI stuck on the // "Downloading LAMMPS …" spinner with no explanation. const message = error instanceof Error ? error.message : String(error); + setEngineError(message); onPrint(`Failed to load LAMMPS: ${message}`); setStatus(undefined); notification.error({ @@ -213,7 +239,7 @@ const SimulationComponent = () => { description: message, }); }); - }, [onPrint, setLammps, setStatus]); + }, [onPrint, setLammps, setStatus, setEngineError]); return <>; }; export default SimulationComponent; diff --git a/src/shell/Sidebar.tsx b/src/shell/Sidebar.tsx index 768b8c7e..0f619d9d 100644 --- a/src/shell/Sidebar.tsx +++ b/src/shell/Sidebar.tsx @@ -27,7 +27,10 @@ import { ColorDot, PulseDot } from "./ui"; /** Persisted collapse choice — the rail should survive reloads. */ const SIDEBAR_COLLAPSED_KEY = "atomify_sidebar_collapsed"; -const navButtonStyle = (active: boolean, collapsed: boolean): CSSProperties => ({ +const navButtonStyle = ( + active: boolean, + collapsed: boolean, +): CSSProperties => ({ display: "flex", alignItems: "center", justifyContent: collapsed ? "center" : "flex-start", @@ -77,6 +80,7 @@ const Sidebar = () => { const projects = useStoreState((state) => state.projects.projects); const activeRun = useStoreState((state) => state.projects.activeRun); const theme = useStoreState((state) => state.settings.theme); + const engineError = useStoreState((state) => state.app.engineError); const status = useStoreState((state) => state.app.status); const setTheme = useStoreActions((actions) => actions.settings.setTheme); const setScreen = useStoreActions((actions) => actions.projects.setScreen); @@ -354,10 +358,20 @@ const Sidebar = () => { {!ui.engineReady && (
{ color: "var(--text-2)", }} > - + {engineError ? ( + + ) : ( + + )} {!collapsed && ( - Engine loading… - {status ? ` ${Math.ceil(100 * status.progress)}%` : ""} + {engineError + ? `Engine unavailable: ${engineError}` + : `Engine loading…${status ? ` ${Math.ceil(100 * status.progress)}%` : ""}`} )}
diff --git a/src/store/app.ts b/src/store/app.ts index d0c6a748..5e1ff06e 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -15,6 +15,8 @@ export interface SimulationFile { export interface AppModel { selectedMenu: string; status?: Status; + engineError?: string; + setEngineError: Action; preferredView?: string; selectedFile?: SimulationFile; setSelectedMenu: Action; @@ -24,6 +26,9 @@ export interface AppModel { } export const appModel: AppModel = { + setEngineError: action((state, error) => { + state.engineError = error; + }), // The shell drives this legacy gate ("view" streams particles into the // renderer); embedded mode and its menu are gone. selectedMenu: "examples",