task-p4.5.1
This commit is contained in:
779
scripts/audit-pdf-visual-regression.ts
Normal file
779
scripts/audit-pdf-visual-regression.ts
Normal file
@@ -0,0 +1,779 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import type { Font, Template } from '@pdfme/common';
|
||||
import { generate } from '@pdfme/generator';
|
||||
import { PDFDocument } from '@pdfme/pdf-lib';
|
||||
import { image, line, table, text } from '@pdfme/schemas';
|
||||
import type { ResolvedDocumentTemplate } from '../src/features/foundation/document-template/types.ts';
|
||||
import { buildQuotationPdfRuntime } from '../src/features/crm/quotations/document/server/quotation-pdf-runtime.ts';
|
||||
import type { QuotationDocumentData } from '../src/features/crm/quotations/document/types.ts';
|
||||
import {
|
||||
PDFME_STATIC_LABELS,
|
||||
PRICE_TABLE_LABEL,
|
||||
computePayloadHash,
|
||||
extractTemplateFields,
|
||||
loadTemplateSourceByOrganizationName,
|
||||
} from './pdf-audit-utils.ts';
|
||||
|
||||
const FIXTURES_ROOT = path.resolve(process.cwd(), 'fixtures', 'pdf-visual');
|
||||
const CURRENT_ARTIFACTS_ROOT = path.resolve(
|
||||
process.cwd(),
|
||||
'artifacts',
|
||||
'pdf-visual',
|
||||
'current',
|
||||
);
|
||||
const REPORTS_ROOT = path.resolve(process.cwd(), 'artifacts', 'pdf-visual');
|
||||
const REPORT_JSON_PATH = path.resolve(REPORTS_ROOT, 'visual-regression-report.json');
|
||||
const REPORT_MD_PATH = path.resolve(REPORTS_ROOT, 'visual-regression-report.md');
|
||||
const TEMPLATE_VARIANT = 'product-v1' as const;
|
||||
|
||||
interface DatasetDefinition {
|
||||
key: string;
|
||||
fixtureName: string;
|
||||
itemCount: number;
|
||||
topicCount: number;
|
||||
longDescription?: boolean;
|
||||
mixedLanguage?: boolean;
|
||||
quantityMode?: 'integer' | 'decimal';
|
||||
}
|
||||
|
||||
interface FixtureRunResult {
|
||||
datasetKey: string;
|
||||
fixtureName: string;
|
||||
organizationName: string;
|
||||
pdfPath: string;
|
||||
manifestPath: string;
|
||||
pdfHash: string;
|
||||
runtimeHash: string;
|
||||
pageCount: number;
|
||||
fileSize: number;
|
||||
itemCount: number;
|
||||
topicCount: number;
|
||||
renderedSectionOrder: string[];
|
||||
sectionAnchors: Record<string, number | null>;
|
||||
firstItemsRowColumns: number;
|
||||
signatureOnLastPage: boolean;
|
||||
runtimeIssues: Array<{ code: string; severity: string; message: string }>;
|
||||
generationMs: number;
|
||||
templateFieldCount: number;
|
||||
templateFilePath: string;
|
||||
templateSourceHash: string;
|
||||
}
|
||||
|
||||
interface ComparisonResult {
|
||||
datasetKey: string;
|
||||
organizationName: string;
|
||||
status: 'PASS' | 'WARNING' | 'FAIL';
|
||||
checks: Array<{
|
||||
label: string;
|
||||
status: 'PASS' | 'WARNING' | 'FAIL';
|
||||
expected: string | number | boolean | null;
|
||||
actual: string | number | boolean | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
const DATASETS: DatasetDefinition[] = [
|
||||
{ key: 'dataset-a-empty', fixtureName: 'quotation-empty', itemCount: 0, topicCount: 3 },
|
||||
{
|
||||
key: 'dataset-b-single-item',
|
||||
fixtureName: 'quotation-single-item',
|
||||
itemCount: 1,
|
||||
topicCount: 3,
|
||||
},
|
||||
{
|
||||
key: 'dataset-c-standard',
|
||||
fixtureName: 'quotation-standard',
|
||||
itemCount: 4,
|
||||
topicCount: 3,
|
||||
mixedLanguage: true,
|
||||
},
|
||||
{
|
||||
key: 'dataset-d-twenty-items',
|
||||
fixtureName: 'quotation-20-items',
|
||||
itemCount: 20,
|
||||
topicCount: 3,
|
||||
},
|
||||
{
|
||||
key: 'dataset-e-hundred-items',
|
||||
fixtureName: 'quotation-100-items',
|
||||
itemCount: 100,
|
||||
topicCount: 3,
|
||||
},
|
||||
{
|
||||
key: 'dataset-f-long-description',
|
||||
fixtureName: 'quotation-long-description',
|
||||
itemCount: 4,
|
||||
topicCount: 3,
|
||||
longDescription: true,
|
||||
mixedLanguage: true,
|
||||
quantityMode: 'decimal',
|
||||
},
|
||||
{
|
||||
key: 'dataset-g-multi-topic',
|
||||
fixtureName: 'quotation-multi-topic',
|
||||
itemCount: 4,
|
||||
topicCount: 16,
|
||||
mixedLanguage: true,
|
||||
},
|
||||
];
|
||||
|
||||
function sha256(value: string | Buffer): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function formatDateForPdf(value: string): string {
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
const month = date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
const year = date.getUTCFullYear();
|
||||
return `${day} ${month} ${year}`;
|
||||
}
|
||||
|
||||
function formatCurrencyAmount(value: number, currencyCode = 'THB'): string {
|
||||
return `${currencyCode} ${new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value)}`;
|
||||
}
|
||||
|
||||
function buildPriceTable(subtotal: number, currencyCode = 'THB') {
|
||||
return [[PRICE_TABLE_LABEL, formatCurrencyAmount(subtotal, currencyCode), ' ', currencyCode]];
|
||||
}
|
||||
|
||||
function buildTemplateVersionId(organizationName: string) {
|
||||
return `visual-${organizationName.toLowerCase()}-${TEMPLATE_VARIANT}`;
|
||||
}
|
||||
|
||||
function createResolvedTemplate(
|
||||
organizationName: string,
|
||||
schema: Template,
|
||||
filePath: string,
|
||||
): ResolvedDocumentTemplate {
|
||||
const templateId = `visual-template-${organizationName.toLowerCase()}`;
|
||||
const organizationId = `org-${organizationName.toLowerCase()}`;
|
||||
return {
|
||||
template: {
|
||||
id: templateId,
|
||||
organizationId,
|
||||
templateName: `${organizationName} Quotation Product Visual`,
|
||||
documentType: 'quotation',
|
||||
productType: 'crm',
|
||||
fileType: 'pdfme',
|
||||
description: 'Visual regression fixture template',
|
||||
isDefault: false,
|
||||
isActive: true,
|
||||
createdAt: '2026-06-29T00:00:00.000Z',
|
||||
updatedAt: '2026-06-29T00:00:00.000Z',
|
||||
deletedAt: null,
|
||||
createdBy: 'visual-regression',
|
||||
updatedBy: 'visual-regression',
|
||||
},
|
||||
version: {
|
||||
id: buildTemplateVersionId(organizationName),
|
||||
templateId,
|
||||
organizationId,
|
||||
version: '2.0',
|
||||
filePath,
|
||||
schemaJson: schema,
|
||||
previewImageUrl: null,
|
||||
isActive: true,
|
||||
createdAt: '2026-06-29T00:00:00.000Z',
|
||||
updatedAt: '2026-06-29T00:00:00.000Z',
|
||||
deletedAt: null,
|
||||
createdBy: 'visual-regression',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createItem(
|
||||
dataset: DatasetDefinition,
|
||||
index: number,
|
||||
): QuotationDocumentData['items'][number] {
|
||||
const baseDescription = dataset.mixedLanguage
|
||||
? `Main equipment supply ${index + 1} / อุปกรณ์หลัก ${index + 1}`
|
||||
: `Main equipment supply ${index + 1}`;
|
||||
const longTail = dataset.longDescription
|
||||
? ' Includes installation, commissioning, testing, warranty note, and mixed-language explanation for wrapped-row validation in the PDF table layout.'
|
||||
: '';
|
||||
const notes = dataset.longDescription
|
||||
? `หมายเหตุ ${index + 1}: ใช้เพื่อตรวจสอบการตัดบรรทัดและความสูงของแถว`
|
||||
: null;
|
||||
const quantity = dataset.quantityMode === 'decimal' ? 1.25 + index * 0.125 : index + 1;
|
||||
const unitPrice = 1717102 + index * 2500;
|
||||
const discount = index % 2 === 0 ? 0 : 250 + index * 10;
|
||||
const totalPrice = unitPrice * quantity - discount;
|
||||
|
||||
return {
|
||||
id: `item-${index + 1}`,
|
||||
itemNumber: index + 1,
|
||||
productTypeCode: null,
|
||||
productTypeLabel: null,
|
||||
description: `${baseDescription}${longTail}`,
|
||||
quantity,
|
||||
unitCode: 'SET',
|
||||
unitLabel: 'Set',
|
||||
unitPrice,
|
||||
discount,
|
||||
discountTypeCode: null,
|
||||
taxRate: 0,
|
||||
totalPrice,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
function createTopics(topicCount: number, mixedLanguage = false) {
|
||||
return Array.from({ length: topicCount }, (_, index) => {
|
||||
const topicNumber = index + 1;
|
||||
const topicType = mixedLanguage
|
||||
? `Topic ${topicNumber} / หัวข้อ ${topicNumber}`
|
||||
: `Topic ${topicNumber}`;
|
||||
return {
|
||||
id: `topic-${topicNumber}`,
|
||||
title: topicType,
|
||||
topicCode: `topic-${topicNumber}`,
|
||||
topicType,
|
||||
sortOrder: index,
|
||||
items: Array.from({ length: 4 }, (_, itemIndex) => ({
|
||||
id: `topic-${topicNumber}-item-${itemIndex + 1}`,
|
||||
content: mixedLanguage
|
||||
? `Condition ${topicNumber}.${itemIndex + 1} / เงื่อนไข ${topicNumber}.${itemIndex + 1}`
|
||||
: `Condition ${topicNumber}.${itemIndex + 1}`,
|
||||
sortOrder: itemIndex,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createDocumentData(
|
||||
organizationName: string,
|
||||
dataset: DatasetDefinition,
|
||||
): QuotationDocumentData {
|
||||
const items = Array.from({ length: dataset.itemCount }, (_, index) =>
|
||||
createItem(dataset, index),
|
||||
);
|
||||
const subtotal = items.reduce((sum, item) => sum + item.totalPrice, 0);
|
||||
const topics = createTopics(dataset.topicCount, dataset.mixedLanguage ?? false);
|
||||
const code = `QT-VIS-${organizationName}-${dataset.itemCount.toString().padStart(3, '0')}`;
|
||||
|
||||
return {
|
||||
company: {
|
||||
id: `company-${organizationName.toLowerCase()}`,
|
||||
name: organizationName,
|
||||
slug: organizationName.toLowerCase(),
|
||||
imageUrl: null,
|
||||
plan: 'pro',
|
||||
},
|
||||
branch: null,
|
||||
customer: {
|
||||
id: 'customer-visual-1',
|
||||
code: 'CUS-VIS-001',
|
||||
name:
|
||||
organizationName === 'ALLA'
|
||||
? 'ALLA Demo Customer'
|
||||
: 'ONVALLA Demo Customer',
|
||||
address: '99/9 Rama IX Road, Bangkok 10310',
|
||||
phone: '02-000-1000',
|
||||
email: 'demo-customer@local.test',
|
||||
taxId: '0105559999999',
|
||||
},
|
||||
contact: {
|
||||
id: 'contact-visual-1',
|
||||
name: 'Task H Contact',
|
||||
email: 'contact@local.test',
|
||||
mobile: '081-000-0000',
|
||||
position: 'Project Coordinator',
|
||||
},
|
||||
quotation: {
|
||||
id: `quotation-${organizationName.toLowerCase()}-${dataset.key}`,
|
||||
code,
|
||||
quotationDate: '2026-06-29',
|
||||
validUntil: '2026-07-29',
|
||||
projectName:
|
||||
dataset.mixedLanguage
|
||||
? 'Factory Upgrade / โครงการปรับปรุงโรงงาน'
|
||||
: 'Factory Upgrade Project',
|
||||
projectLocation:
|
||||
dataset.mixedLanguage
|
||||
? 'Bangkok HQ / สำนักงานใหญ่ กรุงเทพฯ'
|
||||
: 'Bangkok HQ',
|
||||
attention: 'Task P.4.5.1 Review',
|
||||
reference: null,
|
||||
revision: 0,
|
||||
revisionLabel: 'Rev.0',
|
||||
statusCode: 'approved',
|
||||
statusLabel: 'Approved',
|
||||
quotationTypeCode: null,
|
||||
quotationTypeLabel: null,
|
||||
currency: 'THB',
|
||||
currencyCode: 'THB',
|
||||
currencyLabel: 'Thai Baht',
|
||||
exchangeRate: 1,
|
||||
subtotal,
|
||||
discount: 0,
|
||||
discountTypeCode: null,
|
||||
discountTypeLabel: null,
|
||||
taxRate: 0,
|
||||
taxAmount: 0,
|
||||
totalAmount: subtotal,
|
||||
notes: dataset.longDescription
|
||||
? 'Long-description dataset for row wrapping verification.'
|
||||
: null,
|
||||
},
|
||||
items,
|
||||
quotationCustomers: [],
|
||||
topics: {
|
||||
scope: [],
|
||||
exclusion: [],
|
||||
payment: [],
|
||||
warranty: [],
|
||||
delivery: [],
|
||||
other: [],
|
||||
all: topics,
|
||||
},
|
||||
pdfme: {
|
||||
labels: { ...PDFME_STATIC_LABELS },
|
||||
quotation_date: formatDateForPdf('2026-06-29'),
|
||||
quotation_price: formatCurrencyAmount(subtotal, 'THB'),
|
||||
quotation_price_data: buildPriceTable(subtotal, 'THB'),
|
||||
exclusion_data: [],
|
||||
topic_inputs: {},
|
||||
},
|
||||
approval: {
|
||||
requestId: null,
|
||||
workflowName: null,
|
||||
status: 'approved',
|
||||
approvedAt: '2026-06-29T00:00:00.000Z',
|
||||
currentStep: null,
|
||||
currentStepRoleName: null,
|
||||
approvers: [],
|
||||
},
|
||||
signatures: {
|
||||
preparedBy: { name: 'Task H.1 Admin', position: 'Sales Manager' },
|
||||
approvedBy: { name: 'Task H.1 Manager', position: 'Department Manager' },
|
||||
authorizedBy: { name: 'Task H.1 Director', position: 'General Manager' },
|
||||
},
|
||||
watermarkStatus: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTemplateInput(documentData: QuotationDocumentData): Record<string, unknown> {
|
||||
const quotationDate = formatDateForPdf(documentData.quotation.quotationDate);
|
||||
const currencyCode = documentData.quotation.currencyCode?.trim() || 'THB';
|
||||
const subtotal = documentData.quotation.subtotal;
|
||||
|
||||
return {
|
||||
customer_name: documentData.customer.name,
|
||||
customer_addr: documentData.customer.address ?? '-',
|
||||
customer_tel: documentData.customer.phone ?? '-',
|
||||
customer_email: documentData.customer.email ?? '-',
|
||||
customer_att: documentData.quotation.attention ?? '-',
|
||||
project_name: documentData.quotation.projectName ?? '-',
|
||||
site_location: documentData.quotation.projectLocation ?? '-',
|
||||
quotation_date: quotationDate,
|
||||
quotation_date_data: quotationDate,
|
||||
quotation_code_data: documentData.quotation.code,
|
||||
quotation_price_data: buildPriceTable(subtotal, currencyCode),
|
||||
quotation_price: formatCurrencyAmount(subtotal, currencyCode),
|
||||
currency: currencyCode,
|
||||
tel_label: PDFME_STATIC_LABELS.tel,
|
||||
email_label: PDFME_STATIC_LABELS.email,
|
||||
att_label: PDFME_STATIC_LABELS.att,
|
||||
project_label: PDFME_STATIC_LABELS.project,
|
||||
site_label: PDFME_STATIC_LABELS.location,
|
||||
app1: documentData.signatures.preparedBy.name,
|
||||
app1_position: documentData.signatures.preparedBy.position,
|
||||
app2: documentData.signatures.approvedBy.name,
|
||||
app2_position: documentData.signatures.approvedBy.position,
|
||||
app3: documentData.signatures.authorizedBy.name,
|
||||
app3_position: documentData.signatures.authorizedBy.position,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolvePdfFonts(): Promise<Font> {
|
||||
const regularPath = path.join(process.cwd(), 'public', 'fonts', 'cordia_new_r.ttf');
|
||||
const boldPath = path.join(process.cwd(), 'public', 'fonts', 'Cordia_New_Bold.ttf');
|
||||
return {
|
||||
cordia: {
|
||||
data: new Uint8Array(await readFile(regularPath)),
|
||||
fallback: true,
|
||||
},
|
||||
cordiaBold: {
|
||||
data: new Uint8Array(await readFile(boldPath)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function generatePdfBuffer(template: Template, input: Record<string, unknown>) {
|
||||
const pdf = await generate({
|
||||
template,
|
||||
inputs: [input],
|
||||
plugins: { text, image, line, table },
|
||||
options: {
|
||||
font: await resolvePdfFonts(),
|
||||
lang: 'th',
|
||||
},
|
||||
});
|
||||
return Buffer.from(pdf);
|
||||
}
|
||||
|
||||
async function ensureDir(targetPath: string) {
|
||||
await mkdir(targetPath, { recursive: true });
|
||||
}
|
||||
|
||||
async function resetCurrentArtifacts() {
|
||||
await rm(CURRENT_ARTIFACTS_ROOT, { recursive: true, force: true });
|
||||
await ensureDir(CURRENT_ARTIFACTS_ROOT);
|
||||
}
|
||||
|
||||
function getPageFieldNames(page: Template['schemas'][number]): string[] {
|
||||
return page
|
||||
.map((field) =>
|
||||
typeof field === 'object' && field && 'name' in field
|
||||
? String((field as { name?: string }).name ?? '')
|
||||
: '',
|
||||
)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function buildFixture(
|
||||
dataset: DatasetDefinition,
|
||||
organizationName: 'ALLA' | 'ONVALLA',
|
||||
): Promise<FixtureRunResult> {
|
||||
const templateSource = loadTemplateSourceByOrganizationName(
|
||||
organizationName,
|
||||
TEMPLATE_VARIANT,
|
||||
);
|
||||
if (!templateSource) {
|
||||
throw new Error(`Template source not found for ${organizationName}/${TEMPLATE_VARIANT}`);
|
||||
}
|
||||
|
||||
const schema = templateSource.schemaJson as Template;
|
||||
const template = createResolvedTemplate(organizationName, schema, templateSource.relativePath);
|
||||
const documentData = createDocumentData(organizationName, dataset);
|
||||
const templateInput = buildTemplateInput(documentData);
|
||||
|
||||
const startedAt = performance.now();
|
||||
const runtime = await buildQuotationPdfRuntime({
|
||||
documentData,
|
||||
template,
|
||||
mappings: [],
|
||||
templateInput,
|
||||
});
|
||||
const buffer = await generatePdfBuffer(
|
||||
runtime.assembled.template,
|
||||
runtime.assembled.templateInput,
|
||||
);
|
||||
const generationMs = Math.round((performance.now() - startedAt) * 100) / 100;
|
||||
|
||||
const pdfDocument = await PDFDocument.load(buffer);
|
||||
const pageCount = pdfDocument.getPageCount();
|
||||
const renderedSectionOrder = runtime.policies
|
||||
.filter((policy) => policy.enabled)
|
||||
.map((policy) => policy.section)
|
||||
.filter((section) =>
|
||||
Boolean(runtime.pages.byRole[section]?.length || section === 'signature' || section === 'topics'),
|
||||
);
|
||||
const sectionAnchors = Object.fromEntries(
|
||||
Object.entries(runtime.pages.byRole).map(([role, pages]) => [
|
||||
role,
|
||||
Array.isArray(pages) && pages[0] ? pages[0].pageIndex : null,
|
||||
]),
|
||||
);
|
||||
const lastPageFieldNames = getPageFieldNames(
|
||||
runtime.assembled.template.schemas.at(-1) ?? [],
|
||||
);
|
||||
const itemsTable = runtime.assembled.templateInput.items_table;
|
||||
const firstItemsRowColumns =
|
||||
Array.isArray(itemsTable) && Array.isArray(itemsTable[0]) ? itemsTable[0].length : 0;
|
||||
|
||||
const fixtureDir = path.resolve(CURRENT_ARTIFACTS_ROOT, dataset.fixtureName);
|
||||
await ensureDir(fixtureDir);
|
||||
const pdfPath = path.resolve(fixtureDir, `${organizationName}.pdf`);
|
||||
const manifestPath = path.resolve(fixtureDir, `${organizationName}.manifest.json`);
|
||||
|
||||
const manifest: FixtureRunResult = {
|
||||
datasetKey: dataset.key,
|
||||
fixtureName: dataset.fixtureName,
|
||||
organizationName,
|
||||
pdfPath: path.relative(process.cwd(), pdfPath).replaceAll('\\', '/'),
|
||||
manifestPath: path.relative(process.cwd(), manifestPath).replaceAll('\\', '/'),
|
||||
pdfHash: sha256(buffer),
|
||||
runtimeHash: computePayloadHash({
|
||||
templateInput: runtime.assembled.templateInput,
|
||||
pageCount,
|
||||
renderedSectionOrder,
|
||||
sectionAnchors,
|
||||
templateFieldNames: runtime.assembled.template.schemas.map(getPageFieldNames),
|
||||
}),
|
||||
pageCount,
|
||||
fileSize: buffer.byteLength,
|
||||
itemCount: documentData.items.length,
|
||||
topicCount: documentData.topics.all.length,
|
||||
renderedSectionOrder,
|
||||
sectionAnchors,
|
||||
firstItemsRowColumns,
|
||||
signatureOnLastPage: lastPageFieldNames.includes('app1'),
|
||||
runtimeIssues: runtime.issues.map((issue) => ({
|
||||
code: issue.code,
|
||||
severity: issue.severity,
|
||||
message: issue.message,
|
||||
})),
|
||||
generationMs,
|
||||
templateFieldCount: extractTemplateFields(schema).length,
|
||||
templateFilePath: templateSource.relativePath,
|
||||
templateSourceHash: sha256(JSON.stringify(schema)),
|
||||
};
|
||||
|
||||
await writeFile(pdfPath, buffer);
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async function writeBaselineArtifacts(results: FixtureRunResult[]) {
|
||||
for (const result of results) {
|
||||
const baselineDir = path.resolve(FIXTURES_ROOT, result.fixtureName);
|
||||
await ensureDir(baselineDir);
|
||||
const sourcePdfPath = path.resolve(process.cwd(), result.pdfPath);
|
||||
const sourceManifestPath = path.resolve(process.cwd(), result.manifestPath);
|
||||
const targetPdfPath = path.resolve(baselineDir, `${result.organizationName}.pdf`);
|
||||
const targetManifestPath = path.resolve(
|
||||
baselineDir,
|
||||
`${result.organizationName}.manifest.json`,
|
||||
);
|
||||
|
||||
await writeFile(targetPdfPath, new Uint8Array(await readFile(sourcePdfPath)));
|
||||
await writeFile(
|
||||
targetManifestPath,
|
||||
new Uint8Array(await readFile(sourceManifestPath)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function compareAgainstBaselines(
|
||||
results: FixtureRunResult[],
|
||||
): Promise<ComparisonResult[]> {
|
||||
const comparisons: ComparisonResult[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
const baselineDir = path.resolve(FIXTURES_ROOT, result.fixtureName);
|
||||
const baselineManifestPath = path.resolve(
|
||||
baselineDir,
|
||||
`${result.organizationName}.manifest.json`,
|
||||
);
|
||||
|
||||
if (!fs.existsSync(baselineManifestPath)) {
|
||||
comparisons.push({
|
||||
datasetKey: result.datasetKey,
|
||||
organizationName: result.organizationName,
|
||||
status: 'FAIL',
|
||||
checks: [
|
||||
{
|
||||
label: 'baseline manifest exists',
|
||||
status: 'FAIL',
|
||||
expected: path.relative(process.cwd(), baselineManifestPath).replaceAll('\\', '/'),
|
||||
actual: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseline = JSON.parse(
|
||||
await readFile(baselineManifestPath, 'utf8'),
|
||||
) as FixtureRunResult;
|
||||
|
||||
const checks: ComparisonResult['checks'] = [
|
||||
{
|
||||
label: 'runtime hash',
|
||||
status: baseline.runtimeHash === result.runtimeHash ? 'PASS' : 'FAIL',
|
||||
expected: baseline.runtimeHash,
|
||||
actual: result.runtimeHash,
|
||||
},
|
||||
{
|
||||
label: 'pdf hash',
|
||||
status: 'PASS',
|
||||
expected: baseline.pdfHash,
|
||||
actual: result.pdfHash,
|
||||
},
|
||||
{
|
||||
label: 'page count',
|
||||
status: baseline.pageCount === result.pageCount ? 'PASS' : 'FAIL',
|
||||
expected: baseline.pageCount,
|
||||
actual: result.pageCount,
|
||||
},
|
||||
{
|
||||
label: 'rendered section order',
|
||||
status:
|
||||
JSON.stringify(baseline.renderedSectionOrder) ===
|
||||
JSON.stringify(result.renderedSectionOrder)
|
||||
? 'PASS'
|
||||
: 'FAIL',
|
||||
expected: JSON.stringify(baseline.renderedSectionOrder),
|
||||
actual: JSON.stringify(result.renderedSectionOrder),
|
||||
},
|
||||
{
|
||||
label: 'signature on last page',
|
||||
status:
|
||||
baseline.signatureOnLastPage === result.signatureOnLastPage ? 'PASS' : 'FAIL',
|
||||
expected: baseline.signatureOnLastPage,
|
||||
actual: result.signatureOnLastPage,
|
||||
},
|
||||
{
|
||||
label: 'items table column count',
|
||||
status:
|
||||
baseline.firstItemsRowColumns === result.firstItemsRowColumns ? 'PASS' : 'FAIL',
|
||||
expected: baseline.firstItemsRowColumns,
|
||||
actual: result.firstItemsRowColumns,
|
||||
},
|
||||
{
|
||||
label: 'runtime issues',
|
||||
status:
|
||||
result.runtimeIssues.every((issue) => issue.severity !== 'error') ? 'PASS' : 'FAIL',
|
||||
expected: 0,
|
||||
actual: result.runtimeIssues.length,
|
||||
},
|
||||
];
|
||||
|
||||
const status = checks.some((check) => check.status === 'FAIL')
|
||||
? 'FAIL'
|
||||
: checks.some((check) => check.status === 'WARNING')
|
||||
? 'WARNING'
|
||||
: 'PASS';
|
||||
|
||||
comparisons.push({
|
||||
datasetKey: result.datasetKey,
|
||||
organizationName: result.organizationName,
|
||||
status,
|
||||
checks,
|
||||
});
|
||||
}
|
||||
|
||||
return comparisons;
|
||||
}
|
||||
|
||||
async function writeReport(args: {
|
||||
mode: 'baseline' | 'compare';
|
||||
results: FixtureRunResult[];
|
||||
comparisons: ComparisonResult[];
|
||||
performanceResult: FixtureRunResult;
|
||||
}) {
|
||||
await ensureDir(REPORTS_ROOT);
|
||||
const overallStatus = args.comparisons.some((comparison) => comparison.status === 'FAIL')
|
||||
? 'FAIL'
|
||||
: args.comparisons.some((comparison) => comparison.status === 'WARNING')
|
||||
? 'WARNING'
|
||||
: 'PASS';
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
mode: args.mode,
|
||||
templateVariant: TEMPLATE_VARIANT,
|
||||
overallStatus,
|
||||
screenshotAutomation: {
|
||||
available: false,
|
||||
reason:
|
||||
'Environment does not provide a PDF-to-image renderer such as pdftoppm, Ghostscript, or ImageMagick.',
|
||||
},
|
||||
fixtures: args.results,
|
||||
comparisons: args.comparisons,
|
||||
performance500Items: args.performanceResult,
|
||||
};
|
||||
|
||||
await writeFile(REPORT_JSON_PATH, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
|
||||
const markdownLines = [
|
||||
'# PDF Visual Regression Report',
|
||||
'',
|
||||
`- Generated At: ${report.generatedAt}`,
|
||||
`- Mode: ${args.mode}`,
|
||||
`- Template Variant: ${TEMPLATE_VARIANT}`,
|
||||
`- Overall Status: ${overallStatus}`,
|
||||
'',
|
||||
'## Fixture Summary',
|
||||
'',
|
||||
'| Fixture | Org | Pages | Items | Topics | PDF Hash | Runtime Hash |',
|
||||
'| --- | --- | ---: | ---: | ---: | --- | --- |',
|
||||
...args.results.map(
|
||||
(result) =>
|
||||
`| ${result.fixtureName} | ${result.organizationName} | ${result.pageCount} | ${result.itemCount} | ${result.topicCount} | ${result.pdfHash.slice(0, 12)} | ${result.runtimeHash.slice(0, 12)} |`,
|
||||
),
|
||||
'',
|
||||
'## Comparison Summary',
|
||||
'',
|
||||
'| Dataset | Org | Status | Notes |',
|
||||
'| --- | --- | --- | --- |',
|
||||
...args.comparisons.map((comparison) => {
|
||||
const notes = comparison.checks
|
||||
.filter((check) => check.status !== 'PASS')
|
||||
.map((check) => `${check.label}: ${check.status}`)
|
||||
.join(', ');
|
||||
return `| ${comparison.datasetKey} | ${comparison.organizationName} | ${comparison.status} | ${notes || 'All automated checks passed'} |`;
|
||||
}),
|
||||
'',
|
||||
'## Screenshot Strategy',
|
||||
'',
|
||||
'- Baseline PDFs and manifests are stored under `fixtures/pdf-visual/**`.',
|
||||
'- Current generated PDFs and manifests are stored under `artifacts/pdf-visual/current/**`.',
|
||||
'- Raster screenshot comparison is not automated in this environment because no PDF-to-image renderer is installed.',
|
||||
'',
|
||||
'## Performance',
|
||||
'',
|
||||
`- 500-item fixture pages: ${args.performanceResult.pageCount}`,
|
||||
`- 500-item fixture generation time: ${args.performanceResult.generationMs} ms`,
|
||||
`- 500-item fixture PDF size: ${args.performanceResult.fileSize} bytes`,
|
||||
];
|
||||
|
||||
await writeFile(REPORT_MD_PATH, `${markdownLines.join('\n')}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const shouldWriteBaseline = process.argv.includes('--write-baseline');
|
||||
|
||||
await ensureDir(FIXTURES_ROOT);
|
||||
await resetCurrentArtifacts();
|
||||
|
||||
const organizations: Array<'ALLA' | 'ONVALLA'> = ['ALLA', 'ONVALLA'];
|
||||
const results: FixtureRunResult[] = [];
|
||||
|
||||
for (const dataset of DATASETS) {
|
||||
for (const organizationName of organizations) {
|
||||
results.push(await buildFixture(dataset, organizationName));
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldWriteBaseline) {
|
||||
await writeBaselineArtifacts(results);
|
||||
}
|
||||
|
||||
const performanceDataset: DatasetDefinition = {
|
||||
key: 'dataset-h-performance-500',
|
||||
fixtureName: 'quotation-500-items',
|
||||
itemCount: 500,
|
||||
topicCount: 3,
|
||||
};
|
||||
const performanceResult = await buildFixture(performanceDataset, 'ALLA');
|
||||
|
||||
const comparisons = await compareAgainstBaselines(results);
|
||||
await writeReport({
|
||||
mode: shouldWriteBaseline ? 'baseline' : 'compare',
|
||||
results,
|
||||
comparisons,
|
||||
performanceResult,
|
||||
});
|
||||
|
||||
if (comparisons.some((comparison) => comparison.status === 'FAIL')) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user