diff --git a/.gitignore b/.gitignore index c3374a8..0bef111 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,5 @@ ai-act-compass-*.pdf # Vitest coverage output coverage/ .vercel +test-results/ +playwright-report/ diff --git a/e2e/parcours.spec.js b/e2e/parcours.spec.js new file mode 100644 index 0000000..d45b86c --- /dev/null +++ b/e2e/parcours.spec.js @@ -0,0 +1,169 @@ +// End-to-end parcours tests — drives a real browser through the 7-step flow +// and asserts the verdict screen renders the expected primary category. +// Catches the class of bug that unit + component tests can miss: +// - Runtime React errors (scope, missing imports) — PR #3 C2 example +// - UI navigation logic (Bug #1 art. 25 flip Step 7 visibility) +// - Short-circuit interaction (Bug #2 prohibition + carve-out) +// +// Not in `npm test` (Vitest scope). Run manually: `npx playwright test`. +// +// Selectors anchor on the OptionCard aria-label, which is composed as +// ` — <sub> — <desc>`. We match on unique fragments (often `sub` +// like "art. 3(3)") to keep selectors stable across copy changes. + +import { test, expect } from '@playwright/test'; + +const startQualification = (page) => + page.getByRole('button', { name: /start qualification/i }).click(); +const clickContinue = (page) => + page.getByRole('button', { name: /^continue$/i }).click(); +const clickViewVerdict = (page) => + page.getByRole('button', { name: /view verdict|continue/i }).first().click(); + +// OptionCard renders as <button role="radio"> (single) or <button role="checkbox"> (multi). +// Try checkbox first since multi cards are more likely to be the constraining ones. +async function clickCard(page, nameRegex) { + const checkbox = page.getByRole('checkbox', { name: nameRegex }); + if (await checkbox.count() > 0) { + await checkbox.first().click(); + return; + } + const radio = page.getByRole('radio', { name: nameRegex }); + await radio.first().click(); +} + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test('happy path — provider with no triggers lands on RISQUE_MINIMAL', async ({ page }) => { + await startQualification(page); + + // Step 1: provider (anchor on the unique sub "art. 3(3)") + await clickCard(page, /art\. 3\(3\)/); + await clickContinue(page); + + // Step 2: AI system (sub "art. 3(1)") + await clickCard(page, /art\. 3\(1\)/); + await clickContinue(page); + + // Step 3: none of the above + await clickCard(page, /None of the above practices apply/); + await clickContinue(page); + + // Step 4: Annex I coverage = No (desc unique to the No card) + await clickCard(page, /My system does not fall within/); + await clickContinue(page); + + // Step 5: no Annex III selection — Continue allowed (canNext true on empty) + await clickContinue(page); + + // Step 6: no art. 50 triggers + await clickContinue(page); + + // Step 7: not applicable for non-GPAI, non-flipped — View verdict + await clickViewVerdict(page); + + await expect(page.getByText(/qualification verdict/i)).toBeVisible(); + await expect(page.getByText(/minimal risk|risque minimal/i).first()).toBeVisible(); +}); + +test('high-risk deployer Annex III §3 surfaces FRIA quickwin', async ({ page }) => { + await startQualification(page); + + // Step 1: deployer (sub "art. 3(4)") + public_body sub-question + await clickCard(page, /art\. 3\(4\)/); + await clickCard(page, /Body governed by public law/); + await clickContinue(page); + + // Step 2: AI system + await clickCard(page, /art\. 3\(1\)/); + await clickContinue(page); + + // Step 3: none + await clickCard(page, /None of the above practices apply/); + await clickContinue(page); + + // Step 4: Annex I = No + await clickCard(page, /My system does not fall within/); + await clickContinue(page); + + // Step 5: Annex III §3 education + No exception + await clickCard(page, /Education and vocational training/); + await clickCard(page, /No exception applies/); + await clickContinue(page); + + // Step 6: skip + await clickContinue(page); + + // Step 7: not applicable for non-GPAI deployer + await clickViewVerdict(page); + + // Verdict + FRIA quickwin visible (PR #3 Item C FRIA gating) + await expect(page.getByText(/high-risk|haut risque/i).first()).toBeVisible(); + await expect(page.getByText(/FRIA|fundamental rights/i).first()).toBeVisible(); +}); + +test('art. 25 flip — integrator + substantialModification + systemic risk → GPAI_RS (Bug #1 regression)', async ({ page }) => { + await startQualification(page); + + // Step 1: provider (any role works; we just need to advance) + await clickCard(page, /art\. 3\(3\)/); + await clickContinue(page); + + // Step 2: pick "AI system relying on a GPAI" (sub "System integrating a GPAI model") + await clickCard(page, /System integrating a GPAI model/); + // Sub-question appears: Yes — substantial modification (sub "art. 25") + await clickCard(page, /Yes — substantial modification/); + await clickContinue(page); + + // Step 3: none + await clickCard(page, /None of the above practices apply/); + await clickContinue(page); + + // Step 4: Annex I = No + await clickCard(page, /My system does not fall within/); + await clickContinue(page); + + // Step 5: skip + await clickContinue(page); + + // Step 6: skip + await clickContinue(page); + + // Step 7: BUG #1 REGRESSION — the systemic-risk question MUST appear here + // even though nature is 'systeme_sur_gpai' (not 'gpai'), because of the + // declared substantial modification. + await expect(page.getByText(/model with systemic risk/i)).toBeVisible(); + await clickCard(page, /Yes — model with systemic risk/); + await clickViewVerdict(page); + + // Verdict primary should be GPAI_RS + await expect(page.getByText(/GPAI.*systemic|GPAI \/ SR/i).first()).toBeVisible(); +}); + +test('art. 5 carve-out does NOT short-circuit to verdict (Bug #2 regression)', async ({ page }) => { + await startQualification(page); + + // Step 1: provider + await clickCard(page, /art\. 3\(3\)/); + await clickContinue(page); + + // Step 2: AI system + await clickCard(page, /art\. 3\(1\)/); + await clickContinue(page); + + // Step 3: prohibition (h) — sub "art. 5(1)(h)" + await clickCard(page, /art\. 5\(1\)\(h\)/); + // Carve-out sub-question appears — claim the law-enforcement exception (sub "art. 5(2)-(3)") + await clickCard(page, /art\. 5\(2\)-\(3\)/); + + // BUG #2 REGRESSION: with the carve-out claimed, Continue should advance to + // Step 4, NOT short-circuit to the verdict. + await clickContinue(page); + + // Verify we're on Step 4 (Annex I question) + await expect(page.getByText(/safety component.*harmonised|composant de sécurité/i)).toBeVisible(); + // The verdict's "qualification verdict" header should NOT be visible + await expect(page.getByText(/qualification verdict/i)).not.toBeVisible(); +}); diff --git a/package-lock.json b/package-lock.json index 70401f8..f66f34c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -977,6 +978,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3068,13 +3085,13 @@ "license": "ISC" }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -3087,9 +3104,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index e97a12e..0de5ecc 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..5ea6bda --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,28 @@ +import { defineConfig, devices } from '@playwright/test'; + +// E2E configuration — spins up Vite dev server, runs Chromium headless. +// Not wired into `npm test` (that runs Vitest). To execute these: +// npx playwright test +// +// CI integration is left to a follow-up; locally these are a manual gate. +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + timeout: 30 * 1000, + expect: { timeout: 5000 }, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://127.0.0.1:5173', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + webServer: { + command: 'npm run dev', + url: 'http://127.0.0.1:5173', + reuseExistingServer: !process.env.CI, + timeout: 60 * 1000, + }, +});