278 lines
10 KiB
JavaScript
278 lines
10 KiB
JavaScript
import assert from "node:assert/strict";
|
||
import { randomUUID, randomBytes } from "node:crypto";
|
||
import fs from "node:fs";
|
||
import { chromium, expect } from "@playwright/test";
|
||
import { createClient } from "@supabase/supabase-js";
|
||
if (fs.existsSync(".env.local")) process.loadEnvFile(".env.local");
|
||
const origin = new URL(process.argv[2] || "http://127.0.0.1:3007").origin;
|
||
const db = createClient(
|
||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||
process.env.SUPABASE_SECRET_KEY,
|
||
{ auth: { persistSession: false, autoRefreshToken: false } },
|
||
);
|
||
const rooms = [],
|
||
accounts = [];
|
||
const browser = await chromium.launch({
|
||
headless: true,
|
||
args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"],
|
||
});
|
||
try {
|
||
const { data: maps, error } = await db
|
||
.from("datasets")
|
||
.select("id")
|
||
.eq("state", "published")
|
||
.limit(1);
|
||
if (error) throw error;
|
||
assert.ok(maps.length);
|
||
const desktop = await browser.newContext({
|
||
viewport: { width: 1280, height: 900 },
|
||
locale: "en-US",
|
||
});
|
||
const mobile = await browser.newContext({
|
||
viewport: { width: 390, height: 844 },
|
||
locale: "en-US",
|
||
isMobile: true,
|
||
hasTouch: true,
|
||
});
|
||
const host = await desktop.newPage(),
|
||
friend = await mobile.newPage();
|
||
const email = `multiplayer-browser-${randomUUID()}@example.com`,
|
||
password = randomBytes(24).toString("base64url");
|
||
const fixture = await db.auth.admin.createUser({
|
||
email,
|
||
password,
|
||
email_confirm: true,
|
||
user_metadata: { display_name: "Browser Host" },
|
||
});
|
||
if (fixture.error) throw fixture.error;
|
||
accounts.push(fixture.data.user.id);
|
||
const loggedIn = await desktop.request.post(`${origin}/api/v1/auth`, {
|
||
headers: { origin },
|
||
data: { action: "login", email, password },
|
||
});
|
||
assert.equal(loggedIn.status(), 200);
|
||
const pageErrors = [];
|
||
for (const page of [host, friend])
|
||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||
await host.goto(`${origin}/servers/${maps[0].id}`, {
|
||
waitUntil: "networkidle",
|
||
timeout: 60000,
|
||
});
|
||
await host.getByRole("button", { name: "Multiplayer", exact: true }).click();
|
||
await expect(host.locator(".multiplayer-settings")).toContainText(
|
||
"Browser Host",
|
||
);
|
||
await expect(host.getByLabel("Your player name")).toHaveCount(0);
|
||
await host.getByLabel("Player limit (including you)").selectOption("2");
|
||
await host
|
||
.getByRole("button", { name: "Create multiplayer room", exact: true })
|
||
.click();
|
||
await host.waitForURL("**/multiplayer/*", { timeout: 30000 });
|
||
const id = host.url().split("/").pop();
|
||
rooms.push(id);
|
||
await expect(host.getByLabel("Invite link")).toHaveValue(
|
||
`${origin}/multiplayer/${id}`,
|
||
);
|
||
await friend.goto(host.url(), {
|
||
waitUntil: "domcontentloaded",
|
||
timeout: 60000,
|
||
});
|
||
await expect(friend.getByRole("button", { name: /^Chat/ })).toHaveCount(0);
|
||
await friend.getByLabel("Your player name").fill("Browser Friend");
|
||
await friend.getByRole("button", { name: "Join room", exact: true }).click();
|
||
await expect(
|
||
friend.getByText("You’re in! Waiting for the room owner to start.", {
|
||
exact: true,
|
||
}),
|
||
).toBeVisible();
|
||
await expect(
|
||
host.getByRole("button", { name: "Start multiplayer match", exact: true }),
|
||
).toBeEnabled({ timeout: 20000 });
|
||
await host.getByRole("button", { name: /^Chat/ }).click();
|
||
await host
|
||
.getByLabel("Message", { exact: true })
|
||
.fill("Hello from the lobby (: <script>window.chatInjected=true</script>");
|
||
await host.getByRole("button", { name: "Send message", exact: true }).click();
|
||
await expect(host.getByRole("log")).toContainText("Hello from the lobby");
|
||
await expect(friend.locator(".room-chat-unread")).toHaveText("1", {
|
||
timeout: 15000,
|
||
});
|
||
await friend.getByRole("button", { name: /^Chat/ }).click();
|
||
await expect(friend.getByRole("log")).toContainText("Hello from the lobby");
|
||
assert.equal(
|
||
await friend.evaluate(() => window.chatInjected),
|
||
undefined,
|
||
"chat renders HTML as plain text",
|
||
);
|
||
await friend
|
||
.getByLabel("Message", { exact: true })
|
||
.fill("Привет с телефона!");
|
||
await friend.getByLabel("Message", { exact: true }).press("Enter");
|
||
await expect(host.getByRole("log")).toContainText("Привет с телефона!", {
|
||
timeout: 15000,
|
||
});
|
||
fs.mkdirSync("artifacts/multiplayer", { recursive: true });
|
||
await host.screenshot({
|
||
path: "artifacts/multiplayer/desktop-lobby.png",
|
||
fullPage: true,
|
||
});
|
||
await friend.screenshot({
|
||
path: "artifacts/multiplayer/mobile-lobby.png",
|
||
fullPage: true,
|
||
});
|
||
assert.ok(
|
||
await friend.evaluate(
|
||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||
),
|
||
"mobile lobby fits viewport",
|
||
);
|
||
await host
|
||
.getByRole("button", { name: "Start multiplayer match", exact: true })
|
||
.click();
|
||
await expect(friend.getByRole("log")).toContainText("Hello from the lobby", {
|
||
timeout: 15000,
|
||
});
|
||
await friend.getByRole("button", { name: "Close chat", exact: true }).click();
|
||
await host.getByRole("button", { name: "Close chat", exact: true }).click();
|
||
for (let round = 0; round < 5; round++) {
|
||
await Promise.all([
|
||
expect(host.locator(".game-info strong").first()).toHaveText(
|
||
`${round + 1} / 5`,
|
||
{ timeout: 30000 },
|
||
),
|
||
expect(friend.locator(".game-info strong").first()).toHaveText(
|
||
`${round + 1} / 5`,
|
||
{ timeout: 30000 },
|
||
),
|
||
]);
|
||
await Promise.all([
|
||
expect(host.locator(".game-loading")).toHaveCount(0, { timeout: 45000 }),
|
||
expect(friend.locator(".game-loading")).toHaveCount(0, {
|
||
timeout: 45000,
|
||
}),
|
||
]);
|
||
if (round === 0) {
|
||
await host.reload({ waitUntil: "domcontentloaded" });
|
||
await expect(host.locator(".game-loading")).toHaveCount(0, {
|
||
timeout: 45000,
|
||
});
|
||
await host.getByRole("button", { name: /^Chat/ }).click();
|
||
await expect(host.getByRole("log")).toContainText("Привет с телефона!");
|
||
await host
|
||
.getByLabel("Message", { exact: true })
|
||
.fill("Good luck this round!");
|
||
await host
|
||
.getByRole("button", { name: "Send message", exact: true })
|
||
.click();
|
||
await expect(friend.locator(".room-chat-unread")).toHaveText("1", {
|
||
timeout: 15000,
|
||
});
|
||
await friend.getByRole("button", { name: /^Chat/ }).click();
|
||
await expect(friend.getByRole("log")).toContainText(
|
||
"Good luck this round!",
|
||
);
|
||
await friend.screenshot({
|
||
path: "artifacts/multiplayer/mobile-chat.png",
|
||
});
|
||
await host.screenshot({ path: "artifacts/multiplayer/desktop-chat.png" });
|
||
assert.ok(
|
||
await friend.evaluate(
|
||
() => document.documentElement.scrollWidth <= innerWidth,
|
||
),
|
||
"mobile chat fits the viewport",
|
||
);
|
||
await friend.getByLabel("Message", { exact: true }).press("Escape");
|
||
await expect(friend.getByRole("button", { name: /^Chat/ })).toBeFocused();
|
||
await host
|
||
.getByRole("button", { name: "Close chat", exact: true })
|
||
.click();
|
||
}
|
||
await friend
|
||
.getByRole("button", { name: "Open guess map", exact: true })
|
||
.click();
|
||
for (const [page, fraction] of [
|
||
[host, 0.48],
|
||
[friend, 0.7],
|
||
]) {
|
||
const map = page.getByRole("application", { name: /Minecraft map/ });
|
||
const box = await map.boundingBox();
|
||
assert.ok(box);
|
||
await map.click({
|
||
position: { x: box.width * fraction, y: box.height * 0.5 },
|
||
});
|
||
await expect(
|
||
page.getByRole("button", { name: "Submit guess", exact: true }),
|
||
).toBeEnabled();
|
||
if (round === 0 && page === host)
|
||
await page.screenshot({
|
||
path: "artifacts/multiplayer/desktop-round.png",
|
||
});
|
||
await page
|
||
.getByRole("button", { name: "Submit guess", exact: true })
|
||
.click();
|
||
}
|
||
if (round < 4) {
|
||
await expect(
|
||
host.getByRole("heading", { name: "Round standings", exact: true }),
|
||
).toBeVisible({ timeout: 20000 });
|
||
await expect(
|
||
friend.getByRole("heading", { name: "Round standings", exact: true }),
|
||
).toBeVisible({ timeout: 20000 });
|
||
await host.getByRole("button", { name: /^Next round/ }).click();
|
||
}
|
||
console.log(`Browser round ${round + 1}/5 completed.`);
|
||
}
|
||
await expect(
|
||
host.getByRole("heading", { name: "Final standings", exact: true }),
|
||
).toBeVisible({ timeout: 20000 });
|
||
await expect(
|
||
friend.getByRole("heading", { name: "Final standings", exact: true }),
|
||
).toBeVisible({ timeout: 20000 });
|
||
assert.equal(await host.locator(".multiplayer-final tbody tr").count(), 2);
|
||
await friend.getByRole("button", { name: /^Chat/ }).click();
|
||
await friend
|
||
.getByLabel("Message", { exact: true })
|
||
.fill("GG, thanks for playing!");
|
||
await friend
|
||
.getByRole("button", { name: "Send message", exact: true })
|
||
.click();
|
||
await host.getByRole("button", { name: /^Chat/ }).click();
|
||
await expect(host.getByRole("log")).toContainText("GG, thanks for playing!", {
|
||
timeout: 15000,
|
||
});
|
||
await host.screenshot({
|
||
path: "artifacts/multiplayer/desktop-results.png",
|
||
fullPage: true,
|
||
});
|
||
await friend.screenshot({
|
||
path: "artifacts/multiplayer/mobile-results.png",
|
||
fullPage: true,
|
||
});
|
||
assert.ok(
|
||
await friend.evaluate(
|
||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||
),
|
||
"mobile results fit viewport with table scrolling",
|
||
);
|
||
assert.deepEqual(pageErrors, []);
|
||
console.log(
|
||
"Desktop/mobile browser multiplayer passed: lobby and in-game chat, unread badges, plain-text rendering, keyboard controls, reconnect, five shared rounds, map guesses, and final standings chat. No page errors.",
|
||
);
|
||
} finally {
|
||
await browser.close();
|
||
if (rooms.length) {
|
||
const { error } = await db
|
||
.from("multiplayer_rooms")
|
||
.delete()
|
||
.in("id", rooms);
|
||
if (error)
|
||
throw new Error(`Browser test-room cleanup failed: ${error.message}`);
|
||
}
|
||
for (const id of accounts) {
|
||
const profile = await db.from("profiles").delete().eq("id", id);
|
||
if (profile.error) throw profile.error;
|
||
const account = await db.auth.admin.deleteUser(id);
|
||
if (account.error) throw account.error;
|
||
}
|
||
}
|