Files
Crystal/tests/browser.spec.ts
T
samyandClaude Opus 5.5 cdee576818
Checks / web (push) Failing after 21s
Private ROM checks / private-rom (push) Failing after 14m27s
Port the Pokédex list, entries and tab (#35)
The start menu's POKéDEX runs Pokedex in New mode: the list up to the
last seen Pokémon, its cursor and page moves, and DisplayDexEntry with
the category, number, footprint, and the height, weight and pages once
caught. The Pokédex tab shows the same records from the loaded game.
Order, entries and footprints are compared with the pinned PRET source.

AREA, CRY, PRNT, the option and search screens, Old and A to Z modes
and Unown mode remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:19:24 +02:00

262 lines
9.9 KiB
TypeScript

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();
await page.locator("#tab-pokedex").click();
await expect(page.locator("#panel-pokedex")).toContainText("Choose a ROM");
await page.locator("#tab-link").click();
await expect(page.locator("#panel-link")).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<string>();
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([]);
});