Compare commits

...

2 Commits

Author SHA1 Message Date
Cyril
0144044c55 (frontend) add pdf export regression test with embedded image fixtures
adds base64 png/jpg/svg image fixtures to stabilize pdf export snapshots

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-01 11:46:43 +01:00
Cyril
a6da37e231 (frontend) add test to compare generated PDF against reference template
ensures no regression by validating PDF content matches the expected pattern

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-01 11:46:31 +01:00
7 changed files with 175 additions and 0 deletions

View File

@@ -32,6 +32,7 @@ and this project adheres to
- ✨(export) enable ODT export for documents #1524
- ✨(frontend) improve mobile UX by showing subdocs count #1540
- ✅(e2e) add test to compare generated PDF against reference template #1648
### Changed

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 KiB

View File

@@ -1,3 +1,4 @@
import fs from 'fs';
import path from 'path';
import { expect, test } from '@playwright/test';
@@ -13,6 +14,35 @@ import {
import { openSuggestionMenu, writeInEditor } from './utils-editor';
import { createRootSubPage } from './utils-sub-pages';
const REGRESSION_FIXTURE_CONTENT = fs.readFileSync(
path.join(__dirname, 'assets/content.txt'),
'utf-8',
);
const REGRESSION_SNAPSHOT_NAME = 'doc-export-regression.pdf';
const REGRESSION_DOC_TITLE = 'doc-export-regression-reference';
/**
* Playwright snapshots store the raw PDF bytes. However, each export embeds
* dynamic metadata (timestamps, font-subset identifiers, etc.) that would make
* the snapshot differ at every run. To ensure deterministic comparisons we
* strip/neutralize those fields before matching against the reference PDF.
*/
const sanitizePdfBuffer = (buffer: Buffer) => {
const pdfText = buffer.toString('latin1');
const neutralized = pdfText
// Remove per-export timestamps
.replace(/\/CreationDate\s*\(.*?\)/g, '/CreationDate ()')
.replace(/\/ModDate\s*\(.*?\)/g, '/ModDate ()')
// Remove file identifiers
.replace(/\/ID\s*\[<[^>]+>\s*<[^>]+>\]/g, '/ID [<0><0>]')
.replace(/D:\d{14}Z/g, 'D:00000000000000Z')
// Remove subset font prefixes generated by PDF renderer
.replace(/\b[A-Z]{6}\+(Inter18pt-[A-Za-z]+)\b/g, 'STATIC+$1')
.replace(/\b[A-Z]{6}\+(GeistMono-[A-Za-z]+)\b/g, 'STATIC+$1');
return Buffer.from(neutralized, 'latin1');
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
@@ -551,4 +581,118 @@ test.describe('Doc Export', () => {
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
});
/**
* Regression guard for the full PDF export pipeline.
*
* Usage reminder:
* 1. `npx playwright test __tests__/app-impress/doc-export.spec.ts --update-snapshots -g "full document" --project=chromium`
* -> refresh the reference PDF whenever we intentionally change the export output.
* 2. `npx playwright test __tests__/app-impress/doc-export.spec.ts -g "full document" --project=chromium`
* -> CI (and local runs without --update-snapshots) will compare the PDF to the reference
* and fail on any byte-level difference once the dynamic metadata has been sanitized.
*/
test('it keeps the full document PDF export identical to the reference snapshot', async ({
page,
browserName,
}, testInfo) => {
// PDF generation for a large, image-heavy document can be slow in CI.
// Give this regression test a higher timeout budget than the default.
testInfo.setTimeout(120000);
const snapshotPath = testInfo.snapshotPath(REGRESSION_SNAPSHOT_NAME);
test.skip(
!fs.existsSync(snapshotPath) &&
testInfo.config.updateSnapshots === 'none',
`Missing PDF snapshot at ${snapshotPath}. Run Playwright with --update-snapshots to record it.`,
);
// We must use a deterministic title so that block content (and thus the
// exported PDF) stays identical between runs.
await createDoc(page, 'doc-export-regression', browserName, 1);
const titleInput = page.getByRole('textbox', { name: 'Document title' });
await expect(titleInput).toBeVisible();
await titleInput.fill(REGRESSION_DOC_TITLE);
await titleInput.blur();
await verifyDocName(page, REGRESSION_DOC_TITLE);
const regressionDoc = REGRESSION_DOC_TITLE;
const docId = page
.url()
.split('/docs/')[1]
?.split('/')
.filter(Boolean)
.shift();
expect(docId).toBeTruthy();
// Inject the pre-crafted blocknote document via the REST API to avoid
// rebuilding it through the UI (which would be slow and flaky).
const cookies = await page.context().cookies();
const csrfToken = cookies.find(
(cookie) => cookie.name === 'csrftoken',
)?.value;
const headers: Record<string, string> = {
'content-type': 'application/json',
};
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const updateResponse = await page.request.patch(
`http://localhost:8071/api/v1.0/documents/${docId}/`,
{
headers,
data: {
content: REGRESSION_FIXTURE_CONTENT,
websocket: true,
},
},
);
if (!updateResponse.ok()) {
throw new Error(
`Failed to seed document content. Status: ${updateResponse.status()}, body: ${await updateResponse.text()}`,
);
}
await page.reload();
// After reloading, just ensure the editor container is present before exporting.
await expect(page.locator('.--docs--editor-container')).toBeVisible({
timeout: 15000,
});
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await expect(
page.getByTestId('doc-open-modal-download-button'),
).toBeVisible();
await expect(page.getByTestId('doc-export-download-button')).toBeEnabled({
timeout: 15000,
});
// Export to PDF and confirm the generated bytes match the reference file.
const downloadPromise = page.waitForEvent('download', {
timeout: 60000,
predicate: (download) =>
download.suggestedFilename().includes(`${regressionDoc}.pdf`),
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${regressionDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const normalizedPdfBuffer = sanitizePdfBuffer(pdfBuffer);
expect(normalizedPdfBuffer).toMatchSnapshot(REGRESSION_SNAPSHOT_NAME);
});
});

View File

@@ -0,0 +1,29 @@
//utilitary script to print the datauris of the targeted assets
const fs = require('fs');
const path = require('path');
const ASSETS_ROOT = path.resolve(__dirname, '__tests__/app-impress/assets');
function saveDataUrl(file, mime, outName) {
const abs = path.join(ASSETS_ROOT, file);
const base64 = fs.readFileSync(abs).toString('base64');
const dataUrl = `data:${mime};base64,${base64}`;
const outPath = path.join(ASSETS_ROOT, outName);
fs.writeFileSync(outPath, dataUrl, 'utf8');
console.log(`Wrote ${outName}`);
}
// PNG
saveDataUrl('panopng.png', 'image/png', 'pano-png-dataurl.txt');
// JPG
saveDataUrl('panojpg.jpeg', 'image/jpeg', 'pano-jpg-dataurl.txt');
// SVG
const svgPath = path.join(ASSETS_ROOT, 'test.svg');
const svgText = fs.readFileSync(svgPath, 'utf8');
const svgDataUrl =
'data:image/svg+xml;base64,' + Buffer.from(svgText).toString('base64');
fs.writeFileSync(path.join(ASSETS_ROOT, 'test-svg-dataurl.txt'), svgDataUrl, 'utf8');
console.log('Wrote test-svg-dataurl.txt');