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