Compare commits
2
Commits
09cc5c06fb
...
b7969b4c9e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7969b4c9e | ||
|
|
d71b25f834 |
@@ -84,6 +84,7 @@ src/
|
|||||||
│ │ └── zip-inflating.service.ts # Async fflate wrapper
|
│ │ └── zip-inflating.service.ts # Async fflate wrapper
|
||||||
│ ├── batch.ts # Sequential batch engine for multi-file tools
|
│ ├── batch.ts # Sequential batch engine for multi-file tools
|
||||||
│ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline
|
│ ├── pdf-compression.ts # Shared compression presets and rasterize pipeline
|
||||||
|
│ ├── pdf-metadata.ts # PDF metadata inspection and cleaning (pdf-lib only)
|
||||||
│ ├── pdf-overlay.ts # Shared PDF overlay positions and color parsing
|
│ ├── pdf-overlay.ts # Shared PDF overlay positions and color parsing
|
||||||
│ ├── pdf-processing.ts # Reusable PDF merge helper
|
│ ├── pdf-processing.ts # Reusable PDF merge helper
|
||||||
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup
|
│ ├── pdf-thumbnails.ts # Managed pdfjs thumbnail loading and cleanup
|
||||||
@@ -96,7 +97,7 @@ src/
|
|||||||
├── tools/
|
├── tools/
|
||||||
│ ├── +layout.svelte # Shared back-navigation shell
|
│ ├── +layout.svelte # Shared back-navigation shell
|
||||||
│ ├── bea/+page.svelte # Existing ZIP archive workflow
|
│ ├── bea/+page.svelte # Existing ZIP archive workflow
|
||||||
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate}/
|
│ └── {merge,separate,stamp,watermark,encrypt,decrypt,compress,convert,rotate,metadata}/
|
||||||
├── datenschutz/+page.svelte # Privacy policy
|
├── datenschutz/+page.svelte # Privacy policy
|
||||||
└── impressum/+page.svelte # Imprint
|
└── impressum/+page.svelte # Imprint
|
||||||
static/ # Public logo, footer art, and robots.txt
|
static/ # Public logo, footer art, and robots.txt
|
||||||
@@ -112,6 +113,11 @@ committed.
|
|||||||
|
|
||||||
- `/` is the toolbox hub. A ZIP dropped anywhere on that route is queued in memory and forwarded to
|
- `/` is the toolbox hub. A ZIP dropped anywhere on that route is queued in memory and forwarded to
|
||||||
`/tools/bea`; the file is not uploaded or persisted.
|
`/tools/bea`; the file is not uploaded or persisted.
|
||||||
|
- `/tools/metadata` is a single-PDF inspection tool. `src/lib/pdf-metadata.ts` reads document
|
||||||
|
properties with pdf-lib only (no rasterization), returns a discriminated `{ status: 'encrypted' }`
|
||||||
|
result for password-protected files, and detects embedded-file markers by scanning raw bytes.
|
||||||
|
Cleaning neutralizes the standard Info dictionary fields and re-saves through pdf-lib; embedded
|
||||||
|
files and XMP streams are out of scope and are disclosed in the UI instead.
|
||||||
- `tools/bea/+page.svelte` owns selected files, processed archives, pending work, and the
|
- `tools/bea/+page.svelte` owns selected files, processed archives, pending work, and the
|
||||||
thumbnail-width preference. Thumbnail width is the only persisted UI setting and uses localStorage.
|
thumbnail-width preference. Thumbnail width is the only persisted UI setting and uses localStorage.
|
||||||
- The loaded workspace renders `BeaWorkspaceControls.svelte`, a top-aligned toolbar with the
|
- The loaded workspace renders `BeaWorkspaceControls.svelte`, a top-aligned toolbar with the
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import Root from "./card.svelte";
|
import Root from './card.svelte';
|
||||||
import Content from "./card-content.svelte";
|
import Content from './card-content.svelte';
|
||||||
import Description from "./card-description.svelte";
|
import Description from './card-description.svelte';
|
||||||
import Footer from "./card-footer.svelte";
|
import Footer from './card-footer.svelte';
|
||||||
import Header from "./card-header.svelte";
|
import Header from './card-header.svelte';
|
||||||
import Title from "./card-title.svelte";
|
import Title from './card-title.svelte';
|
||||||
import Action from "./card-action.svelte";
|
import Action from './card-action.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
@@ -21,5 +21,5 @@ export {
|
|||||||
Footer as CardFooter,
|
Footer as CardFooter,
|
||||||
Header as CardHeader,
|
Header as CardHeader,
|
||||||
Title as CardTitle,
|
Title as CardTitle,
|
||||||
Action as CardAction,
|
Action as CardAction
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
|
export type PdfMetadataInfo = {
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
subject: string;
|
||||||
|
keywords: string[];
|
||||||
|
creator: string;
|
||||||
|
producer: string;
|
||||||
|
creationDate: Date | null;
|
||||||
|
modificationDate: Date | null;
|
||||||
|
pageCount: number;
|
||||||
|
embeddedFileSuspected: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PdfMetadataResult =
|
||||||
|
| { status: 'ok'; metadata: PdfMetadataInfo }
|
||||||
|
| { status: 'encrypted' };
|
||||||
|
|
||||||
|
// The pdf-lib error class for encrypted documents is not exported from the
|
||||||
|
// package root, so we identify it by its message instead.
|
||||||
|
const isEncryptedPdfError = (error: unknown) =>
|
||||||
|
error instanceof Error && error.message.includes('is encrypted');
|
||||||
|
|
||||||
|
const safeRead = <T>(read: () => T | undefined): T | undefined => {
|
||||||
|
try {
|
||||||
|
return read();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toDateOrNull = (value: Date | undefined): Date | null =>
|
||||||
|
value && !Number.isNaN(value.getTime()) ? value : null;
|
||||||
|
|
||||||
|
// Finds an ASCII marker in raw bytes without decoding the whole file. The
|
||||||
|
// search for '/EmbeddedFile' also matches '/EmbeddedFiles' as a prefix.
|
||||||
|
const containsAsciiMarker = (bytes: Uint8Array, marker: string): boolean => {
|
||||||
|
const firstByte = marker.charCodeAt(0);
|
||||||
|
for (let index = 0; index <= bytes.length - marker.length; index += 1) {
|
||||||
|
if (bytes[index] !== firstByte) continue;
|
||||||
|
let matched = true;
|
||||||
|
for (let offset = 1; offset < marker.length; offset += 1) {
|
||||||
|
if (bytes[index + offset] !== marker.charCodeAt(offset)) {
|
||||||
|
matched = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hasEmbeddedFileMarkers = (bytes: Uint8Array): boolean =>
|
||||||
|
containsAsciiMarker(bytes, '/EmbeddedFile');
|
||||||
|
|
||||||
|
export const readPdfMetadata = async (bytes: Uint8Array): Promise<PdfMetadataResult> => {
|
||||||
|
const embeddedFileSuspected = hasEmbeddedFileMarkers(bytes);
|
||||||
|
|
||||||
|
let document: PDFDocument;
|
||||||
|
try {
|
||||||
|
document = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||||
|
} catch (error) {
|
||||||
|
if (isEncryptedPdfError(error)) return { status: 'encrypted' };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywords = safeRead(() => document.getKeywords());
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
metadata: {
|
||||||
|
title: safeRead(() => document.getTitle()) ?? '',
|
||||||
|
author: safeRead(() => document.getAuthor()) ?? '',
|
||||||
|
subject: safeRead(() => document.getSubject()) ?? '',
|
||||||
|
// PDF keyword lists have no canonical separator; split only on
|
||||||
|
// explicit punctuation so multi-word keywords survive.
|
||||||
|
keywords: keywords
|
||||||
|
? keywords
|
||||||
|
.split(/[;,]/)
|
||||||
|
.map((keyword) => keyword.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [],
|
||||||
|
creator: safeRead(() => document.getCreator()) ?? '',
|
||||||
|
producer: safeRead(() => document.getProducer()) ?? '',
|
||||||
|
creationDate: toDateOrNull(safeRead(() => document.getCreationDate())),
|
||||||
|
modificationDate: toDateOrNull(safeRead(() => document.getModificationDate())),
|
||||||
|
pageCount: document.getPageCount(),
|
||||||
|
embeddedFileSuspected
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stripPdfMetadata = async (bytes: Uint8Array): Promise<Uint8Array> => {
|
||||||
|
const document = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
document.setTitle('');
|
||||||
|
document.setAuthor('');
|
||||||
|
document.setSubject('');
|
||||||
|
document.setKeywords([]);
|
||||||
|
document.setCreator('');
|
||||||
|
document.setProducer('beA-Edit');
|
||||||
|
document.setCreationDate(now);
|
||||||
|
document.setModificationDate(now);
|
||||||
|
|
||||||
|
return document.save();
|
||||||
|
};
|
||||||
@@ -59,5 +59,11 @@ export const tools: Tool[] = [
|
|||||||
title: 'PDFs zusammenfügen',
|
title: 'PDFs zusammenfügen',
|
||||||
description: 'Fügen Sie mehrere PDFs zu einer Datei zusammen.',
|
description: 'Fügen Sie mehrere PDFs zu einer Datei zusammen.',
|
||||||
icon: 'merge'
|
icon: 'merge'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'metadata',
|
||||||
|
title: 'Metadaten',
|
||||||
|
description: 'Zeigen Sie Metadaten eines PDFs an und entfernen Sie sie.',
|
||||||
|
icon: 'info'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
Merge,
|
Merge,
|
||||||
Stamp,
|
Stamp,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
|
Info,
|
||||||
FileText
|
FileText
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@@ -27,7 +28,8 @@
|
|||||||
scissors: Scissors,
|
scissors: Scissors,
|
||||||
merge: Merge,
|
merge: Merge,
|
||||||
stamp: Stamp,
|
stamp: Stamp,
|
||||||
'rotate-cw': RotateCw
|
'rotate-cw': RotateCw,
|
||||||
|
info: Info
|
||||||
};
|
};
|
||||||
|
|
||||||
const isZipFile = (file: File) =>
|
const isZipFile = (file: File) =>
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FileDropzone from '$lib/components/FileDropzone.svelte';
|
||||||
|
import Button from '$lib/components/ui/button.svelte';
|
||||||
|
import { formatBytes } from '$lib/batch';
|
||||||
|
import { downloadBlob } from '$lib/download';
|
||||||
|
import { readPdfMetadata, stripPdfMetadata, type PdfMetadataResult } from '$lib/pdf-metadata';
|
||||||
|
import { AlertTriangle, Info, Lock } from '@lucide/svelte';
|
||||||
|
|
||||||
|
let file = $state<File | null>(null);
|
||||||
|
let result = $state<PdfMetadataResult | null>(null);
|
||||||
|
let isReading = $state(false);
|
||||||
|
let isCleaning = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
const cleanedFileName = $derived(file ? `${file.name.replace(/\.pdf$/i, '')}_bereinigt.pdf` : '');
|
||||||
|
|
||||||
|
const handleFilesSelected = async (files: File[]) => {
|
||||||
|
const selected = files[0];
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
file = selected;
|
||||||
|
result = null;
|
||||||
|
error = null;
|
||||||
|
isReading = true;
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(await selected.arrayBuffer());
|
||||||
|
result = await readPdfMetadata(bytes);
|
||||||
|
} catch {
|
||||||
|
file = null;
|
||||||
|
error = 'Diese Datei konnte nicht als PDF gelesen werden. Bitte prüfen Sie die Datei.';
|
||||||
|
} finally {
|
||||||
|
isReading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadCleaned = async () => {
|
||||||
|
if (!file || isCleaning) return;
|
||||||
|
isCleaning = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const cleaned = await stripPdfMetadata(bytes);
|
||||||
|
downloadBlob(cleaned, cleanedFileName);
|
||||||
|
} catch {
|
||||||
|
error = 'Die Metadaten konnten nicht entfernt werden. Bitte versuchen Sie es erneut.';
|
||||||
|
} finally {
|
||||||
|
isCleaning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value: Date | null) =>
|
||||||
|
value ? value.toLocaleString('de-DE', { dateStyle: 'long', timeStyle: 'short' }) : '';
|
||||||
|
|
||||||
|
const infoRows = $derived.by(() => {
|
||||||
|
if (!result || result.status !== 'ok') return [];
|
||||||
|
const metadata = result.metadata;
|
||||||
|
return [
|
||||||
|
{ label: 'Titel', value: metadata.title },
|
||||||
|
{ label: 'Autor', value: metadata.author },
|
||||||
|
{ label: 'Betreff', value: metadata.subject },
|
||||||
|
{ label: 'Schlüsselwörter', value: metadata.keywords.join(', ') },
|
||||||
|
{ label: 'Erstellt mit', value: metadata.creator },
|
||||||
|
{ label: 'PDF-Erzeuger', value: metadata.producer },
|
||||||
|
{ label: 'Erstellt am', value: formatDate(metadata.creationDate) },
|
||||||
|
{ label: 'Geändert am', value: formatDate(metadata.modificationDate) },
|
||||||
|
{ label: 'Seiten', value: String(metadata.pageCount) },
|
||||||
|
{ label: 'Größe', value: file ? formatBytes(file.size) : '' },
|
||||||
|
{ label: 'Verschlüsselung', value: 'Nein' }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-4xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Metadaten</h1>
|
||||||
|
<p class="mt-2 text-sm text-primary">
|
||||||
|
Prüfen Sie, welche Informationen Ihr PDF enthält, und entfernen Sie sie vor dem Weitergeben.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileDropzone
|
||||||
|
onFilesSelected={handleFilesSelected}
|
||||||
|
label={file ? 'Anderes PDF auswählen' : 'PDF auswählen'}
|
||||||
|
sublabel="PDF hier ablegen oder zum Auswählen klicken"
|
||||||
|
ariaLabel="PDF-Dateien hinzufügen"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if isReading}
|
||||||
|
<p class="text-sm text-primary" role="status">Metadaten werden gelesen …</p>
|
||||||
|
{:else if error}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{:else if result}
|
||||||
|
{#if result.status === 'encrypted'}
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-4 flex gap-3" role="alert">
|
||||||
|
<Lock class="h-5 w-5 shrink-0 text-red-600 mt-0.5" />
|
||||||
|
<div class="text-sm text-red-800">
|
||||||
|
<p class="font-medium">Dieses PDF ist verschlüsselt</p>
|
||||||
|
<p class="mt-1">
|
||||||
|
Verschlüsselte Dokumente können nicht gelesen werden. Bitte entfernen Sie zuerst das
|
||||||
|
Passwort mit dem Werkzeug
|
||||||
|
<a href="/tools/decrypt" class="font-medium underline">Passwort entfernen</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-2xl border border-border bg-card p-6 space-y-5" data-metadata-card>
|
||||||
|
<div class="flex items-center gap-2 text-foreground">
|
||||||
|
<Info class="h-5 w-5" />
|
||||||
|
<h2 class="text-lg font-semibold">Dokument-Informationen</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-primary truncate" title={file?.name}>{file?.name}</p>
|
||||||
|
|
||||||
|
<dl class="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-[12rem_1fr]">
|
||||||
|
{#each infoRows as row (row.label)}
|
||||||
|
<dt class="text-sm font-medium text-foreground">{row.label}</dt>
|
||||||
|
<dd class="text-sm text-primary wrap-break-word" data-metadata-value={row.label}>
|
||||||
|
{row.value || '–'}
|
||||||
|
</dd>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{#if result.metadata.embeddedFileSuspected}
|
||||||
|
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 flex gap-3">
|
||||||
|
<AlertTriangle class="h-5 w-5 shrink-0 text-amber-600 mt-0.5" />
|
||||||
|
<p class="text-sm text-amber-800">
|
||||||
|
Die Datei enthält möglicherweise eingebettete Anlagen. Diese werden beim Bereinigen
|
||||||
|
nicht entfernt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border bg-muted/50 p-4 text-sm text-primary">
|
||||||
|
<p>
|
||||||
|
Beim Bereinigen bleibt der Inhalt vollständig erhalten (kein Qualitätsverlust). Entfernt
|
||||||
|
werden die Standard-Dokumenteigenschaften (Titel, Autor, Betreff usw.). Nicht entfernt
|
||||||
|
werden sogenannte XMP-Metadaten (Extensible Metadata Platform): Diese stehen nicht im
|
||||||
|
Bereich „Dokumenteigenschaften“, sondern können direkt ins PDF-Dokument oder in
|
||||||
|
eingebettete Bilder eingebettet sein.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onclick={downloadCleaned}
|
||||||
|
disabled={result.status !== 'ok' || isCleaning}
|
||||||
|
data-clean-download
|
||||||
|
>
|
||||||
|
{isCleaning ? 'Wird bereinigt …' : 'Bereinigt herunterladen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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<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');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
||||||
|
import {
|
||||||
|
createEncryptedPdf,
|
||||||
|
createPdf,
|
||||||
|
dropFiles,
|
||||||
|
expectPdfDownload,
|
||||||
|
pdfDropzone,
|
||||||
|
trackPageErrors
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
const createPdfWithMetadata = async () => {
|
||||||
|
const document = await PDFDocument.create();
|
||||||
|
const font = await document.embedFont(StandardFonts.Helvetica);
|
||||||
|
for (let pageNumber = 1; pageNumber <= 2; pageNumber += 1) {
|
||||||
|
const page = document.addPage([420, 594]);
|
||||||
|
page.drawText(`Vertraulich – Seite ${pageNumber}`, {
|
||||||
|
x: 40,
|
||||||
|
y: 520,
|
||||||
|
size: 20,
|
||||||
|
font,
|
||||||
|
color: rgb(0.1, 0.2, 0.4)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.setTitle('Schriftsatz 12. Kammer');
|
||||||
|
document.setAuthor('Dr. Echte Anwältin');
|
||||||
|
document.setSubject('Klageerwiderung');
|
||||||
|
document.setKeywords(['vertraulich, Mandat 4711']);
|
||||||
|
document.setCreator('Anwaltssoftware Pro');
|
||||||
|
document.setProducer('PDF-Fabrik 3.2');
|
||||||
|
document.setCreationDate(new Date('2024-05-06T09:12:00Z'));
|
||||||
|
document.setModificationDate(new Date('2024-05-07T16:40:00Z'));
|
||||||
|
return Buffer.from(await document.save());
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadataValue = (page: Page, label: string) =>
|
||||||
|
page.locator(`[data-metadata-value="${label}"]`);
|
||||||
|
|
||||||
|
test('metadata tool shows document properties of a dropped PDF', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/metadata');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'schriftsatz.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createPdfWithMetadata()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(metadataValue(page, 'Titel')).toHaveText('Schriftsatz 12. Kammer');
|
||||||
|
await expect(metadataValue(page, 'Autor')).toHaveText('Dr. Echte Anwältin');
|
||||||
|
await expect(metadataValue(page, 'Betreff')).toHaveText('Klageerwiderung');
|
||||||
|
await expect(metadataValue(page, 'Schlüsselwörter')).toHaveText('vertraulich, Mandat 4711');
|
||||||
|
await expect(metadataValue(page, 'Erstellt mit')).toHaveText('Anwaltssoftware Pro');
|
||||||
|
await expect(metadataValue(page, 'PDF-Erzeuger')).toHaveText('PDF-Fabrik 3.2');
|
||||||
|
await expect(metadataValue(page, 'Erstellt am')).toContainText('2024');
|
||||||
|
await expect(metadataValue(page, 'Geändert am')).toContainText('2024');
|
||||||
|
await expect(metadataValue(page, 'Seiten')).toHaveText('2');
|
||||||
|
await expect(metadataValue(page, 'Verschlüsselung')).toHaveText('Nein');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('metadata tool shows a dash for empty fields', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/metadata');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'nackt.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Nackt') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(metadataValue(page, 'Titel')).toHaveText('–');
|
||||||
|
await expect(metadataValue(page, 'Autor')).toHaveText('–');
|
||||||
|
await expect(metadataValue(page, 'Schlüsselwörter')).toHaveText('–');
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('metadata tool downloads a cleaned PDF with empty document properties', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/metadata');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'schriftsatz.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createPdfWithMetadata()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Bereinigt herunterladen' }).click();
|
||||||
|
const bytes = await expectPdfDownload(await downloadPromise, /^schriftsatz_bereinigt\.pdf$/);
|
||||||
|
|
||||||
|
const document = await PDFDocument.load(bytes, { updateMetadata: false });
|
||||||
|
expect((document.getTitle() ?? '').trim()).toBe('');
|
||||||
|
expect((document.getAuthor() ?? '').trim()).toBe('');
|
||||||
|
expect((document.getSubject() ?? '').trim()).toBe('');
|
||||||
|
expect((document.getKeywords() ?? '').trim()).toBe('');
|
||||||
|
expect(document.getProducer()).toBe('beA-Edit');
|
||||||
|
expect(document.getPageCount()).toBe(2);
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('metadata tool rejects encrypted PDFs with a decrypt hint', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
await page.goto('/tools/metadata');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{
|
||||||
|
name: 'geheim.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: await createEncryptedPdf(1, 'Geheim', 'test-passwort')
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(page.getByText('Dieses PDF ist verschlüsselt')).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Passwort entfernen' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/tools/decrypt'
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('button', { name: 'Bereinigt herunterladen' })).toBeDisabled();
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('metadata tool warns when embedded files are suspected', async ({ page }) => {
|
||||||
|
const pageErrors = trackPageErrors(page);
|
||||||
|
const pdf = await createPdf(1, 'Anlage');
|
||||||
|
const withMarker = Buffer.concat([pdf, Buffer.from('\n% /EmbeddedFile\n')]);
|
||||||
|
|
||||||
|
await page.goto('/tools/metadata');
|
||||||
|
await dropFiles(page, pdfDropzone(page), [
|
||||||
|
{ name: 'mit-anlage.pdf', mimeType: 'application/pdf', buffer: withMarker }
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText(
|
||||||
|
'Die Datei enthält möglicherweise eingebettete Anlagen. Diese werden beim Bereinigen nicht entfernt.'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Bereinigt herunterladen' })).toBeEnabled();
|
||||||
|
expect(pageErrors).toEqual([]);
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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<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([]);
|
|
||||||
});
|
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user