Files
bea-edit/tests/e2e/toolbox.spec.ts
T
rehlert 09cc5c06fb
Build and Push Docker Image / build (push) Successful in 1m26s
rotate + hover on start-page
2026-08-29 12:04:29 +02:00

988 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>((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([]);
});