Something went wrong. Try again.
A device emulator for screen-and-buttons firmware: your own C compiled to WebAssembly, driven by a browser page the firmware itself describes. Freeze, annotate and replay; diff the emulator against real hardware.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199// Headless verification: does the page actually work. Two things, in// order://// 1. The missing-module path: if wasm/dist/emu.wasm doesn't exist yet,// confirm the page says so clearly (per main.ts's showWasmError) and// is NOT a blank screen. This is the state a fresh clone is in before// anyone has built anything.// 2. The real path: if the module exists (e.g. after `bun run// example:build`), confirm the device actually comes up, AND drive// real synthetic input at it, then read the canvas pixels back and// confirm they changed. This is the concrete proof this repo's own// README promises: not just "the page didn't crash", but "a firmware,// compiled fresh, drew something in response to real input, through// the real ABI, in a real browser".//// Input means a touch stroke first, and then, only if the panel did// not move, each button the firmware DECLARED, pressed in turn. Touch// alone is not a safe proxy for "is this thing alive": a firmware is// perfectly entitled to ignore a finger in whatever it happens to be// showing (a stopwatch face has nothing to draw for one), and this// script is not allowed to know which app that is or which button// would wake it. Trying every declared input is the device-agnostic// way to ask the same question, and it is a stronger check than the// touch stroke was on its own, not a weaker one: the failure below// now means nothing this device declares can make it draw.//// Uses puppeteer-core against a local Chrome install (no bundled Chromium// download), the same pattern documented in AGENTS.md.import puppeteer from "puppeteer-core";import { join } from "node:path";import { existsSync } from "node:fs";import { closeBrowser } from "./browserClose";
const ROOT = join(import.meta.dir, "..");const PORT = 53409;const WASM_FILE = join(ROOT, "wasm", "dist", "emu.wasm");
function findChrome(): string { const candidates = [ "C:/Program Files/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", "/usr/bin/google-chrome", "/usr/bin/chromium-browser", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", ]; for (const c of candidates) if (existsSync(c)) return c; throw new Error( `no local Chrome found in the usual locations. Set CHROME_PATH to your Chrome/Chromium executable, ` + `or install Chrome. puppeteer-core deliberately does not bundle its own Chromium download.` );}const CHROME = process.env.CHROME_PATH || findChrome();
function fail(msg: string): never { console.error(`FAIL: ${msg}`); process.exit(1);}
async function waitForServer(url: string, timeoutMs: number): Promise<void> { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { const r = await fetch(url); if (r.ok) return; } catch {} await new Promise((r) => setTimeout(r, 150)); } throw new Error(`server did not come up within ${timeoutMs}ms`);}
const server = Bun.spawn(["bun", "run", "server.ts"], { cwd: ROOT, env: { ...process.env, PORT: String(PORT) }, stdout: "pipe", stderr: "pipe",});
let browser: Awaited<ReturnType<typeof puppeteer.launch>> | null = null;try { await waitForServer(`http://127.0.0.1:${PORT}/`, 15000); console.log("server up");
browser = await puppeteer.launch({ executablePath: CHROME, headless: true }); const page = await browser.newPage(); await page.setViewport({ width: 1400, height: 1000 }); page.on("pageerror", (e) => console.error("page error:", e));
// NOT networkidle0: the page opens a live-reload websocket on boot // (main.ts, connectLiveReload) that stays open and reconnects on close by // design, so the network never goes idle and that waitUntil would hang // forever. await page.goto(`http://127.0.0.1:${PORT}/`, { waitUntil: "domcontentloaded" }); // The boot sequence is async (fetch -> instantiate -> emu_init); give it // a moment to settle either way. await new Promise((r) => setTimeout(r, 1000));
const bodyHasContent = await page.evaluate(() => document.body.textContent!.trim().length > 0); if (!bodyHasContent) fail("page body is empty (blank screen) after boot attempt"); console.log("page rendered non-empty content");
const wasmBuilt = existsSync(WASM_FILE);
if (!wasmBuilt) { console.log(`${WASM_FILE} does not exist -- checking the graceful-failure path`); const errorShown = await page.evaluate(() => { const el = document.querySelector("#wasmError"); return el && !el.classList.contains("hidden") && (el.textContent || "").length > 0; }); if (!errorShown) fail("wasm module is missing but #wasmError is not showing a message: this would look like a silent blank/broken page"); const errorText = await page.$eval("#wasmError", (el) => el.textContent || ""); console.log(`#wasmError shows: ${errorText.slice(0, 200)}`); if (!/wasm/i.test(errorText)) fail("#wasmError message does not even mention wasm; not clear enough to act on"); console.log("PASS: missing-module failure is surfaced clearly on the page, not a blank screen"); console.log(`\nRun "bun run example:build" then re-run this script to check the full interactive path.`); } else { console.log(`${WASM_FILE} exists -- checking the device actually came up`); const deviceName = await page.$eval("#deviceName", (el) => el.textContent || ""); const errorHidden = await page.evaluate(() => document.querySelector("#wasmError")?.classList.contains("hidden") ?? true); if (!errorHidden) { const errorText = await page.$eval("#wasmError", (el) => el.textContent || ""); fail(`wasm module exists but the page still shows an error: ${errorText}`); } console.log(`device came up: "${deviceName}"`);
// The interactive proof: read the panel canvas before and after // driving a real synthetic mouse stroke across it, and confirm the // pixels actually changed. This is the difference between "the page // didn't crash" and "the firmware drew something in response to real // input" - the thing a newcomer actually needs to see to trust this // tool at all. const panelBox = await page.$eval("#panel", (el) => { const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; }); const before = await page.evaluate(() => { const c = document.querySelector("canvas#panel") as HTMLCanvasElement; return Array.from(c.getContext("2d")!.getImageData(0, 0, c.width, c.height).data); });
const cx = panelBox.x + panelBox.width * 0.3; const cy = panelBox.y + panelBox.height * 0.3; await page.mouse.move(cx, cy); await page.mouse.down(); for (let i = 0; i < 6; i++) { await page.mouse.move(cx + i * 8, cy + i * 4); await new Promise((r) => setTimeout(r, 30)); } await page.mouse.up(); await new Promise((r) => setTimeout(r, 300)); // let a few more ticks run
const readPanel = () => page.evaluate(() => { const c = document.querySelector("canvas#panel") as HTMLCanvasElement; return Array.from(c.getContext("2d")!.getImageData(0, 0, c.width, c.height).data); }); const countDiff = (a: number[], b: number[]) => { let n = 0; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) n++; return n; };
let changed = countDiff(before, await readPanel()); let via = "a real synthetic touch stroke";
if (changed === 0) { // The chrome's buttons are built one per emu_device() entry (main.ts), // so this presses exactly what this firmware said it has, in the // order it declared them, and stops at the first one that draws. const buttons = await page.$$("#bezel .dev-btn"); console.log(`touch alone moved nothing; trying the ${buttons.length} button(s) this device declared`); for (let i = 0; i < buttons.length; i++) { await buttons[i]!.click(); await new Promise((r) => setTimeout(r, 400)); changed = countDiff(before, await readPanel()); if (changed > 0) { via = `a real synthetic press of declared button ${i}`; break; } } }
console.log(`panel pixels changed after ${via}: ${changed} byte(s) different out of ${before.length}`); if (changed === 0) fail( "drove a real touch stroke across the panel AND pressed every button this firmware declared, and NOT ONE PIXEL changed; nothing this device declares as an input is reaching it" ); console.log("PASS: the panel rendered fresh pixels in response to real input, through the real ABI, in a real browser"); }} finally { if (browser) await closeBrowser(browser); // Plain server.kill() (SIGTERM) was observed to leave the Bun dev server // (development.hmr spawns a watcher) still listening on Windows even // after the parent process object reports killed. Force the whole tree. try { Bun.spawnSync(["taskkill", "/pid", String(server.pid), "/t", "/f"], { stdout: "ignore", stderr: "ignore" }); } catch {} server.kill();}