Fix playground CI rendering and E2E snapshots
@@ -1,32 +1,34 @@
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import { pdf2img as _pdf2img, Pdf2ImgOptions } from './pdf2img.js';
|
||||
import { pdf2size as _pdf2size, Pdf2SizeOptions } from './pdf2size.js';
|
||||
|
||||
let workerPort: Worker | null = null;
|
||||
import workerSrc from './pdfjs-worker.js?worker&url';
|
||||
|
||||
const clonePdfData = (pdf: ArrayBuffer | Uint8Array) =>
|
||||
pdf instanceof Uint8Array ? new Uint8Array(pdf) : new Uint8Array(pdf);
|
||||
|
||||
const getWorkerPort = () => {
|
||||
if (typeof Worker === 'undefined') {
|
||||
return null;
|
||||
const loadingTaskMap = new WeakMap<object, { destroy: () => Promise<void> }>();
|
||||
|
||||
const getDocument = async (pdf: ArrayBuffer | Uint8Array) => {
|
||||
if (
|
||||
typeof Worker !== 'undefined' &&
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc !== workerSrc
|
||||
) {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc;
|
||||
}
|
||||
|
||||
workerPort ??= new Worker(new URL('./pdfjs-worker.ts', import.meta.url), { type: 'module' });
|
||||
return workerPort;
|
||||
};
|
||||
|
||||
const getDocument = (pdf: ArrayBuffer | Uint8Array) => {
|
||||
const port = getWorkerPort();
|
||||
|
||||
if (port && pdfjsLib.GlobalWorkerOptions.workerPort !== port) {
|
||||
pdfjsLib.GlobalWorkerOptions.workerPort = port;
|
||||
}
|
||||
|
||||
return pdfjsLib.getDocument({
|
||||
const loadingTask = pdfjsLib.getDocument({
|
||||
data: clonePdfData(pdf),
|
||||
isEvalSupported: false,
|
||||
}).promise;
|
||||
});
|
||||
const document = await loadingTask.promise;
|
||||
loadingTaskMap.set(document, { destroy: () => loadingTask.destroy() });
|
||||
return document;
|
||||
};
|
||||
|
||||
const destroyDocument = async (document: object) => {
|
||||
const loadingTask = loadingTaskMap.get(document);
|
||||
loadingTaskMap.delete(document);
|
||||
await loadingTask?.destroy();
|
||||
};
|
||||
|
||||
function dataURLToArrayBuffer(dataURL: string): ArrayBuffer {
|
||||
@@ -53,6 +55,7 @@ export const pdf2img = async (
|
||||
): Promise<ArrayBuffer[]> =>
|
||||
_pdf2img(pdf, options, {
|
||||
getDocument,
|
||||
destroyDocument,
|
||||
createCanvas: (width, height) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
@@ -69,6 +72,7 @@ export const pdf2img = async (
|
||||
export const pdf2size = async (pdf: ArrayBuffer | Uint8Array, options: Pdf2SizeOptions = {}) =>
|
||||
_pdf2size(pdf, options, {
|
||||
getDocument,
|
||||
destroyDocument,
|
||||
});
|
||||
|
||||
export { img2pdf } from './img2pdf.js';
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ImageType } from './types.js';
|
||||
|
||||
interface Environment {
|
||||
getDocument: (pdf: ArrayBuffer | Uint8Array) => Promise<PDFDocumentProxy>;
|
||||
destroyDocument?: (pdfDoc: PDFDocumentProxy) => Promise<void>;
|
||||
createCanvas: (width: number, height: number) => HTMLCanvasElement | OffscreenCanvas;
|
||||
canvasToArrayBuffer: (
|
||||
canvas: HTMLCanvasElement | OffscreenCanvas,
|
||||
@@ -28,40 +29,44 @@ export async function pdf2img(
|
||||
const { scale = 1, imageType = 'jpeg', range = {} } = options;
|
||||
const { start = 0, end = Infinity } = range;
|
||||
|
||||
const { getDocument, createCanvas, canvasToArrayBuffer } = env;
|
||||
const { getDocument, destroyDocument, createCanvas, canvasToArrayBuffer } = env;
|
||||
|
||||
const pdfDoc = await getDocument(pdf);
|
||||
const numPages = pdfDoc.numPages;
|
||||
try {
|
||||
const numPages = pdfDoc.numPages;
|
||||
|
||||
const startPage = Math.max(start + 1, 1);
|
||||
const endPage = Math.min(end + 1, numPages);
|
||||
const startPage = Math.max(start + 1, 1);
|
||||
const endPage = Math.min(end + 1, numPages);
|
||||
|
||||
const results: ArrayBuffer[] = [];
|
||||
const results: ArrayBuffer[] = [];
|
||||
|
||||
for (let pageNum = startPage; pageNum <= endPage; pageNum++) {
|
||||
const page = await pdfDoc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale });
|
||||
for (let pageNum = startPage; pageNum <= endPage; pageNum++) {
|
||||
const page = await pdfDoc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = createCanvas(viewport.width, viewport.height);
|
||||
if (!canvas) {
|
||||
throw new Error('Failed to create canvas');
|
||||
const canvas = createCanvas(viewport.width, viewport.height);
|
||||
if (!canvas) {
|
||||
throw new Error('Failed to create canvas');
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
if (!context) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
|
||||
await page.render({
|
||||
canvas: canvas as unknown as HTMLCanvasElement,
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
}).promise;
|
||||
const arrayBuffer = canvasToArrayBuffer(canvas, imageType);
|
||||
results.push(arrayBuffer);
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
if (!context) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
|
||||
await page.render({
|
||||
canvas: canvas as unknown as HTMLCanvasElement,
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
}).promise;
|
||||
const arrayBuffer = canvasToArrayBuffer(canvas, imageType);
|
||||
results.push(arrayBuffer);
|
||||
return results;
|
||||
} finally {
|
||||
await destroyDocument?.(pdfDoc);
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
throw new Error(`[@pdfme/converter] pdf2img failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Size, pt2mm } from '@pdfme/common';
|
||||
|
||||
interface Environment {
|
||||
getDocument: (pdf: ArrayBuffer | Uint8Array) => Promise<PDFDocumentProxy>;
|
||||
destroyDocument?: (pdfDoc: PDFDocumentProxy) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface Pdf2SizeOptions {
|
||||
@@ -15,19 +16,20 @@ export async function pdf2size(
|
||||
env: Environment,
|
||||
): Promise<Size[]> {
|
||||
const { scale = 1 } = options;
|
||||
const { getDocument } = env;
|
||||
|
||||
const { getDocument, destroyDocument } = env;
|
||||
const pdfDoc = await getDocument(pdf);
|
||||
|
||||
const promises = Promise.all(
|
||||
Array.from({ length: pdfDoc.numPages }, async (_, i) => {
|
||||
return await pdfDoc.getPage(i + 1).then((page) => {
|
||||
const { height, width } = page.getViewport({ scale, rotation: 0 });
|
||||
try {
|
||||
return await Promise.all(
|
||||
Array.from({ length: pdfDoc.numPages }, async (_, i) => {
|
||||
return await pdfDoc.getPage(i + 1).then((page) => {
|
||||
const { height, width } = page.getViewport({ scale, rotation: 0 });
|
||||
|
||||
return { height: pt2mm(height), width: pt2mm(width) };
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return promises;
|
||||
return { height: pt2mm(height), width: pt2mm(width) };
|
||||
});
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await destroyDocument?.(pdfDoc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,3 +13,8 @@ declare module 'pdfjs-dist/legacy/build/pdf.mjs' {
|
||||
}
|
||||
|
||||
declare module 'pdfjs-dist/legacy/build/pdf.worker.mjs';
|
||||
|
||||
declare module '*?worker&url' {
|
||||
const workerUrl: string;
|
||||
export default workerUrl;
|
||||
}
|
||||
|
||||
@@ -68,16 +68,16 @@ export const useUIPreProcessor = ({ template, size, zoomLevel, maxZoom }: UIPreP
|
||||
_pageSizes = schemas.map(() => ({ width, height }));
|
||||
} else {
|
||||
const _basePdf = await getB64BasePdf(basePdf);
|
||||
|
||||
const uint8Array = b64toUint8Array(_basePdf);
|
||||
// Create a new ArrayBuffer copy to avoid detachment issues
|
||||
const pdfArrayBuffer = new ArrayBuffer(uint8Array.byteLength);
|
||||
new Uint8Array(pdfArrayBuffer).set(uint8Array);
|
||||
const createPdfArrayBuffer = () => {
|
||||
const buffer = new ArrayBuffer(uint8Array.byteLength);
|
||||
new Uint8Array(buffer).set(uint8Array);
|
||||
return buffer;
|
||||
};
|
||||
|
||||
const [_pages, imgBuffers] = await Promise.all([
|
||||
pdf2size(pdfArrayBuffer),
|
||||
pdf2img(pdfArrayBuffer.slice(), { scale: maxZoom }),
|
||||
]);
|
||||
// Run sequentially with isolated buffers to avoid pdf.js worker races and buffer detachment.
|
||||
const _pages = await pdf2size(createPdfArrayBuffer());
|
||||
const imgBuffers = await pdf2img(createPdfArrayBuffer(), { scale: maxZoom });
|
||||
_pageSizes = _pages;
|
||||
paperWidth = _pageSizes[0].width * ZOOM;
|
||||
paperHeight = _pageSizes[0].height * ZOOM;
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 64 KiB |
BIN
playground/e2e/__image_snapshots__/final-form-pdf-page-2.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 34 KiB |
@@ -1,11 +1,10 @@
|
||||
import puppeteer, { Browser, Page } from 'puppeteer';
|
||||
import { pdf2img } from '@pdfme/converter';
|
||||
import { createRunner, parse, PuppeteerRunnerExtension } from '@puppeteer/replay';
|
||||
import { Template, Schema, cloneDeep } from '@pdfme/common';
|
||||
import { text, table, image, barcodes, select, checkbox, radioGroup } from '@pdfme/schemas';
|
||||
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
|
||||
import { stripVTControlCharacters } from 'node:util';
|
||||
import type { MatchImageOptions } from 'vitest-image-snapshot';
|
||||
import templateCreationRecord from './templateCreationRecord.json';
|
||||
import formInputRecord from './formInputRecord.json';
|
||||
|
||||
const previewUrlPattern = /https?:\/\/(?:localhost|127\.0\.0\.1):\d+\/?/;
|
||||
|
||||
@@ -142,6 +141,121 @@ function getPdfSnapshotOptions(labelPrefix: string): MatchImageOptions {
|
||||
: snapshotOptions;
|
||||
}
|
||||
|
||||
const modifiedTemplateFieldNames = {
|
||||
text: 'headline',
|
||||
table: 'lineItems',
|
||||
image: 'brandImage',
|
||||
qrcode: 'supportQr',
|
||||
select: 'status',
|
||||
checkbox: 'approved',
|
||||
radioGroup: 'selected',
|
||||
} as const;
|
||||
|
||||
type PlaygroundStorageState = {
|
||||
template?: Template;
|
||||
inputs?: Record<string, string>[];
|
||||
mode?: 'form' | 'viewer';
|
||||
};
|
||||
|
||||
const cloneSchema = <T extends Schema>(schema: T, overrides: Partial<T>): T =>
|
||||
({
|
||||
...cloneDeep(schema),
|
||||
...overrides,
|
||||
position: {
|
||||
...schema.position,
|
||||
...overrides.position,
|
||||
},
|
||||
}) as T;
|
||||
|
||||
function buildModifiedTemplate(): Template {
|
||||
const basePdf: Template['basePdf'] = {
|
||||
width: 210,
|
||||
height: 297,
|
||||
padding: [20, 10, 20, 10],
|
||||
};
|
||||
|
||||
return {
|
||||
basePdf,
|
||||
schemas: [
|
||||
[
|
||||
cloneSchema(text.propPanel.defaultSchema, {
|
||||
name: modifiedTemplateFieldNames.text,
|
||||
position: { x: 20, y: 20 },
|
||||
}),
|
||||
cloneSchema(table.propPanel.defaultSchema, {
|
||||
name: modifiedTemplateFieldNames.table,
|
||||
position: { x: 20, y: 40 },
|
||||
width: 150,
|
||||
}),
|
||||
cloneSchema(image.propPanel.defaultSchema, {
|
||||
name: modifiedTemplateFieldNames.image,
|
||||
position: { x: 20, y: 90 },
|
||||
readOnly: true,
|
||||
}),
|
||||
cloneSchema(barcodes.qrcode.propPanel.defaultSchema, {
|
||||
name: modifiedTemplateFieldNames.qrcode,
|
||||
position: { x: 70, y: 95 },
|
||||
readOnly: true,
|
||||
}),
|
||||
cloneSchema(select.propPanel.defaultSchema, {
|
||||
name: modifiedTemplateFieldNames.select,
|
||||
position: { x: 115, y: 98 },
|
||||
}),
|
||||
cloneSchema(checkbox.propPanel.defaultSchema, {
|
||||
name: modifiedTemplateFieldNames.checkbox,
|
||||
position: { x: 115, y: 120 },
|
||||
}),
|
||||
cloneSchema(radioGroup.propPanel.defaultSchema, {
|
||||
name: modifiedTemplateFieldNames.radioGroup,
|
||||
position: { x: 130, y: 120 },
|
||||
}),
|
||||
],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildFinalFormInputs(): Record<string, string>[] {
|
||||
const tableRows = Array.from({ length: 40 }, (_, index) => [
|
||||
`Person ${String(index + 1).padStart(2, '0')}`,
|
||||
`City ${((index % 5) + 1).toString()}`,
|
||||
`Summary ${index + 1}`,
|
||||
]);
|
||||
|
||||
return [
|
||||
{
|
||||
[modifiedTemplateFieldNames.text]: 'Filled by CI',
|
||||
[modifiedTemplateFieldNames.table]: JSON.stringify(tableRows),
|
||||
[modifiedTemplateFieldNames.select]: 'option2',
|
||||
[modifiedTemplateFieldNames.checkbox]: 'true',
|
||||
[modifiedTemplateFieldNames.radioGroup]: 'true',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function loadRouteWithStorage(
|
||||
page: Page,
|
||||
path: '/designer' | '/form-viewer',
|
||||
storageState: PlaygroundStorageState,
|
||||
) {
|
||||
await page.goto(`${baseUrl}${path}`, { waitUntil: 'networkidle2', timeout });
|
||||
await page.evaluate((state) => {
|
||||
localStorage.removeItem('template');
|
||||
localStorage.removeItem('inputs');
|
||||
localStorage.removeItem('mode');
|
||||
|
||||
if (state.template) {
|
||||
localStorage.setItem('template', JSON.stringify(state.template));
|
||||
}
|
||||
if (state.inputs) {
|
||||
localStorage.setItem('inputs', JSON.stringify(state.inputs));
|
||||
}
|
||||
if (state.mode) {
|
||||
localStorage.setItem('mode', state.mode);
|
||||
}
|
||||
}, storageState);
|
||||
await page.reload({ waitUntil: 'networkidle2', timeout });
|
||||
}
|
||||
|
||||
async function waitForDesignerReady(page: Page, expectedText?: string) {
|
||||
await page.waitForFunction(
|
||||
(text) => {
|
||||
@@ -153,17 +267,13 @@ async function waitForDesignerReady(page: Page, expectedText?: string) {
|
||||
const canvas = document.querySelector('.pdfme-designer-canvas');
|
||||
const spinner = document.querySelector('.pdfme-designer-root svg.lucide-loader-circle');
|
||||
const paper = document.querySelector('.pdfme-designer-canvas [style*="background-image"]');
|
||||
const renderRoots = Array.from(
|
||||
document.querySelectorAll('.pdfme-designer-canvas .selectable[title] > div'),
|
||||
const titledSelectables = Array.from(
|
||||
document.querySelectorAll('.pdfme-designer-canvas .selectable[title]'),
|
||||
);
|
||||
const renderersReady =
|
||||
renderRoots.length > 0 &&
|
||||
renderRoots.every((element) => {
|
||||
const content = element as HTMLElement;
|
||||
return (
|
||||
content.childElementCount > 0 || (content.textContent?.trim().length ?? 0) > 0
|
||||
);
|
||||
});
|
||||
const renderersReady = titledSelectables.every((element) => {
|
||||
const content = element.firstElementChild;
|
||||
return !(content instanceof HTMLElement) || content.dataset.pdfmeRenderReady === 'true';
|
||||
});
|
||||
const fontsLoaded = !document.fonts || document.fonts.status === 'loaded';
|
||||
return hasExpectedText && !!canvas && !spinner && !!paper && fontsLoaded && renderersReady;
|
||||
},
|
||||
@@ -187,6 +297,41 @@ async function waitForDesignerReady(page: Page, expectedText?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForFormReady(page: Page, expectedText?: string) {
|
||||
await page.waitForFunction(
|
||||
(text) => {
|
||||
const container = document.querySelector('div.flex-1.w-full');
|
||||
const hasExpectedText =
|
||||
typeof text === 'string' && text.length > 0
|
||||
? (container?.textContent?.includes(text) ?? false)
|
||||
: true;
|
||||
const titledSelectables = Array.from(document.querySelectorAll('.selectable[title]'));
|
||||
const renderersReady =
|
||||
titledSelectables.length > 0 &&
|
||||
titledSelectables.every((element) => {
|
||||
const content = element.firstElementChild;
|
||||
return !(content instanceof HTMLElement) || content.dataset.pdfmeRenderReady === 'true';
|
||||
});
|
||||
const fontsLoaded = !document.fonts || document.fonts.status === 'loaded';
|
||||
return hasExpectedText && renderersReady && fontsLoaded;
|
||||
},
|
||||
{ timeout },
|
||||
expectedText,
|
||||
);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
if (document.fonts) {
|
||||
await document.fonts.ready;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 150);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function generatePdf(page: Page, browser: Browser): Promise<Buffer> {
|
||||
await page.waitForSelector('#generate-pdf', { timeout });
|
||||
await page.click('#generate-pdf');
|
||||
@@ -326,7 +471,7 @@ describe('Playground E2E Tests', () => {
|
||||
// 5. Load the Pedigree designer directly to avoid flaky list-page navigation in CI
|
||||
await page.goto(`${baseUrl}/?template=pedigree`, { waitUntil: 'networkidle2', timeout });
|
||||
|
||||
await waitForDesignerReady(page);
|
||||
await waitForDesignerReady(page, 'Pet Name');
|
||||
|
||||
// 7. Screenshot & compare
|
||||
await captureAndCompareScreenshot(page, 'pedigree-designer');
|
||||
@@ -335,45 +480,25 @@ describe('Playground E2E Tests', () => {
|
||||
await generateAndComparePDF(page, browser, 'pedigree');
|
||||
});
|
||||
|
||||
// Skip the problematic test in CI environment
|
||||
it('should modify template, generate PDF and compare, then input form data', async () => {
|
||||
it('should load a deterministic template, generate PDF and compare, then render form inputs', async () => {
|
||||
if (!browser || !page) throw new Error('Browser/Page not initialized');
|
||||
const extension = new PuppeteerRunnerExtension(browser, page, { timeout });
|
||||
|
||||
// 9. Press Reset button
|
||||
await page.$eval('#reset-template', (el: Element) => (el as HTMLElement).click());
|
||||
const template = buildModifiedTemplate();
|
||||
|
||||
// 10. Replay templateCreationRecord operations to add elements
|
||||
const templateCreationUserFlow = parse(templateCreationRecord);
|
||||
const templateCreationRunner = await createRunner(templateCreationUserFlow, extension);
|
||||
await templateCreationRunner.run();
|
||||
await waitForDesignerReady(page);
|
||||
await loadRouteWithStorage(page, '/designer', { template });
|
||||
await waitForDesignerReady(page, 'Type Something...');
|
||||
|
||||
// 11. Screenshot & compare
|
||||
await captureAndCompareScreenshot(page, 'modified-template-designer');
|
||||
|
||||
// 12. Generate PDF & compare
|
||||
await generateAndComparePDF(page, browser, 'modified-template');
|
||||
|
||||
// 13. Save locally
|
||||
await page.click('#save-local');
|
||||
await loadRouteWithStorage(page, '/form-viewer', {
|
||||
template,
|
||||
inputs: buildFinalFormInputs(),
|
||||
mode: 'form',
|
||||
});
|
||||
await waitForFormReady(page, 'Filled by CI');
|
||||
|
||||
// 14. Move to form viewer
|
||||
await page.click('#form-viewer-nav');
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const container = document.querySelector('div.flex-1.w-full');
|
||||
return container ? container.textContent?.includes('Type Something...') : false;
|
||||
},
|
||||
{ timeout },
|
||||
);
|
||||
|
||||
// 15. Input form data
|
||||
const formInputUserFlow = parse(formInputRecord);
|
||||
const formInputRunner = await createRunner(formInputUserFlow, extension);
|
||||
await formInputRunner.run();
|
||||
|
||||
// 16. Generate PDF & compare
|
||||
await generateAndComparePDF(page, browser, 'final-form');
|
||||
});
|
||||
});
|
||||
|
||||