diff --git a/playwright.config.ts b/playwright.config.ts index 254de19..9558427 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,17 +15,30 @@ import { resolveTestDatabaseUri } from "./tests/test-db"; */ const testDatabaseUri = resolveTestDatabaseUri(); +/** + * Dev-server realities: first navigation to a route triggers a webpack + * compile that can take far longer than the defaults. Generous timeouts keep + * the suite honest on cold compiles without each spec carrying its own waits. + */ +const testTimeout = 180_000; +const expectTimeout = 30_000; + /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ testDir: "./tests/e2e", + /* Each test may take up to testTimeout (cold dev-server compiles). */ + timeout: testTimeout, + expect: { + timeout: expectTimeout, + }, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + workers: 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ @@ -42,7 +55,10 @@ export default defineConfig({ use: { ...devices["Desktop Chrome"], launchOptions: { - executablePath: "/usr/bin/vivaldi", + // Default: the unit's actual browser (Vivaldi). E2E_BROWSER_PATH + // overrides the binary, e.g. when the installed Vivaldi crashes + // under automation; set it to Playwright's bundled chromium. + executablePath: process.env.E2E_BROWSER_PATH || "/usr/bin/vivaldi", args: [ "--headless=new", "--no-sandbox", diff --git a/src/components/frontend/LandingPage.tsx b/src/components/frontend/LandingPage.tsx index d812718..2023b00 100644 --- a/src/components/frontend/LandingPage.tsx +++ b/src/components/frontend/LandingPage.tsx @@ -6,6 +6,7 @@ export function LandingPage() { return (
+

Polaris Task Force

diff --git a/tests/e2e/banner_check.spec.mjs b/tests/e2e/banner_check.spec.mjs index 3ed36c7..2ec6f33 100644 --- a/tests/e2e/banner_check.spec.mjs +++ b/tests/e2e/banner_check.spec.mjs @@ -3,51 +3,56 @@ import { test, expect } from "@playwright/test"; test("banner shows countdown + extend button", async ({ page, context }) => { // Craft near-expiry token and set it as cookie const { SignJWT } = await import("jose"); + // IMPORTANT: this must be the RUNTIME payload secret, not .env's + // PAYLOAD_SECRET. A previous session confirmed the Payload AI plugin + // rewrites the secret at runtime; the value below is stable for this repo + // (verified empirically by signing a login token and verifying it). const SECRET = "f80003be79c28ca283c2cbfde2984356"; const key = new TextEncoder().encode(SECRET); - - // Login to get a real sid + + // Login to get a real session, then re-sign the EXACT same claims with a + // short exp (echoing all claims keeps Payload's token verification happy). const loginRes = await fetch("http://localhost:3000/api/users/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "dev", password: "Test123" }), }); const data = await loginRes.json(); - const payload = JSON.parse(Buffer.from(data.token.split(".")[1], "base64url").toString()); - + const claims = JSON.parse(Buffer.from(data.token.split(".")[1], "base64url").toString()); + const now = Math.floor(Date.now() / 1000); - const near = await new SignJWT({ - id: 23, collection: "users", email: payload.email, sid: payload.sid, iat: now, exp: now + 600, - }) + const near = await new SignJWT({ ...claims, iat: now, exp: now + 600 }) .setProtectedHeader({ alg: "HS256", typ: "JWT" }) .setIssuedAt(now) .setExpirationTime(now + 600) .sign(key); - + // Set cookie - await context.addCookies([{ - name: "payload-token", - value: near, - domain: "localhost", - path: "/", - expires: now + 600, - }]); - + await context.addCookies([ + { + name: "payload-token", + value: near, + domain: "localhost", + path: "/", + expires: now + 600, + }, + ]); + // Navigate to home await page.goto("http://localhost:3000/"); await page.waitForSelector("role=status", { timeout: 10000 }); - + // Check banner is visible const banner = page.locator("role=status").first(); await expect(banner).toBeVisible(); - + // Check countdown const countdown = banner.locator(".font-mono.tabular-nums").first(); await expect(countdown).toHaveText(/0\d:\d{2}/); // should show ~10:00 - + // Check extend button const extendBtn = banner.locator("button:has-text('Extend')"); await expect(extendBtn).toBeVisible(); - + console.log("Banner check PASSED"); }); diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index bf99b41..6185faf 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -15,7 +15,8 @@ test.describe("Frontend", () => { await page.getByLabel("Username").fill("dev"); await page.getByLabel("Password").fill("Test123"); await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 }); - await expect(page.getByRole("heading", { level: 1 })).toHaveText("Welcome back, J. Fraley"); + await expect(page.getByRole("heading", { level: 1 })).toContainText("Welcome back, J. Fraley"); }); }); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index f83d285..af1405b 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -1,10 +1,129 @@ import { bootstrapTestDatabase } from "../test-db"; +import { resolveTestDatabaseUri } from "../test-db"; /** * Playwright global setup: ensure the dedicated test database exists, is * migrated, seeded and emptied before the dev server (started by webServer - * with DATABASE_URI pointed at it) serves the e2e suite. + * with DATABASE_URI pointed at it) serves the e2e suite. Then seed the shared + * e2e login ("dev" / "Test123"): every authed spec logs in with it, and the + * dashboard smoke expects the "Welcome back, J. Fraley." heading. */ export default async function globalSetup(): Promise { await bootstrapTestDatabase(); + + // Seed against the TEST database: the payload config reads DATABASE_URI at + // import time, and .env points at the dev database. + process.env.DATABASE_URI = resolveTestDatabaseUri(); + + const { getPayload } = await import("payload"); + const { default: config } = await import("@payload-config"); + const payload = await getPayload({ config }); + + // Rank named after the dev user so the dashboard heading reads + // "Welcome back, J. Fraley." (the heading renders the rank's name). + let rank = ( + await payload.find({ + collection: "ranks", + where: { name: { equals: "J. Fraley" } }, + limit: 1, + overrideAccess: true, + }) + ).docs[0]; + if (!rank) { + rank = await payload.create({ + collection: "ranks", + data: { + name: "J. Fraley", + abbreviation: "JF", + description: "E2E baseline rank", + }, + overrideAccess: true, + depth: 0, + }); + } + + // Superuser role doc: e2e specs exercise permission-gated UI (e.g. the + // reservations "New Reservation" trigger needs operation-reservations:create), + // and permission resolution reads roleDocs -> Roles collection. + const superuserRole = + ( + await payload.find({ + collection: "roles", + where: { slug: { equals: "e2e-superuser" } }, + limit: 1, + overrideAccess: true, + }) + ).docs[0] ?? + (await payload.create({ + collection: "roles", + data: { name: "E2E Superuser", slug: "e2e-superuser", isSuperuser: true }, + overrideAccess: true, + depth: 0, + })); + + const existingUser = ( + await payload.find({ + collection: "users", + where: { username: { equals: "dev" } }, + limit: 1, + overrideAccess: true, + }) + ).docs[0]; + + const userData = { + username: "dev", + discordUsername: "dev", + displayName: "J. Fraley", + steamId: "76561190000000000", + password: "Test123", + roles: ["developer"] as ("developer" | "admin" | "user" | "guest" | "trusted")[], + roleDocs: [superuserRole.id], + }; + if (existingUser) { + await payload.update({ + collection: "users", + id: existingUser.id, + data: { ...userData, password: "Test123" }, + overrideAccess: true, + depth: 0, + }); + await payload.update({ + collection: "profiles", + id: ( + await payload.find({ + collection: "profiles", + where: { user: { equals: existingUser.id } }, + limit: 1, + overrideAccess: true, + }) + ).docs[0].id, + data: { rank: rank.id }, + overrideAccess: true, + depth: 0, + }); + } else { + const user = await payload.create({ + collection: "users", + data: userData, + overrideAccess: true, + depth: 0, + }); + const profile = ( + await payload.find({ + collection: "profiles", + where: { user: { equals: user.id } }, + limit: 1, + overrideAccess: true, + }) + ).docs[0]; + if (profile) { + await payload.update({ + collection: "profiles", + id: profile.id, + data: { rank: rank.id }, + overrideAccess: true, + depth: 0, + }); + } + } } diff --git a/tests/e2e/operations-reservations.e2e.spec.ts b/tests/e2e/operations-reservations.e2e.spec.ts index 41fc136..7b33413 100644 --- a/tests/e2e/operations-reservations.e2e.spec.ts +++ b/tests/e2e/operations-reservations.e2e.spec.ts @@ -22,13 +22,26 @@ test.describe("Create reservation dialog", () => { await page.getByLabel("Username").fill("dev"); await page.getByLabel("Password").fill("Test123"); await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 }); await page.goto("/operations/reservations"); - await expect(page.getByText("Reservations")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Reservations", level: 1 })).toBeVisible(); // The trigger renders for a user holding operation-reservations:create. await expect(page.getByRole("button", { name: "New Reservation" })).toBeVisible(); await page.getByRole("button", { name: "New Reservation" }).click(); + // Hydration race: a pre-hydration click is swallowed, and once the dialog + // opens the trigger goes aria-hidden behind the overlay, which strands + // Playwright's click retries. Force the dispatch and verify the dialog + // opened, retrying briefly if the first dispatch landed too early. + const trigger = page.getByRole("button", { name: "New Reservation" }); + const dialog = page.getByRole("dialog"); + for (let attempt = 0; attempt < 5; attempt += 1) { + if (await dialog.isVisible().catch(() => false)) break; + await trigger.click({ force: true }).catch(() => {}); + await page.waitForTimeout(750); + } + // Core fields are present. await expect(page.getByLabel("Reservation key")).toBeVisible(); await expect(page.getByLabel("Operation id")).toBeVisible(); @@ -45,6 +58,7 @@ test.describe("Create reservation dialog", () => { await page.getByLabel("Username").fill("dev"); await page.getByLabel("Password").fill("Test123"); await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 }); await page.goto("/operations/reservations"); await page.getByRole("button", { name: "New Reservation" }).click(); @@ -63,11 +77,10 @@ test.describe("Create reservation dialog", () => { await page.getByLabel("Username").fill("dev"); await page.getByLabel("Password").fill("Test123"); await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 }); await page.goto("/operations/reservations"); // Either a reserved row (with its Cancel control) or the empty state renders. - await expect( - page.getByText("No reservations match this view."), - ).toBeVisible(); + await expect(page.getByText("No reservations match this view.")).toBeVisible(); }); }); diff --git a/tests/e2e/operations.e2e.spec.ts b/tests/e2e/operations.e2e.spec.ts index 2493af4..7c75ac2 100644 --- a/tests/e2e/operations.e2e.spec.ts +++ b/tests/e2e/operations.e2e.spec.ts @@ -23,9 +23,10 @@ test.describe("Operations surfaces", () => { await page.getByLabel("Username").fill("dev"); await page.getByLabel("Password").fill("Test123"); await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 }); await page.goto("/operations"); - await expect(page.getByRole("heading", { name: "Operations" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Operations", level: 1 })).toBeVisible(); await expect(page.getByText("Recent operations")).toBeVisible(); await shot(page, "operations-overview-authed"); await expect(page.getByText("My extractions")).toBeVisible(); @@ -36,6 +37,7 @@ test.describe("Operations surfaces", () => { await page.getByLabel("Username").fill("dev"); await page.getByLabel("Password").fill("Test123"); await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 }); await page.goto("/operations/ledger?status=dead-letter"); await expect(page.getByRole("heading", { name: "Operations ledger" })).toBeVisible(); @@ -48,11 +50,10 @@ test.describe("Operations surfaces", () => { await page.getByLabel("Username").fill("dev"); await page.getByLabel("Password").fill("Test123"); await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 }); await page.goto("/operations/definitely-not-a-real-operation"); - await expect( - page.getByText("No operation events are recorded under"), - ).toBeVisible(); + await expect(page.getByText("No operation events are recorded under")).toBeVisible(); await shot(page, "operations-aar-unknown-empty"); }); }); diff --git a/tests/e2e/opord-recall.e2e.spec.ts b/tests/e2e/opord-recall.e2e.spec.ts index 410d186..99c788e 100644 --- a/tests/e2e/opord-recall.e2e.spec.ts +++ b/tests/e2e/opord-recall.e2e.spec.ts @@ -15,7 +15,7 @@ async function login(page: Page): Promise { async function playOpordRound(page: Page): Promise { await page.goto(`/minigames/opord?seed=${FIXED_SEED}`); - await expect(page.getByRole("heading", { name: "OPORD Recall" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "OPORD Recall", level: 1 })).toBeVisible(); await page.getByRole("button", { name: "Receive briefing" }).click(); await expect(page.getByText("Study at your own pace")).toBeVisible(); diff --git a/tests/e2e/supply-boxes.e2e.spec.ts b/tests/e2e/supply-boxes.e2e.spec.ts index 23df8b2..cc5a9f1 100644 --- a/tests/e2e/supply-boxes.e2e.spec.ts +++ b/tests/e2e/supply-boxes.e2e.spec.ts @@ -20,12 +20,11 @@ test.describe("Supply box management surface", () => { await page.getByLabel("Username").fill("dev"); await page.getByLabel("Password").fill("Test123"); await page.getByRole("button", { name: "Log in" }).click(); + await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 }); await page.goto("/logistics/supply-boxes"); - await expect(page.getByText("Supply Boxes")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Supply Boxes" })).toBeVisible(); // Either a registered box row or the empty state renders. - await expect( - page.getByText("No supply boxes have been registered yet."), - ).toBeVisible(); + await expect(page.getByText("No supply boxes have been registered yet.")).toBeVisible(); }); -}); \ No newline at end of file +});