Botanica/scripts/verify-game.cjs

452 lines
16 KiB
JavaScript

const fs = require("node:fs");
const assert = require("node:assert/strict");
const { randomBytes } = require("node:crypto");
const { execFileSync } = require("node:child_process");
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || "playwright");
const base = process.env.GAME_URL || "http://localhost:8155",
directory = "artifacts/m11";
const report = {
checks: [],
errors: [],
widths: [],
fixtures: [],
playerId: null,
childId: null,
};
function check(name) {
report.checks.push(name);
console.log("PASS " + name);
}
function sql(query) {
return execFileSync(
"docker",
[
"compose",
"exec",
"-T",
"db",
"sh",
"-c",
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -t -A -v ON_ERROR_STOP=1',
],
{ input: query, encoding: "utf8" },
).trim();
}
(async () => {
fs.mkdirSync(directory, { recursive: true });
const browser = await chromium.launch({
headless: true,
...(process.env.BROWSER_EXECUTABLE
? { executablePath: process.env.BROWSER_EXECUTABLE }
: {}),
});
const context = await browser.newContext({
viewport: { width: 1440, height: 1100 },
reducedMotion: "reduce",
acceptDownloads: true,
}),
page = await context.newPage();
await context.addInitScript(() =>
Object.defineProperty(window.crypto, "randomUUID", {
value: undefined,
configurable: true,
}),
);
page.on("pageerror", (e) => report.errors.push(e.message));
page.setDefaultTimeout(15000);
const username = "qa_" + randomBytes(5).toString("hex"),
password = randomBytes(20).toString("hex");
async function go(path) {
await page.goto(base + path);
await page.locator(".game-hud").waitFor();
}
async function actionClick(button) {
const response = page.waitForResponse(
(r) =>
r.url().endsWith("/api/v1/game/action") &&
r.request().method() === "POST",
);
await button.click();
const r = await response;
assert(r.ok(), await r.text());
return r.json();
}
async function quest(name) {
await go("/quests");
await actionClick(
page
.locator(".game-card")
.filter({ has: page.getByRole("heading", { name, exact: true }) })
.getByRole("button", { name: "Claim reward" }),
);
}
async function read(path) {
const r = await context.request.get(base + "/api/v1" + path);
assert(r.ok(), await r.text());
return r.json();
}
try {
await page.goto(base);
await page
.getByRole("button", { name: "Create account", exact: true })
.click();
await page.getByLabel("Username", { exact: true }).fill(username);
await page.getByLabel("Password", { exact: true }).fill(password);
await page
.getByRole("button", { name: "Start growing", exact: true })
.click();
await page
.getByRole("heading", { name: "Keep your recovery codes" })
.waitFor();
assert.equal(await page.locator(".recovery-codes code").count(), 5);
await page.getByRole("button", { name: /I've saved my codes/ }).click();
await page.locator(".growing-slot").first().waitFor();
assert.equal(await page.locator(".growing-slot").count(), 4);
report.playerId = (await read("/me")).id;
check("Register, one-time recovery UI and isolated four-slot greenhouse");
await page.screenshot({
path: directory + "/greenhouse-desktop.png",
fullPage: true,
});
for (let i = 0; i < 3; i++)
await actionClick(
page
.locator(".growing-slot")
.nth(i)
.getByRole("button", { name: "Plant starter seed", exact: true }),
);
const original = await page
.locator(".growing-slot")
.first()
.locator(".slot-info p")
.innerText();
await page.waitForFunction(
(text) =>
document.querySelector(".growing-slot .slot-info p")?.textContent !==
text,
original,
{ timeout: 5000 },
);
check("Live server-synchronized countdown advances between state polls");
check(
"Cryptographic action IDs work without secure-context randomUUID (HTTP LAN compatibility)",
);
await page
.locator(".growing-slot")
.first()
.getByRole("button", { name: "Harvest plant" })
.waitFor({ timeout: 30000 });
const harvested = await actionClick(
page
.locator(".growing-slot")
.first()
.getByRole("button", { name: "Harvest plant" }),
);
await page.getByRole("heading", { name: "A first of its kind." }).waitFor();
await page.screenshot({
path: directory + "/first-discovery.png",
fullPage: true,
});
await quest("First green");
await go("/plants/" + harvested.plantId);
await page.getByLabel("Favorite", { exact: true }).check();
await page
.getByRole("button", { name: "Protected from sale", exact: true })
.waitFor();
assert(
await page
.getByRole("button", { name: "Protected from sale", exact: true })
.isDisabled(),
);
await page.getByLabel("Favorite", { exact: true }).uncheck();
await page.getByRole("button", { name: /Sell specimen/ }).click();
await actionClick(
page.getByRole("button", { name: "Confirm sale", exact: true }),
);
await page
.getByRole("button", { name: "Sold · lineage preserved" })
.waitFor();
await quest("A little seed money");
check(
"Harvest, discovery, favorite safeguard, confirmed sale and one-time quest claims",
);
await go("/shop");
await actionClick(
page
.locator(".game-card")
.filter({
has: page.getByRole("heading", {
name: "Extra growing slot",
exact: true,
}),
})
.getByRole("button", { name: "Purchase", exact: true }),
);
await quest("Room to grow");
await go("/shop");
await actionClick(
page
.locator(".game-card")
.filter({
has: page.getByRole("heading", { name: "Starter seed", exact: true }),
})
.getByRole("button", { name: "Purchase", exact: true }),
);
check("Exact-price seed and growing-slot purchases");
await go("/");
assert.equal(await page.locator(".growing-slot").count(), 5);
console.log("Waiting for two real two-minute crops…");
for (const i of [1, 2]) {
const button = page
.locator(".growing-slot")
.nth(i)
.getByRole("button", { name: "Harvest plant" });
await button.waitFor({ timeout: 130000 });
await actionClick(button);
}
const plants = (await read("/plants?limit=100")).items.filter(
(p) => p.gameEligible,
);
assert.equal(plants.length, 2);
await go("/breed");
await page
.getByLabel("Parent A", { exact: true })
.selectOption(plants[0].id);
await page
.getByLabel("Parent B", { exact: true })
.selectOption(plants[1].id);
const breeding = page.waitForResponse(
(r) =>
r.url().endsWith("/api/v1/breeding") && r.request().method() === "POST",
);
await page.getByRole("button", { name: "Breed selected parents" }).click();
const bred = await breeding;
assert(bred.ok(), await bred.text());
const child = (await bred.json()).plant;
report.childId = child.id;
assert.deepEqual(child.parents, [plants[0].id, plants[1].id]);
await page.locator(".offspring-card").waitFor();
await page.screenshot({
path: directory + "/breeding-desktop.png",
fullPage: true,
});
await go("/plants/" + child.id);
await page.getByLabel("Favorite", { exact: true }).check();
await page.getByRole("button", { name: "Protected from sale" }).waitFor();
await page.reload();
await page.getByLabel("Favorite", { exact: true }).waitFor();
assert(await page.getByLabel("Favorite", { exact: true }).isChecked());
check("Paid inherited breeding, persisted lineage and favorite child");
await go("/lab?plant=" + child.id);
await page.locator(".render-workbench").waitFor();
assert((await page.locator(".plant-preview svg").count()) > 0);
await go("/studies");
assert((await page.locator(".plant-preview svg").count()) > 0);
await go("/examples");
await page.locator(".game-card").first().waitFor();
assert.equal(await page.locator(".game-card").count(), 100);
await page
.getByRole("link", { name: "Inspect reference →", exact: true })
.first()
.click();
await page
.getByRole("heading", { name: "Genotype & phenotype", exact: true })
.waitFor();
assert.equal(await page.locator(".plant-economy-actions").count(), 0);
await go("/examples");
await page
.getByRole("button", { name: "More references →", exact: true })
.click();
await page.waitForFunction(
() => document.querySelectorAll(".game-card").length === 3,
);
check(
"Advanced genome lab, deterministic renderer atlas and preserved reference herbarium",
);
// Explicit, UUID-scoped fixtures apply only to this newly registered QA account.
// The real onboarding above uses no time/balance shortcuts. Integration tests cover worker resources with a controlled clock.
if (process.env.GAME_QA_FIXTURES === "1") {
assert.match(report.playerId, /^[a-f0-9-]{36}$/);
sql(
`UPDATE players SET xp=3000 WHERE id='${report.playerId}'; WITH funded AS (UPDATE players SET coin_balance=coin_balance+20000 WHERE id='${report.playerId}' RETURNING id,coin_balance) INSERT INTO coin_ledger(id,player_id,amount,balance_after,reason,reference) SELECT gen_random_uuid(),id,20000,coin_balance,'qa_fixture','m11-browser' FROM funded;`,
);
report.fixtures.push(
"Only new QA account: level 10 and ledger-recorded 20,000-coin grant for late-game UI",
);
await go("/shop");
for (const name of ["Grow lamp", "Irrigation", "Second greenhouse"])
await actionClick(
page
.locator(".game-card")
.filter({ has: page.getByRole("heading", { name, exact: true }) })
.getByRole("button", { name: "Purchase", exact: true }),
);
await go("/research");
for (const name of [
"Living compost",
"Botanical notes",
"Selective breeding",
])
await actionClick(
page
.locator(".game-card")
.filter({ has: page.getByRole("heading", { name, exact: true }) })
.getByRole("button", { name: "Research", exact: true }),
);
await go("/workers");
for (const kind of ["gardener", "breeder"])
await actionClick(
page.getByRole("button", { name: "Hire " + kind, exact: true }),
);
const gardener = page.locator(".worker-card").filter({
has: page.getByRole("heading", {
name: "Junior gardener",
exact: true,
}),
}),
breeder = page.locator(".worker-card").filter({
has: page.getByRole("heading", {
name: "Breeding specialist",
exact: true,
}),
});
await gardener.getByLabel("Enable automation", { exact: true }).check();
await gardener
.getByLabel("Sell duplicate Common harvests automatically", {
exact: true,
})
.check();
await actionClick(
gardener.getByRole("button", { name: "Save assignment", exact: true }),
);
await breeder
.getByLabel("Parent A", { exact: true })
.selectOption(plants[0].id);
await breeder
.getByLabel("Parent B", { exact: true })
.selectOption(plants[1].id);
await breeder
.getByLabel("Policy", { exact: true })
.selectOption("target_trait");
await breeder
.getByLabel("Desired pigment", { exact: true })
.selectOption(child.phenotype.pigment);
await breeder.getByLabel("Enable automation", { exact: true }).check();
await actionClick(
breeder.getByRole("button", { name: "Save assignment", exact: true }),
);
await page.screenshot({
path: directory + "/workers-desktop.png",
fullPage: true,
});
sql(
`UPDATE workers SET last_processed_at=last_processed_at-interval '1 hour' WHERE player_id='${report.playerId}';`,
);
report.fixtures.push(
"Only QA workers: persisted cursors shifted back one hour",
);
const s = await read("/game/state");
assert(s.report && s.report.harvested > 0 && s.report.breeds > 0);
const after = await read("/game/state");
assert.equal(after.report, null);
await go("/workers");
check(
"Late-game fixtures: upgrades, second house, research, worker policies and replay-safe hour offline",
);
// Leave QA workers paused so automated acceptance does not keep producing records.
sql(
`UPDATE workers SET enabled=false WHERE player_id='${report.playerId}';`,
);
}
for (const width of [1440, 360]) {
await page.setViewportSize({ width, height: 1000 });
for (const route of [
"/",
"/shop",
"/gallery",
"/breed",
"/workers",
"/research",
"/quests",
"/settings",
"/lab",
]) {
await go(route);
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - innerWidth,
);
report.widths.push({ route, width, overflow });
assert(overflow <= 1, `${route} overflow ${overflow}px at ${width}`);
if (route === "/")
await page.screenshot({
path: directory + "/greenhouse-" + width + ".png",
fullPage: true,
});
}
}
check("All gameplay routes usable at 360px and 1440px with reduced motion");
await go("/settings");
await page.getByRole("button", { name: "Sign out", exact: true }).click();
await page
.getByRole("button", { name: "Enter greenhouse", exact: true })
.waitFor();
await page.getByLabel("Username", { exact: true }).fill(username);
await page.getByLabel("Password", { exact: true }).fill(password);
await page
.getByRole("button", { name: "Enter greenhouse", exact: true })
.click();
await page.locator(".game-hud").waitFor();
assert.equal((await read("/me")).id, report.playerId);
check("Logout and password login restore only the same account");
const other = await browser.newContext(),
otherPage = await other.newPage();
await otherPage.goto(base);
const denied = await other.request.get(
base + "/api/v1/plants/" + report.childId,
);
assert.equal(denied.status(), 401);
await other.close();
check("Unauthenticated collection access denied");
if (process.env.GAME_PUBLIC_URL) {
const publicUrl = process.env.GAME_PUBLIC_URL,
remote = await browser.newContext(),
login = await remote.request.post(publicUrl + "/api/v1/auth/login", {
headers: { origin: publicUrl },
data: { username, password },
});
assert(login.ok(), "Public HTTPS login failed: " + login.status());
const me = await remote.request.get(publicUrl + "/api/v1/me");
assert(me.ok(), "Public session was not restored");
assert.equal((await me.json()).id, report.playerId);
const cookie = (await remote.cookies()).find(
(c) => c.name === "botanica_session",
);
assert(cookie?.secure && cookie?.httpOnly && cookie?.sameSite === "Lax");
await remote.close();
check(
"Public HTTPS through Cloudflare: authenticated cookie is Secure, HttpOnly and SameSite=Lax",
);
}
assert.deepEqual(report.errors, []);
check("No browser JavaScript errors");
} catch (error) {
const message = String(error.message).replaceAll(password, "[redacted]");
report.failure = { url: page.url(), message };
if (await page.locator(".game-hud").count())
await page.screenshot({
path: directory + "/failure.png",
fullPage: true,
});
throw new Error(message);
} finally {
fs.writeFileSync(
directory + "/browser-report.json",
JSON.stringify(report, null, 2),
);
await browser.close();
}
console.log(JSON.stringify(report, null, 2));
})().catch((e) => {
console.error(e.message);
process.exitCode = 1;
});