diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1eeaab025..1ea554c7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: ".nvmrc" @@ -228,7 +228,7 @@ jobs: run: npm ci - name: Restore Chromium browser cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }} @@ -241,7 +241,7 @@ jobs: - name: Upload UI diagnostics if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: critical-ui-diagnostics-${{ github.run_id }} path: | @@ -249,6 +249,9 @@ jobs: playwright-report/ if-no-files-found: ignore + # Production-journey regression suite. Blocking (PT-05): a red production + # journey must fail pr-required instead of normalising as an advisory red. + # Prototype /mockups specs run separately in ui-mockups below. ui-regression: name: UI regression needs: changes @@ -338,6 +341,53 @@ jobs: playwright-report/ if-no-files-found: ignore + # Prototype /mockups specs live in their own advisory lane so a red mockup + # can never mask (or block on) a production-journey regression (PT-05). + ui-mockups: + name: Advisory mockup UI + needs: changes + if: github.event_name == 'pull_request' && needs.changes.outputs.ui_changed == 'true' + continue-on-error: true + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version-file: ".nvmrc" + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Restore Chromium browser cache + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Install Chromium browser + run: npx playwright install --with-deps chromium + + - name: Chromium mockup regression + run: npm run test:e2e:mockups + + - name: Upload mockup UI diagnostics + if: failure() + uses: actions/upload-artifact@v7 + with: + name: mockup-ui-diagnostics-${{ github.run_id }} + path: | + test-results/ + playwright-report/ + if-no-files-found: ignore + db-reset-verify: name: Migration replay needs: changes diff --git a/package.json b/package.json index bfea96457..82ca26a38 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "test:e2e:critical": "node scripts/run-playwright.mjs --project=chromium --grep @critical", "test:e2e:regression": "node scripts/run-playwright.mjs --project=chromium --grep-invert \"@critical|@quarantine\"", "test:e2e:quarantine": "node scripts/run-playwright.mjs --project=chromium --grep @quarantine --pass-with-no-tests", - "test:e2e:chromium": "node scripts/run-playwright.mjs --project=chromium", + "test:e2e:mockups": "node scripts/run-playwright.mjs --project=chromium-mockups", + "test:e2e:chromium": "node scripts/run-playwright.mjs --project=chromium --project=chromium-mockups", "test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts", "verify:cheap": "npm run check:runtime && npm run check:github-actions && npm run sitemap:check && npm run brand:check && npm run check:type-scale && npm run check:icon-scale && npm run lint && npm run typecheck && npm run test", "verify:pr-local": "node scripts/verify-pr-local.mjs", diff --git a/playwright.config.ts b/playwright.config.ts index 7ac060372..94b7f97c5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,6 +7,14 @@ const baseURL = getPlaywrightBaseUrl(); // browser downloads; point this at that binary instead of the managed one. const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; +// Prototype /mockups journeys live in their own advisory project so a red +// mockup can never mask a production-journey regression (PT-05). The two +// Tag-level filters keep production and prototype journeys disjoint even when +// they share a spec file; firefox/webkit retain the legacy full testMatch. +const productionSpecPattern = /.*ui-(smoke|stress|accessibility|tools|overlap|universal-search)\.spec\.ts/; +const mockupSpecPattern = /.*ui-(tools|tools-collapse|tools-task-directory)\.spec\.ts/; +const mockupTag = /@mockup/; + export default defineConfig({ testDir: "./tests", testMatch: @@ -27,6 +35,17 @@ export default defineConfig({ projects: [ { name: "chromium", + testMatch: productionSpecPattern, + grepInvert: mockupTag, + use: { + ...devices["Desktop Chrome"], + ...(chromiumExecutablePath ? { launchOptions: { executablePath: chromiumExecutablePath } } : {}), + }, + }, + { + name: "chromium-mockups", + testMatch: mockupSpecPattern, + grep: mockupTag, use: { ...devices["Desktop Chrome"], ...(chromiumExecutablePath ? { launchOptions: { executablePath: chromiumExecutablePath } } : {}), diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index f49daa6d3..3939b3538 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1886,8 +1886,16 @@ test.describe("Clinical KB UI smoke coverage", () => { const medicationLink = strip.getByRole("link", { name: "Clozapine", exact: true }); await expect(medicationLink).toHaveAttribute("href", "/medications/clozapine"); - await medicationLink.click(); - await expect(page).toHaveURL(/\/medications\/clozapine/, { timeout: 15_000 }); + // Re-click on each retry: a single click can be swallowed while the answer + // surface is still hydrating (same guard as the launcher mode switches), and + // app-router URLs only commit after the destination responds — a cold dev + // compile of /medications/[slug] can take ~30s when this spec runs without + // the @critical warm-up journeys. + await expect(async () => { + if (/\/medications\/clozapine/.test(page.url())) return; + await medicationLink.click(); + await expect(page).toHaveURL(/\/medications\/clozapine/, { timeout: 5_000 }); + }).toPass({ timeout: 45_000 }); await expectNoPageHorizontalOverflow(page); }); @@ -2419,9 +2427,6 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectSingleMedicationPage(page); await expect(page.getByRole("link", { name: "Back to medication search" })).toBeVisible(); - await gotoApp(page, "/mockups/medication-prescribing"); - await expect(page).toHaveURL(/\/medications\/acamprosate$/); - await expectSingleMedicationPage(page); expect(parentNodeErrors).toEqual([]); }); diff --git a/tests/ui-tools-collapse.spec.ts b/tests/ui-tools-collapse.spec.ts index 10bb33693..d5d31b011 100644 --- a/tests/ui-tools-collapse.spec.ts +++ b/tests/ui-tools-collapse.spec.ts @@ -16,7 +16,7 @@ function mockupSearch(page: Page) { return page.locator("#main-content").getByRole("searchbox").first(); } -test.describe("Tools mockups collapse the primary region when filtering", () => { +test.describe("Tools mockups collapse the primary region when filtering @mockup", () => { test.describe.configure({ timeout: 60_000 }); test("command center hides Start here and avoids a populated grid over an empty state", async ({ page }) => { diff --git a/tests/ui-tools-task-directory.spec.ts b/tests/ui-tools-task-directory.spec.ts index 774966516..fe01d6731 100644 --- a/tests/ui-tools-task-directory.spec.ts +++ b/tests/ui-tools-task-directory.spec.ts @@ -18,7 +18,7 @@ async function expectNoHorizontalOverflow(page: Page) { expect(overflow).toBeLessThanOrEqual(2); } -test.describe("Tools task directory mockup (Concept 4)", () => { +test.describe("Tools task directory mockup (Concept 4) @mockup", () => { test.describe.configure({ timeout: 60_000 }); test("renders task-grouped rows and is reachable", async ({ page }) => { diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 54c8a6978..6b3121503 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1514,7 +1514,7 @@ test.describe("Responsive layout guards", () => { ] as const; for (const route of mockupRoutes) { - test(`${route.name} never overflows horizontally across sizes`, async ({ page }) => { + test(`${route.name} never overflows horizontally across sizes @mockup`, async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await gotoLauncher(page, route.path); await expect(page.getByRole("heading", { level: 1 }).first()).toBeVisible();