Implemented step '6.2 Frontend Testing Strategy'

master
oabrivard 1 month ago
parent 05d950218c
commit bbc93bd46b

@ -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

@ -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

@ -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`)

@ -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)
})
})

@ -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
}
},

@ -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}]]}
{"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}]]}

@ -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,
},
})

@ -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 = () => {
<Route path="/results" component={ResultsRoute} />
<Route path="/leaderboard" component={LeaderboardRoute} />
<Route path="/profile" component={ProfileRoute} />
<Route path="/admin/questions" component={AdminQuestionsRoute} />
</Router>
)
}

@ -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 => (
<a href={props.href} aria-label={props['aria-label']}>
{props.children}
</a>
),
}))
describe('AppShell', () => {
it('renders title and primary navigation actions', () => {
render(() => <AppShell>{<div>content</div>}</AppShell>)
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()
})
})

@ -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<RouteSectionProps> = (props) => {
return (
@ -36,6 +37,14 @@ const AppShell: Component<RouteSectionProps> = (props) => {
<IconButton component={A} href="/profile" aria-label="Profile" color="inherit">
<PersonIcon />
</IconButton>
<IconButton
component={A}
href="/admin/questions"
aria-label="Admin Questions"
color="inherit"
>
<AdminPanelSettingsIcon />
</IconButton>
</Stack>
</Toolbar>
</AppBar>

@ -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()
})
})
})

@ -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()
})
})
})

@ -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(() => <AdminQuestionsRoute />)
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()
})
})

@ -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<AdminQuestion[]>(listAdminQuestions())
const [form, setForm] = createSignal<FormState>(defaultState)
const [error, setError] = createSignal<string | null>(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 (
<Box>
<Stack spacing={2}>
<Typography variant="h4" sx={{ fontWeight: 800 }}>
Admin - Questions
</Typography>
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Stack spacing={2}>
<Typography variant="h6">Créer une question</Typography>
{error() && <Typography color="error">{error()}</Typography>}
<TextField
label="Theme"
value={form().theme}
onInput={(event) => updateField('theme', readInputValue(event))}
inputProps={{ 'data-testid': 'admin-theme-input' }}
fullWidth
/>
<TextField
label="Question"
value={form().text}
onInput={(event) => updateField('text', readInputValue(event))}
inputProps={{ 'data-testid': 'admin-question-input' }}
fullWidth
/>
<TextField
label="Réponse"
value={form().answer}
onInput={(event) => updateField('answer', readInputValue(event))}
inputProps={{ 'data-testid': 'admin-answer-input' }}
fullWidth
/>
<TextField
label="Indice"
value={form().hint}
onInput={(event) => updateField('hint', readInputValue(event))}
inputProps={{ 'data-testid': 'admin-hint-input' }}
fullWidth
/>
<TextField
select
label="Difficulté"
value={form().difficulty}
onChange={(event) => updateField('difficulty', readInputValue(event))}
inputProps={{ 'data-testid': 'admin-difficulty-select' }}
fullWidth
>
<MenuItem value="easy">easy</MenuItem>
<MenuItem value="medium">medium</MenuItem>
<MenuItem value="hard">hard</MenuItem>
</TextField>
<Button variant="contained" onClick={submit} data-testid="admin-create-question">
Ajouter la question
</Button>
</Stack>
</CardContent>
</Card>
<Card variant="outlined" sx={{ bgcolor: '#111827', borderColor: '#1f2937' }}>
<CardContent>
<Stack spacing={1}>
<Typography variant="h6">Questions existantes</Typography>
<For each={items()}>
{(item) => (
<Stack spacing={1} sx={{ border: '1px solid #1f2937', p: 1.5, borderRadius: 1 }}>
<Typography sx={{ fontWeight: 700 }}>{item.text}</Typography>
<Typography sx={{ opacity: 0.8 }}>Theme: {item.theme}</Typography>
<Typography sx={{ opacity: 0.8 }}>Réponse: {item.answer}</Typography>
<Button
variant="outlined"
color="error"
onClick={() => remove(item.id)}
data-testid={`admin-delete-${item.id}`}
>
Supprimer
</Button>
</Stack>
)}
</For>
</Stack>
</CardContent>
</Card>
</Stack>
</Box>
)
}

@ -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(() => <HomeRoute />)
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(() => <HomeRoute />)
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()
})
})

@ -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' }}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>

@ -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(() => <ProfileRoute />)
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()
})
})

@ -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' } }}

@ -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)
})
})

@ -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: 'Cest 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))
}

@ -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' })
})
})

@ -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)
})
})

@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

@ -5,5 +5,7 @@ export default defineConfig({
plugins: [solid()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
exclude: ['e2e/**'],
},
})

@ -0,0 +1,7 @@
import { cleanup } from '@solidjs/testing-library'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
localStorage.clear()
})

@ -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",

@ -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}]]}
{"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}]]}

@ -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(() => (
<GameCard
title="Partie"
theme="Science"
questionText="Quelle est la formule de l'eau ?"
timerSlot={<span>timer-slot</span>}
scoreSlot={<span>score-slot</span>}
answerSlot={<span>answer-slot</span>}
attemptSlot={<span>attempt-slot</span>}
actionsSlot={<span>actions-slot</span>}
feedbackSlot={<span>feedback-slot</span>}
/>
))
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()
})
})

@ -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(() => <ScoreDisplay score={9} labelPrefix="Points:" />)
expect(screen.getByText('Points: 9')).toBeTruthy()
})
})

@ -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(() => <ThemeBadge theme="Histoire" labelPrefix="Thème : " />)
expect(screen.getByText('Thème : Histoire')).toBeTruthy()
})
})

@ -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(() => <Timer remainingMs={9_000} />)
expect(screen.getByText('0:09')).toBeTruthy()
expect(screen.getByText('10 secondes restantes')).toBeTruthy()
})
it('suppresses warning text when showWarning is false', () => {
render(() => <Timer remainingMs={9_000} showWarning={false} />)
expect(screen.getByText('0:09')).toBeTruthy()
expect(screen.queryByText('10 secondes restantes')).toBeNull()
})
})

@ -5,5 +5,6 @@ export default defineConfig({
plugins: [solid()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
},
})

@ -0,0 +1,6 @@
import { cleanup } from '@solidjs/testing-library'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})

@ -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<compat/fsevents>":
version: 2.3.2
resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>::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<compat/fsevents>, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin<compat/fsevents>":
version: 2.3.3
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin<compat/fsevents>::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"

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

Loading…
Cancel
Save