Files
familienarchiv/frontend/e2e/documents.spec.ts
Marcel 5fb6a1eec0 feat(frontend): replace iframe with PDF.js viewer (#39)
- Install pdfjs-dist v5 and add optimizeDeps pre-bundle config
- New PdfViewer.svelte component: renders each page on a <canvas> with
  correct device-pixel-ratio scaling, overlays a text layer (enables
  text selection; foundation for annotations in #40), prev/next
  navigation, zoom controls, and lazy page rendering (only current ±1
  pre-fetched — avoids freezing on multi-page documents)
- Replace the <iframe> in documents/[id]/+page.svelte with PdfViewer;
  image attachments continue to use <img>; detection now uses
  doc.contentType instead of filename extension
- Unit tests for navigation controls and page counter (pdfjs mocked)
- E2E tests: PDF renders as canvas (not iframe), nav controls visible,
  image fallback stays as <img>; minimal.pdf fixture for upload tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:10:02 +01:00

221 lines
9.1 KiB
TypeScript

import { test, expect } from '@playwright/test';
import path from 'path';
/**
* Document management E2E tests.
* Assumes auth setup has run (storageState is applied by playwright.config.ts).
* Assumes the backend has at least one document in the database.
*/
test.describe('Document list', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Wait for SvelteKit hydration to complete so onclick/oninput handlers are active.
await page.waitForSelector('[data-hydrated]');
});
test('renders the search bar and document list', async ({ page }) => {
await expect(page.getByPlaceholder('Suche in Titel, Inhalt, Ort...')).toBeVisible();
await expect(page.getByRole('link', { name: /Neues Dokument/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/documents-home.png' });
});
test('navigation bar shows active state for Dokumente', async ({ page }) => {
const navLink = page.getByRole('navigation').getByRole('link', { name: 'Dokumente' });
await expect(navLink).toHaveClass(/text-brand-navy/);
});
test('text search filters the document list', async ({ page }) => {
// Navigate directly with the query param — tests that search results are filtered
// correctly without depending on the debounced oninput → goto chain in CI.
await page.goto('/?q=zzz_unlikely_to_match_anything');
await expect(page.getByText('Keine Dokumente gefunden')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/documents-search-no-results.png' });
});
test('clearing the search returns all documents', async ({ page }) => {
// Navigate with an active query first, then click the reset link.
await page.goto('/?q=xyz_unlikely');
await page.getByTitle('Filter zurücksetzen').click();
await page.waitForURL('/');
await expect(page).toHaveURL('/');
await page.screenshot({ path: 'test-results/e2e/documents-reset-search.png' });
});
test('advanced filters panel opens and closes', async ({ page }) => {
const btn = page.getByRole('button', { name: 'Filter', exact: true });
await btn.click();
await expect(page.getByLabel('Von')).toBeVisible();
await expect(page.getByLabel('Bis')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/documents-filters-open.png' });
await btn.click();
await expect(page.getByLabel('Von')).not.toBeVisible();
});
test('date range filter triggers a new search', async ({ page }) => {
await page.getByRole('button', { name: 'Filter', exact: true }).click();
await page.getByLabel('Von').fill('2000-01-01');
await page.waitForURL(/from=2000-01-01/);
await expect(page).toHaveURL(/from=2000-01-01/);
await page.screenshot({ path: 'test-results/e2e/documents-date-filter.png' });
});
});
test.describe('Document detail', () => {
test('clicking a document opens the detail page', async ({ page }) => {
await page.goto('/');
// Click the first document link in the list
const firstDoc = page.locator('ul li a').first();
const href = await firstDoc.getAttribute('href');
await firstDoc.click();
await expect(page).toHaveURL(href!);
await page.screenshot({ path: 'test-results/e2e/document-detail.png' });
});
});
test.describe('New document', () => {
test('renders the upload form', async ({ page }) => {
await page.goto('/documents/new');
await expect(page.getByRole('heading', { name: /Neues Dokument/i })).toBeVisible();
await expect(page.getByLabel('Titel')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-new.png' });
});
});
test.describe('Document creation', () => {
test('user fills in a title and lands on the new document detail page', async ({ page }) => {
await page.goto('/documents/new');
await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('E2E Testbrief');
await page.getByRole('button', { name: /Speichern/i }).click();
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-create.png' });
});
});
test.describe('Document editing', () => {
test('user opens an existing document, changes the title, and sees the update', async ({
page
}) => {
// Find the document created in the previous describe
await page.goto('/?q=E2E+Testbrief');
await page.waitForSelector('[data-hydrated]');
const docLink = page.getByRole('link', { name: 'E2E Testbrief' }).first();
const href = await docLink.getAttribute('href');
await page.goto(`${href}/edit`);
await page.waitForSelector('[data-hydrated]');
await page.getByLabel('Titel').fill('E2E Testbrief (überarbeitet)');
await page.getByRole('button', { name: /Speichern/i }).click();
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
await expect(page.getByText('E2E Testbrief (überarbeitet)')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-edit-save.png' });
});
});
test.describe('Document edit', () => {
test('renders the edit form with pre-filled data', async ({ page }) => {
// Navigate to home, find first document, go to its edit page
await page.goto('/');
const firstDocLink = page.locator('ul li a').first();
const href = await firstDocLink.getAttribute('href');
await page.goto(`${href}/edit`);
await expect(page.getByRole('heading', { name: /Bearbeiten/i })).toBeVisible();
await expect(page.getByLabel('Titel')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-edit.png' });
});
test('shows a validation error for an invalid date format', async ({ page }) => {
await page.goto('/');
const firstDocLink = page.locator('ul li a').first();
const href = await firstDocLink.getAttribute('href');
await page.goto(`${href}/edit`);
// Wait for hydration so oninput={handleDateInput} is registered.
await page.waitForSelector('[data-hydrated]');
const dateInput = page.getByLabel('Datum');
// Type partial digits: '99' → dateDisplay='99', dateIso='' → dateInvalid=true
await dateInput.fill('');
await dateInput.pressSequentially('99');
await expect(page.getByText(/TT\.MM\.JJJJ/i)).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
});
});
// ─── PDF Viewer ───────────────────────────────────────────────────────────────
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
test.describe('PDF viewer', () => {
let pdfDocHref: string;
test.beforeAll(async ({ browser }) => {
// Create a document and upload the PDF fixture so later tests have a
// real file attached. Runs once for the whole describe block.
const ctx = await browser.newContext();
const p = await ctx.newPage();
await p.goto('/documents/new');
await p.waitForSelector('[data-hydrated]');
await p.getByLabel('Titel').fill('E2E PDF Viewer Test');
await p.getByRole('button', { name: /Speichern/i }).click();
await p.waitForURL(/\/documents\/[^/]+$/);
// Upload the PDF on the edit page
const href = p.url().replace(/\/$/, '');
pdfDocHref = href;
await p.goto(`${href}/edit`);
await p.waitForSelector('[data-hydrated]');
await p.locator('input[type="file"][name="file"]').setInputFiles(PDF_FIXTURE);
await p.getByRole('button', { name: /Speichern/i }).click();
await p.waitForURL(/\/documents\/[^/]+$/);
await ctx.close();
});
test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({
page
}) => {
await page.goto(pdfDocHref);
await page.waitForSelector('[data-hydrated]');
// There must be NO iframe — we replaced it with PDF.js canvas rendering.
await expect(page.locator('iframe')).not.toBeAttached();
// At least one canvas element must be visible (one per rendered page).
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 15000 });
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-canvas.png' });
});
test('page navigation controls are visible', async ({ page }) => {
await page.goto(pdfDocHref);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 15000 });
await expect(page.getByRole('button', { name: /prev|previous|zurück|vorige/i })).toBeVisible();
await expect(page.getByRole('button', { name: /next|weiter|nächste/i })).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-nav.png' });
});
test('non-PDF attachment renders as an img element, not canvas', async ({ page }) => {
// The seed document "Urlaubspostkarte Ostsee" has a .jpg original filename.
// Navigate to it and confirm an <img> is used (no canvas, no iframe).
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.goto('/?q=Urlaubspostkarte');
const link = page.getByRole('link', { name: /Urlaubspostkarte/i }).first();
const href = await link.getAttribute('href');
await page.goto(href!);
await page.waitForSelector('[data-hydrated]');
// No canvas — this is an image document
await expect(page.locator('canvas')).not.toBeAttached();
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-image-fallback.png' });
});
});