import { test, expect } from '@playwright/test'; import path from 'path'; import { fileURLToPath } from 'url'; import fs from 'fs'; import { AxeBuilder } from '@axe-core/playwright'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); /** * E2E tests for the annotation-backed transcription system — issue #176. * * Strategy: * - Transcription blocks are created via API in beforeAll (no need to draw on canvas in CI). * - Browser tests verify rendering, editing, auto-save feedback, reordering, deletion, and a11y. */ let docHref: string; let docId: string; test.describe('Transcription panel', () => { test.beforeAll(async ({ request }) => { const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; // 1. Create a document with a PDF so the Transkription tab is meaningful. const createRes = await request.post('/api/documents', { multipart: { title: 'E2E Transkription Test', documentDate: '1945-05-08' } }); if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); const doc = await createRes.json(); docId = doc.id; docHref = `${baseURL}/documents/${docId}`; await request.put(`/api/documents/${docId}`, { multipart: { title: doc.title, documentDate: '1945-05-08', file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: fs.readFileSync(PDF_FIXTURE) } } }); // 2. Create a document_annotation so we can attach blocks to it. const annotARes = await request.post(`/api/documents/${docId}/annotations`, { data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.1, color: '#00C7B1' } }); if (!annotARes.ok()) throw new Error(`Create annotation A failed: ${annotARes.status()}`); const annotA = await annotARes.json(); const annotBRes = await request.post(`/api/documents/${docId}/annotations`, { data: { pageNumber: 1, x: 0.1, y: 0.3, width: 0.2, height: 0.1, color: '#00C7B1' } }); if (!annotBRes.ok()) throw new Error(`Create annotation B failed: ${annotBRes.status()}`); const annotB = await annotBRes.json(); // 3. Create two transcription blocks via API. const blockARes = await request.post(`/api/documents/${docId}/transcription-blocks`, { data: { pageNumber: 1, x: annotA.x, y: annotA.y, width: annotA.width, height: annotA.height, text: 'Liebe Mutter,', label: 'Anrede' } }); if (!blockARes.ok()) throw new Error(`Create block A failed: ${blockARes.status()}`); await blockARes.json(); const blockBRes = await request.post(`/api/documents/${docId}/transcription-blocks`, { data: { pageNumber: 1, x: annotB.x, y: annotB.y, width: annotB.width, height: annotB.height, text: 'ich schreibe dir aus Breslau.', label: null } }); if (!blockBRes.ok()) throw new Error(`Create block B failed: ${blockBRes.status()}`); await blockBRes.json(); }); // ─── Tab visibility ──────────────────────────────────────────────────────── test('Transkription tab is visible in the bottom panel tab bar', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await expect(page.getByRole('button', { name: 'Transkription' })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/transcription-tab-visible.png' }); }); // ─── Block rendering ────────────────────────────────────────────────────── test('blocks are rendered in sort order with correct text and label', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkription' }).click(); await page.waitForSelector('[data-testid="bottom-panel-content"]'); await expect(page.getByText('Liebe Mutter,')).toBeVisible(); await expect(page.getByText('ich schreibe dir aus Breslau.')).toBeVisible(); // Label for block A await expect(page.getByText('Anrede')).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/transcription-blocks-rendered.png' }); }); test('block numbers are rendered in turquoise badge', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkription' }).click(); await page.waitForSelector('[data-testid="bottom-panel-content"]'); // Block 1 and 2 badges must be visible await expect(page.getByText('1').first()).toBeVisible(); await expect(page.getByText('2').first()).toBeVisible(); }); test('next-block CTA shows Block 3 hint after two blocks', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkription' }).click(); await page.waitForSelector('[data-testid="bottom-panel-content"]'); await expect(page.getByText(/Block 3/)).toBeVisible(); }); // ─── Text editing & auto-save feedback ──────────────────────────────────── test('editing a block shows "Speichere..." then "Gespeichert" indicator', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkription' }).click(); await page.waitForSelector('[data-testid="bottom-panel-content"]'); const firstTextarea = page.getByRole('textbox').first(); await firstTextarea.click(); await firstTextarea.fill('Liebe Mutter, ich bin wohlauf.'); // "Speichere..." should appear (debounce triggers after 1.5s) await expect(page.getByText(/Speichere\.\.\./)).toBeVisible({ timeout: 5000 }); // After save completes, "Gespeichert ✓" appears await expect(page.getByText(/Gespeichert/)).toBeVisible({ timeout: 8000 }); await page.screenshot({ path: 'test-results/e2e/transcription-autosave.png' }); }); test('edited text persists after page reload', async ({ page }) => { test.setTimeout(40_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkription' }).click(); await page.waitForSelector('[data-testid="bottom-panel-content"]'); const firstTextarea = page.getByRole('textbox').first(); await firstTextarea.fill('Persistierter Text'); // Wait for auto-save to complete await expect(page.getByText(/Gespeichert/)).toBeVisible({ timeout: 8000 }); // Reload await page.reload(); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkription' }).click(); await expect(page.getByText('Persistierter Text')).toBeVisible(); }); // ─── Block reordering ───────────────────────────────────────────────────── test('move-up button is disabled on the first block', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkription' }).click(); await page.waitForSelector('[data-testid="bottom-panel-content"]'); const upButtons = page.getByRole('button', { name: 'Nach oben' }); await expect(upButtons.first()).toBeDisabled(); }); test('move-down button is disabled on the last block', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkription' }).click(); await page.waitForSelector('[data-testid="bottom-panel-content"]'); const downButtons = page.getByRole('button', { name: 'Nach unten' }); await expect(downButtons.last()).toBeDisabled(); }); test('clicking move-down on the first block swaps block order', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkription' }).click(); await page.waitForSelector('[data-testid="bottom-panel-content"]'); const textareas = page.getByRole('textbox'); const before = await textareas.first().inputValue(); const downButtons = page.getByRole('button', { name: 'Nach unten' }); await downButtons.first().click(); // After reorder, the block that was second should now appear first const after = await textareas.first().inputValue(); expect(after).not.toBe(before); await page.screenshot({ path: 'test-results/e2e/transcription-reorder.png' }); }); // ─── Block deletion ─────────────────────────────────────────────────────── test('cancelling delete confirmation keeps the block', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkription' }).click(); await page.waitForSelector('[data-testid="bottom-panel-content"]'); // Dismiss the confirm dialog automatically page.once('dialog', (dialog) => dialog.dismiss()); const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first(); await deleteBtn.click(); // Block should still be present await expect(page.getByRole('textbox').first()).toBeVisible(); }); // ─── Comment thread ─────────────────────────────────────────────────────── test('clicking Kommentieren button opens comment compose in the block', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkription' }).click(); await page.waitForSelector('[data-testid="bottom-panel-content"]'); await page.getByText('Kommentieren').first().click(); await expect(page.getByPlaceholder(/Kommentar/)).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/transcription-comment-open.png' }); }); // ─── Accessibility ──────────────────────────────────────────────────────── test('transcription panel passes axe accessibility check', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkription' }).click(); await page.waitForSelector('[data-testid="bottom-panel-content"]'); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toHaveLength(0); }); // ─── Empty state ────────────────────────────────────────────────────────── test('shows empty state when document has no transcription blocks', async ({ page, request }) => { test.setTimeout(30_000); const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; const emptyDocRes = await request.post('/api/documents', { multipart: { title: 'E2E Empty Transcription Test' } }); if (!emptyDocRes.ok()) throw new Error(`Create empty doc failed: ${emptyDocRes.status()}`); const emptyDoc = await emptyDocRes.json(); await page.goto(`${baseURL}/documents/${emptyDoc.id}`); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkription' }).click(); await page.waitForSelector('[data-testid="bottom-panel-content"]'); await expect(page.getByText(/Markiere einen Bereich/)).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/transcription-empty-state.png' }); }); });