import { expect, test, type Download, type Locator, type Page } from '@playwright/test'; import { decodePDFRawStream, degrees, PDFArray, PDFDocument, PDFRawStream, StandardFonts, rgb } from 'pdf-lib'; import { PDFDocument as EncryptablePdfDocument } from '@cantoo/pdf-lib'; import { unzipSync, zipSync } from 'fflate'; import { readFile } from 'node:fs/promises'; const createPdf = async (pageCount: number, label: string) => { const document = await PDFDocument.create(); const font = await document.embedFont(StandardFonts.Helvetica); for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) { const page = document.addPage([420, 594]); page.drawText(`${label} – Seite ${pageNumber}`, { x: 40, y: 520, size: 20, font, color: rgb(0.1, 0.2, 0.4) }); } return Buffer.from(await document.save()); }; const createStampPng = (page: Page) => page.evaluate( async () => new Promise((resolve, reject) => { const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32; const context = canvas.getContext('2d'); if (!context) { reject(new Error('Kein 2D-Kontext')); return; } context.fillStyle = '#cc1a1a'; context.fillRect(0, 0, 32, 32); canvas.toBlob(async (blob) => { if (!blob) { reject(new Error('PNG konnte nicht erzeugt werden')); return; } const bytes = new Uint8Array(await blob.arrayBuffer()); let binary = ''; bytes.forEach((byte) => { binary += String.fromCharCode(byte); }); resolve(btoa(binary)); }, 'image/png'); }) ); const dropFiles = async ( page: Page, target: Locator | 'window', files: Array<{ name: string; mimeType: string; buffer: Buffer }> ) => { await page.locator('[data-dropzone-ready="true"]').first().waitFor(); const dataTransfer = await page.evaluateHandle( (droppedFiles) => { const transfer = new DataTransfer(); for (const droppedFile of droppedFiles) { const bytes = Uint8Array.from(atob(droppedFile.base64), (character) => character.charCodeAt(0) ); transfer.items.add( new File([bytes], droppedFile.name, { type: droppedFile.mimeType, lastModified: Date.now() }) ); } return transfer; }, files.map((file) => ({ name: file.name, mimeType: file.mimeType, base64: file.buffer.toString('base64') })) ); if (target === 'window') { await page.evaluate((transfer) => { window.dispatchEvent( new DragEvent('drop', { dataTransfer: transfer, bubbles: true, cancelable: true }) ); }, dataTransfer); } else { await target.dispatchEvent('drop', { dataTransfer }); } await dataTransfer.dispose(); }; const expectPdfDownload = async (download: Download, name: RegExp) => { expect(download.suggestedFilename()).toMatch(name); const path = await download.path(); expect(path).not.toBeNull(); const bytes = await readFile(path!); expect(bytes.subarray(0, 5).toString()).toBe('%PDF-'); return bytes; }; const getPageContentStreamCount = (document: PDFDocument, pageIndex: number) => { const contents = document.getPage(pageIndex).node.Contents(); if (!contents) return 0; return contents instanceof PDFArray ? contents.size() : 1; }; const getPageContentText = (document: PDFDocument, pageIndex: number) => { const contents = document.getPage(pageIndex).node.Contents(); const refs = contents instanceof PDFArray ? contents.asArray() : contents ? [contents] : []; return refs .map((ref) => document.context.lookup(ref)) .filter((stream): stream is PDFRawStream => stream instanceof PDFRawStream) .map((stream) => Buffer.from(decodePDFRawStream(stream).decode()).toString('latin1')) .join('\n'); }; const createEncryptedPdf = async (pageCount: number, label: string, password: string) => { const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label)); document.encrypt({ userPassword: password, ownerPassword: 'owner-passwort', permissions: { printing: true, copying: true } }); return Buffer.from(await document.save()); }; const createPreRotatedPdf = async (pageCount: number, label: string) => { const document = await PDFDocument.create(); const font = await document.embedFont(StandardFonts.Helvetica); for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) { const page = document.addPage([420, 594]); page.drawText(`${label} – Seite ${pageNumber}`, { x: 40, y: 520, size: 20, font, color: rgb(0.1, 0.2, 0.4) }); if (pageNumber === 1) page.setRotation(degrees(90)); } return Buffer.from(await document.save()); }; const trackPageErrors = (page: Page) => { const errors: Error[] = []; page.on('pageerror', (error) => errors.push(error)); return errors; }; const pdfDropzone = (page: Page) => page.getByRole('button', { name: 'PDF-Dateien hinzufügen' }); const zipDropzone = (page: Page) => page.getByRole('button', { name: 'beA-ZIP-Dateien hinzufügen' }); const createZip = async (label: string, pageCount = 1) => Buffer.from(zipSync({ [`${label}.pdf`]: await createPdf(pageCount, label) })); const newZipButton = (page: Page) => page.getByRole('button', { name: 'Neue ZIP bearbeiten' }); const settingsButton = (page: Page) => page.getByRole('button', { name: 'Einstellungen', exact: true }); const replaceZipInput = (page: Page) => page.locator('input[data-zip-picker="replace-zip"]'); const appendZipInput = (page: Page) => page.locator('input[data-zip-picker="append-zip"]'); const workspaceStatus = (page: Page, pattern: RegExp) => page.getByText(pattern); const waitForLoadedWorkspace = async ( page: Page, status: RegExp, options?: { timeout?: number } ) => { await expect(newZipButton(page)).toBeVisible(); await expect(workspaceStatus(page, status)).toBeVisible({ timeout: options?.timeout ?? 15_000 }); }; const replaceThroughDialog = async (page: Page, zip: Buffer, confirm: boolean) => { await newZipButton(page).click(); await replaceZipInput(page).setInputFiles([ { name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zip } ]); await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toBeVisible(); if (confirm) { await page.getByRole('button', { name: 'Neue ZIP öffnen' }).click(); } else { await page.getByRole('button', { name: 'Abbrechen' }).click(); } await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toBeHidden(); }; test('start page exposes only working tools and forwards a dropped ZIP to beA', async ({ page }) => { const pageErrors = trackPageErrors(page); const pdf = await createPdf(1, 'beA'); const zip = Buffer.from(zipSync({ 'anlage.pdf': pdf })); await page.goto('/'); await expect(page.getByRole('heading', { name: 'Werkzeugkasten für beA' })).toBeVisible(); await expect(page.getByRole('link', { name: /Stempeln/ })).toBeVisible(); await expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0); await dropFiles(page, 'window', [ { name: 'bea-archiv.zip', mimeType: 'application/zip', buffer: zip } ]); await expect(page).toHaveURL(/\/tools\/bea$/); await expect(page.getByText('1 lose Datei')).toBeVisible({ timeout: 15_000 }); expect(pageErrors).toEqual([]); }); test('beA reports an invalid ZIP and lets the user remove it', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/bea'); await dropFiles(page, zipDropzone(page), [ { name: 'defektes-archiv.zip', mimeType: 'application/zip', buffer: Buffer.from('kein gueltiges ZIP-Archiv') } ]); await expect(page.getByText('defektes-archiv.zip', { exact: true })).toBeVisible(); await expect(page.getByText('Konnte nicht geöffnet werden.')).toBeVisible(); await expect(page.getByRole('status')).toContainText('1 fehlgeschlagen'); await expect(page.getByRole('button', { name: 'Erneut versuchen' })).toBeVisible(); await page.getByRole('button', { name: 'Entfernen' }).click(); await expect(zipDropzone(page)).toBeVisible(); expect(pageErrors).toEqual([]); }); test('short tool pages keep the footer flush with the viewport bottom', async ({ page }) => { const expectFooterAtViewportBottom = async (viewport: { width: number; height: number }) => { await page.setViewportSize(viewport); await page.goto('/tools/watermark'); const footerBottom = await page.locator('footer').evaluate((footer) => { return footer.getBoundingClientRect().bottom; }); expect(Math.abs(footerBottom - viewport.height)).toBeLessThanOrEqual(1); }; await expectFooterAtViewportBottom({ width: 1440, height: 900 }); await expectFooterAtViewportBottom({ width: 390, height: 844 }); }); test('merge accepts dropped PDFs and downloads a merged PDF', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/merge'); await dropFiles(page, pdfDropzone(page), [ { name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') }, { name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') } ]); await expect(page.getByText('2 Dateien ausgewählt')).toBeVisible(); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'PDFs zusammenfügen' }).click(); await expectPdfDownload(await downloadPromise, /^zusammengefuegt\.pdf$/); expect(pageErrors).toEqual([]); }); test('separate renders dropped PDF pages and removes a selected page', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/separate'); await dropFiles(page, pdfDropzone(page), [ { name: 'seiten.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Trennen') } ]); await expect(page.getByRole('button', { name: /Seite 1/ })).toBeVisible({ timeout: 15_000 }); await page.getByRole('button', { name: /Seite 2/ }).click(); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Seiten entfernen & herunterladen' }).click(); await expectPdfDownload(await downloadPromise, /^seiten_ohne_seiten\.pdf$/); expect(pageErrors).toEqual([]); }); test('rotate turns pages losslessly via /Rotate and reset clears deltas', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/rotate'); await dropFiles(page, pdfDropzone(page), [ { name: 'drehung.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Drehen') } ]); const downloadButton = page.getByRole('button', { name: 'Drehen & herunterladen' }); await expect(page.getByRole('button', { name: 'Seite 2 um 90° nach rechts drehen' })).toBeVisible( { timeout: 15_000 } ); await expect(downloadButton).toBeDisabled(); const rotateRightPage2 = page.getByRole('button', { name: 'Seite 2 um 90° nach rechts drehen' }); await rotateRightPage2.click(); await rotateRightPage2.click(); await expect(page.getByText('1 von 3 Seiten gedreht')).toBeVisible(); await expect(page.getByText('180°', { exact: true })).toBeVisible(); const downloadPromise = page.waitForEvent('download'); await downloadButton.click(); const bytes = await expectPdfDownload(await downloadPromise, /^drehung_gedreht\.pdf$/); const output = await PDFDocument.load(bytes); expect(output.getPage(1).getRotation().angle).toBe(180); expect(output.getPage(0).getRotation().angle).toBe(0); await page.getByRole('button', { name: 'Zurücksetzen' }).click(); await expect(page.getByText('0 von 3 Seiten gedreht')).toBeVisible(); await expect(downloadButton).toBeDisabled(); expect(pageErrors).toEqual([]); }); test('rotate adds the delta on top of an existing /Rotate value', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/rotate'); await dropFiles(page, pdfDropzone(page), [ { name: 'vorgedreht.pdf', mimeType: 'application/pdf', buffer: await createPreRotatedPdf(2, 'Vorgedreht') } ]); await expect(page.getByRole('button', { name: 'Seite 1 um 90° nach rechts drehen' })).toBeVisible( { timeout: 15_000 } ); await page.getByRole('button', { name: 'Seite 1 um 90° nach rechts drehen' }).click(); await expect(page.getByText('1 von 2 Seiten gedreht')).toBeVisible(); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Drehen & herunterladen' }).click(); const bytes = await expectPdfDownload(await downloadPromise, /^vorgedreht_gedreht\.pdf$/); const output = await PDFDocument.load(bytes); expect(output.getPage(0).getRotation().angle).toBe(180); expect(output.getPage(1).getRotation().angle).toBe(0); expect(pageErrors).toEqual([]); }); test('watermark accepts numeric controls and downloads text and image watermarks', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/watermark'); await dropFiles(page, pdfDropzone(page), [ { name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') } ]); await expect(page.getByRole('heading', { name: 'Wasserzeichen-Einstellungen' })).toBeVisible(); await page.locator('#watermark-color').fill('#00ff00'); await page.locator('#watermark-opacity').fill('0.45'); await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click(); await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 }); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'marke.pdf herunterladen' }).click(); await expectPdfDownload(await downloadPromise, /^marke_wasserzeichen\.pdf$/); await page.getByLabel('Bild', { exact: true }).check(); await dropFiles(page, page.getByRole('button', { name: 'Wasserzeichenbild hinzufügen' }), [ { name: 'logo.png', mimeType: 'image/png', buffer: Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Z5rAAAAAASUVORK5CYII=', 'base64' ) } ]); // The file is already done, so reset it to pending by removing and re-adding it. await page.getByRole('button', { name: 'marke.pdf entfernen' }).click(); await dropFiles(page, pdfDropzone(page), [ { name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') } ]); await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click(); await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 }); const imageDownloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'marke.pdf herunterladen' }).click(); await expectPdfDownload(await imageDownloadPromise, /^marke_wasserzeichen\.pdf$/); expect(pageErrors).toEqual([]); }); test('stamp presets fill the editable text and EILT is applied to every page', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/stamp'); await dropFiles(page, pdfDropzone(page), [ { name: 'akte.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Akte') } ]); const stampInput = page.locator('#stamp-text'); for (const preset of ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT']) { await page.getByRole('button', { name: preset, exact: true }).click(); await expect(stampInput).toHaveValue(preset); } await page.getByRole('button', { name: 'EILT', exact: true }).click(); await page.locator('#stamp-rotation').fill('20'); await expect(page.getByTestId('stamp-preview')).toContainText('EILT'); await expect(page.getByTestId('stamp-preview')).not.toHaveCSS('transform', 'none'); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click(); const outputBytes = await expectPdfDownload(await downloadPromise, /^akte_stempel\.pdf$/); const output = await PDFDocument.load(outputBytes); expect(output.getPageCount()).toBe(3); for (let pageIndex = 0; pageIndex < output.getPageCount(); pageIndex += 1) { expect(getPageContentStreamCount(output, pageIndex)).toBeGreaterThan(1); } expect(pageErrors).toEqual([]); }); test('stamp custom text adds a German date and only changes selected pages', async ({ page }) => { const pageErrors = trackPageErrors(page); const inputBytes = await createPdf(3, 'Auswahl'); const input = await PDFDocument.load(inputBytes); const originalStreamCounts = input .getPages() .map((_, pageIndex) => getPageContentStreamCount(input, pageIndex)); await page.goto('/tools/stamp'); await dropFiles(page, pdfDropzone(page), [ { name: 'auswahl.pdf', mimeType: 'application/pdf', buffer: inputBytes } ]); await page.locator('#stamp-text').fill('nur hier'); await page.getByRole('checkbox', { name: 'Datum anhängen' }).check(); await expect(page.getByTestId('stamp-preview')).toHaveText(/NUR HIER \d{2}\.\d{2}\.\d{4}/); await page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' }).uncheck(); await page.getByRole('button', { name: /Seite 2/ }).click(); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click(); const outputBytes = await expectPdfDownload(await downloadPromise, /^auswahl_stempel\.pdf$/); const output = await PDFDocument.load(outputBytes); expect(output.getPageCount()).toBe(3); expect(getPageContentStreamCount(output, 0)).toBe(originalStreamCounts[0]); expect(getPageContentStreamCount(output, 1)).toBeGreaterThan(originalStreamCounts[1]); expect(getPageContentStreamCount(output, 2)).toBe(originalStreamCounts[2]); expect(pageErrors).toEqual([]); }); test('stamp uses an image instead of text and makes the border optional', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/stamp'); await dropFiles(page, pdfDropzone(page), [ { name: 'bildakte.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Bild') } ]); await page.locator('#stamp-image').setInputFiles({ name: 'stempel.png', mimeType: 'image/png', buffer: Buffer.from(await createStampPng(page), 'base64') }); await expect(page.getByText('stempel.png')).toBeVisible(); await expect(page.getByTestId('stamp-preview').locator('img')).toBeVisible(); await expect(page.locator('#stamp-text')).toHaveCount(0); await expect(page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })).toBeVisible(); await expect( page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' }) ).not.toBeChecked(); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click(); const outputBytes = await expectPdfDownload(await downloadPromise, /^bildakte_stempel\.pdf$/); const output = await PDFDocument.load(outputBytes); expect(output.getPageCount()).toBe(2); // Image without frame: the image XObject is drawn, no stroked rectangle is present. for (const pageIndex of [0, 1]) { const content = getPageContentText(output, pageIndex); expect(content).toContain(' Do\n'); expect(content).not.toContain(' RG'); } await page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' }).check(); await expect(page.getByTestId('stamp-preview')).toHaveCSS('border-style', 'solid'); const borderedDownloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click(); const borderedBytes = await expectPdfDownload( await borderedDownloadPromise, /^bildakte_stempel\.pdf$/ ); const bordered = await PDFDocument.load(borderedBytes); // The optional frame adds a stroked rectangle around the drawn image. for (const pageIndex of [0, 1]) { const content = getPageContentText(bordered, pageIndex); expect(content).toContain(' Do\n'); expect(content).toContain(' RG'); expect(content).toMatch(/\bh\nS\n/); } expect(pageErrors).toEqual([]); }); test('stamp explains how to handle a password-protected PDF', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/stamp'); await dropFiles(page, pdfDropzone(page), [ { name: 'geschuetzt.pdf', mimeType: 'application/pdf', buffer: await createEncryptedPdf(1, 'Geschützt', 'test-passwort') } ]); await expect(page.getByRole('alert')).toContainText('passwortgeschützt'); await expect(page.getByRole('alert')).toContainText('Passwort entfernen'); expect(pageErrors).toEqual([]); }); test('watermark batch applies to every file and bundles results in one ZIP', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/watermark'); await dropFiles(page, pdfDropzone(page), [ { name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') }, { name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') }, { name: 'kaputt.pdf', mimeType: 'application/pdf', buffer: Buffer.from('kein gueltiges PDF') } ]); await expect(page.getByText('Dateien (3)')).toBeVisible(); await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click(); await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 15_000 }); await expect(page.getByText('Fehler')).toBeVisible(); // With several files the per-file page selection is replaced by all pages. await expect(page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' })).toHaveCount(0); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Alle herunterladen' }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toMatch(/^wasserzeichen_\d{4}-\d{2}-\d{2}\.zip$/); const zipPath = await download.path(); expect(zipPath).not.toBeNull(); const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!)))); expect(entries.sort()).toEqual(['eins_wasserzeichen.pdf', 'zwei_wasserzeichen.pdf']); expect(pageErrors).toEqual([]); }); test('encrypt protects a dropped PDF and decrypt rebuilds it with the password', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/encrypt'); await dropFiles(page, pdfDropzone(page), [ { name: 'geheim.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Geheim') } ]); await page.locator('#encrypt-password').fill('test-passwort'); await page.locator('#encrypt-password-confirmation').fill('test-passwort'); await page.getByRole('button', { name: 'Passwort setzen', exact: true }).click(); await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 }); const encryptedDownloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'geheim.pdf herunterladen' }).click(); const encryptedBytes = await expectPdfDownload( await encryptedDownloadPromise, /^geheim_geschuetzt\.pdf$/ ); await page.goto('/tools/decrypt'); await dropFiles(page, pdfDropzone(page), [ { name: 'geheim_geschuetzt.pdf', mimeType: 'application/pdf', buffer: encryptedBytes } ]); await page.locator('#decrypt-password').fill('test-passwort'); await page.getByRole('button', { name: 'Passwort entfernen', exact: true }).click(); await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 }); const decryptedDownloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'geheim_geschuetzt.pdf herunterladen' }).click(); await expectPdfDownload(await decryptedDownloadPromise, /^geheim_geschuetzt_entsperrt\.pdf$/); expect(pageErrors).toEqual([]); }); test('encrypt batch protects every file and bundles results in one ZIP', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/encrypt'); await dropFiles(page, pdfDropzone(page), [ { name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') }, { name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') } ]); await expect(page.getByText('Dateien (2)')).toBeVisible(); await page.locator('#encrypt-password').fill('test-passwort'); await page.locator('#encrypt-password-confirmation').fill('test-passwort'); await page.getByRole('button', { name: 'Passwort setzen', exact: true }).click(); await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 15_000 }); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Alle herunterladen' }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toMatch(/^geschuetzt_\d{4}-\d{2}-\d{2}\.zip$/); const zipPath = await download.path(); expect(zipPath).not.toBeNull(); const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!)))); expect(entries.sort()).toEqual(['eins_geschuetzt.pdf', 'zwei_geschuetzt.pdf']); expect(pageErrors).toEqual([]); }); test('decrypt batch unlocks every file and bundles results in one ZIP', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/decrypt'); await dropFiles(page, pdfDropzone(page), [ { name: 'eins_geschuetzt.pdf', mimeType: 'application/pdf', buffer: await createEncryptedPdf(1, 'Eins', 'test-passwort') }, { name: 'zwei_geschuetzt.pdf', mimeType: 'application/pdf', buffer: await createEncryptedPdf(2, 'Zwei', 'test-passwort') }, { name: 'kaputt.pdf', mimeType: 'application/pdf', buffer: Buffer.from('kein gueltiges PDF') } ]); await expect(page.getByText('Dateien (3)')).toBeVisible(); await page.locator('#decrypt-password').fill('test-passwort'); await page.getByRole('button', { name: 'Passwort entfernen', exact: true }).click(); await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 20_000 }); await expect(page.getByText(/Fehler beim Entsperren/)).toBeVisible(); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Alle herunterladen' }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toMatch(/^entsperrt_\d{4}-\d{2}-\d{2}\.zip$/); const zipPath = await download.path(); expect(zipPath).not.toBeNull(); const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!)))); expect(entries.sort()).toEqual([ 'eins_geschuetzt_entsperrt.pdf', 'zwei_geschuetzt_entsperrt.pdf' ]); expect(pageErrors).toEqual([]); }); test('compress processes a single dropped file and offers its download per row', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/compress'); await dropFiles(page, pdfDropzone(page), [ { name: 'gross.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Komprimieren') } ]); await expect(page.getByText('Dateien (1)')).toBeVisible(); await page.getByRole('button', { name: 'Komprimieren', exact: true }).click(); await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 }); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'gross.pdf herunterladen' }).click(); await expectPdfDownload(await downloadPromise, /^gross_komprimiert\.pdf$/); expect(pageErrors).toEqual([]); }); test('compress appends further files and bundles all results in one ZIP', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/compress'); await dropFiles(page, pdfDropzone(page), [ { name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') } ]); await dropFiles(page, pdfDropzone(page), [ { name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') } ]); await expect(page.getByText('Dateien (2)')).toBeVisible(); await page.getByRole('button', { name: 'Komprimieren', exact: true }).click(); await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 30_000 }); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Alle herunterladen' }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toMatch(/^komprimiert_\d{4}-\d{2}-\d{2}\.zip$/); const zipPath = await download.path(); expect(zipPath).not.toBeNull(); const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!)))); expect(entries.sort()).toEqual(['eins_komprimiert.pdf', 'zwei_komprimiert.pdf']); expect(pageErrors).toEqual([]); }); test('compress batch keeps processing after a failing file and supports retry and removal', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/compress'); await dropFiles(page, pdfDropzone(page), [ { name: 'gut.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Gut') }, { name: 'kaputt.pdf', mimeType: 'application/pdf', buffer: Buffer.from('kein gueltiges PDF') } ]); await page.getByRole('button', { name: 'Komprimieren', exact: true }).click(); await expect(page.getByText('Fertig ✓')).toHaveCount(1, { timeout: 30_000 }); await expect(page.getByText('Fehler')).toBeVisible(); // Only one result exists, so the bulk ZIP action stays hidden. await expect(page.getByRole('button', { name: 'Alle herunterladen' })).toHaveCount(0); // Retrying the broken file fails again without disturbing the finished row. await page.getByRole('button', { name: 'kaputt.pdf erneut versuchen' }).click(); await expect(page.getByText('Fertig ✓')).toHaveCount(1); await expect(page.getByText('Fehler')).toBeVisible(); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'gut.pdf herunterladen' }).click(); await expectPdfDownload(await downloadPromise, /^gut_komprimiert\.pdf$/); await page.getByRole('button', { name: 'kaputt.pdf entfernen' }).click(); await expect(page.getByText('kaputt.pdf')).toHaveCount(0); await expect(page.getByRole('button', { name: 'gut.pdf herunterladen' })).toBeVisible(); expect(pageErrors).toEqual([]); }); test('convert bundles a multi-page PDF as a correctly named ZIP', async ({ page }) => { const pageErrors = trackPageErrors(page); await page.goto('/tools/convert'); await dropFiles(page, pdfDropzone(page), [ { name: 'bilder.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Bilder') } ]); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Konvertieren & herunterladen' }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe('bilder_bilder.zip'); expect(download.suggestedFilename()).not.toContain('.pdf'); expect(pageErrors).toEqual([]); }); test('cancelling the replacement confirmation keeps the current archive', async ({ page }) => { const pageErrors = trackPageErrors(page); const zipA = await createZip('archiv-a'); const zipB = await createZip('archiv-b'); await page.goto('/tools/bea'); await dropFiles(page, zipDropzone(page), [ { name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA } ]); await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/); await replaceThroughDialog(page, zipB, false); await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible(); await expect(page.getByText('2 ZIPs')).toHaveCount(0); expect(pageErrors).toEqual([]); }); test('confirming the replacement opens only the new ZIP and drops stale results', async ({ page }) => { const pageErrors = trackPageErrors(page); const zipA = await createZip('archiv-a', 3); const zipB = await createZip('archiv-b'); await page.goto('/tools/bea'); await dropFiles(page, zipDropzone(page), [ { name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA } ]); // Replace while the first ZIP may still be extracting; generation guarding // must ensure no late result from archiv-a reappears. await replaceThroughDialog(page, zipB, true); await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/); await expect(page.getByText('1 lose Datei')).toHaveCount(1); expect(pageErrors).toEqual([]); }); test('cancelling the system file picker leaves the workspace unchanged', async ({ page }) => { const pageErrors = trackPageErrors(page); const zipA = await createZip('archiv-a'); await page.goto('/tools/bea'); await dropFiles(page, zipDropzone(page), [ { name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA } ]); await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/); await newZipButton(page).click(); await replaceZipInput(page).setInputFiles([]); await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toHaveCount(0); await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible(); expect(pageErrors).toEqual([]); }); test('the replacement input accepts the same ZIP twice', async ({ page }) => { const pageErrors = trackPageErrors(page); const zipA = await createZip('archiv-a'); const zipB = await createZip('archiv-b'); await page.goto('/tools/bea'); await dropFiles(page, zipDropzone(page), [ { name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA } ]); await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/); await replaceThroughDialog(page, zipB, true); await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/); await replaceThroughDialog(page, zipB, true); await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/); await expect(page.getByText('1 lose Datei')).toHaveCount(1); expect(pageErrors).toEqual([]); }); test('appending a ZIP keeps both archive editors visible', async ({ page }) => { const pageErrors = trackPageErrors(page); const zipA = await createZip('archiv-a'); const zipB = await createZip('archiv-b'); await page.goto('/tools/bea'); await dropFiles(page, zipDropzone(page), [ { name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA } ]); await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/); await settingsButton(page).click(); await appendZipInput(page).setInputFiles([ { name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zipB } ]); await waitForLoadedWorkspace(page, /2 ZIPs · 2 Archive geöffnet/); await expect(page.getByText('1 lose Datei')).toHaveCount(2); expect(pageErrors).toEqual([]); }); test('bulk export only appears for two or more processed archives', async ({ page }) => { const pageErrors = trackPageErrors(page); const zipA = await createZip('archiv-a'); const zipB = await createZip('archiv-b'); await page.goto('/tools/bea'); await dropFiles(page, zipDropzone(page), [ { name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA } ]); await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/); await settingsButton(page).click(); await expect( page.getByRole('button', { name: 'Alle Archive als PDF herunterladen' }) ).toHaveCount(0); await appendZipInput(page).setInputFiles([ { name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zipB } ]); await waitForLoadedWorkspace(page, /2 ZIPs · 2 Archive geöffnet/); // The popover stays open, so the new bulk export action appears in place. const exportButton = page.getByRole('button', { name: 'Alle Archive als PDF herunterladen' }); await expect(exportButton).toBeVisible(); const downloadPromise = page.waitForEvent('download'); await exportButton.click(); await expectPdfDownload(await downloadPromise, /\.pdf$/); expect(pageErrors).toEqual([]); }); test('clearing the workspace requires confirmation and returns to the dropzone', async ({ page }) => { const pageErrors = trackPageErrors(page); const zipA = await createZip('archiv-a'); await page.goto('/tools/bea'); await dropFiles(page, zipDropzone(page), [ { name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA } ]); await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/); await settingsButton(page).click(); await page.getByRole('button', { name: 'Aktuelle Bearbeitung leeren' }).click(); await expect(page.getByText('Aktuelle Bearbeitung leeren?')).toBeVisible(); await page.getByRole('button', { name: 'Abbrechen' }).click(); await expect(workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/)).toBeVisible(); await page.getByRole('button', { name: 'Aktuelle Bearbeitung leeren' }).click(); await page.getByRole('button', { name: 'Bearbeitung leeren', exact: true }).click(); await expect(zipDropzone(page)).toBeVisible(); await expect(newZipButton(page)).toHaveCount(0); expect(pageErrors).toEqual([]); }); test('the preview-size slider works with arrow keys and survives a reload', async ({ page }) => { const pageErrors = trackPageErrors(page); const zipA = await createZip('archiv-a'); await page.goto('/tools/bea'); await dropFiles(page, zipDropzone(page), [ { name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA } ]); await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/); await expect(page.locator('li[style*="width: 200px"]').first()).toBeVisible(); await settingsButton(page).click(); const slider = page.getByRole('slider', { name: 'Vorschaugröße' }); await expect(page.getByText('200 px')).toBeVisible(); await slider.focus(); await page.keyboard.press('ArrowRight'); await expect(page.getByText('201 px')).toBeVisible(); await expect(page.locator('li[style*="width: 201px"]').first()).toBeVisible(); await page.reload(); await page.locator('[data-dropzone-ready="true"]').first().waitFor(); await dropFiles(page, zipDropzone(page), [ { name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA } ]); await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/); await settingsButton(page).click(); await expect(page.getByText('201 px')).toBeVisible(); await expect(await page.evaluate(() => localStorage.getItem('thumbnailWidth'))).toBe('201'); expect(pageErrors).toEqual([]); }); test('settings closes on Escape and outside click and returns focus to its trigger', async ({ page }) => { const pageErrors = trackPageErrors(page); const zipA = await createZip('archiv-a'); await page.goto('/tools/bea'); await dropFiles(page, zipDropzone(page), [ { name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA } ]); await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/); await expect(settingsButton(page)).toHaveAccessibleName('Einstellungen'); await settingsButton(page).click(); await expect(page.getByText('Vorschaugröße')).toBeVisible(); await page.keyboard.press('Escape'); await expect(page.getByText('Vorschaugröße')).toHaveCount(0); await expect(settingsButton(page)).toBeFocused(); await settingsButton(page).click(); await expect(page.getByText('Vorschaugröße')).toBeVisible(); await page.getByRole('heading', { name: 'beA-Edit' }).click(); await expect(page.getByText('Vorschaugröße')).toHaveCount(0); expect(pageErrors).toEqual([]); }); test('mobile toolbar and settings popover stay inside the viewport', async ({ page }) => { const pageErrors = trackPageErrors(page); const zipA = await createZip('archiv-a'); await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/tools/bea'); await dropFiles(page, zipDropzone(page), [ { name: 'archiv-a.zip', mimeType: 'application/zip', buffer: zipA } ]); await waitForLoadedWorkspace(page, /1 ZIP · 1 Archiv geöffnet/); await settingsButton(page).click(); const popover = page.locator('[data-slot="popover-content"]'); await expect(popover).toBeVisible(); await expect(page.getByText('Weitere ZIP hinzufügen')).toBeVisible(); const popoverBox = await popover.boundingBox(); expect(popoverBox).not.toBeNull(); expect(popoverBox!.x).toBeGreaterThanOrEqual(0); expect(popoverBox!.x + popoverBox!.width).toBeLessThanOrEqual(390); expect(popoverBox!.y).toBeGreaterThanOrEqual(0); expect(popoverBox!.y + popoverBox!.height).toBeLessThanOrEqual(844); const popoverMetrics = await popover.evaluate((element) => ({ scrollWidth: element.scrollWidth, clientWidth: element.clientWidth })); expect(popoverMetrics.scrollWidth).toBeLessThanOrEqual(popoverMetrics.clientWidth); // The toolbar sits in normal flow, so it must not cover the archive header. const toolbarBox = await workspaceStatus(page, /1 ZIP · 1 Archiv geöffnet/).boundingBox(); const archiveHeaderBox = await page.getByText('1 lose Datei').first().boundingBox(); expect(toolbarBox).not.toBeNull(); expect(archiveHeaderBox).not.toBeNull(); expect(toolbarBox!.y + toolbarBox!.height).toBeLessThanOrEqual(archiveHeaderBox!.y); expect(pageErrors).toEqual([]); });