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' });
+}
+