Files
Crystal/tests/browser.spec.ts
T

268 lines
10 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("#player-name").scrollIntoViewIfNeeded();
await expect(page.locator("#player-name")).toBeInViewport({ ratio: 0.9 });
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;
});
const wait = () => new Promise((resolve) => setTimeout(resolve, 180));
await emulator.load(new Uint8Array(bytes), canvas);
emulator.play();
await wait();
emulator.pause();
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.play();
await wait();
emulator.pause();
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([]);
});