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
65 changes: 65 additions & 0 deletions e2e/engine-isolation.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
1 change: 1 addition & 0 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export async function waitForEngine(page: Page): Promise<void> {
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. */
Expand Down
36 changes: 36 additions & 0 deletions e2e/static-server.mjs
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
21 changes: 21 additions & 0 deletions playwright.safari.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
46 changes: 36 additions & 10 deletions src/components/Simulation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: "",
Expand All @@ -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 ...",
Expand All @@ -206,14 +231,15 @@ 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({
message: "Failed to load LAMMPS",
description: message,
});
});
}, [onPrint, setLammps, setStatus]);
}, [onPrint, setLammps, setStatus, setEngineError]);
return <></>;
};
export default SimulationComponent;
31 changes: 25 additions & 6 deletions src/shell/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -354,10 +358,20 @@ const Sidebar = () => {

{!ui.engineReady && (
<div
data-testid="engine-loading-chip"
data-testid={
engineError ? "engine-error-chip" : "engine-loading-chip"
}
role={engineError ? "alert" : "status"}
title={
collapsed
? `Engine loading…${status ? ` ${Math.ceil(100 * status.progress)}%` : ""}`
? engineError
? `Engine unavailable: ${engineError}`
: `Engine loading…${status ? ` ${Math.ceil(100 * status.progress)}%` : ""}`
: undefined
}
aria-label={
collapsed && engineError
? `Engine unavailable: ${engineError}`
: undefined
}
style={{
Expand All @@ -374,11 +388,16 @@ const Sidebar = () => {
color: "var(--text-2)",
}}
>
<PulseDot size={7} />
{engineError ? (
<span aria-hidden="true">!</span>
) : (
<PulseDot size={7} />
)}
{!collapsed && (
<span style={{ flex: 1 }}>
Engine loading…
{status ? ` ${Math.ceil(100 * status.progress)}%` : ""}
{engineError
? `Engine unavailable: ${engineError}`
: `Engine loading…${status ? ` ${Math.ceil(100 * status.progress)}%` : ""}`}
</span>
)}
</div>
Expand Down
5 changes: 5 additions & 0 deletions src/store/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface SimulationFile {
export interface AppModel {
selectedMenu: string;
status?: Status;
engineError?: string;
setEngineError: Action<AppModel, string | undefined>;
preferredView?: string;
selectedFile?: SimulationFile;
setSelectedMenu: Action<AppModel, string>;
Expand All @@ -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",
Expand Down
Loading