import { test, expect, type Page } 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 overlay and transcribe-mode UI — issue #176. * * Strategy: * - Transcription blocks are seeded via API in beforeAll — no canvas drawing in CI. * - Browser tests verify transcribe-mode toggling, annotation overlay rendering, * the visibility toggle, and scroll-sync between annotations and blocks. */ let docHref: string; let docId: string; let annotAId: string; let annotBId: string; let blockAId: string; test.describe('Annotation overlay and transcribe mode', () => { test.beforeAll(async ({ request }) => { const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; // 1. Create a document and upload a PDF so the annotation layer is active. const createRes = await request.post('/api/documents', { multipart: { title: 'E2E Annotation 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}`; const uploadRes = 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) } } }); if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`); // 2. Create two transcription blocks (each brings its own annotation). const blockARes = await request.post(`/api/documents/${docId}/transcription-blocks`, { data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.3, height: 0.1, text: 'Erste Zeile.', label: 'Anrede' } }); if (!blockARes.ok()) throw new Error(`Create block A failed: ${blockARes.status()}`); const blockA = await blockARes.json(); blockAId = blockA.id; annotAId = blockA.annotationId; const blockBRes = await request.post(`/api/documents/${docId}/transcription-blocks`, { data: { pageNumber: 1, x: 0.1, y: 0.35, width: 0.3, height: 0.1, text: 'Zweite Zeile.', label: null } }); if (!blockBRes.ok()) throw new Error(`Create block B failed: ${blockBRes.status()}`); const blockB = await blockBRes.json(); annotBId = blockB.annotationId; }); /** * Navigate to the document, enter transcribe mode, and wait until the PDF * has fully rendered (page counter appears) and the annotation rect is visible. * Centralises the timing gate used by multiple tests. */ async function openTranscribeMode(page: Page, annotationId: string) { await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkribieren' }).click(); // Wait for the PDF to finish loading — the page counter only renders when totalPages > 0 await page.locator('.tabular-nums').waitFor({ timeout: 15_000 }); // Wait for annotation rect (annotations API) and at least one block textarea (blocks API) // to be ready — these are two independent fetches. await Promise.all([ page.locator(`[data-testid="annotation-${annotationId}"]`).waitFor({ timeout: 10_000 }), page.getByRole('textbox').first().waitFor({ timeout: 10_000 }) ]); } // ─── Transcribe mode toggle ──────────────────────────────────────────────── test('Transkribieren button is visible on a PDF document', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/annotation-transcribe-btn.png' }); }); test('clicking Transkribieren enters transcribe mode and shows the Fertig button', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkribieren' }).click(); await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Transkribieren' })).not.toBeVisible(); await page.screenshot({ path: 'test-results/e2e/annotation-transcribe-mode-active.png' }); }); test('clicking Fertig exits transcribe mode and restores the Transkribieren button', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkribieren' }).click(); await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible(); await page.getByRole('button', { name: 'Fertig' }).click(); await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Fertig' })).not.toBeVisible(); }); test('pressing Escape exits transcribe mode', async ({ page }) => { test.setTimeout(30_000); await page.goto(docHref); await page.waitForSelector('[data-hydrated]'); await page.getByRole('button', { name: 'Transkribieren' }).click(); await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible(); await page.keyboard.press('Escape'); await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible(); }); // ─── Annotation overlay rendering ───────────────────────────────────────── test('annotation rects are rendered on the PDF after entering transcribe mode', async ({ page }) => { test.setTimeout(40_000); await openTranscribeMode(page, annotAId); await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toBeVisible(); await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/annotation-rects-rendered.png' }); }); test('numbered badges appear on annotation rects', async ({ page }) => { test.setTimeout(40_000); await openTranscribeMode(page, annotAId); const annotA = page.locator(`[data-testid="annotation-${annotAId}"]`); await expect(annotA.locator('div', { hasText: '1' })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/annotation-numbered-badges.png' }); }); // ─── Annotation visibility toggle ───────────────────────────────────────── test('annotation visibility toggle button appears when annotations exist', async ({ page }) => { test.setTimeout(40_000); await openTranscribeMode(page, annotAId); await expect(page.getByRole('button', { name: 'Annotierungen verbergen' })).toBeVisible(); }); test('clicking the visibility toggle hides annotation rects', async ({ page }) => { test.setTimeout(40_000); await openTranscribeMode(page, annotAId); await page.getByRole('button', { name: 'Annotierungen verbergen' }).click(); await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).not.toBeVisible(); await expect(page.getByRole('button', { name: 'Annotierungen anzeigen' })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/annotation-hidden.png' }); }); test('clicking the visibility toggle again restores annotation rects', async ({ page }) => { test.setTimeout(40_000); await openTranscribeMode(page, annotAId); await page.getByRole('button', { name: 'Annotierungen verbergen' }).click(); await page.getByRole('button', { name: 'Annotierungen anzeigen' }).click(); await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toBeVisible(); }); // ─── Scroll-sync: annotation → block ────────────────────────────────────── test('clicking an annotation rect scrolls the matching block into view in the right panel', async ({ page }) => { test.setTimeout(40_000); await openTranscribeMode(page, annotAId); await page.locator(`[data-testid="annotation-${annotAId}"]`).click(); await expect(page.locator(`[data-block-id="${blockAId}"]`)).toBeVisible({ timeout: 5_000 }); await page.screenshot({ path: 'test-results/e2e/annotation-click-scroll-sync.png' }); }); test('clicking annotation B activates the corresponding block in the panel', async ({ page }) => { test.setTimeout(40_000); await openTranscribeMode(page, annotBId); await page.locator(`[data-testid="annotation-${annotBId}"]`).click(); // Block B's annotation should become active (full opacity), A's should dim await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toHaveCSS('opacity', '1'); await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toHaveCSS( 'opacity', '0.3' ); }); // ─── Scroll-sync: block → annotation (dimming) ──────────────────────────── test('focusing a block dims all other annotation rects', async ({ page }) => { test.setTimeout(40_000); await openTranscribeMode(page, annotAId); // Focus block A's textarea to set it as active await page.getByRole('textbox').first().click(); // Non-active annotation (B) must be dimmed await expect(page.locator(`[data-testid="annotation-${annotBId}"]`)).toHaveCSS( 'opacity', '0.3' ); // Active annotation (A) must be at full opacity await expect(page.locator(`[data-testid="annotation-${annotAId}"]`)).toHaveCSS('opacity', '1'); await page.screenshot({ path: 'test-results/e2e/annotation-dimming.png' }); }); // ─── Accessibility ───────────────────────────────────────────────────────── test('transcribe mode passes axe accessibility check', async ({ page }) => { test.setTimeout(40_000); await openTranscribeMode(page, annotAId); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toHaveLength(0); }); });