Files
bea-edit/tests/e2e/helpers.ts
T
2026-10-28 16:09:53 +01:00

328 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, 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');
};
const PDF_STRING_ESCAPES: Record<string, string> = {
n: '\n',
r: '\r',
t: '\t',
b: '\b',
f: '\f',
'(': '(',
')': ')',
'\\': '\\'
};
/** Decodes a single PDF string token (`<hex>` or `(literal)`). */
const decodePdfString = (token: string): string => {
if (token.startsWith('<')) {
return Buffer.from(token.slice(1, -1).replace(/\s/g, ''), 'hex').toString('latin1');
}
const raw = token.slice(1, -1);
let decoded = '';
for (let index = 0; index < raw.length; index += 1) {
const char = raw[index];
if (char !== '\\') {
decoded += char;
continue;
}
const escaped = raw[index + 1];
index += 1;
if (escaped === undefined) break;
if (escaped >= '0' && escaped <= '7') {
let octal = escaped;
while (octal.length < 3 && raw[index + 1] >= '0' && raw[index + 1] <= '7') {
octal += raw[(index += 1)];
}
decoded += String.fromCharCode(Number.parseInt(octal, 8));
continue;
}
decoded += PDF_STRING_ESCAPES[escaped] ?? escaped;
}
return decoded;
};
const PDF_STRING_TOKEN = String.raw`<[0-9a-fA-F\s]*>|\((?:\\.|[^()\\])*\)`;
const TEXT_SHOWING_PATTERN = new RegExp(
String.raw`\[((?:[^\[\]\\]|\\.)*)\]\s*TJ|(${PDF_STRING_TOKEN})\s*(?:Tj|'|")`,
'g'
);
const STRING_TOKEN_PATTERN = new RegExp(PDF_STRING_TOKEN, 'g');
/**
* Decoded operands of every text-showing operator (`Tj`, `TJ`, `'`, `"`).
* pdf-lib writes drawn text as hex strings (`<426C2E2031> Tj`), so the raw
* content stream is not readable without this decoding.
*
* Limitations: other text operators (`Td`, `Tf`, …) and non-text strings are
* ignored. A page that shows text through an operator this helper does not know
* returns `[]`, so `not.toContain(...)` assertions on it prove nothing — assert
* on a positive match instead.
*/
export const getPageTextValues = (document: PDFDocument, pageIndex: number) => {
const content = getPageContentText(document, pageIndex);
const values: string[] = [];
for (const match of content.matchAll(TEXT_SHOWING_PATTERN)) {
const [, arrayBody, singleToken] = match;
if (singleToken !== undefined) {
values.push(decodePdfString(singleToken));
continue;
}
for (const token of arrayBody.matchAll(STRING_TOKEN_PATTERN)) {
values.push(decodePdfString(token[0]));
}
}
return values;
};
export const getPageText = (document: PDFDocument, pageIndex: number) =>
getPageTextValues(document, pageIndex).join('\n');
export type PageTextMatrix = { a: number; b: number; c: number; d: number; x: number; y: number };
/**
* Translation and rotation of every text matrix (`Tm`) in a page's content
* stream, in drawing order. Lets tests assert on the drawing geometry without
* hard-coding the hex-encoded text operands.
*/
export const getPageTextMatrices = (document: PDFDocument, pageIndex: number): PageTextMatrix[] => {
const content = getPageContentText(document, pageIndex);
const matrices: PageTextMatrix[] = [];
for (const match of content.matchAll(
/([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) Tm/g
)) {
const [a, b, c, d, x, y] = match.slice(1).map(Number);
matrices.push({ a, b, c, d, x, y });
}
if (matrices.length === 0) throw new Error(`Page ${pageIndex + 1} contains no text matrix.`);
return matrices;
};
/** First text matrix of a page (the only one in a single-mark document). */
export const getPageTextMatrix = (document: PDFDocument, pageIndex: number): PageTextMatrix =>
getPageTextMatrices(document, pageIndex)[0];
/** Angle of a text matrix in degrees, normalised to `0 … 359`. */
export const getPageTextAngle = (matrix: PageTextMatrix) =>
((Math.atan2(matrix.b, matrix.a) * 180) / Math.PI + 360) % 360;
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- oder PDF-Dateien hinzufügen' });
export const createZip = async (label: string, pageCount = 1) =>
Buffer.from(zipSync({ [`${label}.pdf`]: await createPdf(pageCount, label) }));
/** ZIP with multiple named single-page PDF attachments. */
export const createMultiFileZip = async (labels: string[]) =>
Buffer.from(
zipSync(
Object.fromEntries(
await Promise.all(labels.map(async (label) => [`${label}.pdf`, await createPdf(1, label)]))
)
)
);
export const newZipButton = (page: Page) =>
page.getByRole('button', { name: 'Neue Datei 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 Datei öffnen' }).click();
} else {
await page.getByRole('button', { name: 'Abbrechen' }).click();
}
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toBeHidden();
};