452 lines
16 KiB
JavaScript
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;
|
|
});
|