diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index bec8efc1..79ae5ca5 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -24,6 +24,9 @@ jobs: - name: Toolchain check run: node scripts/check-node-toolchain.mjs + - name: README version sync + run: node scripts/sync-readme-versions.mjs --check + - name: Deploy port-selection tests run: bash scripts/ci-deploy-ports.test.sh @@ -145,6 +148,46 @@ jobs: IMAGING_EDGE: "1" run: pnpm test:imaging + # Advisory live-quality checks: broken uploads/theme assets, legacy malformed + # avatar figures and warm-page latency. These depend on live content and data + # rather than the code under test, so they are informational only — + # `continue-on-error` keeps the pipeline green while still surfacing the + # signal in the job log and uploaded report. The deterministic caching contract + # is enforced by the blocking deploy gate instead. + tests-quality: + needs: check + if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + repository: ${{ gitea.repository }} + token: ${{ gitea.token }} + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install quality test browser + run: pnpm exec playwright install chromium + + - name: Advisory quality checks + continue-on-error: true + env: + PLAYWRIGHT_BASE_URL: https://epicnabbo.nl + run: pnpm test:e2e:quality + + - name: Upload quality results + if: always() + uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2 + with: + name: quality-results + path: | + playwright-report/quality/ + test-results/quality/ + if-no-files-found: warn + retention-days: 14 + preflight: needs: [tests-unit, tests-integration, tests-ui] if: gitea.event_name == 'pull_request' || (gitea.event_name == 'push' && startsWith(gitea.ref, 'refs/heads/codex/')) diff --git a/README.md b/README.md index b790303e..198459d3 100644 --- a/README.md +++ b/README.md @@ -1065,6 +1065,7 @@ fewer than 1 GB would remain, the script refuses to start. | `pnpm test:coverage` | 8 GB | Vitest with coverage | | `pnpm test:ui` | 8 GB | Playwright | | `pnpm test:e2e` | 8 GB | Playwright | +| `pnpm test:e2e:quality` | 8 GB | Playwright (advisory) | | `pnpm test:integration` | 8 GB | Vitest integration config | | `pnpm typecheck` | 6 GB | `tsc --noEmit` | @@ -1132,7 +1133,8 @@ the headroom left for everything else on the machine; the effective ceiling is | `pnpm test:coverage` | Run all tests with coverage thresholds enforced | | `pnpm test:ui` | Playwright UI tests (`playwright.ui.config.ts`) | | `pnpm test:ui:update` | Playwright UI tests, updating snapshots | -| `pnpm test:e2e` | Playwright end-to-end tests | +| `pnpm test:e2e` | Playwright end-to-end tests (blocking deploy gate) | +| `pnpm test:e2e:quality` | Advisory content/latency checks (non-blocking) | | `pnpm test:integration` | Integration tests (own Vitest config) | | `pnpm test:housekeeping` | Housekeeping feature tests | | `pnpm lint` | Lint and format check (Biome) | @@ -1295,6 +1297,25 @@ pnpm analyze # Build and report per-route bundle sizes pnpm biome:check # Lint and format ``` +### End-to-end tests + +`pnpm test:e2e` is the **blocking deploy gate**. It only contains checks the CMS +can guarantee on its own (anonymous redirects, the register form fields and the +captcha DOM contract, cache-control headers), so a green run means a code deploy +is safe. + +Checks that depend on **live content or data** — a missing upload or theme asset +(`/help` article images), a legacy malformed avatar figure, warm-page latency — +live in a separate advisory suite: + +```bash +pnpm test:e2e:quality # playwright.quality.config.ts, never blocks a deploy +``` + +It is run against the public site as a `continue-on-error` CI job, so it still +surfaces a real content problem without turning the pipeline red. Point either +suite at any running CMS with `PLAYWRIGHT_BASE_URL`. + ### Contributing 1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test` diff --git a/e2e/performance.spec.ts b/e2e/performance.spec.ts index 7ded0ed9..ae363b45 100644 --- a/e2e/performance.spec.ts +++ b/e2e/performance.spec.ts @@ -1,28 +1,11 @@ import { expect, test } from "@playwright/test"; -// Latency + caching contract for the anonymous pages. The perf work relies on -// the edge/shared cache (`src/proxy.ts`): public content pages advertise a -// short `s-maxage`, while session-dependent pages (login, register, settings) -// must stay `private, no-store`. Budgets are deliberately loose so a cold -// cache or a slow CI hop does not flap the gate. -const BUDGET_MS = Number(process.env.E2E_LOAD_BUDGET_MS || 4000); - -const WARM_ROUTES = ["/", "/news", "/staff", "/shop", "/login", "/register"]; - -for (const route of WARM_ROUTES) { - test(`${route} responds within the warm budget`, async ({ request }) => { - await request.get(route); // warm the render + data caches - const started = Date.now(); - const response = await request.get(route); - const elapsed = Date.now() - started; - expect([200, 304]).toContain(response.status()); - expect( - elapsed, - `GET ${route} took ${elapsed}ms (budget ${BUDGET_MS}ms)`, - ).toBeLessThan(BUDGET_MS); - }); -} - +// Caching contract for the anonymous pages. This is deterministic and stays in +// the blocking deploy gate: the perf work relies on the edge/shared cache +// (`src/proxy.ts`) — public content pages advertise a short `s-maxage`, while +// session-dependent pages (login, register, settings) must stay +// `private, no-store`. Timing budgets are advisory and live in +// `e2e/quality/performance.spec.ts`. test("anonymous content pages advertise a shared cache", async ({ request, }) => { diff --git a/e2e/quality/content-health.spec.ts b/e2e/quality/content-health.spec.ts index 534a8c92..119febd8 100644 --- a/e2e/quality/content-health.spec.ts +++ b/e2e/quality/content-health.spec.ts @@ -3,13 +3,16 @@ import { expectNoApplicationError, expectNoBrokenImages, expectNoHorizontalOverflow, + isMaintenanceRedirect, scrollThroughPage, -} from "./helpers"; +} from "../helpers"; -// Read-only smoke coverage for the anonymous-facing pages: each must return a -// real document, render without an error boundary, and load every image. This -// is where a missing upload, a bad avatar figure or a broken theme asset shows -// up — none of which the unit suite can catch. +// ADVISORY suite — run with `pnpm test:e2e:quality`, not part of the deploy +// gate. These checks depend on live content/upload state and user data (a +// missing upload, a legacy malformed avatar figure, a bad theme asset), so +// they are informational: they tell you the site has a real content problem, +// but they must never block a code deploy. Every assertion the CMS can +// guarantee on its own lives in the blocking specs instead. const PUBLIC_ROUTES = [ "/", "/news", @@ -22,9 +25,7 @@ const PUBLIC_ROUTES = [ ] as const; for (const route of PUBLIC_ROUTES) { - test(`${route} renders, loads its images and does not overflow`, async ({ - page, - }) => { + test(`${route} loads every image and does not overflow`, async ({ page }) => { const response = await page.goto(route); expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true); @@ -37,6 +38,17 @@ for (const route of PUBLIC_ROUTES) { }); } +test("the register page loads its theme/avatar images", async ({ page }) => { + const response = await page.goto("/register"); + test.skip( + isMaintenanceRedirect(page.url()) || response?.status() === 404, + "register is not reachable on this deploy", + ); + await scrollThroughPage(page); + await expectNoBrokenImages(page); + await expectNoHorizontalOverflow(page); +}); + test("the homepage stays usable on a narrow viewport", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); const response = await page.goto("/"); diff --git a/e2e/quality/performance.spec.ts b/e2e/quality/performance.spec.ts new file mode 100644 index 00000000..58ef29b4 --- /dev/null +++ b/e2e/quality/performance.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; + +// ADVISORY suite — run with `pnpm test:e2e:quality`. Warm-page latency depends +// on the host, the edge and the shared data caches, so a slow CI hop can trip +// these budgets without anything being wrong. The cache-control *contract* +// (deterministic) lives in the blocking `e2e/performance.spec.ts`; only the +// timing budgets are advisory. +const BUDGET_MS = Number(process.env.E2E_LOAD_BUDGET_MS || 4000); + +const WARM_ROUTES = ["/", "/news", "/staff", "/shop", "/login", "/register"]; + +for (const route of WARM_ROUTES) { + test(`${route} responds within the warm budget`, async ({ request }) => { + await request.get(route); // warm the render + data caches + const started = Date.now(); + const response = await request.get(route); + const elapsed = Date.now() - started; + expect([200, 304]).toContain(response.status()); + expect( + elapsed, + `GET ${route} took ${elapsed}ms (budget ${BUDGET_MS}ms)`, + ).toBeLessThan(BUDGET_MS); + }); +} diff --git a/e2e/register.spec.ts b/e2e/register.spec.ts index eb9acb1b..5f98a0cf 100644 --- a/e2e/register.spec.ts +++ b/e2e/register.spec.ts @@ -1,11 +1,5 @@ import { expect, test } from "@playwright/test"; -import { - expectNoApplicationError, - expectNoBrokenImages, - expectNoHorizontalOverflow, - isMaintenanceRedirect, - scrollThroughPage, -} from "./helpers"; +import { expectNoApplicationError, isMaintenanceRedirect } from "./helpers"; // The register form is the gate for every new player, so it gets a deeper // read-only pass than the generic public-page spec. It also pins the captcha @@ -39,14 +33,6 @@ test.describe("register", () => { await expectNoApplicationError(page); }); - test("loads its theme/avatar images and fits the viewport", async ({ - page, - }) => { - await scrollThroughPage(page); - await expectNoBrokenImages(page); - await expectNoHorizontalOverflow(page); - }); - test("exposes exactly one captcha widget with a site key", async ({ page, }) => { diff --git a/package.json b/package.json index 70f2a958..fdb3f338 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "build": "pnpm assets:editor && bash scripts/with-memory-cap.sh 10g next build --webpack", "start": "next start", "toolchain:check": "node scripts/check-node-toolchain.mjs", + "readme:versions": "node scripts/sync-readme-versions.mjs", + "readme:versions:check": "node scripts/sync-readme-versions.mjs --check", "lint": "biome check .", "biome:lint": "biome check .", "format": "biome format --write .", @@ -42,6 +44,7 @@ "test:ui:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.ui.config.ts --update-snapshots", "test:imaging": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.imaging.config.ts", "test:imaging:update": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.imaging.config.ts --update-snapshots", + "test:e2e:quality": "bash scripts/with-memory-cap.sh 8g playwright test --config playwright.quality.config.ts", "performance:report": "node scripts/performance-report.mjs", "test:integration": "bash scripts/with-memory-cap.sh 8g vitest run --config vitest.integration.config.ts" }, diff --git a/playwright.config.ts b/playwright.config.ts index 9c753f8b..9265963d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,7 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./e2e", - testIgnore: ["**/ui/**", "**/news-real/**"], + testIgnore: ["**/ui/**", "**/news-real/**", "**/quality/**"], fullyParallel: false, workers: 1, retries: 1, diff --git a/playwright.quality.config.ts b/playwright.quality.config.ts new file mode 100644 index 00000000..99f387c6 --- /dev/null +++ b/playwright.quality.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Advisory quality suite. Unlike the main e2e config (the blocking deploy +// gate), this checks things that depend on live content and data — broken +// uploads, legacy malformed avatar figures, warm-page latency. Those are real +// signals but they are not something a code change can guarantee, so they must +// never block a deploy. Point it at a running CMS with PLAYWRIGHT_BASE_URL +// (defaults to the local host port). +const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002"; + +export default defineConfig({ + testDir: "./e2e/quality", + fullyParallel: false, + workers: 1, + retries: 1, + timeout: 60000, + expect: { timeout: 15000 }, + reporter: [ + ["list"], + ["html", { outputFolder: "playwright-report/quality", open: "never" }], + ], + outputDir: "test-results/quality", + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/scripts/README.md b/scripts/README.md index e7597d8c..ca7ea2e8 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -20,6 +20,7 @@ This directory contains utility scripts for managing the EpicNext-Cms project. | `perf-report.sh` | Runs performance profiling. | | `setup-dev.sh` | Configures development environment. | | `check-updates.sh` | Checks for package and image updates. | +| `sync-readme-versions.mjs` | Syncs the versions in `README.md` with `package.json` / `.nvmrc`. | | `maintenance.sh` | Toggles maintenance mode flag. | | `doctor.sh` | Runs a system diagnostic report. | | `setup-cron.sh` | Configures automated cron jobs. | @@ -30,3 +31,18 @@ Run any script directly, or use the dashboard: ```bash ./scripts/dashboard.sh ``` + +### Keeping the README versions in sync + +Bumping a dependency (or the pinned Node.js version) leaves `README.md` stale. +Rather than editing it by hand, run: + +```bash +pnpm readme:versions # rewrite README.md to match package.json / .nvmrc +pnpm readme:versions:check # verify only; exits 1 when out of date (used in CI) +``` + +It manages the `pnpm` and `Node.js` rows of the System Requirements table and +the "Core stack" line. The CI `check` job runs the `--check` variant, so a +version change cannot reach `main` with a stale README — the pipeline fails +until you run `pnpm readme:versions`. diff --git a/scripts/sync-readme-versions.mjs b/scripts/sync-readme-versions.mjs new file mode 100644 index 00000000..3e3aa082 --- /dev/null +++ b/scripts/sync-readme-versions.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** + * Collapse the version facts the README advertises into a single object, read + * from the source of truth (`package.json` + `.nvmrc`). Kept as a pure function + * so the test suite can pin the extraction without touching the filesystem. + */ +export function collectVersions(pkg, nvmrc) { + const deps = { ...pkg.dependencies, ...pkg.devDependencies }; + const require = (name) => { + const value = deps[name]; + if (typeof value !== "string" || !/^\d+\.\d+/.test(value)) + throw new Error(`package.json is missing a concrete ${name} version`); + return value; + }; + + const pnpm = /^pnpm@([^+]+)/.exec(pkg.packageManager ?? "")?.[1]; + if (!pnpm) throw new Error("package.json packageManager must pin pnpm"); + + const node = nvmrc.trim(); + if (!/^\d+\.\d+\.\d+$/.test(node)) + throw new Error(".nvmrc must pin an exact Node.js version"); + + return { + pnpm, + node, + next: require("next"), + react: require("react"), + typescript: require("typescript"), + drizzleOrm: require("drizzle-orm"), + zod: require("zod"), + vitest: require("vitest"), + biome: require("@biomejs/biome"), + playwright: require("@playwright/test"), + }; +} + +/** The single "Core stack" line, built from the live versions. */ +export function coreStackLine(v) { + return `Core stack: **Next.js ${v.next}** (App Router) · **React ${v.react}** · **TypeScript ${v.typescript}** · **Drizzle ORM ${v.drizzleOrm}** · **Zod ${v.zod}** · **Vitest ${v.vitest}** · **Biome ${v.biome}** · **Playwright ${v.playwright}**.`; +} + +const CORE_STACK_RE = /^Core stack: \*\*Next\.js .*$/m; + +/** + * Rewrite every managed version reference in `readme` to match `versions`. + * Returns the new text plus a human-readable list of what changed. Throws if a + * managed anchor is missing, so a README restructure fails loudly instead of + * silently dropping out of sync. + */ +export function syncReadmeVersions(readme, versions) { + const changes = []; + let next = readme; + + // `| Node.js | 26.10.0 | … (>=26.10.0 <27) |` — version cell + engines note. + next = replaceVersionCell( + next, + "Node\\.js", + versions.node, + "Node.js", + changes, + ); + const nodeMajor = Number.parseInt(versions.node.split(".")[0], 10); + const nodeNote = /(>=)\d+\.\d+\.\d+( <)\d+/; + if (!nodeNote.test(next)) + throw new Error("README is missing the Node.js engines note"); + next = next.replace(nodeNote, `$1${versions.node}$2${nodeMajor + 1}`); + + // `| pnpm | 12.11.2 | … |`. + next = replaceVersionCell(next, "pnpm", versions.pnpm, "pnpm", changes); + + // `Core stack: **Next.js …** · …`. + const stackMatch = CORE_STACK_RE.exec(next); + if (!stackMatch) throw new Error("README is missing the core stack line"); + const expectedStack = coreStackLine(versions); + if (stackMatch[0] !== expectedStack) { + changes.push("core stack line"); + next = next.replace(CORE_STACK_RE, expectedStack); + } + + return { readme: next, changes }; +} + +/** + * Replace the version token of a `| name | version | … |` table row, padding + * the trailing spaces so the closing pipe stays in the same column when the + * new version is a different length. + */ +function replaceVersionCell(readme, namePattern, version, label, changes) { + const re = new RegExp( + `^(\\| ${namePattern}(?: +)\\| )(\\S+)( +)(\\|.*)$`, + "m", + ); + const match = re.exec(readme); + if (!match) + throw new Error(`README is missing the ${label} requirements row`); + const [, pre, current, pad, post] = match; + if (current === version) return readme; + changes.push(`${label} ${current} -> ${version}`); + const padLength = Math.max(1, pad.length - (version.length - current.length)); + return readme.replace(re, `${pre}${version}${" ".repeat(padLength)}${post}`); +} + +function main() { + const check = process.argv.includes("--check"); + const pkg = JSON.parse( + readFileSync(resolve(projectRoot, "package.json"), "utf8"), + ); + const nvmrc = readFileSync(resolve(projectRoot, ".nvmrc"), "utf8"); + const readmePath = resolve(projectRoot, "README.md"); + const readme = readFileSync(readmePath, "utf8"); + + const { readme: updated, changes } = syncReadmeVersions( + readme, + collectVersions(pkg, nvmrc), + ); + + if (changes.length === 0) { + console.log("README versions are in sync with package.json / .nvmrc"); + return; + } + + if (check) { + console.error("README versions are out of date:"); + for (const change of changes) console.error(` - ${change}`); + console.error("Run `pnpm readme:versions` to fix."); + process.exitCode = 1; + return; + } + + writeFileSync(readmePath, updated); + console.log("README versions updated:"); + for (const change of changes) console.log(` - ${change}`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main(); diff --git a/scripts/sync-readme-versions.test.mjs b/scripts/sync-readme-versions.test.mjs new file mode 100644 index 00000000..e94a5ffb --- /dev/null +++ b/scripts/sync-readme-versions.test.mjs @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { + collectVersions, + coreStackLine, + syncReadmeVersions, +} from "./sync-readme-versions.mjs"; + +const versions = { + pnpm: "12.11.2", + node: "26.10.0", + next: "16.4.1", + react: "19.3.1", + typescript: "7.0.2", + drizzleOrm: "0.45.4", + zod: "4.6.7", + vitest: "5.0.4", + biome: "2.5.15", + playwright: "1.64.1", +}; + +const pkg = { + packageManager: `pnpm@${versions.pnpm}+sha512.deadbeef`, + dependencies: { + next: versions.next, + react: versions.react, + zod: versions.zod, + }, + devDependencies: { + typescript: versions.typescript, + "drizzle-orm": versions.drizzleOrm, + vitest: versions.vitest, + "@biomejs/biome": versions.biome, + "@playwright/test": versions.playwright, + }, +}; + +const readme = [ + "| Node.js | 26.10.0 | Pinned in `.nvmrc` (`>=26.10.0 <27`) |", + "| pnpm | 12.11.2 | Pinned via `packageManager` |", + "", + coreStackLine(versions), + "", +].join("\n"); + +describe("collectVersions", () => { + it("pulls every managed version from package.json and .nvmrc", () => { + expect(collectVersions(pkg, `${versions.node}\n`)).toEqual(versions); + }); + + it("rejects a packageManager without a pinned pnpm", () => { + expect(() => + collectVersions({ ...pkg, packageManager: "npm@10" }, "26.10.0"), + ).toThrow(/pnpm/); + }); + + it("rejects a ranged dependency instead of a pinned one", () => { + const ranged = { + ...pkg, + dependencies: { ...pkg.dependencies, next: "^16" }, + }; + expect(() => collectVersions(ranged, "26.10.0")).toThrow(/next/); + }); +}); + +describe("syncReadmeVersions", () => { + it("reports no changes when the README already matches", () => { + const result = syncReadmeVersions(readme, versions); + expect(result.changes).toEqual([]); + expect(result.readme).toBe(readme); + }); + + it("updates the pnpm row and keeps the table aligned", () => { + const bumped = { ...versions, pnpm: "13.0.0" }; + const original = readme.split("\n").find((line) => line.includes("pnpm")); + const { readme: updated, changes } = syncReadmeVersions(readme, bumped); + const line = updated.split("\n").find((l) => l.includes("pnpm")); + + expect(changes).toContain("pnpm 12.11.2 -> 13.0.0"); + expect(line).toContain("| 13.0.0"); + expect(line?.length).toBe(original?.length); + expect(line?.endsWith("|\n") || line?.endsWith("|")).toBe(true); + }); + + it("rewrites the core stack line from the live versions", () => { + const bumped = { ...versions, next: "17.0.0", zod: "5.0.0" }; + const { readme: updated, changes } = syncReadmeVersions(readme, bumped); + expect(changes).toContain("core stack line"); + expect(updated).toContain(coreStackLine(bumped)); + expect(updated).not.toContain("Next.js 16.4.1"); + }); + + it("updates both the Node version cell and the engines note", () => { + const bumped = { ...versions, node: "27.3.1" }; + const { readme: updated, changes } = syncReadmeVersions(readme, bumped); + expect(changes).toContain("Node.js 26.10.0 -> 27.3.1"); + expect(updated).toContain("| 27.3.1 "); + expect(updated).toContain("`>=27.3.1 <28`"); + }); + + it("fails loudly when a managed anchor is missing", () => { + expect(() => + syncReadmeVersions("no table here\nno core stack", versions), + ).toThrow(/requirements row/); + }); +}); + +describe("coreStackLine", () => { + it("renders the documented ordering", () => { + expect(coreStackLine(versions)).toBe( + "Core stack: **Next.js 16.4.1** (App Router) · **React 19.3.1** · **TypeScript 7.0.2** · **Drizzle ORM 0.45.4** · **Zod 4.6.7** · **Vitest 5.0.4** · **Biome 2.5.15** · **Playwright 1.64.1**.", + ); + }); +});