This commit is contained in:
1 parent
2127f5f459
commit
8b80d05ca2
56 files changed
+6910
-1
No files matched your search
@@ -0,0 +1,236 @@
|
||||
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("heading", { name: "A little adventure. Anywhere." }),
|
||||
).toBeVisible();
|
||||
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],
|
||||
[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);
|
||||
}
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
Reference in new issue
Block a user