Fix playground CI rendering and E2E snapshots

This commit is contained in:
hand-dot
2026-03-22 09:25:35 +09:00
parent 402e224472
commit 22d4fd1f9e
11 changed files with 248 additions and 107 deletions

View File

@@ -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';

View File

@@ -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}`);
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -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');
});
});