From d71b25f834c6c2eeb39d0b060c346535db3b81aa Mon Sep 17 00:00:00 2001 From: rehlert Date: Sat, 29 Aug 2026 12:10:26 +0200 Subject: [PATCH] splitting tests --- tests/e2e/bea.spec.ts | 284 ++++++++++ tests/e2e/compress.spec.ts | 83 +++ tests/e2e/convert.spec.ts | 17 + tests/e2e/decrypt.spec.ts | 45 ++ tests/e2e/encrypt.spec.ts | 66 +++ tests/e2e/helpers.ts | 196 +++++++ tests/e2e/layout.spec.ts | 17 + tests/e2e/merge.spec.ts | 17 + tests/e2e/rotate.spec.ts | 72 +++ tests/e2e/separate.spec.ts | 17 + tests/e2e/stamp.spec.ts | 137 +++++ tests/e2e/start-page.spec.ts | 24 + tests/e2e/toolbox.spec.ts | 987 ----------------------------------- tests/e2e/watermark.spec.ts | 78 +++ 14 files changed, 1053 insertions(+), 987 deletions(-) create mode 100644 tests/e2e/bea.spec.ts create mode 100644 tests/e2e/compress.spec.ts create mode 100644 tests/e2e/convert.spec.ts create mode 100644 tests/e2e/decrypt.spec.ts create mode 100644 tests/e2e/encrypt.spec.ts create mode 100644 tests/e2e/helpers.ts create mode 100644 tests/e2e/layout.spec.ts create mode 100644 tests/e2e/merge.spec.ts create mode 100644 tests/e2e/rotate.spec.ts create mode 100644 tests/e2e/separate.spec.ts create mode 100644 tests/e2e/stamp.spec.ts create mode 100644 tests/e2e/start-page.spec.ts delete mode 100644 tests/e2e/toolbox.spec.ts create mode 100644 tests/e2e/watermark.spec.ts diff --git a/tests/e2e/bea.spec.ts b/tests/e2e/bea.spec.ts new file mode 100644 index 0000000..28fb2c7 --- /dev/null +++ b/tests/e2e/bea.spec.ts @@ -0,0 +1,284 @@ +import { expect, test } from '@playwright/test'; +import { + appendZipInput, + createZip, + dropFiles, + expectPdfDownload, + newZipButton, + replaceThroughDialog, + replaceZipInput, + settingsButton, + trackPageErrors, + waitForLoadedWorkspace, + workspaceStatus, + zipDropzone +} from './helpers'; + +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('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([]); +}); diff --git a/tests/e2e/compress.spec.ts b/tests/e2e/compress.spec.ts new file mode 100644 index 0000000..f914d74 --- /dev/null +++ b/tests/e2e/compress.spec.ts @@ -0,0 +1,83 @@ +import { expect, test } from '@playwright/test'; +import { unzipSync } from 'fflate'; +import { readFile } from 'node:fs/promises'; +import { createPdf, dropFiles, expectPdfDownload, pdfDropzone, trackPageErrors } from './helpers'; + +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([]); +}); diff --git a/tests/e2e/convert.spec.ts b/tests/e2e/convert.spec.ts new file mode 100644 index 0000000..c4c4e71 --- /dev/null +++ b/tests/e2e/convert.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; +import { createPdf, dropFiles, pdfDropzone, trackPageErrors } from './helpers'; + +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([]); +}); diff --git a/tests/e2e/decrypt.spec.ts b/tests/e2e/decrypt.spec.ts new file mode 100644 index 0000000..10f2189 --- /dev/null +++ b/tests/e2e/decrypt.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; +import { unzipSync } from 'fflate'; +import { readFile } from 'node:fs/promises'; +import { createEncryptedPdf, dropFiles, pdfDropzone, trackPageErrors } from './helpers'; + +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([]); +}); diff --git a/tests/e2e/encrypt.spec.ts b/tests/e2e/encrypt.spec.ts new file mode 100644 index 0000000..7e36f5e --- /dev/null +++ b/tests/e2e/encrypt.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from '@playwright/test'; +import { unzipSync } from 'fflate'; +import { readFile } from 'node:fs/promises'; +import { createPdf, dropFiles, expectPdfDownload, pdfDropzone, trackPageErrors } from './helpers'; + +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([]); +}); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 0000000..8c86c47 --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,196 @@ +import { expect, 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 { zipSync } from 'fflate'; +import { readFile } from 'node:fs/promises'; + +export 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()); +}; + +export 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'); + }) + ); + +export 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(); +}; + +export 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; +}; + +export const getPageContentStreamCount = (document: PDFDocument, pageIndex: number) => { + const contents = document.getPage(pageIndex).node.Contents(); + if (!contents) return 0; + return contents instanceof PDFArray ? contents.size() : 1; +}; + +export 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'); +}; + +export 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()); +}; + +export 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()); +}; + +export const trackPageErrors = (page: Page) => { + const errors: Error[] = []; + page.on('pageerror', (error) => errors.push(error)); + return errors; +}; + +export const pdfDropzone = (page: Page) => + page.getByRole('button', { name: 'PDF-Dateien hinzufügen' }); +export const zipDropzone = (page: Page) => + page.getByRole('button', { name: 'beA-ZIP-Dateien hinzufügen' }); + +export const createZip = async (label: string, pageCount = 1) => + Buffer.from(zipSync({ [`${label}.pdf`]: await createPdf(pageCount, label) })); + +export const newZipButton = (page: Page) => + page.getByRole('button', { name: 'Neue ZIP bearbeiten' }); +export const settingsButton = (page: Page) => + page.getByRole('button', { name: 'Einstellungen', exact: true }); +export const replaceZipInput = (page: Page) => page.locator('input[data-zip-picker="replace-zip"]'); +export const appendZipInput = (page: Page) => page.locator('input[data-zip-picker="append-zip"]'); +export const workspaceStatus = (page: Page, pattern: RegExp) => page.getByText(pattern); + +export 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 }); +}; + +export 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(); +}; diff --git a/tests/e2e/layout.spec.ts b/tests/e2e/layout.spec.ts new file mode 100644 index 0000000..30ea68a --- /dev/null +++ b/tests/e2e/layout.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; + +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 }); +}); diff --git a/tests/e2e/merge.spec.ts b/tests/e2e/merge.spec.ts new file mode 100644 index 0000000..ffc2549 --- /dev/null +++ b/tests/e2e/merge.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; +import { createPdf, dropFiles, expectPdfDownload, pdfDropzone, trackPageErrors } from './helpers'; + +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([]); +}); diff --git a/tests/e2e/rotate.spec.ts b/tests/e2e/rotate.spec.ts new file mode 100644 index 0000000..5d27e0b --- /dev/null +++ b/tests/e2e/rotate.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test'; +import { PDFDocument } from 'pdf-lib'; +import { + createPdf, + createPreRotatedPdf, + dropFiles, + expectPdfDownload, + pdfDropzone, + trackPageErrors +} from './helpers'; + +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([]); +}); diff --git a/tests/e2e/separate.spec.ts b/tests/e2e/separate.spec.ts new file mode 100644 index 0000000..96f0fd0 --- /dev/null +++ b/tests/e2e/separate.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; +import { createPdf, dropFiles, expectPdfDownload, pdfDropzone, trackPageErrors } from './helpers'; + +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([]); +}); diff --git a/tests/e2e/stamp.spec.ts b/tests/e2e/stamp.spec.ts new file mode 100644 index 0000000..f29f0e7 --- /dev/null +++ b/tests/e2e/stamp.spec.ts @@ -0,0 +1,137 @@ +import { expect, test } from '@playwright/test'; +import { PDFDocument } from 'pdf-lib'; +import { + createEncryptedPdf, + createPdf, + createStampPng, + dropFiles, + expectPdfDownload, + getPageContentStreamCount, + getPageContentText, + pdfDropzone, + trackPageErrors +} from './helpers'; + +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([]); +}); diff --git a/tests/e2e/start-page.spec.ts b/tests/e2e/start-page.spec.ts new file mode 100644 index 0000000..b722d50 --- /dev/null +++ b/tests/e2e/start-page.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; +import { zipSync } from 'fflate'; +import { createPdf, dropFiles, trackPageErrors } from './helpers'; + +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([]); +}); diff --git a/tests/e2e/toolbox.spec.ts b/tests/e2e/toolbox.spec.ts deleted file mode 100644 index 9f21669..0000000 --- a/tests/e2e/toolbox.spec.ts +++ /dev/null @@ -1,987 +0,0 @@ -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([]); -}); diff --git a/tests/e2e/watermark.spec.ts b/tests/e2e/watermark.spec.ts new file mode 100644 index 0000000..54069bb --- /dev/null +++ b/tests/e2e/watermark.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test'; +import { unzipSync } from 'fflate'; +import { readFile } from 'node:fs/promises'; +import { createPdf, dropFiles, expectPdfDownload, pdfDropzone, trackPageErrors } from './helpers'; + +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('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([]); +});