diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db36d02..a036674 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,3 +48,29 @@ jobs: working-directory: app - run: npm run lint working-directory: app + + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 20 + cache: npm + cache-dependency-path: app/package-lock.json + - run: npm ci + working-directory: app + - run: npx playwright install --with-deps chromium + working-directory: app + # Build and serve a production bundle, not `next dev`: several past + # regressions (e.g. searchParams 500s) only reproduce in prod rendering. + - run: npm run build + working-directory: app + # `@perf`-tagged specs (Web Vitals thresholds, cold-start journey) are + # timing-sensitive / require a deployed URL, so they are not gated here. + - name: Run e2e against production build + working-directory: app + run: | + npm start & + npx --yes wait-on --timeout 60000 http://localhost:3000 + BASE_URL=http://localhost:3000 npx playwright test --grep-invert @perf diff --git a/app/e2e/journey-performance.spec.ts b/app/e2e/journey-performance.spec.ts index 62b6d3d..c55a317 100644 --- a/app/e2e/journey-performance.spec.ts +++ b/app/e2e/journey-performance.spec.ts @@ -39,7 +39,7 @@ const SURNAMES = [ const baseURL = process.env.BASE_URL ?? ""; const isRemote = /^https?:\/\//.test(baseURL) && !/localhost|127\.0\.0\.1/.test(baseURL); -test.describe("home → search → athlete journey (cold path)", () => { +test.describe("home → search → athlete journey (cold path) @perf", () => { // A retry would warm the function and hide a cold-path regression. test.describe.configure({ retries: 0 }); diff --git a/app/e2e/performance.spec.ts b/app/e2e/performance.spec.ts index 42e903d..a81b470 100644 --- a/app/e2e/performance.spec.ts +++ b/app/e2e/performance.spec.ts @@ -8,7 +8,7 @@ const pages = [ ]; for (const { name, path } of pages) { - test(`${name} page (${path}) loads with acceptable Web Vitals`, async ({ + test(`${name} page (${path}) loads with acceptable Web Vitals @perf`, async ({ page, }) => { // Inject web-vitals collection before navigating diff --git a/app/e2e/result-share-and-pills.spec.ts b/app/e2e/result-share-and-pills.spec.ts index 05267d1..6016be1 100644 --- a/app/e2e/result-share-and-pills.spec.ts +++ b/app/e2e/result-share-and-pills.spec.ts @@ -16,12 +16,12 @@ test("result page shows AG percentile pills on each discipline card", async ({ p await page.waitForURL(new RegExp(`/race/${RACE_SLUG}/result/\\d+`)); // Each of the four discipline cards should have an amber percentile pill. - // Pills render either "%" or "—". + // Pills render either "Top %" (see formatTopPercent) or "—". const pills = page.locator(".bg-amber-400\\/15"); await expect(pills).toHaveCount(4); for (let i = 0; i < 4; i++) { const text = (await pills.nth(i).innerText()).trim(); - expect(text).toMatch(/^(—|\d{1,3}%)$/); + expect(text).toMatch(/^(—|Top \d{1,3}%)$/); } }); diff --git a/app/playwright.config.ts b/app/playwright.config.ts index 4220575..819eeb4 100644 --- a/app/playwright.config.ts +++ b/app/playwright.config.ts @@ -3,6 +3,12 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./e2e", timeout: 30_000, + // On CI the server is a freshly-started production process: the first hit to a + // data-heavy dynamic route pays a one-time ~190MB corpus load, which under + // parallel load can lose a race with the 5s expect timeout. Retries re-run the + // failed test on a now-warm process; genuine failures still fail every attempt. + // Local runs stay strict (0) so real flakes surface. + retries: process.env.CI ? 2 : 0, use: { baseURL: process.env.BASE_URL || "http://localhost:3000", headless: true,