Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,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

### Fixed

Expand Down

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
144 changes: 144 additions & 0 deletions src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import fs from 'fs';
import path from 'path';

import { expect, test } from '@playwright/test';
Expand All @@ -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('/');
});
Expand Down Expand Up @@ -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);
});
});
Binary file not shown.
29 changes: 29 additions & 0 deletions src/frontend/apps/e2e/print-datauris.js
Original file line number Diff line number Diff line change
@@ -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');
Loading