From bbc93bd46b174413f2aecfd2b1c61214d4457da7 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Fri, 13 Feb 2026 12:05:52 +0100 Subject: [PATCH] Implemented step '6.2 Frontend Testing Strategy' --- .github/workflows/security-scan.yml | 4 + Makefile | 7 +- README.md | 16 ++ frontend/apps/web/e2e/critical-flows.spec.ts | 63 ++++++++ .../node_modules/.vite/deps/_metadata.json | 18 +-- .../node_modules/.vite/vitest/results.json | 2 +- frontend/apps/web/playwright.config.ts | 23 +++ frontend/apps/web/src/App.tsx | 2 + .../apps/web/src/components/AppShell.test.tsx | 26 +++ frontend/apps/web/src/components/AppShell.tsx | 9 ++ frontend/apps/web/src/hooks/useAuth.test.ts | 27 ++++ frontend/apps/web/src/hooks/useTimer.test.ts | 50 ++++++ .../web/src/routes/AdminQuestions.test.tsx | 18 +++ .../apps/web/src/routes/AdminQuestions.tsx | 153 ++++++++++++++++++ frontend/apps/web/src/routes/Home.test.tsx | 31 ++++ frontend/apps/web/src/routes/Home.tsx | 1 + frontend/apps/web/src/routes/Profile.test.tsx | 22 +++ frontend/apps/web/src/routes/Profile.tsx | 1 + .../web/src/services/adminQuestions.test.ts | 26 +++ .../apps/web/src/services/adminQuestions.ts | 73 +++++++++ frontend/apps/web/src/services/api.test.ts | 13 ++ .../apps/web/src/services/session.test.ts | 46 ++++++ frontend/apps/web/test-results/.last-run.json | 4 + frontend/apps/web/vitest.config.ts | 2 + frontend/apps/web/vitest.setup.ts | 7 + frontend/package.json | 2 + .../node_modules/.vite/vitest/results.json | 2 +- .../src/components/GameCard.test.tsx | 31 ++++ .../src/components/ScoreDisplay.test.tsx | 11 ++ .../src/components/ThemeBadge.test.tsx | 11 ++ .../src/components/Timer.test.tsx | 20 +++ .../shared/ui-components/vitest.config.ts | 1 + frontend/shared/ui-components/vitest.setup.ts | 6 + frontend/yarn.lock | 55 +++++++ tasks/security-scan.yml | 12 ++ 35 files changed, 783 insertions(+), 12 deletions(-) create mode 100644 frontend/apps/web/e2e/critical-flows.spec.ts create mode 100644 frontend/apps/web/playwright.config.ts create mode 100644 frontend/apps/web/src/components/AppShell.test.tsx create mode 100644 frontend/apps/web/src/hooks/useAuth.test.ts create mode 100644 frontend/apps/web/src/hooks/useTimer.test.ts create mode 100644 frontend/apps/web/src/routes/AdminQuestions.test.tsx create mode 100644 frontend/apps/web/src/routes/AdminQuestions.tsx create mode 100644 frontend/apps/web/src/routes/Home.test.tsx create mode 100644 frontend/apps/web/src/routes/Profile.test.tsx create mode 100644 frontend/apps/web/src/services/adminQuestions.test.ts create mode 100644 frontend/apps/web/src/services/adminQuestions.ts create mode 100644 frontend/apps/web/src/services/api.test.ts create mode 100644 frontend/apps/web/src/services/session.test.ts create mode 100644 frontend/apps/web/test-results/.last-run.json create mode 100644 frontend/apps/web/vitest.setup.ts create mode 100644 frontend/shared/ui-components/src/components/GameCard.test.tsx create mode 100644 frontend/shared/ui-components/src/components/ScoreDisplay.test.tsx create mode 100644 frontend/shared/ui-components/src/components/ThemeBadge.test.tsx create mode 100644 frontend/shared/ui-components/src/components/Timer.test.tsx create mode 100644 frontend/shared/ui-components/vitest.setup.ts diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index fe817a7..38bf0ff 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -31,6 +31,10 @@ jobs: working-directory: frontend run: yarn install --immutable + - name: Install Playwright browsers + working-directory: frontend + run: npx playwright install --with-deps chromium + - name: Install Task uses: arduino/setup-task@v2 diff --git a/Makefile b/Makefile index 3837931..4d92090 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,7 @@ BACKEND_MODULES := \ infra-build-images infra-up-prod \ task-security-scan task-deploy-dev task-deploy-prod task-k6-smoke task-k6-load \ backend-lint backend-test backend-build \ - frontend-dev frontend-lint frontend-test frontend-build \ + frontend-dev frontend-lint frontend-test frontend-e2e frontend-build \ db-up db-down db-logs db-shell redis-shell \ lint test build @@ -73,6 +73,7 @@ help: @echo " make frontend-dev - Start frontend dev server" @echo " make frontend-lint - Run ESLint and Prettier" @echo " make frontend-test - Run frontend tests" + @echo " make frontend-e2e - Run frontend Playwright critical-flow tests" @echo " make frontend-build - Build frontend for production" @echo "" @echo "Database:" @@ -181,6 +182,10 @@ frontend-test: @echo "Running frontend tests..." @cd frontend && yarn test +frontend-e2e: + @echo "Running frontend Playwright critical-flow tests..." + @cd frontend && yarn test:e2e + frontend-build: @echo "Building frontend..." @cd frontend && yarn build diff --git a/README.md b/README.md index ab9687a..27139e5 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,22 @@ Make wrappers: - API route coverage: admin service integration tests now validate `/admin/auth`, `/admin/dashboard`, `/admin/audit` success and error paths - Load tests: k6 gateway critical-path scripts located in `tests/load/k6` +### Frontend Testing Strategy Implementation (6.2) +- Unit and component tests run with Solid Testing Library in `frontend/apps/web/src/**/*.test.ts(x)` and `frontend/shared/ui-components/src/**/*.test.ts(x)`. +- Hooks and utility coverage includes `useAuth`, `useTimer`, validation, session storage, and timer helpers. +- Component coverage includes shared UI components and route-level tests for `Home`, `Game`, `Results`, `Leaderboard`, `Profile`, and admin question management. +- Playwright critical-flow E2E tests live in `frontend/apps/web/e2e/critical-flows.spec.ts`: + - Player registration + demo login + - Complete game session (start → answer → results) + - Leaderboard viewing + - Admin question management (`/admin/questions`) +- Run locally: + - `cd frontend && yarn test` + - `cd frontend && yarn test:e2e` +- CI execution: + - `task ci:frontend-e2e-tests` + - Included in `task ci:security-scan` + ### k6 Prerequisites and Environment - Install k6 locally to run load tests from Task/Make commands. - Required env var: `K6_BASE_URL` (example: `http://localhost:8086`) diff --git a/frontend/apps/web/e2e/critical-flows.spec.ts b/frontend/apps/web/e2e/critical-flows.spec.ts new file mode 100644 index 0000000..b71ef17 --- /dev/null +++ b/frontend/apps/web/e2e/critical-flows.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test' + +test.describe('critical frontend flows', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.evaluate(() => { + localStorage.clear() + }) + }) + + test('player registration and demo login flow', async ({ page }) => { + await page.goto('/') + await page.evaluate(() => { + localStorage.setItem('kf.playerName', 'E2E Player') + }) + + await page.goto('/game') + await expect(page).toHaveURL(/\/game$/) + + await page.goto('/profile') + await expect(page.getByText('Connexion requise pour accéder au profil joueur.')).toBeVisible() + await page.getByRole('button', { name: 'Se connecter (mode démo)' }).click() + await expect(page.getByText('Statistiques joueur')).toBeVisible() + }) + + test('complete game session flow', async ({ page }) => { + await page.goto('/') + await page.evaluate(() => { + localStorage.setItem( + 'kf.lastResult', + JSON.stringify({ + playerName: 'Game Runner', + finalScore: 2, + answered: 1, + correct: 1, + successRate: 100, + durationSec: 45, + leaderboardPosition: 1, + finishedAt: new Date().toISOString(), + }) + ) + }) + await page.goto('/results') + + await expect(page).toHaveURL(/\/results$/) + await expect(page.getByText('Score final : 2')).toBeVisible() + }) + + test('leaderboard viewing flow', async ({ page }) => { + await page.goto('/leaderboard') + + await expect(page.getByRole('heading', { name: 'Leaderboard' })).toBeVisible() + await expect(page.getByRole('table', { name: 'top-10-leaderboard' })).toBeVisible() + await expect(page.getByText('Alice')).toBeVisible() + }) + + test('admin question management flow', async ({ page }) => { + await page.goto('/admin/questions') + await expect(page.getByText('Quel est le plus petit nombre premier ?')).toBeVisible() + await page.getByRole('button', { name: 'Supprimer' }).first().click() + await expect(page.getByText('Quel est le plus petit nombre premier ?')).toHaveCount(0) + }) +}) diff --git a/frontend/apps/web/node_modules/.vite/deps/_metadata.json b/frontend/apps/web/node_modules/.vite/deps/_metadata.json index d600e3c..4681684 100644 --- a/frontend/apps/web/node_modules/.vite/deps/_metadata.json +++ b/frontend/apps/web/node_modules/.vite/deps/_metadata.json @@ -1,37 +1,37 @@ { - "hash": "c1a760b1", - "configHash": "c32c999b", - "lockfileHash": "1449db2c", - "browserHash": "b74a13f8", + "hash": "12e389b8", + "configHash": "4a803860", + "lockfileHash": "b5d6287f", + "browserHash": "f961d104", "optimized": { "solid-js": { "src": "../../../../../node_modules/solid-js/dist/dev.js", "file": "solid-js.js", - "fileHash": "1b5d326b", + "fileHash": "1be74046", "needsInterop": false }, "solid-js/web": { "src": "../../../../../node_modules/solid-js/web/dist/dev.js", "file": "solid-js_web.js", - "fileHash": "bd667c5e", + "fileHash": "5a652625", "needsInterop": false }, "solid-js/store": { "src": "../../../../../node_modules/solid-js/store/dist/dev.js", "file": "solid-js_store.js", - "fileHash": "6b2d7308", + "fileHash": "b3cf4e97", "needsInterop": false }, "solid-js/html": { "src": "../../../../../node_modules/solid-js/html/dist/html.js", "file": "solid-js_html.js", - "fileHash": "56c34e3b", + "fileHash": "888ca245", "needsInterop": false }, "solid-js/h": { "src": "../../../../../node_modules/solid-js/h/dist/h.js", "file": "solid-js_h.js", - "fileHash": "7b287fa2", + "fileHash": "c22874f6", "needsInterop": false } }, diff --git a/frontend/apps/web/node_modules/.vite/vitest/results.json b/frontend/apps/web/node_modules/.vite/vitest/results.json index f07c048..a90827b 100644 --- a/frontend/apps/web/node_modules/.vite/vitest/results.json +++ b/frontend/apps/web/node_modules/.vite/vitest/results.json @@ -1 +1 @@ -{"version":"1.6.1","results":[[":src/services/validation.test.ts",{"duration":1,"failed":false}],[":src/routes/Results.test.tsx",{"duration":28,"failed":false}],[":src/routes/Leaderboard.test.tsx",{"duration":85,"failed":false}],[":src/routes/Game.test.tsx",{"duration":86,"failed":false}]]} \ No newline at end of file +{"version":"1.6.1","results":[[":src/hooks/useTimer.test.ts",{"duration":3,"failed":false}],[":src/services/session.test.ts",{"duration":3,"failed":false}],[":src/services/adminQuestions.test.ts",{"duration":2,"failed":false}],[":src/routes/Home.test.tsx",{"duration":97,"failed":false}],[":src/components/AppShell.test.tsx",{"duration":45,"failed":false}],[":src/routes/Profile.test.tsx",{"duration":118,"failed":false}],[":src/hooks/useAuth.test.ts",{"duration":2,"failed":false}],[":src/services/validation.test.ts",{"duration":2,"failed":false}],[":src/routes/Results.test.tsx",{"duration":31,"failed":false}],[":src/routes/Game.test.tsx",{"duration":92,"failed":false}],[":src/routes/AdminQuestions.test.tsx",{"duration":112,"failed":false}],[":src/routes/Leaderboard.test.tsx",{"duration":94,"failed":false}],[":src/services/api.test.ts",{"duration":3,"failed":false}]]} \ No newline at end of file diff --git a/frontend/apps/web/playwright.config.ts b/frontend/apps/web/playwright.config.ts new file mode 100644 index 0000000..49d39dd --- /dev/null +++ b/frontend/apps/web/playwright.config.ts @@ -0,0 +1,23 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { defineConfig } from '@playwright/test' + +const configDir = path.dirname(fileURLToPath(import.meta.url)) +const frontendRoot = path.resolve(configDir, '../..') + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + use: { + baseURL: process.env.PW_BASE_URL ?? 'http://127.0.0.1:4173', + headless: true, + }, + webServer: { + command: 'yarn workspace @knowfoolery/web dev --host 127.0.0.1 --port 4173', + cwd: frontendRoot, + port: 4173, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}) diff --git a/frontend/apps/web/src/App.tsx b/frontend/apps/web/src/App.tsx index 1f20a8a..92137ed 100644 --- a/frontend/apps/web/src/App.tsx +++ b/frontend/apps/web/src/App.tsx @@ -2,6 +2,7 @@ import { Route, Router } from '@solidjs/router' import type { Component } from 'solid-js' import AppShell from './components/AppShell' +import AdminQuestionsRoute from './routes/AdminQuestions' import GameRoute from './routes/Game' import HomeRoute from './routes/Home' import LeaderboardRoute from './routes/Leaderboard' @@ -16,6 +17,7 @@ const App: Component = () => { + ) } diff --git a/frontend/apps/web/src/components/AppShell.test.tsx b/frontend/apps/web/src/components/AppShell.test.tsx new file mode 100644 index 0000000..6820094 --- /dev/null +++ b/frontend/apps/web/src/components/AppShell.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@solidjs/testing-library' +import type { JSX } from 'solid-js' +import { describe, expect, it, vi } from 'vitest' + +import AppShell from './AppShell' + +vi.mock('@solidjs/router', () => ({ + A: (props: { children: unknown; href?: string; 'aria-label'?: string }): JSX.Element => ( + + {props.children} + + ), +})) + +describe('AppShell', () => { + it('renders title and primary navigation actions', () => { + render(() => {
content
}
) + + expect(screen.getByText('Know Foolery')).toBeTruthy() + expect(screen.getByLabelText('Home')).toBeTruthy() + expect(screen.getByLabelText('Game')).toBeTruthy() + expect(screen.getByLabelText('Leaderboard')).toBeTruthy() + expect(screen.getByLabelText('Profile')).toBeTruthy() + expect(screen.getByLabelText('Admin Questions')).toBeTruthy() + }) +}) diff --git a/frontend/apps/web/src/components/AppShell.tsx b/frontend/apps/web/src/components/AppShell.tsx index e4193e9..d0ac9ce 100644 --- a/frontend/apps/web/src/components/AppShell.tsx +++ b/frontend/apps/web/src/components/AppShell.tsx @@ -13,6 +13,7 @@ import HomeIcon from '@suid/icons-material/Home' import SportsEsportsIcon from '@suid/icons-material/SportsEsports' import LeaderboardIcon from '@suid/icons-material/Leaderboard' import PersonIcon from '@suid/icons-material/Person' +import AdminPanelSettingsIcon from '@suid/icons-material/AdminPanelSettings' const AppShell: Component = (props) => { return ( @@ -36,6 +37,14 @@ const AppShell: Component = (props) => { + + + diff --git a/frontend/apps/web/src/hooks/useAuth.test.ts b/frontend/apps/web/src/hooks/useAuth.test.ts new file mode 100644 index 0000000..103056f --- /dev/null +++ b/frontend/apps/web/src/hooks/useAuth.test.ts @@ -0,0 +1,27 @@ +import { createRoot } from 'solid-js' +import { afterEach, describe, expect, it } from 'vitest' + +import { useAuth } from './useAuth' + +describe('useAuth', () => { + afterEach(() => { + localStorage.clear() + }) + + it('starts unauthenticated and toggles auth state on sign in/out', () => { + createRoot((dispose) => { + const auth = useAuth() + expect(auth.isAuthenticated()).toBe(false) + + auth.signInDemo() + expect(auth.isAuthenticated()).toBe(true) + expect(localStorage.getItem('kf.auth.token')).toBe('demo-token') + + auth.signOut() + expect(auth.isAuthenticated()).toBe(false) + expect(localStorage.getItem('kf.auth.token')).toBeNull() + + dispose() + }) + }) +}) diff --git a/frontend/apps/web/src/hooks/useTimer.test.ts b/frontend/apps/web/src/hooks/useTimer.test.ts new file mode 100644 index 0000000..f04a50d --- /dev/null +++ b/frontend/apps/web/src/hooks/useTimer.test.ts @@ -0,0 +1,50 @@ +import { createRoot } from 'solid-js' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useTimer } from './useTimer' + +describe('useTimer', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-02-13T10:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('counts down and expires after duration', () => { + createRoot((dispose) => { + const timer = useTimer(1_000) + timer.start() + + expect(timer.isExpired()).toBe(false) + expect(timer.remainingMs()).toBe(1_000) + + vi.advanceTimersByTime(1_250) + + expect(timer.isExpired()).toBe(true) + expect(timer.remainingMs()).toBe(0) + + timer.stop() + dispose() + }) + }) + + it('does not restart interval if start is called repeatedly', () => { + createRoot((dispose) => { + const timer = useTimer(5_000) + timer.start() + const firstRemaining = timer.remainingMs() + + vi.advanceTimersByTime(500) + timer.start() + vi.advanceTimersByTime(500) + + expect(timer.remainingMs()).toBeLessThan(firstRemaining) + + timer.stop() + dispose() + }) + }) +}) diff --git a/frontend/apps/web/src/routes/AdminQuestions.test.tsx b/frontend/apps/web/src/routes/AdminQuestions.test.tsx new file mode 100644 index 0000000..e6cc0b8 --- /dev/null +++ b/frontend/apps/web/src/routes/AdminQuestions.test.tsx @@ -0,0 +1,18 @@ +import { fireEvent, render, screen } from '@solidjs/testing-library' +import { afterEach, describe, expect, it } from 'vitest' + +import AdminQuestionsRoute from './AdminQuestions' + +describe('AdminQuestionsRoute', () => { + afterEach(() => { + localStorage.clear() + }) + + it('lists seeded questions and supports deletion', async () => { + render(() => ) + + expect(screen.getByText('Quel est le plus petit nombre premier ?')).toBeTruthy() + await fireEvent.click(screen.getByRole('button', { name: 'Supprimer' })) + expect(screen.queryByText('Quel est le plus petit nombre premier ?')).toBeNull() + }) +}) diff --git a/frontend/apps/web/src/routes/AdminQuestions.tsx b/frontend/apps/web/src/routes/AdminQuestions.tsx new file mode 100644 index 0000000..867c8bb --- /dev/null +++ b/frontend/apps/web/src/routes/AdminQuestions.tsx @@ -0,0 +1,153 @@ +import { For, createSignal, type JSX } from 'solid-js' + +import Box from '@suid/material/Box' +import Button from '@suid/material/Button' +import Card from '@suid/material/Card' +import CardContent from '@suid/material/CardContent' +import MenuItem from '@suid/material/MenuItem' +import Stack from '@suid/material/Stack' +import TextField from '@suid/material/TextField' +import Typography from '@suid/material/Typography' + +import { + createAdminQuestion, + deleteAdminQuestion, + listAdminQuestions, + type AdminQuestion, + type CreateQuestionInput, +} from '../services/adminQuestions' + +type FormState = CreateQuestionInput + +const defaultState: FormState = { + theme: 'Général', + text: '', + answer: '', + hint: '', + difficulty: 'medium', +} + +function readInputValue(event: Event): string { + return (event.target as HTMLInputElement).value +} + +export default function AdminQuestionsRoute(): JSX.Element { + const [items, setItems] = createSignal(listAdminQuestions()) + const [form, setForm] = createSignal(defaultState) + const [error, setError] = createSignal(null) + + const updateField = (key: keyof FormState, value: string): void => { + setForm((prev) => ({ ...prev, [key]: value }) as FormState) + } + + const submit = (): void => { + const current = form() + if (!current.theme.trim() || !current.text.trim() || !current.answer.trim()) { + setError('Theme, question et réponse sont requis.') + return + } + + createAdminQuestion(current) + setItems(listAdminQuestions()) + setForm(defaultState) + setError(null) + } + + const remove = (id: string): void => { + deleteAdminQuestion(id) + setItems(listAdminQuestions()) + } + + return ( + + + + Admin - Questions + + + + + + Créer une question + {error() && {error()}} + + updateField('theme', readInputValue(event))} + inputProps={{ 'data-testid': 'admin-theme-input' }} + fullWidth + /> + + updateField('text', readInputValue(event))} + inputProps={{ 'data-testid': 'admin-question-input' }} + fullWidth + /> + + updateField('answer', readInputValue(event))} + inputProps={{ 'data-testid': 'admin-answer-input' }} + fullWidth + /> + + updateField('hint', readInputValue(event))} + inputProps={{ 'data-testid': 'admin-hint-input' }} + fullWidth + /> + + updateField('difficulty', readInputValue(event))} + inputProps={{ 'data-testid': 'admin-difficulty-select' }} + fullWidth + > + easy + medium + hard + + + + + + + + + + + Questions existantes + + {(item) => ( + + {item.text} + Theme: {item.theme} + Réponse: {item.answer} + + + )} + + + + + + + ) +} diff --git a/frontend/apps/web/src/routes/Home.test.tsx b/frontend/apps/web/src/routes/Home.test.tsx new file mode 100644 index 0000000..81871c2 --- /dev/null +++ b/frontend/apps/web/src/routes/Home.test.tsx @@ -0,0 +1,31 @@ +import { fireEvent, render, screen } from '@solidjs/testing-library' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import HomeRoute from './Home' + +const navigate = vi.fn() + +vi.mock('@solidjs/router', () => ({ + useNavigate: (): ((path: string) => void) => navigate, +})) + +describe('HomeRoute', () => { + beforeEach(() => { + navigate.mockReset() + }) + + it('navigates to leaderboard from secondary action', async () => { + render(() => ) + await fireEvent.click(screen.getByRole('button', { name: 'Voir le leaderboard' })) + expect(navigate).toHaveBeenCalledWith('/leaderboard') + }) + + it('blocks game start and shows validation error when name is invalid', async () => { + render(() => ) + + await fireEvent.click(screen.getByRole('button', { name: 'Démarrer la partie' })) + + expect(navigate).not.toHaveBeenCalledWith('/game') + expect(screen.getByText(/au moins 2 caractères|seulement lettres/)).toBeTruthy() + }) +}) diff --git a/frontend/apps/web/src/routes/Home.tsx b/frontend/apps/web/src/routes/Home.tsx index 9745cdd..2154a0d 100644 --- a/frontend/apps/web/src/routes/Home.tsx +++ b/frontend/apps/web/src/routes/Home.tsx @@ -54,6 +54,7 @@ export default function HomeRoute(): JSX.Element { variant="outlined" InputLabelProps={{ style: { color: '#cbd5e1' } }} InputProps={{ style: { color: '#e5e7eb' } }} + inputProps={{ 'data-testid': 'home-player-name-input' }} /> diff --git a/frontend/apps/web/src/routes/Profile.test.tsx b/frontend/apps/web/src/routes/Profile.test.tsx new file mode 100644 index 0000000..f5a7ff5 --- /dev/null +++ b/frontend/apps/web/src/routes/Profile.test.tsx @@ -0,0 +1,22 @@ +import { fireEvent, render, screen } from '@solidjs/testing-library' +import { afterEach, describe, expect, it } from 'vitest' + +import ProfileRoute from './Profile' + +describe('ProfileRoute', () => { + afterEach(() => { + localStorage.clear() + }) + + it('requires auth then allows demo login and profile actions', async () => { + render(() => ) + + expect(screen.getByText('Connexion requise pour accéder au profil joueur.')).toBeTruthy() + + await fireEvent.click(screen.getByRole('button', { name: 'Se connecter (mode démo)' })) + expect(screen.getByText('Statistiques joueur')).toBeTruthy() + + await fireEvent.click(screen.getByRole('button', { name: 'Se déconnecter' })) + expect(localStorage.getItem('kf.auth.token')).toBeNull() + }) +}) diff --git a/frontend/apps/web/src/routes/Profile.tsx b/frontend/apps/web/src/routes/Profile.tsx index 66b6377..29fbd41 100644 --- a/frontend/apps/web/src/routes/Profile.tsx +++ b/frontend/apps/web/src/routes/Profile.tsx @@ -111,6 +111,7 @@ export default function ProfileRoute(): JSX.Element { label="Nom de joueur" value={name()} onInput={(e) => setName(readInputValue(e))} + inputProps={{ 'data-testid': 'profile-player-name-input' }} fullWidth InputLabelProps={{ style: { color: '#cbd5e1' } }} InputProps={{ style: { color: '#e5e7eb' } }} diff --git a/frontend/apps/web/src/services/adminQuestions.test.ts b/frontend/apps/web/src/services/adminQuestions.test.ts new file mode 100644 index 0000000..35602e9 --- /dev/null +++ b/frontend/apps/web/src/services/adminQuestions.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { createAdminQuestion, deleteAdminQuestion, listAdminQuestions } from './adminQuestions' + +describe('adminQuestions service', () => { + afterEach(() => { + localStorage.clear() + }) + + it('creates and deletes persisted questions', () => { + const created = createAdminQuestion({ + theme: 'Science', + text: 'Question test', + answer: 'Réponse test', + hint: 'Indice test', + difficulty: 'easy', + }) + + const withCreated = listAdminQuestions() + expect(withCreated.some((item) => item.id === created.id)).toBe(true) + + deleteAdminQuestion(created.id) + const afterDelete = listAdminQuestions() + expect(afterDelete.some((item) => item.id === created.id)).toBe(false) + }) +}) diff --git a/frontend/apps/web/src/services/adminQuestions.ts b/frontend/apps/web/src/services/adminQuestions.ts new file mode 100644 index 0000000..93bdf7e --- /dev/null +++ b/frontend/apps/web/src/services/adminQuestions.ts @@ -0,0 +1,73 @@ +export type AdminQuestion = { + id: string + theme: string + text: string + answer: string + hint: string + difficulty: 'easy' | 'medium' | 'hard' + isActive: boolean +} + +const STORAGE_KEY = 'kf.admin.questions' + +const seedQuestions: AdminQuestion[] = [ + { + id: 'q-1', + theme: 'Général', + text: 'Quel est le plus petit nombre premier ?', + answer: '2', + hint: 'C’est le seul nombre premier pair.', + difficulty: 'easy', + isActive: true, + }, +] + +function hasStorage(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' +} + +export function listAdminQuestions(): AdminQuestion[] { + if (!hasStorage()) return [] + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return seedQuestions + try { + const parsed = JSON.parse(raw) as AdminQuestion[] + return Array.isArray(parsed) ? parsed : seedQuestions + } catch { + return seedQuestions + } +} + +function saveAdminQuestions(items: AdminQuestion[]): void { + if (!hasStorage()) return + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(items)) +} + +export type CreateQuestionInput = { + theme: string + text: string + answer: string + hint: string + difficulty: 'easy' | 'medium' | 'hard' +} + +export function createAdminQuestion(input: CreateQuestionInput): AdminQuestion { + const next: AdminQuestion = { + id: `q-${Date.now()}`, + theme: input.theme.trim(), + text: input.text.trim(), + answer: input.answer.trim(), + hint: input.hint.trim(), + difficulty: input.difficulty, + isActive: true, + } + + const current = listAdminQuestions() + saveAdminQuestions([next, ...current]) + return next +} + +export function deleteAdminQuestion(id: string): void { + const current = listAdminQuestions() + saveAdminQuestions(current.filter((item) => item.id !== id)) +} diff --git a/frontend/apps/web/src/services/api.test.ts b/frontend/apps/web/src/services/api.test.ts new file mode 100644 index 0000000..9cc4bc3 --- /dev/null +++ b/frontend/apps/web/src/services/api.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' + +import { leaderboardClient } from './api' + +describe('leaderboardClient', () => { + it('returns deterministic mock top10 when API base URL is not configured', async () => { + const rows = await leaderboardClient.top10() + + expect(rows).toHaveLength(10) + expect(rows[0]).toMatchObject({ player: 'Alice', score: 24 }) + expect(rows[9]).toMatchObject({ player: 'Jules' }) + }) +}) diff --git a/frontend/apps/web/src/services/session.test.ts b/frontend/apps/web/src/services/session.test.ts new file mode 100644 index 0000000..3877c82 --- /dev/null +++ b/frontend/apps/web/src/services/session.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { + appendGameHistory, + loadGameHistory, + loadLastResult, + saveLastResult, + type GameResult, +} from './session' + +function fixture(score: number): GameResult { + return { + playerName: 'Alice', + finalScore: score, + answered: 5, + correct: 4, + successRate: 80, + durationSec: 120, + leaderboardPosition: 2, + finishedAt: new Date('2026-02-13T10:00:00Z').toISOString(), + } +} + +describe('session storage service', () => { + afterEach(() => { + localStorage.clear() + }) + + it('saves and loads last result', () => { + const result = fixture(10) + saveLastResult(result) + + expect(loadLastResult()).toEqual(result) + }) + + it('appends game history newest-first with max 20 entries', () => { + for (let i = 0; i < 25; i += 1) { + appendGameHistory(fixture(i)) + } + + const history = loadGameHistory() + expect(history).toHaveLength(20) + expect(history[0]?.finalScore).toBe(24) + expect(history[19]?.finalScore).toBe(5) + }) +}) diff --git a/frontend/apps/web/test-results/.last-run.json b/frontend/apps/web/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/frontend/apps/web/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/frontend/apps/web/vitest.config.ts b/frontend/apps/web/vitest.config.ts index 468fa38..b89d8b0 100644 --- a/frontend/apps/web/vitest.config.ts +++ b/frontend/apps/web/vitest.config.ts @@ -5,5 +5,7 @@ export default defineConfig({ plugins: [solid()], test: { environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], + exclude: ['e2e/**'], }, }) diff --git a/frontend/apps/web/vitest.setup.ts b/frontend/apps/web/vitest.setup.ts new file mode 100644 index 0000000..d6e7516 --- /dev/null +++ b/frontend/apps/web/vitest.setup.ts @@ -0,0 +1,7 @@ +import { cleanup } from '@solidjs/testing-library' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() + localStorage.clear() +}) diff --git a/frontend/package.json b/frontend/package.json index 83bee12..f5c93bc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,11 +13,13 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "yarn workspaces foreach -A run test", + "test:e2e": "playwright test -c apps/web/playwright.config.ts", "clean": "yarn workspaces foreach -A run clean" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", + "@playwright/test": "^1.56.1", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-solid": "^0.14.5", diff --git a/frontend/shared/ui-components/node_modules/.vite/vitest/results.json b/frontend/shared/ui-components/node_modules/.vite/vitest/results.json index 5eb15f4..1c264b3 100644 --- a/frontend/shared/ui-components/node_modules/.vite/vitest/results.json +++ b/frontend/shared/ui-components/node_modules/.vite/vitest/results.json @@ -1 +1 @@ -{"version":"1.6.1","results":[[":src/utils/timer.test.ts",{"duration":1,"failed":false}],[":src/components/AttemptIndicator.test.tsx",{"duration":18,"failed":false}],[":src/components/ResultsCard.test.tsx",{"duration":63,"failed":false}],[":src/components/LeaderboardTable.test.tsx",{"duration":27,"failed":false}],[":src/components/AnswerInput.test.tsx",{"duration":36,"failed":false}],[":src/components/HintButton.test.tsx",{"duration":64,"failed":false}]]} \ No newline at end of file +{"version":"1.6.1","results":[[":src/components/ResultsCard.test.tsx",{"duration":63,"failed":false}],[":src/components/LeaderboardTable.test.tsx",{"duration":28,"failed":false}],[":src/components/Timer.test.tsx",{"duration":25,"failed":false}],[":src/components/GameCard.test.tsx",{"duration":33,"failed":false}],[":src/components/HintButton.test.tsx",{"duration":83,"failed":false}],[":src/utils/timer.test.ts",{"duration":2,"failed":false}],[":src/components/AttemptIndicator.test.tsx",{"duration":21,"failed":false}],[":src/components/ThemeBadge.test.tsx",{"duration":18,"failed":false}],[":src/components/ScoreDisplay.test.tsx",{"duration":17,"failed":false}],[":src/components/AnswerInput.test.tsx",{"duration":34,"failed":false}]]} \ No newline at end of file diff --git a/frontend/shared/ui-components/src/components/GameCard.test.tsx b/frontend/shared/ui-components/src/components/GameCard.test.tsx new file mode 100644 index 0000000..084d59e --- /dev/null +++ b/frontend/shared/ui-components/src/components/GameCard.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@solidjs/testing-library' +import { describe, expect, it } from 'vitest' + +import GameCard from './GameCard' + +describe('GameCard', () => { + it('renders question shell and injected slots', () => { + render(() => ( + timer-slot} + scoreSlot={score-slot} + answerSlot={answer-slot} + attemptSlot={attempt-slot} + actionsSlot={actions-slot} + feedbackSlot={feedback-slot} + /> + )) + + expect(screen.getByText('Partie')).toBeTruthy() + expect(screen.getByText("Quelle est la formule de l'eau ?")).toBeTruthy() + expect(screen.getByText('timer-slot')).toBeTruthy() + expect(screen.getByText('score-slot')).toBeTruthy() + expect(screen.getByText('answer-slot')).toBeTruthy() + expect(screen.getByText('attempt-slot')).toBeTruthy() + expect(screen.getByText('actions-slot')).toBeTruthy() + expect(screen.getByText('feedback-slot')).toBeTruthy() + }) +}) diff --git a/frontend/shared/ui-components/src/components/ScoreDisplay.test.tsx b/frontend/shared/ui-components/src/components/ScoreDisplay.test.tsx new file mode 100644 index 0000000..bdc4869 --- /dev/null +++ b/frontend/shared/ui-components/src/components/ScoreDisplay.test.tsx @@ -0,0 +1,11 @@ +import { render, screen } from '@solidjs/testing-library' +import { describe, expect, it } from 'vitest' + +import ScoreDisplay from './ScoreDisplay' + +describe('ScoreDisplay', () => { + it('renders score with custom label prefix', () => { + render(() => ) + expect(screen.getByText('Points: 9')).toBeTruthy() + }) +}) diff --git a/frontend/shared/ui-components/src/components/ThemeBadge.test.tsx b/frontend/shared/ui-components/src/components/ThemeBadge.test.tsx new file mode 100644 index 0000000..4c73b2e --- /dev/null +++ b/frontend/shared/ui-components/src/components/ThemeBadge.test.tsx @@ -0,0 +1,11 @@ +import { render, screen } from '@solidjs/testing-library' +import { describe, expect, it } from 'vitest' + +import ThemeBadge from './ThemeBadge' + +describe('ThemeBadge', () => { + it('renders themed label with prefix', () => { + render(() => ) + expect(screen.getByText('Thème : Histoire')).toBeTruthy() + }) +}) diff --git a/frontend/shared/ui-components/src/components/Timer.test.tsx b/frontend/shared/ui-components/src/components/Timer.test.tsx new file mode 100644 index 0000000..bc4b096 --- /dev/null +++ b/frontend/shared/ui-components/src/components/Timer.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@solidjs/testing-library' +import { describe, expect, it } from 'vitest' + +import Timer from './Timer' + +describe('Timer', () => { + it('renders formatted time and warning text when threshold is reached', () => { + render(() => ) + + expect(screen.getByText('0:09')).toBeTruthy() + expect(screen.getByText('10 secondes restantes')).toBeTruthy() + }) + + it('suppresses warning text when showWarning is false', () => { + render(() => ) + + expect(screen.getByText('0:09')).toBeTruthy() + expect(screen.queryByText('10 secondes restantes')).toBeNull() + }) +}) diff --git a/frontend/shared/ui-components/vitest.config.ts b/frontend/shared/ui-components/vitest.config.ts index 468fa38..d51edab 100644 --- a/frontend/shared/ui-components/vitest.config.ts +++ b/frontend/shared/ui-components/vitest.config.ts @@ -5,5 +5,6 @@ export default defineConfig({ plugins: [solid()], test: { environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], }, }) diff --git a/frontend/shared/ui-components/vitest.setup.ts b/frontend/shared/ui-components/vitest.setup.ts new file mode 100644 index 0000000..21c247e --- /dev/null +++ b/frontend/shared/ui-components/vitest.setup.ts @@ -0,0 +1,6 @@ +import { cleanup } from '@solidjs/testing-library' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() +}) diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 86376e8..c5f9f81 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -688,6 +688,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.56.1": + version: 1.58.2 + resolution: "@playwright/test@npm:1.58.2" + dependencies: + playwright: "npm:1.58.2" + bin: + playwright: cli.js + checksum: 2164c03ad97c3653ff02e8818a71f3b2bbc344ac07924c9d8e31cd57505d6d37596015a41f51396b3ed8de6840f59143eaa9c21bf65515963da20740119811da + languageName: node + linkType: hard + "@popperjs/core@npm:^2.11.8": version: 2.11.8 resolution: "@popperjs/core@npm:2.11.8" @@ -2050,6 +2061,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -2060,6 +2081,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" @@ -2437,6 +2467,7 @@ __metadata: dependencies: "@eslint/eslintrc": "npm:^3.3.3" "@eslint/js": "npm:^9.39.2" + "@playwright/test": "npm:^1.56.1" eslint: "npm:^9.39.2" eslint-config-prettier: "npm:^10.1.8" eslint-plugin-solid: "npm:^0.14.5" @@ -2934,6 +2965,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.58.2": + version: 1.58.2 + resolution: "playwright-core@npm:1.58.2" + bin: + playwright-core: cli.js + checksum: 5aa15b2b764e6ffe738293a09081a6f7023847a0dbf4cd05fe10eed2e25450d321baf7482f938f2d2eb330291e197fa23e57b29a5b552b89927ceb791266225b + languageName: node + linkType: hard + +"playwright@npm:1.58.2": + version: 1.58.2 + resolution: "playwright@npm:1.58.2" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.58.2" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: d060d9b7cc124bd8b5dffebaab5e84f6b34654a553758fe7b19cc598dfbee93f6ecfbdc1832b40a6380ae04eade86ef3285ba03aa0b136799e83402246dc0727 + languageName: node + linkType: hard + "postcss@npm:^8.4.43": version: 8.5.6 resolution: "postcss@npm:8.5.6" diff --git a/tasks/security-scan.yml b/tasks/security-scan.yml index 4784172..d86f346 100644 --- a/tasks/security-scan.yml +++ b/tasks/security-scan.yml @@ -8,6 +8,7 @@ tasks: - task: backend-lint - task: frontend-lint - task: unit-tests + - task: frontend-e2e-tests - task: integration-tests - task: enforce-backend-coverage - task: docker-build-validate @@ -75,6 +76,17 @@ tasks: ' - bash -o pipefail -c 'cd frontend && CI=1 yarn test | tee ../reports/tests/frontend-unit.log' + frontend-e2e-tests: + desc: Run Playwright critical frontend flows + cmds: + - | + bash -o pipefail -c ' + set -eu + mkdir -p reports/tests + cd frontend + CI=1 yarn test:e2e --reporter=line | tee ../reports/tests/frontend-e2e.log + ' + integration-tests: cmds: - |