import { expect, test } from "@playwright/test"; import { syntheticRom } from "./synthetic-rom"; test("ROM gate, help and responsive layouts", async ({ page }) => { const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); await page.goto("/"); await expect( page.getByRole("tab", { name: "Game", exact: true }), ).toHaveAttribute("aria-selected", "true"); await expect(page.locator("#play")).toBeDisabled(); await page.locator("#rom-file").setInputFiles({ name: "wrong.gbc", mimeType: "application/octet-stream", buffer: Buffer.from("wrong"), }); await expect(page.locator("#status")).toContainText("2 MiB"); await page.locator("#help").click(); await expect(page.locator("#help-dialog")).toBeVisible(); await page.locator("#close-help").click(); for (const [width, height] of [ [1440, 1000], [1024, 900], [768, 1024], [390, 844], [320, 740], [320, 568], [844, 390], ]) { await page.setViewportSize({ width, height }); expect( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), ).toBe(true); const buttons = await page .locator("[data-game-button]") .evaluateAll((elements) => elements.map((el) => { const rect = el.getBoundingClientRect(); return { width: rect.width, height: rect.height }; }), ); for (const button of buttons) { expect(button.width).toBeGreaterThanOrEqual(44); expect(button.height).toBeGreaterThanOrEqual(44); } await page.locator("#rom-label").scrollIntoViewIfNeeded(); await expect(page.locator("#rom-label")).toBeInViewport({ ratio: 0.9 }); } expect(errors).toEqual([]); }); test("game menu tabs expose real tools and honest planned states with keyboard navigation", async ({ page, }) => { await page.goto("/"); const game = page.getByRole("tab", { name: "Game", exact: true }); await game.focus(); await page.keyboard.press("ArrowRight"); await expect(page.locator("#tab-saves")).toBeFocused(); await expect(page.locator("#panel-saves")).toBeVisible(); await expect(page.locator("#save")).toBeDisabled(); await expect(page.locator("#backup")).toBeDisabled(); await page.keyboard.press("ArrowRight"); await expect(page.locator("#panel-map")).toBeVisible(); await expect(page.locator("#map-empty")).toBeVisible(); await page.keyboard.press("End"); await expect(page.locator("#tab-friends")).toBeFocused(); await expect(page.locator("#panel-friends")).toContainText(/planned/i); await page.keyboard.press("Home"); await expect(game).toBeFocused(); await expect(page.locator("#panel-game")).toBeVisible(); for (const id of ["pokedex", "link"]) { await page.locator(`#tab-${id}`).click(); await expect(page.locator(`#panel-${id}`)).toContainText(/planned/i); } }); test("actual WASM executes an original cartridge, reads input and round-trips state", async ({ page, }) => { const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); await page.goto("/"); const result = await page.evaluate(async (bytes) => { const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts"); const canvas = document.createElement("canvas"); document.body.append(canvas); const emulator = new BinjgbEmulator((error) => { throw error; }); await emulator.load(new Uint8Array(bytes), canvas); // Advance emulated frames independently of the CI browser's animation rate. emulator.advanceFrames(12); const before = emulator.exportBattery(); const state = emulator.captureState(); const pixels = canvas.getContext("2d")!.getImageData(0, 0, 160, 144).data; const colors = new Set(); for (let i = 0; i < pixels.length; i += 4) colors.add(`${pixels[i]},${pixels[i + 1]},${pixels[i + 2]}`); emulator.setButton("a", true); emulator.advanceFrames(12); const pressed = emulator.exportBattery()[0]; emulator.restoreState(state); const restored = emulator.exportBattery()[0]; const badSizeRejected = (() => { try { emulator.restoreState(new Uint8Array([1])); return false; } catch { return true; } })(); emulator.dispose(); await emulator.load(new Uint8Array(bytes), canvas); emulator.restoreState(state); const reopened = emulator.exportBattery()[0]; emulator.dispose(); canvas.remove(); return { unpressed: before[0], pressed, restored, reopened, colors: colors.size, stateSize: state.length, ramSize: before.length, badSizeRejected, }; }, Array.from(syntheticRom())); expect(result.ramSize).toBe(32768); expect(result.stateSize).toBeGreaterThan(32768); expect(result.colors).toBeGreaterThan(1); expect(result.unpressed & 1).toBe(1); expect(result.pressed & 1).toBe(0); expect(result.restored).toBe(result.unpressed); expect(result.reopened).toBe(result.unpressed); expect(result.badSizeRejected).toBe(true); expect(errors).toEqual([]); }); test("full player lifecycle with test-only validator replacement", async ({ page, context, browserName, }) => { const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); // The shipped validator is never relaxed. Only this browser's module response // is replaced to exercise the real UI with our original synthetic cartridge. await page.route("**/src/rom/validate.ts", (route) => route.fulfill({ contentType: "application/javascript", body: 'export async function validateRom(file) { return { bytes: new Uint8Array(await file.arrayBuffer()), sha1: "synthetic-browser-test", name: "Original test cartridge" }; }', }), ); await page.goto("/reference.html"); const cartridge = { name: "synthetic.gbc", mimeType: "application/octet-stream", buffer: Buffer.from(syntheticRom()), }; await page.locator("#rom-file").setInputFiles(cartridge); await expect(page.locator("#status")).toContainText("verified"); await page.locator("#play").click(); await expect(page.locator("#power-label")).toHaveText("PLAYING"); const action = page.locator('[data-game-button="a"]'); await action.scrollIntoViewIfNeeded(); const bounds = (await action.boundingBox())!; await page.mouse.move( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ); await page.mouse.down(); await expect(action).toHaveAttribute("aria-pressed", "true"); await page.mouse.up(); await expect(action).toHaveAttribute("aria-pressed", "false"); await page.locator("#game-screen").focus(); await page.keyboard.down("KeyX"); await expect(page.locator('[data-game-button="a"]')).toHaveAttribute( "aria-pressed", "true", ); await page.keyboard.up("KeyX"); await action.focus(); await page.keyboard.down("Space"); await expect(action).toHaveAttribute("aria-pressed", "true"); await page.keyboard.up("Space"); await expect(action).toHaveAttribute("aria-pressed", "false"); // Loaded layouts must keep screen and gameplay controls visible together. for (const [width, height] of [ [390, 844], [320, 740], [844, 390], ]) { await page.setViewportSize({ width, height }); await page.evaluate(() => scrollTo(0, 0)); const geometry = await page.locator(".console").evaluate((el) => { const rect = el.getBoundingClientRect(); return { top: rect.top, bottom: rect.bottom, viewport: innerHeight, fits: document.documentElement.scrollWidth <= innerWidth, }; }); expect(geometry.fits).toBe(true); expect(geometry.top).toBeGreaterThanOrEqual(0); expect(geometry.bottom).toBeLessThanOrEqual(geometry.viewport); } await page.setViewportSize({ width: 390, height: 844 }); if (browserName === "chromium") { const cdp = await context.newCDPSession(page); const up = page.locator('[data-game-button="up"]'); const points = await Promise.all( [up, action].map(async (button, id) => { const rect = (await button.boundingBox())!; return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, id }; }), ); await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: points, }); await expect(up).toHaveAttribute("aria-pressed", "true"); await expect(action).toHaveAttribute("aria-pressed", "true"); await cdp.send("Input.dispatchTouchEvent", { type: "touchCancel", touchPoints: [], }); await expect(up).toHaveAttribute("aria-pressed", "false"); await expect(action).toHaveAttribute("aria-pressed", "false"); await cdp.detach(); } await page.locator("#play").click(); await expect(page.locator("#power-label")).toHaveText("PAUSED"); await expect(page.locator("#save-status")).toContainText("Session saved"); const downloadEvent = page.waitForEvent("download"); await page.locator("#backup").click(); const download = await downloadEvent; expect(download.suggestedFilename()).toContain(".crystal-save.json"); page.once("dialog", (dialog) => dialog.accept()); await page.locator("#save-file").setInputFiles((await download.path())!); await expect(page.locator("#status")).toContainText("Backup restored"); await page.reload(); await page.locator("#rom-file").setInputFiles(cartridge); await expect(page.locator("#status")).toContainText("saved session is ready"); const second = await context.newPage(); await second.route("**/src/rom/validate.ts", (route) => route.fulfill({ contentType: "application/javascript", body: 'export async function validateRom(file) { return { bytes: new Uint8Array(await file.arrayBuffer()), sha1: "synthetic-browser-test", name: "Test" }; }', }), ); await second.goto("/reference.html"); await second.locator("#rom-file").setInputFiles(cartridge); await expect(second.locator("#status")).toContainText("another tab"); await second.close(); expect(errors).toEqual([]); });