288 lines
9.8 KiB
JavaScript
288 lines
9.8 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { interpolate, renderReportHtml, renderEmailHtml, renderEmailMessage, renderEmailText, emailCompatibilityWarnings } from './templateEngine.js'
|
|
|
|
test('interpolates nested values', () => {
|
|
assert.equal(interpolate('Hello {{customer.name}}', { customer: { name: 'Kauri' } }), 'Hello Kauri')
|
|
})
|
|
|
|
test('report output escapes injected markup', () => {
|
|
const html = renderReportHtml({ blocks: [{ type: 'text', props: { text: '{{value}}' } }] }, { value: '<script>x</script>' })
|
|
assert.match(html, /<script>/)
|
|
assert.doesNotMatch(html, /<script>x/)
|
|
})
|
|
|
|
test('email renderer produces HTML', () => {
|
|
const html = renderEmailHtml({ blocks: [{ type: 'heading', props: { text: 'Hello' } }] }, {})
|
|
assert.match(html, /Hello/)
|
|
assert.match(html, /doctype/i)
|
|
})
|
|
|
|
test('report renderer applies constrained pack theme styling', () => {
|
|
const html = renderReportHtml({
|
|
theme: {
|
|
backgroundColor: '#f6f1e8',
|
|
accentColor: '#8b5e34',
|
|
pageBackgroundMode: 'watermark',
|
|
pageBackgroundImage: 'https://templates.arrowstack.dev/media/token/bg.webp'
|
|
},
|
|
blocks: [{
|
|
type: 'heading',
|
|
props: { text: 'Styled' },
|
|
style: { backgroundColor: '#fffaf2', padding: 18, borderRadius: 12 }
|
|
}]
|
|
}, {})
|
|
assert.match(html, /#f6f1e8/)
|
|
assert.match(html, /#8b5e34/)
|
|
assert.match(html, /bg\.webp/)
|
|
assert.match(html, /padding:18px/)
|
|
})
|
|
|
|
test('report renderer constrains logo and hero images for print layout', () => {
|
|
const html = renderReportHtml({
|
|
blocks: [
|
|
{ id: 'logo', type: 'image', props: { url: 'https://example.com/logo.png', alt: 'Company logo' } },
|
|
{ id: 'hero', type: 'image', props: { url: 'https://example.com/hero.jpg', alt: 'Hero banner' } }
|
|
]
|
|
}, {})
|
|
assert.match(html, /image-logo/)
|
|
assert.match(html, /max-height:72px/)
|
|
assert.match(html, /image-hero/)
|
|
assert.match(html, /max-height:260px/)
|
|
assert.match(html, /object-fit:cover/)
|
|
})
|
|
|
|
test('report blocks avoid page breaks by default and support explicit page controls', () => {
|
|
const html = renderReportHtml({
|
|
blocks: [{
|
|
id: 'title',
|
|
type: 'heading',
|
|
props: { text: 'Title' },
|
|
style: { keepWithNext: true, pageBreakBefore: true }
|
|
}]
|
|
}, {})
|
|
assert.match(html, /break-before:page/)
|
|
assert.match(html, /break-after:avoid-page/)
|
|
assert.match(html, /page-break-inside:avoid/)
|
|
})
|
|
|
|
test('report renderer applies independent page margins in millimetres', () => {
|
|
const html = renderReportHtml({
|
|
theme: {
|
|
pageMarginTop: 8,
|
|
pageMarginRight: 10,
|
|
pageMarginBottom: 14,
|
|
pageMarginLeft: 16,
|
|
pagePadding: 22
|
|
},
|
|
blocks: [{ type: 'text', props: { text: 'Margins' } }]
|
|
}, {})
|
|
assert.match(html, /margin:8mm 10mm 14mm 16mm/)
|
|
assert.match(html, /min-height:calc\(297mm - 22mm\)/)
|
|
assert.match(html, /padding:22px/)
|
|
})
|
|
|
|
test('report page chrome defaults to clean without stacked artwork', () => {
|
|
const html = renderReportHtml({
|
|
theme: {
|
|
pageBackgroundImage: 'https://example.com/paper.webp',
|
|
borderImage: 'https://example.com/border.png'
|
|
},
|
|
blocks: [{ type: 'text', props: { text: 'Clean report' } }]
|
|
}, {})
|
|
assert.match(html, /background-image:none/)
|
|
assert.match(html, /border:0px solid transparent/)
|
|
})
|
|
|
|
test('report page chrome can opt into subtle background and decorative border', () => {
|
|
const html = renderReportHtml({
|
|
theme: {
|
|
pageBackgroundImage: 'https://example.com/paper.webp',
|
|
pageBackgroundMode: 'watermark',
|
|
pageBackgroundOpacity: 0.1,
|
|
borderImage: 'https://example.com/border.png',
|
|
decorativeBorder: true
|
|
},
|
|
blocks: [{ type: 'text', props: { text: 'Styled report' } }]
|
|
}, {})
|
|
assert.match(html, /paper\.webp/)
|
|
assert.match(html, /opacity:0\.1/)
|
|
assert.match(html, /border:10px solid transparent/)
|
|
})
|
|
|
|
|
|
test('auto density compacts data-heavy reports and groups consecutive KPIs', () => {
|
|
const blocks = [
|
|
{ type: 'heading', props: { text: 'Dense' } },
|
|
{ type: 'kpi', props: { label: 'A', value: '1' } },
|
|
{ type: 'kpi', props: { label: 'B', value: '2' } },
|
|
{ type: 'kpi', props: { label: 'C', value: '3' } },
|
|
...Array.from({ length: 16 }, (_, index) => ({ type: 'text', props: { text: `Row ${index}` } }))
|
|
]
|
|
const html = renderReportHtml({ blocks }, {})
|
|
assert.match(html, /data-density="compact"/)
|
|
assert.match(html, /class="kpi-grid"/)
|
|
assert.match(html, /grid-template-columns:repeat\(3,minmax\(0,1fr\)\)/)
|
|
})
|
|
|
|
test('compact density flows past ordinary page breaks but preserves forced breaks', () => {
|
|
const html = renderReportHtml({
|
|
theme: { reportDensity: 'compact' },
|
|
blocks: [
|
|
{ type: 'heading', props: { text: 'Auto flow' }, style: { pageBreakBefore: true } },
|
|
{ type: 'heading', props: { text: 'Forced' }, style: { pageBreakBefore: true, forcePageBreak: true } }
|
|
]
|
|
}, {})
|
|
assert.match(html, /Auto flow/)
|
|
assert.match(html, /break-before:auto/)
|
|
assert.match(html, /Forced/)
|
|
assert.match(html, /break-before:page/)
|
|
})
|
|
|
|
test('splittable tables repeat headers and use compact table styling', () => {
|
|
const html = renderReportHtml({
|
|
theme: { reportDensity: 'compact' },
|
|
blocks: [{
|
|
type: 'table',
|
|
props: { rowsPath: 'rows', columns: [{ label: 'Name', key: 'name' }] },
|
|
style: { allowPageBreak: true }
|
|
}]
|
|
}, { rows: [{ name: 'A' }, { name: 'B' }] })
|
|
assert.match(html, /class="table-wrap allow-break compact-table"/)
|
|
assert.match(html, /thead\{display:table-header-group\}/)
|
|
assert.match(html, /page-break-inside:auto/)
|
|
})
|
|
|
|
test('comfortable density preserves explicit section page breaks', () => {
|
|
const html = renderReportHtml({
|
|
theme: { reportDensity: 'comfortable' },
|
|
blocks: [{ type: 'heading', props: { text: 'Section' }, style: { pageBreakBefore: true } }]
|
|
}, {})
|
|
assert.match(html, /data-density="comfortable"/)
|
|
assert.match(html, /break-before:page/)
|
|
})
|
|
|
|
|
|
test('theme typography and spacing tokens drive report output', () => {
|
|
const html = renderReportHtml({
|
|
theme: {
|
|
fontFamily: 'Georgia',
|
|
baseFontSize: 17,
|
|
headingSize: 30,
|
|
lineHeight: 1.6,
|
|
blockGap: 18,
|
|
cardPadding: 20,
|
|
tableCellPadding: 12
|
|
},
|
|
blocks: [
|
|
{ type: 'heading', props: { text: 'Heading' } },
|
|
{ type: 'kpi', props: { label: 'A', value: '1' } },
|
|
{ type: 'table', props: { rowsPath: 'rows', columns: [{ label: 'Name', key: 'name' }] } }
|
|
]
|
|
}, { rows: [{ name: 'One' }] })
|
|
|
|
assert.match(html, /font:17px\/1\.6 Georgia,sans-serif/)
|
|
assert.match(html, /h2\{font-size:30px/)
|
|
assert.match(html, /margin:0 0 18px/)
|
|
assert.match(html, /padding:20px 22px/)
|
|
assert.match(html, /padding:12px 14px/)
|
|
})
|
|
|
|
test('theme typography tokens drive email output', () => {
|
|
const html = renderEmailHtml({
|
|
theme: {
|
|
fontFamily: 'Verdana',
|
|
baseFontSize: 18,
|
|
headingSize: 31,
|
|
lineHeight: 1.7,
|
|
accentColor: '#123456'
|
|
},
|
|
blocks: [
|
|
{ type: 'heading', props: { text: 'Heading' } },
|
|
{ type: 'text', props: { text: 'Body copy' } },
|
|
{ type: 'button', props: { text: 'Go', url: 'https://example.com' } }
|
|
]
|
|
}, {})
|
|
|
|
assert.match(html, /font-family:Verdana/)
|
|
assert.match(html, /font-size:18px/)
|
|
assert.match(html, /font-size:31px/)
|
|
assert.match(html, /#123456/i)
|
|
})
|
|
|
|
|
|
test('email message includes subject preheader html text and metadata', () => {
|
|
const message = renderEmailMessage({
|
|
email: { subject: 'Hello {{customer.name}}', preheader: 'Your update is ready' },
|
|
blocks: [
|
|
{ type: 'heading', props: { text: 'Hello {{customer.name}}' } },
|
|
{ type: 'button', props: { text: 'Open', url: 'https://example.com' } }
|
|
]
|
|
}, { customer: { name: 'Kauri' } }, { key: 'welcome', version: 2 })
|
|
|
|
assert.equal(message.subject, 'Hello Kauri')
|
|
assert.equal(message.preheader, 'Your update is ready')
|
|
assert.match(message.html, /Your update is ready/)
|
|
assert.match(message.text, /Hello Kauri/)
|
|
assert.match(message.text, /https:\/\/example\.com/)
|
|
assert.equal(message.metadata.key, 'welcome')
|
|
assert.equal(message.metadata.version, 2)
|
|
assert.ok(message.metadata.html_bytes > 0)
|
|
})
|
|
|
|
test('email renderer produces inbox-safe table markup and plain text table', () => {
|
|
const template = {
|
|
email: { subject: 'Order update', preheader: 'Latest lines' },
|
|
blocks: [{
|
|
id: 'orders',
|
|
type: 'table',
|
|
props: {
|
|
rowsPath: 'orders',
|
|
columns: [
|
|
{ label: 'Order', key: 'order' },
|
|
{ label: 'Status', key: 'status' }
|
|
]
|
|
}
|
|
}]
|
|
}
|
|
const data = { orders: [{ order: 'PO-1', status: 'Ready' }] }
|
|
const html = renderEmailHtml(template, data)
|
|
const text = renderEmailText(template, data)
|
|
assert.match(html, /PO-1/)
|
|
assert.match(html, /Ready/)
|
|
assert.match(text, /Order \| Status/)
|
|
assert.match(text, /PO-1 \| Ready/)
|
|
})
|
|
|
|
test('email compatibility checker catches common delivery risks', () => {
|
|
const template = {
|
|
email: { subject: '' },
|
|
blocks: [
|
|
{ id: 'hero', type: 'image', props: { url: 'http://example.com/hero.png', alt: '' } },
|
|
{
|
|
id: 'wide',
|
|
type: 'table',
|
|
props: {
|
|
rowsPath: 'rows',
|
|
columns: Array.from({ length: 7 }, (_, index) => ({ label: `C${index}`, key: `c${index}` }))
|
|
}
|
|
}
|
|
]
|
|
}
|
|
const warnings = emailCompatibilityWarnings(template, { rows: [] }, '')
|
|
const codes = warnings.map(item => item.code)
|
|
assert.ok(codes.includes('missing_subject'))
|
|
assert.ok(codes.includes('missing_preheader'))
|
|
assert.ok(codes.includes('missing_alt'))
|
|
assert.ok(codes.includes('insecure_image_url'))
|
|
assert.ok(codes.includes('wide_table'))
|
|
})
|
|
|
|
test('email spacer renders safely', () => {
|
|
const html = renderEmailHtml({
|
|
email: { subject: 'Spacer', preheader: 'Preview' },
|
|
blocks: [{ type: 'spacer', props: { height: 32 } }]
|
|
}, {})
|
|
assert.match(html, /height:32px/)
|
|
})
|