diff --git a/.gitignore b/.gitignore index 73f3706..7e044ee 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,10 @@ pids # Coverage directory used by tools like istanbul coverage/ +# Playwright artifacts +playwright-report/ +test-results/ + # Dependency directories .npm .pnpm diff --git a/docs/E2E-TESTING.md b/docs/E2E-TESTING.md index ab374e2..98cc0d2 100644 --- a/docs/E2E-TESTING.md +++ b/docs/E2E-TESTING.md @@ -22,6 +22,12 @@ npx playwright install chromium ## Running Tests ```bash +# Run with Playwright-managed dev server (recommended) +npm run test:e2e + +# If you want to reuse an already-running dev server, start it on 5177: +# npm run dev -- --port 5177 --strictPort --host 127.0.0.1 +# then run: # Run all E2E tests npm run test:e2e @@ -103,7 +109,7 @@ These logs are visible in: **Fixes Applied**: - Restored missing `DiffResultsRepo.ts` file -- Fixed port configuration (5173 everywhere) +- Fixed port configuration (dedicated 5177 for E2E) - Fixed database name ('lexicon-forge' instead of 'LexiconForge') - Updated test assertions to match actual log messages diff --git a/playwright-report/index.html b/playwright-report/index.html deleted file mode 100644 index 8cf3300..0000000 --- a/playwright-report/index.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - Playwright Test Report - - - - -
- - - \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts index b119a03..47d64a8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -26,13 +26,14 @@ export default defineConfig({ // Reporter to use reporter: [ - ['html'], + ['html', { open: 'never' }], ['list'] ], use: { // Base URL to use in actions like `await page.goto('/')` - baseURL: 'http://localhost:5173', + // Use a dedicated port to avoid collisions with other local dev servers. + baseURL: 'http://127.0.0.1:5177', // Collect trace when retrying the failed test trace: 'on-first-retry', @@ -52,11 +53,11 @@ export default defineConfig({ }, ], - // Use existing dev server instead of starting a new one - // webServer: { - // command: 'npm run dev', - // url: 'http://localhost:5177', - // reuseExistingServer: true, - // timeout: 120 * 1000, - // }, + // Start a dedicated dev server for E2E to avoid “wrong app on same port” issues. + webServer: { + command: 'npm run dev -- --port 5177 --strictPort --host 127.0.0.1', + url: 'http://127.0.0.1:5177', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, }); diff --git a/tests/e2e/chapterview-large.spec.ts b/tests/e2e/chapterview-large.spec.ts new file mode 100644 index 0000000..b493794 --- /dev/null +++ b/tests/e2e/chapterview-large.spec.ts @@ -0,0 +1,157 @@ +import { test, expect } from '@playwright/test'; +import { importSessionFromFile, prepareFreshApp } from './helpers/sessionHarness'; + +const buildLargeTranslationHtml = (paragraphCount: number, fillerRepeat: number): string => { + const filler = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(fillerRepeat).trim(); + const paragraphs: string[] = []; + for (let i = 0; i < paragraphCount; i += 1) { + paragraphs.push(`Paragraph ${i + 1}: ${filler}`); + } + return paragraphs.join('

'); +}; + +test.describe('ChapterView (E2E) — Large Translation + Diff Navigation', () => { + // This spec does a fresh app boot + session import; give the beforeEach enough headroom + // when running alongside other parallel E2E tests. + test.describe.configure({ timeout: 60_000 }); + + test.beforeEach(async ({ page }) => { + await prepareFreshApp(page, { + // Enable diff heatmap to validate marker layout in real browser geometry. + appSettings: { showDiffHeatmap: true }, + }); + }); + + test('imports a large session and renders within a reasonable time', async ({ page }) => { + const chapterId = 'fixture-large-chapter'; + const paragraphCount = 120; + const translation = buildLargeTranslationHtml(paragraphCount, 12); + + const sessionData = { + metadata: { format: 'lexiconforge-full-1', exportedAt: new Date().toISOString() }, + settings: null, + urlMappings: [], + novels: [], + promptTemplates: [], + amendmentLogs: [], + chapters: [ + { + stableId: chapterId, + url: 'https://example.com/large', + canonicalUrl: 'https://example.com/large', + title: 'Large Fixture Chapter', + content: 'Raw content (fixture)', + nextUrl: null, + prevUrl: null, + chapterNumber: 1, + fanTranslation: null, + feedback: [], + translations: [ + { + id: 'translation-1', + version: 1, + translatedTitle: 'Large Fixture Chapter', + translation, + proposal: null, + footnotes: [], + suggestedIllustrations: [], + usageMetrics: { + totalTokens: 100, + promptTokens: 60, + completionTokens: 40, + estimatedCost: 0.0, + requestTime: 0, + provider: 'Gemini', + model: 'gemini-2.5-flash', + }, + provider: 'Gemini', + model: 'gemini-2.5-flash', + temperature: 0.7, + systemPrompt: 'fixture', + promptId: null, + promptName: null, + isActive: true, + createdAt: new Date().toISOString(), + }, + ], + }, + ], + diffResults: [ + { + chapterId, + aiVersionId: 'ai-v1', + fanVersionId: null, + rawVersionId: 'raw-v1', + algoVersion: '1.0.0', + analyzedAt: Date.now(), + costUsd: 0, + model: 'gpt-4o-mini', + markers: [ + { + chunkId: 'fixture-marker-0', + colors: ['blue'], + reasons: ['fan-divergence'], + explanations: ['Fixture marker'], + aiRange: { start: 0, end: 10 }, + position: 0, + }, + { + chunkId: 'fixture-marker-mid', + colors: ['orange'], + reasons: ['raw-divergence'], + explanations: ['Fixture marker'], + aiRange: { start: 0, end: 10 }, + position: 60, + }, + { + chunkId: 'fixture-marker-last', + colors: ['grey'], + reasons: ['stylistic'], + explanations: ['Fixture marker'], + aiRange: { start: 0, end: 10 }, + position: 119, + }, + ], + }, + ], + }; + + const t0 = Date.now(); + await importSessionFromFile(page, sessionData); + + await page + .locator('span[data-lf-type="text"][data-lf-chunk]') + .first() + .waitFor({ state: 'visible' }); + + const ms = Date.now() - t0; + console.log(`[E2E] Large session import+render took ${ms}ms`); + expect(ms).toBeLessThan(15_000); + + const paragraphs = page.locator('[data-testid^="diff-paragraph-"]'); + await expect(paragraphs).toHaveCount(paragraphCount); + + const pips = page.locator('[data-testid^="diff-pip-"]'); + await expect(pips).toHaveCount(3); + + // Clicking a later marker should scroll the corresponding paragraph into view. + const lastPip = pips.nth(2); + const pos = await lastPip.getAttribute('data-diff-position'); + expect(pos).toBe('119'); + + const lastPara = page.locator(`[data-testid^="diff-paragraph-"][data-diff-position=\"${pos}\"]`); + await lastPip.locator('button').first().click(); + + // Verify the paragraph intersects viewport (scroll happened). + await expect + .poll(async () => { + return await lastPara.evaluate((el) => { + const r = el.getBoundingClientRect(); + const vh = window.innerHeight || document.documentElement.clientHeight; + return r.bottom > 0 && r.top < vh; + }); + }, { timeout: 5_000 }) + .toBe(true); + }); +}); + diff --git a/tests/e2e/chapterview-media.spec.ts b/tests/e2e/chapterview-media.spec.ts new file mode 100644 index 0000000..5aa5f20 --- /dev/null +++ b/tests/e2e/chapterview-media.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from '@playwright/test'; +import { importSessionFromFile, prepareFreshApp } from './helpers/sessionHarness'; + +const ONE_BY_ONE_PNG = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII='; + +test.describe('ChapterView (E2E) — Media Rendering', () => { + test.beforeEach(async ({ page }) => { + await prepareFreshApp(page); + }); + + test('renders inline illustrations from suggestedIllustrations', async ({ page }) => { + const chapterId = 'fixture-media-chapter'; + const sessionData = { + metadata: { format: 'lexiconforge-full-1', exportedAt: new Date().toISOString() }, + settings: null, + urlMappings: [], + novels: [], + promptTemplates: [], + chapters: [ + { + stableId: chapterId, + url: 'https://example.com/media', + canonicalUrl: 'https://example.com/media', + title: 'Media Fixture Chapter', + content: 'Raw content (fixture)', + nextUrl: null, + prevUrl: null, + chapterNumber: 1, + fanTranslation: null, + feedback: [], + translations: [ + { + id: 'translation-1', + version: 1, + translatedTitle: 'Media Fixture Chapter', + translation: 'Some text.

[ILLUSTRATION-1]

More text.', + proposal: null, + footnotes: [], + suggestedIllustrations: [ + { + placementMarker: '[ILLUSTRATION-1]', + imagePrompt: 'A tiny red square.', + generatedImage: { + imageData: ONE_BY_ONE_PNG, + requestTime: 0, + cost: 0, + metadata: { + version: 1, + prompt: 'A tiny red square.', + generatedAt: new Date().toISOString(), + }, + }, + }, + ], + usageMetrics: { + totalTokens: 42, + promptTokens: 20, + completionTokens: 22, + estimatedCost: 0, + requestTime: 0, + provider: 'Gemini', + model: 'gemini-2.5-flash', + }, + provider: 'Gemini', + model: 'gemini-2.5-flash', + temperature: 0.7, + systemPrompt: 'fixture', + promptId: null, + promptName: null, + isActive: true, + createdAt: new Date().toISOString(), + }, + ], + }, + ], + diffResults: [], + amendmentLogs: [], + }; + + await importSessionFromFile(page, sessionData); + + // Illustration should render as an from the persisted base64 payload. + const illustration = page.getByAltText('A tiny red square.').first(); + await expect(illustration).toBeVisible(); + await expect(illustration).toHaveAttribute('src', ONE_BY_ONE_PNG); + + // Audio player should render and expose the generate button (enabled by default task type). + const audioGenerateButton = page.locator('button[title^=\"Generate background music\"]').first(); + await expect(audioGenerateButton).toBeVisible(); + await expect(audioGenerateButton).toBeEnabled(); + }); +}); diff --git a/tests/e2e/debug-console.spec.ts b/tests/e2e/debug-console.spec.ts index f03a02b..a25cfbb 100644 --- a/tests/e2e/debug-console.spec.ts +++ b/tests/e2e/debug-console.spec.ts @@ -1,6 +1,8 @@ import { test, expect } from '@playwright/test'; -test('capture console logs on page load', async ({ page }) => { +const testDiagnostics = process.env.LF_E2E_DIAGNOSTICS === '1' ? test : test.skip; + +testDiagnostics('capture console logs on page load', async ({ page }) => { const logs: string[] = []; page.on('console', msg => { @@ -15,10 +17,10 @@ test('capture console logs on page load', async ({ page }) => { console.log(text); }); - console.log('Navigating to http://localhost:5173/...'); + console.log('Navigating to baseURL / ...'); try { - await page.goto('http://localhost:5173/', { + await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 10000 }); diff --git a/tests/e2e/diagnostic.spec.ts b/tests/e2e/diagnostic.spec.ts index dd5252d..492e528 100644 --- a/tests/e2e/diagnostic.spec.ts +++ b/tests/e2e/diagnostic.spec.ts @@ -10,7 +10,10 @@ import { test, expect } from '@playwright/test'; -test.describe('Initialization Diagnostics', () => { +const describeDiagnostics = + process.env.LF_E2E_DIAGNOSTICS === '1' ? test.describe : test.describe.skip; + +describeDiagnostics('Initialization Diagnostics', () => { test('Step 1: Page loads and React renders', async ({ page }) => { const logs: string[] = []; const errors: string[] = []; @@ -28,7 +31,7 @@ test.describe('Initialization Diagnostics', () => { }); console.log('\n=== STEP 1: Navigation ==='); - await page.goto('http://localhost:5173/', { + await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 }); @@ -67,7 +70,7 @@ test.describe('Initialization Diagnostics', () => { }); console.log('\n=== STEP 2: Store Initialization ==='); - await page.goto('http://localhost:5173/', { waitUntil: 'domcontentloaded' }); + await page.goto('/', { waitUntil: 'domcontentloaded' }); // Wait for initialization await page.waitForTimeout(5000); @@ -91,7 +94,7 @@ test.describe('Initialization Diagnostics', () => { test('Step 3: IndexedDB database state', async ({ page }) => { console.log('\n=== STEP 3: IndexedDB Database State ==='); - await page.goto('http://localhost:5173/', { waitUntil: 'domcontentloaded' }); + await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(5000); // Check what databases exist @@ -146,7 +149,7 @@ test.describe('Initialization Diagnostics', () => { }); console.log('\n=== STEP 4: Full Initialization Wait ==='); - await page.goto('http://localhost:5173/', { waitUntil: 'domcontentloaded' }); + await page.goto('/', { waitUntil: 'domcontentloaded' }); // Check for initialization spinner const hasSpinner = await page.locator('text=Initializing Session').count(); @@ -194,7 +197,7 @@ test.describe('Initialization Diagnostics', () => { }); console.log('\n=== STEP 5: StrictMode Double-Render Check ==='); - await page.goto('http://localhost:5173/', { waitUntil: 'domcontentloaded' }); + await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(5000); console.log(`\ninitializeStore calls detected: ${initCalls.length}`); diff --git a/tests/e2e/helpers/sessionHarness.ts b/tests/e2e/helpers/sessionHarness.ts new file mode 100644 index 0000000..d865f3b --- /dev/null +++ b/tests/e2e/helpers/sessionHarness.ts @@ -0,0 +1,59 @@ +import type { Page } from '@playwright/test'; + +const DEFAULT_DB_NAMES = ['lexicon-forge']; + +export async function clearIndexedDB(page: Page, dbNames: string[] = DEFAULT_DB_NAMES): Promise { + await page.evaluate((names) => { + return new Promise((resolve, reject) => { + let remaining = names.length; + if (remaining === 0) { + resolve(); + return; + } + + names.forEach((dbName) => { + const request = indexedDB.deleteDatabase(dbName); + request.onsuccess = () => { + console.log('[TEST] Deleted database:', dbName); + remaining -= 1; + if (remaining === 0) resolve(); + }; + request.onerror = () => reject(request.error); + }); + }); + }, dbNames); +} + +export async function prepareFreshApp( + page: Page, + options: { appSettings?: Record } = {} +): Promise { + if (options.appSettings) { + await page.addInitScript((appSettings) => { + localStorage.clear(); + localStorage.setItem('app-settings', JSON.stringify(appSettings)); + }, options.appSettings); + } + + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await clearIndexedDB(page); + await page.reload({ waitUntil: 'domcontentloaded' }); + + // App only renders InputBar after initialization completes. + await page + .locator('input[placeholder^="Paste chapter URL"]') + .first() + .waitFor({ state: 'visible' }); +} + +export async function importSessionFromFile(page: Page, payload: unknown, name = 'session.json'): Promise { + await page.setInputFiles('input[type="file"]', { + name, + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify(payload), 'utf-8'), + }); + + // Wait until ChapterView content is rendered (session imported + chapter selected). + await page.locator('[data-translation-content]').first().waitFor({ state: 'visible' }); +} +