// Runs the simulation in a headless Chromium through Playwright: a small static // server serves the built app (dist/), the page is opened with the parameters // in the URL, and the audio is read back through the app's test hook. import { createServer, type Server } from "node:http"; import { existsSync, readFileSync, statSync } from "node:fs"; import { extname, join, normalize } from "node:path"; import { defaultParams, type PhysParams } from "../params.ts"; import type { RunResult } from "./run.ts"; const MIME: Record = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".wasm": "application/wasm", ".json": "application/json" }; function serveStatic(root: string): Promise<{ server: Server; port: number }> { const server = createServer((req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); let path = normalize(decodeURIComponent(url.pathname)); if (path.endsWith("/")) path += "index.html"; const file = join(root, path); if (!file.startsWith(root) || !existsSync(file) || !statSync(file).isFile()) { res.writeHead(404); res.end(); return; } res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" }); res.end(readFileSync(file)); }); return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, port: (server.address() as { port: number }).port }))); } type Browser = { newPage(): Promise; close(): Promise }; type Page = { goto(url: string): Promise; evaluate(fn: string | ((...a: never[]) => T)): Promise; waitForTimeout(ms: number): Promise; on(event: string, cb: (arg: { message: string }) => void): void; close(): Promise; }; let browser: Browser | null = null; let served: { server: Server; port: number } | null = null; async function getBrowser(chromePath: string | undefined): Promise { if (browser) return browser; // Playwright is imported lazily so that the Dawn engine does not need it. const mod = (await import("playwright")) as unknown as { chromium: { launch(o: object): Promise } }; browser = await mod.chromium.launch({ headless: true, executablePath: chromePath || undefined, args: ["--enable-unsafe-webgpu", "--enable-features=Vulkan", "--use-angle=vulkan", "--ignore-gpu-blocklist", "--no-sandbox"], }); return browser; } export interface ChromeOptions { /** Path of the Chrome or Chromium executable; Playwright's bundled Chromium when omitted. */ chromePath?: string; /** Directory holding the built app (index.html); default dist/. */ distDir?: string; } export async function runSimulationChrome(overrides: Partial, log: (s: string) => void, options: ChromeOptions = {}): Promise { const dist = options.distDir ?? join(process.cwd(), "dist"); if (!existsSync(join(dist, "index.html"))) throw new Error(`Built app not found at ${dist}; run "npm run build" first`); if (!served) served = await serveStatic(dist); const b = await getBrowser(options.chromePath); const page = await b.newPage(); page.on("pageerror", (e) => log(` page error: ${e.message}`)); const q = new URLSearchParams(); for (const [k, v] of Object.entries(overrides)) q.set(k, String(v)); q.set("autostart", "1"); await page.goto(`http://127.0.0.1:${served.port}/?${q.toString()}`); const t0 = performance.now(); let lastLog = t0; type Status = { phase: string; t: number; stepsPerSec: number; dt: number; nx: number; ny: number; totalSteps: number; stick: { maxForce: number } | null } | null; let st: Status = null; let maxContactForce = 0; for (;;) { await page.waitForTimeout(500); const noGpu = await page.evaluate("(() => { const e = document.getElementById('nogpu'); return e && !e.hidden ? e.textContent : null; })()"); if (noGpu) throw new Error(`Chrome has no WebGPU: ${noGpu}`); st = await page.evaluate("window.steelsim ? window.steelsim.status() : null"); if (st?.stick) maxContactForce = Math.max(maxContactForce, st.stick.maxForce); if (st && st.phase === "done") break; const now = performance.now(); if (now - lastLog > 5000 && st) { lastLog = now; log(` ${st.phase} t=${st.t.toFixed(2)} s, ${(st.stepsPerSec / 1e3).toFixed(0)} k steps/s`); } if (now - t0 > 3600e3) throw new Error("simulation timed out"); } const audio = new Float32Array(await page.evaluate("window.steelsim.audio()")); const seconds = (performance.now() - t0) / 1000; await page.close(); return { params: { ...defaultParams, ...overrides }, audio, fs: ({ ...defaultParams, ...overrides }).fs, steps: st!.totalSteps, seconds, grid: { nx: st!.nx, ny: st!.ny }, dt: st!.dt, maxContactForce, }; } export async function closeChrome(): Promise { await browser?.close(); browser = null; served?.server.close(); served = null; }