From 9dd7d74c704f377461270f73717ac8435e78f436 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Sat, 14 Feb 2026 14:16:21 +0100 Subject: [PATCH] Added additional fullstack end to end tests --- .../fullstack-e2e-integration-tests.md | 52 +++ .../apps/web/e2e/full-stack/helpers/stack.ts | 50 +++ .../e2e/full-stack/parity.integration.spec.ts | 305 ++++++++++++++++++ .../node_modules/.vite/vitest/results.json | 2 +- frontend/apps/web/test-results/.last-run.json | 22 +- frontend/package.json | 4 +- frontend/scripts/check-e2e-parity.mjs | 59 ++++ .../node_modules/.vite/vitest/results.json | 2 +- 8 files changed, 491 insertions(+), 5 deletions(-) create mode 100644 docs/4_work_plan/fullstack-e2e-integration-tests.md create mode 100644 frontend/apps/web/e2e/full-stack/parity.integration.spec.ts create mode 100755 frontend/scripts/check-e2e-parity.mjs diff --git a/docs/4_work_plan/fullstack-e2e-integration-tests.md b/docs/4_work_plan/fullstack-e2e-integration-tests.md new file mode 100644 index 0000000..c297466 --- /dev/null +++ b/docs/4_work_plan/fullstack-e2e-integration-tests.md @@ -0,0 +1,52 @@ +# Full-Stack E2E Integration Tests Plan + +## Goal +Ensure full-stack end-to-end integration tests cover at least the same scenarios as frontend-only E2E tests, while adding backend and database assertions where feasible. + +## Strategy +1. Build a source-to-fullstack scenario parity matrix. +2. Implement full-stack tests that mirror source scenario names. +3. Add typed DB helpers for deterministic assertions. +4. Add a parity gate script that fails if full-stack coverage drops below source coverage. +5. Wire parity and full-stack execution into standard local commands. + +## Coverage Matrix +| Source E2E Scenario | Full-Stack Equivalent | +| --- | --- | +| player registration and demo login flow | player registration and demo login flow | +| complete game session flow | complete game session flow | +| leaderboard viewing flow | leaderboard viewing flow | +| admin question management flow | admin question management flow | +| profile route requires auth, then allows access after demo login | profile route requires auth, then allows access after demo login | +| start new game from home and render first playable question | start new game from home and render first playable question | +| hint usage reduces awarded score for a correct answer | hint usage reduces awarded score for a correct answer | +| after 3 wrong answers, game advances to next question | after 3 wrong answers, game advances to next question | +| session timeout ends game and redirects to results | session timeout ends game and redirects to results | +| results remain visible after page reload | results remain visible after page reload | +| leaderboard shows empty and error states from API | leaderboard shows empty and error states from API | +| admin can create, edit, then delete a question | admin can create, edit, then delete a question | +| admin form validation blocks invalid submission | admin form validation blocks invalid submission | +| mobile player flow keeps core controls visible | mobile player flow keeps core controls visible | + +## Implementation Phases + +### Phase 1: Parity Suite +- Add a dedicated full-stack parity spec mirroring all source E2E scenario names. +- Keep tests serial and deterministic. + +### Phase 2: DB and API Assertion Helpers +- Extend full-stack helpers with typed assertion/query utilities. +- Add scenario-specific reset/seed calls before each test. + +### Phase 3: Parity Gate +- Add a script that parses source and full-stack test names. +- Fail if any source test name has no full-stack counterpart. + +### Phase 4: Command Wiring +- Add package script for parity check. +- Keep `test:e2e:fullstack` as execution command for the integration suite. + +## Exit Criteria +- All source E2E scenarios have matching full-stack scenario names. +- Full-stack suite passes. +- Parity check passes and can be run locally/CI. diff --git a/frontend/apps/web/e2e/full-stack/helpers/stack.ts b/frontend/apps/web/e2e/full-stack/helpers/stack.ts index 89ed7f5..845adf7 100644 --- a/frontend/apps/web/e2e/full-stack/helpers/stack.ts +++ b/frontend/apps/web/e2e/full-stack/helpers/stack.ts @@ -54,6 +54,23 @@ export function queryScalar(dbName: string, sql: string): string { ]) } +export function queryRows(dbName: string, sql: string): string { + return run('docker', [ + 'exec', + postgresContainer, + 'psql', + '-U', + 'knowfoolery', + '-d', + dbName, + '-A', + '-F', + '|', + '-c', + sql, + ]) +} + export function resetDatabases(): void { queryScalar('questions', 'TRUNCATE TABLE questions RESTART IDENTITY CASCADE;') queryScalar( @@ -76,6 +93,39 @@ ON CONFLICT (id) DO NOTHING; ) } +export function countActiveQuestions(): number { + return Number.parseInt( + queryScalar('questions', 'SELECT COUNT(*) FROM questions WHERE is_active = true;'), + 10 + ) +} + +export function countLeaderboardEntries(): number { + return Number.parseInt( + queryScalar('leaderboards', 'SELECT COUNT(*) FROM leaderboard_entries;'), + 10 + ) +} + +export function countLeaderboardEntriesByPlayer(playerID: string): number { + return Number.parseInt( + queryScalar( + 'leaderboards', + `SELECT COUNT(*) FROM leaderboard_entries WHERE player_id = ${sqlLiteral(playerID)};` + ), + 10 + ) +} + +export function questionIsActive(questionID: string): boolean { + return ( + queryScalar( + 'questions', + `SELECT is_active::text FROM questions WHERE id = ${sqlLiteral(questionID)};` + ) === 'true' + ) +} + export async function bringUpFullStack(): Promise { fs.copyFileSync(envFile, prodEnvFile) diff --git a/frontend/apps/web/e2e/full-stack/parity.integration.spec.ts b/frontend/apps/web/e2e/full-stack/parity.integration.spec.ts new file mode 100644 index 0000000..005a257 --- /dev/null +++ b/frontend/apps/web/e2e/full-stack/parity.integration.spec.ts @@ -0,0 +1,305 @@ +import { expect, test, type Page } from '@playwright/test' + +import { + countActiveQuestions, + countLeaderboardEntriesByPlayer, + questionIsActive, + resetDatabases, + seedQuestions, +} from './helpers/stack' + +const apiBase = process.env.FULLSTACK_API_BASE_URL ?? 'http://127.0.0.1:18096' + +async function setInputValue(selector: string, value: string, page: Page): Promise { + await page.locator(selector).evaluate((el, next) => { + const input = el as HTMLInputElement + input.value = next + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + }, value) +} + +async function assertBackendReachable( + request: Parameters[0]['request'] +): Promise { + const resp = await request.post(`${apiBase}/api/v1/questions/random`, { data: {} }) + expect(resp.ok()).toBeTruthy() + expect(countActiveQuestions()).toBeGreaterThan(0) +} + +test.describe('full-stack parity with frontend e2e scenarios', () => { + test.beforeEach(async ({ page }) => { + resetDatabases() + seedQuestions() + await page.goto('/') + await page.evaluate(() => { + localStorage.clear() + }) + }) + + test('player registration and demo login flow', async ({ page, request }) => { + 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() + + await assertBackendReachable(request) + }) + + test('complete game session flow', async ({ page, request }) => { + 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() + + await assertBackendReachable(request) + }) + + test('leaderboard viewing flow', async ({ page, request }) => { + const playerID = `player-${Date.now()}` + const playerName = `Parity${Date.now().toString().slice(-4)}` + const resp = await request.post(`${apiBase}/api/v1/leaderboard/update`, { + data: { + session_id: `sess-${Date.now()}`, + player_id: playerID, + player_name: playerName, + total_score: 11, + questions_asked: 6, + questions_correct: 5, + hints_used: 1, + duration_seconds: 123, + completed_at: new Date().toISOString(), + completion_type: 'completed', + }, + }) + expect(resp.ok()).toBeTruthy() + + 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(playerName)).toBeVisible() + expect(countLeaderboardEntriesByPlayer(playerID)).toBe(1) + }) + + test('admin question management flow', async ({ page, request }) => { + 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) + + const deleteResp = await request.delete( + `${apiBase}/api/v1/admin/questions/00000000-0000-0000-0000-000000000001` + ) + expect(deleteResp.status()).toBe(204) + expect(questionIsActive('00000000-0000-0000-0000-000000000001')).toBeFalsy() + }) + + test('profile route requires auth, then allows access after demo login', async ({ + page, + request, + }) => { + 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() + await expect(page.getByText(/Parties jouées/i)).toBeVisible() + await assertBackendReachable(request) + }) + + test('start new game from home and render first playable question', async ({ page, request }) => { + await page.goto('/') + await setInputValue('[data-testid="home-player-name-input"]', 'E2E Player', page) + await page.getByRole('button', { name: 'Démarrer la partie' }).click() + + await expect(page).toHaveURL(/\/game$/) + await expect(page.getByRole('heading', { name: 'Partie' })).toBeVisible() + await expect(page.getByText('Thème : Général')).toBeVisible() + await expect(page.getByText('Quel est le plus petit nombre premier ?')).toBeVisible() + await expect(page.getByLabel('Ta réponse')).toBeVisible() + await expect(page.getByText('3 restant(s)')).toBeVisible() + await assertBackendReachable(request) + }) + + test('hint usage reduces awarded score for a correct answer', async ({ page, request }) => { + await page.goto('/game') + + await page.getByRole('button', { name: 'Indice (score réduit)' }).click() + await expect(page.getByText('Confirmer l’indice')).toBeVisible() + await page.locator('button', { hasText: 'Oui, utiliser un indice' }).click() + await expect(page.getByText(/Indice:/i)).toBeVisible() + + await setInputValue('[data-testid="game-answer-input"]', '2', page) + await page.getByRole('button', { name: 'Envoyer' }).click() + await expect(page.getByText('Score: 1')).toBeVisible() + + await setInputValue('[data-testid="game-answer-input"]', 'mars', page) + await page.getByRole('button', { name: 'Envoyer' }).click() + await expect(page).toHaveURL(/\/results$/) + await expect(page.getByText('Score final : 3')).toBeVisible() + await assertBackendReachable(request) + }) + + test('after 3 wrong answers, game advances to next question', async ({ page, request }) => { + await page.goto('/game') + await setInputValue('[data-testid="game-answer-input"]', 'wrong', page) + await page.getByRole('button', { name: 'Envoyer' }).click() + await setInputValue('[data-testid="game-answer-input"]', 'wrong', page) + await page.getByRole('button', { name: 'Envoyer' }).click() + await setInputValue('[data-testid="game-answer-input"]', 'wrong', page) + await page.getByRole('button', { name: 'Envoyer' }).click() + + await expect(page).toHaveURL(/\/game$/) + await expect(page.getByText('Quelle planète est surnommée la planète rouge ?')).toBeVisible() + await expect(page.getByText("Plus d'essais.")).not.toBeVisible() + await assertBackendReachable(request) + }) + + test('session timeout ends game and redirects to results', async ({ page, request }) => { + await page.goto('/') + await page.evaluate(() => { + localStorage.setItem('kf.e2e.timerMs', '300') + }) + await page.goto('/game') + await expect(page.getByRole('heading', { name: 'Partie' })).toBeVisible() + await expect(page).toHaveURL(/\/results$/) + await expect(page.getByText('Score final : 0')).toBeVisible() + await assertBackendReachable(request) + }) + + test('results remain visible after page reload', async ({ page, request }) => { + await page.goto('/') + await page.evaluate(() => { + localStorage.setItem( + 'kf.lastResult', + JSON.stringify({ + playerName: 'Reload Tester', + finalScore: 7, + answered: 4, + correct: 3, + successRate: 75, + durationSec: 95, + leaderboardPosition: 2, + finishedAt: new Date().toISOString(), + }) + ) + }) + await page.goto('/results') + await expect(page.getByText('Score final : 7')).toBeVisible() + await page.reload() + await expect(page.getByText('Score final : 7')).toBeVisible() + await expect(page.getByText('Position leaderboard : #2')).toBeVisible() + await assertBackendReachable(request) + }) + + test('leaderboard shows empty and error states from API', async ({ page }) => { + await page.goto('/') + await page.evaluate(() => { + localStorage.setItem('kf.leaderboard.mode', 'api') + }) + await page.goto('/leaderboard') + await expect(page.getByText('Aucun score pour le moment.')).toBeVisible() + + await page.route('**/leaderboard/top10', async (route) => { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'boom' }), + }) + }) + await page.getByRole('button', { name: 'Rafraîchir' }).click() + await expect( + page.getByText('Impossible de charger le leaderboard. Veuillez réessayer.') + ).toBeVisible() + }) + + test('admin can create, edit, then delete a question', async ({ request }) => { + const createResp = await request.post(`${apiBase}/api/v1/admin/questions`, { + data: { + theme: 'Science', + text: 'Quelle planète est rouge ?', + answer: 'Mars', + hint: '4e planète', + difficulty: 'medium', + }, + }) + expect(createResp.status()).toBe(201) + const created = await createResp.json() + const id = created.data.id as string + + const updateResp = await request.put(`${apiBase}/api/v1/admin/questions/${id}`, { + data: { + theme: 'Science', + text: 'Quelle planète est appelée planète rouge ?', + answer: 'Mars', + hint: '4e planète', + difficulty: 'hard', + is_active: true, + }, + }) + expect(updateResp.ok()).toBeTruthy() + + const deleteResp = await request.delete(`${apiBase}/api/v1/admin/questions/${id}`) + expect(deleteResp.status()).toBe(204) + expect(questionIsActive(id)).toBeFalsy() + }) + + test('admin form validation blocks invalid submission', async ({ page }) => { + await page.goto('/admin/questions') + await page.getByTestId('admin-theme-input').fill('') + await page.getByTestId('admin-question-input').fill('') + await page.getByTestId('admin-answer-input').fill('') + await page.getByTestId('admin-create-question').click() + await expect(page.getByText('Theme, question et réponse sont requis.')).toBeVisible() + }) +}) + +test.describe('full-stack parity mobile scenario', () => { + test.use({ viewport: { width: 390, height: 844 } }) + + test.beforeEach(async ({ page }) => { + resetDatabases() + seedQuestions() + await page.goto('/') + await page.evaluate(() => { + localStorage.clear() + }) + }) + + test('mobile player flow keeps core controls visible', async ({ page, request }) => { + await page.goto('/') + await setInputValue('[data-testid="home-player-name-input"]', 'Mobile Player', page) + await page.getByRole('button', { name: 'Démarrer la partie' }).click() + + await expect(page).toHaveURL(/\/game$/) + await expect(page.getByRole('button', { name: 'Envoyer' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Indice (score réduit)' })).toBeVisible() + await expect(page.getByLabel('Ta réponse')).toBeVisible() + await assertBackendReachable(request) + }) +}) diff --git a/frontend/apps/web/node_modules/.vite/vitest/results.json b/frontend/apps/web/node_modules/.vite/vitest/results.json index 8676962..20774b8 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/session.test.ts",{"duration":4,"failed":false}],[":src/services/adminQuestions.test.ts",{"duration":3,"failed":false}],[":src/hooks/useTimer.test.ts",{"duration":4,"failed":false}],[":src/services/api.test.ts",{"duration":5,"failed":false}],[":src/services/validation.test.ts",{"duration":2,"failed":false}],[":src/hooks/useAuth.test.ts",{"duration":2,"failed":false}],[":src/routes/Home.test.tsx",{"duration":134,"failed":false}],[":src/components/AppShell.test.tsx",{"duration":51,"failed":false}],[":src/routes/Profile.test.tsx",{"duration":117,"failed":false}],[":src/routes/AdminQuestions.test.tsx",{"duration":139,"failed":false}],[":src/routes/Results.test.tsx",{"duration":36,"failed":false}],[":src/routes/Leaderboard.test.tsx",{"duration":98,"failed":false}],[":src/routes/Game.test.tsx",{"duration":163,"failed":false}]]} \ No newline at end of file +{"version":"1.6.1","results":[[":src/services/session.test.ts",{"duration":4,"failed":false}],[":src/hooks/useTimer.test.ts",{"duration":4,"failed":false}],[":src/services/adminQuestions.test.ts",{"duration":3,"failed":false}],[":src/services/api.test.ts",{"duration":5,"failed":false}],[":src/hooks/useAuth.test.ts",{"duration":2,"failed":false}],[":src/services/validation.test.ts",{"duration":3,"failed":false}],[":src/routes/Home.test.tsx",{"duration":122,"failed":false}],[":src/routes/Profile.test.tsx",{"duration":128,"failed":false}],[":src/routes/AdminQuestions.test.tsx",{"duration":134,"failed":false}],[":src/components/AppShell.test.tsx",{"duration":48,"failed":false}],[":src/routes/Leaderboard.test.tsx",{"duration":84,"failed":false}],[":src/routes/Results.test.tsx",{"duration":36,"failed":false}],[":src/routes/Game.test.tsx",{"duration":155,"failed":false}]]} \ No newline at end of file diff --git a/frontend/apps/web/test-results/.last-run.json b/frontend/apps/web/test-results/.last-run.json index cbcc1fb..d07bb6d 100644 --- a/frontend/apps/web/test-results/.last-run.json +++ b/frontend/apps/web/test-results/.last-run.json @@ -1,4 +1,22 @@ { - "status": "passed", - "failedTests": [] + "status": "failed", + "failedTests": [ + "847833760f0942085220-1471dfd4442c71d0da76", + "83ca259d6a4f6f998541-f0145fd6eb222509d174", + "1a5533bf5eebe0cc515b-70ca5f1873820161b53d", + "1a5533bf5eebe0cc515b-782c944811d8f92e614e", + "1a5533bf5eebe0cc515b-e26e4613ad89959638dc", + "1a5533bf5eebe0cc515b-a725afc66ce7751fb5ab", + "1a5533bf5eebe0cc515b-e53427a85937ee7063c0", + "1a5533bf5eebe0cc515b-5ff8ce13eba7e3f027d8", + "1a5533bf5eebe0cc515b-87eaacb7a57bf52b9585", + "1a5533bf5eebe0cc515b-1112346923e720f1a0d3", + "1a5533bf5eebe0cc515b-543b02dd778e8efeb628", + "1a5533bf5eebe0cc515b-565e338e9eceb24acbdc", + "1a5533bf5eebe0cc515b-e9ad1e0b8fd00d286c5d", + "1a5533bf5eebe0cc515b-b28a31c390b26a2b570f", + "1a5533bf5eebe0cc515b-0056a4ff41c1b12788a8", + "1a5533bf5eebe0cc515b-a8b41500e036db4cd4b2", + "b3734b29a45147dc0d58-6c2492472bac455a96da" + ] } \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 23ca8d4..642bedd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,9 @@ "test": "yarn workspaces foreach -A run test", "test:e2e": "playwright test -c apps/web/playwright.config.ts", "test:e2e:fullstack": "playwright test -c apps/web/playwright.fullstack.config.ts", - "clean": "yarn workspaces foreach -A run clean" + "clean": "yarn workspaces foreach -A run clean", + "test:e2e:parity": "node scripts/check-e2e-parity.mjs", + "test:e2e:fullstack:all": "yarn test:e2e:parity && yarn test:e2e:fullstack" }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", diff --git a/frontend/scripts/check-e2e-parity.mjs b/frontend/scripts/check-e2e-parity.mjs new file mode 100755 index 0000000..d54e89d --- /dev/null +++ b/frontend/scripts/check-e2e-parity.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import fs from 'node:fs' +import path from 'node:path' + +const root = path.resolve(process.cwd()) +const sourceSpecs = [ + path.join(root, 'apps/web/e2e/critical-flows.spec.ts'), + path.join(root, 'apps/web/e2e/critical-flows-extended.spec.ts'), +] +const fullstackDir = path.join(root, 'apps/web/e2e/full-stack') + +function read(filePath) { + return fs.readFileSync(filePath, 'utf8') +} + +function extractTestNames(content) { + const names = [] + const regex = /\btest\s*\(\s*(['"])(.*?)\1/g + let match + while ((match = regex.exec(content)) !== null) { + names.push(match[2]) + } + return names +} + +function listFullstackSpecs(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.spec.ts')) + .map((entry) => path.join(dir, entry.name)) +} + +const sourceNames = new Set() +for (const spec of sourceSpecs) { + for (const name of extractTestNames(read(spec))) { + sourceNames.add(name) + } +} + +const fullstackNames = new Set() +for (const spec of listFullstackSpecs(fullstackDir)) { + for (const name of extractTestNames(read(spec))) { + fullstackNames.add(name) + } +} + +const missing = [...sourceNames] + .filter((name) => !fullstackNames.has(name)) + .sort((a, b) => a.localeCompare(b)) + +if (missing.length > 0) { + console.error('Full-stack parity check failed. Missing scenarios:') + for (const name of missing) { + console.error(`- ${name}`) + } + process.exit(1) +} + +console.log(`Full-stack parity check passed (${sourceNames.size} source scenarios covered).`) 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 4382dd2..ae56d21 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/components/ResultsCard.test.tsx",{"duration":64,"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":30,"failed":false}],[":src/components/HintButton.test.tsx",{"duration":75,"failed":false}],[":src/utils/timer.test.ts",{"duration":1,"failed":false}],[":src/components/ThemeBadge.test.tsx",{"duration":18,"failed":false}],[":src/components/AttemptIndicator.test.tsx",{"duration":20,"failed":false}],[":src/components/ScoreDisplay.test.tsx",{"duration":18,"failed":false}],[":src/components/AnswerInput.test.tsx",{"duration":35,"failed":false}]]} \ No newline at end of file +{"version":"1.6.1","results":[[":src/utils/timer.test.ts",{"duration":2,"failed":false}],[":src/components/AttemptIndicator.test.tsx",{"duration":24,"failed":false}],[":src/components/ResultsCard.test.tsx",{"duration":72,"failed":false}],[":src/components/ThemeBadge.test.tsx",{"duration":20,"failed":false}],[":src/components/ScoreDisplay.test.tsx",{"duration":21,"failed":false}],[":src/components/Timer.test.tsx",{"duration":29,"failed":false}],[":src/components/GameCard.test.tsx",{"duration":39,"failed":false}],[":src/components/LeaderboardTable.test.tsx",{"duration":36,"failed":false}],[":src/components/AnswerInput.test.tsx",{"duration":41,"failed":false}],[":src/components/HintButton.test.tsx",{"duration":80,"failed":false}]]} \ No newline at end of file