import assert from "node:assert/strict"; import { randomUUID, randomBytes } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import { chromium } from "@playwright/test"; import { createClient } from "@supabase/supabase-js"; process.loadEnvFile(".env.local"); const origin = process.argv[2] || "https://minegeo.vercel.app", reportId = process.argv[3]; assert.match( reportId ?? "", /^[0-9a-f-]{36}$/, "Pass a disposable Linear test issue UUID for webhook verification", ); const db = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.SUPABASE_SECRET_KEY, { auth: { persistSession: false, autoRefreshToken: false } }, ); const browser = await chromium.launch({ headless: true }), accounts = [], contexts = []; let roomId, createdReport = false; const checked = (result) => { if (result.error) throw new Error(result.error.message); return result.data; }; const phase = async (value) => { await writeFile( ".data/support-deploy/appearance-fixture.json", JSON.stringify({ reportId, phase: value }), ); console.log("Appearance verification: " + value); }; async function until(check, label, timeout = 120000) { const end = Date.now() + timeout; while (Date.now() < end) { if (await check()) return; await new Promise((resolve) => setTimeout(resolve, 1500)); } throw new Error("Timed out: " + label); } async function api( context, path, body, method = body === undefined ? "GET" : "POST", status = 200, ) { const response = await context.request.fetch(origin + "/api/v1/" + path, { method, headers: { origin }, ...(body === undefined ? {} : { data: body }), }); assert.equal( response.status(), status, path + ": " + (await response.text()), ); return response.json(); } async function account(role) { const email = "appearance-" + randomUUID() + "@example.com", password = randomBytes(24).toString("base64url"); const result = checked( await db.auth.admin.createUser({ email, password, email_confirm: true, user_metadata: { display_name: role === "admin" ? "Prefix admin test" : "Badge player test", }, }), ); const id = result.user.id; accounts.push(id); checked( await db.from("profiles").insert({ id, role, display_name: role === "admin" ? "Prefix admin test" : "Badge player test", }), ); const context = await browser.newContext({ locale: "en-US" }); contexts.push(context); await api(context, "auth", { action: "login", email, password, next: "/settings", }); return { id, context, page: await context.newPage() }; } try { await mkdir(".data/support-deploy", { recursive: true }); await phase("starting"); const player = await account("contributor"), mod = await account("admin"); await api(player.context, "profile", { glowColor: "#54baff" }, "PATCH", 403); await api( player.context, "profile", { custom_prefix: "Spoof" }, "PATCH", 400, ); await api(player.context, "moderation/users", undefined, "GET", 403); await api( player.context, "moderation/users/" + player.id, { prefix: "Spoof", icon: "star" }, "PATCH", 403, ); await player.page.goto(origin + "/settings", { waitUntil: "networkidle" }); assert.equal(await player.page.locator("input[type=color]").count(), 0); await mod.page.goto(origin + "/settings", { waitUntil: "networkidle" }); assert.ok((await mod.page.locator(".username-admin svg").count()) > 0); await mod.page.goto(origin + "/moderation", { waitUntil: "networkidle" }); await mod.page .getByLabel("Player name or account ID", { exact: true }) .fill(player.id); await mod.page .getByRole("button", { name: "Find players", exact: true }) .click(); await mod.page .locator(".prefix-player-list button") .filter({ hasText: player.id }) .click(); await mod.page .getByLabel("Custom prefix", { exact: true }) .fill("Cartographer"); await mod.page.getByLabel("Prefix icon", { exact: true }).selectOption("gem"); await mod.page .getByRole("button", { name: "Save prefix", exact: true }) .click(); await until( async () => { const saved = await mod.page .getByText("Player prefix saved.", { exact: true }) .count(); const alerts = await mod.page.locator("[role=alert]").allTextContents(); const visibleErrors = alerts.filter((message) => message.trim()); if (visibleErrors.length) throw new Error("Prefix save: " + visibleErrors.join(" | ")); return saved > 0; }, "prefix save", 30000, ); await player.page.reload({ waitUntil: "networkidle" }); assert.ok( (await player.page .locator(".username-custom-prefix") .filter({ hasText: "Cartographer" }) .count()) > 0, ); const current = checked( await db.from("profiles").select("*").eq("id", player.id).single(), ); assert.equal(current.role, "contributor"); assert.equal(current.name_glow, false); assert.equal(current.bug_finder, false); checked( await db.from("bug_reports").insert({ id: reportId, client_id: randomUUID(), owner_id: player.id, access_hash: randomBytes(32).toString("hex"), display_name: "Badge player test", title: "Appearance verification", description: "Temporary test report for real webhook badge and glow verification.", state: "open", issue_synced: true, linear_identifier: "Verification", }), ); createdReport = true; await phase("ready-for-grant"); await until( async () => { const row = checked( await db .from("profiles") .select("name_glow,bug_finder") .eq("id", player.id) .single(), ); return row.name_glow && row.bug_finder; }, "real webhook bug finder grant", 240000, ); await player.page.reload({ waitUntil: "networkidle" }); await player.page.locator("input[type=color]").waitFor(); assert.ok( (await player.page.locator(".username-bug-finder svg").count()) > 0, ); await player.page.locator("input[type=color]").evaluate((input) => { Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", ).set.call(input, "#54baff"); input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); }); await player.page .getByRole("button", { name: "Save glow color", exact: true }) .click(); await player.page.getByText("Glow color saved.", { exact: true }).waitFor(); await player.page.reload({ waitUntil: "networkidle" }); assert.equal( await player.page.locator("input[type=color]").inputValue(), "#54baff", ); assert.equal( await player.page .locator(".account-page-heading .username-glow") .evaluate((element) => getComputedStyle(element).color), "rgb(84, 186, 255)", ); const map = checked( await db.from("datasets").select("id").eq("state", "published").limit(1), )[0]; assert.ok(map); roomId = ( await api(player.context, "rooms", { datasetId: map.id, difficulty: "easy", displayName: "Temporary host alias", playerLimit: 3, }) ).id; const guest = await browser.newContext({ locale: "en-US" }); contexts.push(guest); await api(guest, "rooms/" + roomId + "/join", { displayName: "Badge player test", }); await api(mod.context, "rooms/" + roomId + "/join", { displayName: "Temporary mod alias", }); const room = await api(player.context, "rooms/" + roomId); const host = room.players.find((p) => p.id === room.hostId); assert.equal(host.name, "Badge player test"); assert.equal(host.appearance.glow_color, "#54baff"); assert.equal(host.appearance.bug_finder, true); assert.equal(host.appearance.custom_prefix, "Cartographer"); const guestView = await api(guest, "rooms/" + roomId), guestPlayer = guestView.players.find((p) => p.id === guestView.you); assert.equal( guestPlayer.appearance, undefined, "copying a name cannot copy a badge", ); await api(player.context, "rooms/" + roomId + "/chat", { clientId: randomUUID(), text: "Verified colored badge in chat.", }); const chat = await api(guest, "rooms/" + roomId + "/chat"); assert.equal(chat.messages[0].appearance.bug_finder, true); assert.equal(chat.messages[0].appearance.custom_prefix, "Cartographer"); await player.page.goto(origin + "/multiplayer/" + roomId, { waitUntil: "networkidle", }); await player.page.getByRole("button", { name: /^Chat/ }).click(); await player.page .getByText("Verified colored badge in chat.", { exact: true }) .waitFor(); assert.ok( (await player.page .locator(".room-chat-log .username-bug-finder svg") .count()) > 0, ); await player.page.setViewportSize({ width: 390, height: 844 }); assert.ok( await player.page.evaluate( () => document.documentElement.scrollWidth <= innerWidth + 1, ), "mobile roster overflow", ); await player.page.screenshot({ path: ".data/support-deploy/appearance-multiplayer-mobile.png", fullPage: true, }); await player.page.goto(origin + "/settings", { waitUntil: "networkidle" }); assert.ok( await player.page.evaluate( () => document.documentElement.scrollWidth <= innerWidth + 1, ), "mobile settings overflow", ); await player.page.screenshot({ path: ".data/support-deploy/appearance-settings-mobile.png", fullPage: true, }); await mod.page .getByRole("button", { name: "Remove prefix", exact: true }) .click(); await until( async () => checked( await db .from("profiles") .select("custom_prefix") .eq("id", player.id) .single(), ).custom_prefix === null, "prefix removal", ); await player.page.reload({ waitUntil: "networkidle" }); assert.equal(await player.page.locator(".username-custom-prefix").count(), 0); assert.ok((await player.page.locator(".username-bug-finder").count()) > 0); await phase("ready-for-revoke"); await until( async () => !checked( await db .from("profiles") .select("name_glow") .eq("id", player.id) .single(), ).name_glow, "real webhook revoke", 240000, ); await player.page.reload({ waitUntil: "networkidle" }); assert.equal(await player.page.locator("input[type=color]").count(), 0); assert.equal(await player.page.locator(".username-glow").count(), 0); assert.ok((await player.page.locator(".username-bug-finder").count()) > 0); assert.equal( checked( await db .from("profiles") .select("glow_color") .eq("id", player.id) .single(), ).glow_color, "#54baff", ); await phase("passed"); console.log( "Profile appearance production passed: admin-only prefixes, removal, automatic badges, real webhook grant/revoke, persistent color, authenticated multiplayer/chat, guest spoof prevention and mobile layouts.", ); } catch (error) { for (const context of contexts) for (const page of context.pages()) if (page.url().includes("/moderation")) { await page .screenshot({ path: ".data/support-deploy/appearance-failure.png", fullPage: true, }) .catch(() => {}); console.error( "Moderator alerts: " + JSON.stringify( await page.locator("[role=alert]").allTextContents(), ), ); } console.error(error.message); process.exitCode = 1; await phase("failed"); } finally { if (roomId) checked(await db.from("multiplayer_rooms").delete().eq("id", roomId)); if (createdReport) checked(await db.from("bug_reports").delete().eq("id", reportId)); for (const context of contexts) await context.request .post(origin + "/api/v1/auth/logout", { headers: { origin }, data: {} }) .catch(() => {}); for (const id of accounts) { checked(await db.from("profiles").delete().eq("id", id)); checked(await db.auth.admin.deleteUser(id)); } await browser.close(); }