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
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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:
- |