Files
bea-edit/tests/e2e/toolbox.spec.ts
T
rehlert 57b38fe2c7
Build and Push Docker Image / build (push) Successful in 54s
bea settings
2026-08-27 18:15:22 +02:00

547 lines
21 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 { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import { 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 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 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: /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('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({
timeout: 15_000
});
await page.locator('#watermark-color').fill('#00ff00');
await page.locator('#watermark-opacity').fill('0.45');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Wasserzeichen anwenden & 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'
)
}
]);
const imageDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Wasserzeichen anwenden & herunterladen' }).click();
await expectPdfDownload(await imageDownloadPromise, /^marke_wasserzeichen\.pdf$/);
await expect(page.getByText('Fehler beim Anwenden')).toHaveCount(0);
expect(pageErrors).toEqual([]);
});
test('encrypt creates a protected 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');
const encryptedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Passwort setzen & herunterladen' }).click();
const encryptedDownload = await encryptedDownloadPromise;
const encryptedBytes = await expectPdfDownload(encryptedDownload, /^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');
const decryptedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Passwort entfernen & herunterladen' }).click();
await expectPdfDownload(await decryptedDownloadPromise, /^geheim_geschuetzt_entsperrt\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('compress previews result size before downloading', 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 page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
await expect(page.getByText(/Ergebnisgröße:/)).toBeVisible({ timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'PDF herunterladen' }).click();
await expectPdfDownload(await downloadPromise, /^gross_komprimiert\.pdf$/);
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([]);
});