import { renderToEmailHtml, renderToPreviewHtml } from '../renderer/email-html-renderer'
import type { Section, GlobalStyles } from '../types'
import { DEFAULT_GLOBAL_STYLES, createSection, createRow } from '../types'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeSingleSection(overrides: Partial = {}): Section[] {
const section: Section = {
id: 'section-1',
rows: [
{
id: 'row-1',
layout: '1',
columns: [
[
{
id: 'text-1',
type: 'text',
content: 'Hello email
',
},
],
],
},
],
paddingTop: 16,
paddingBottom: 16,
paddingLeft: 0,
paddingRight: 0,
...overrides,
}
return [section]
}
const defaultStyles: GlobalStyles = DEFAULT_GLOBAL_STYLES
// ---------------------------------------------------------------------------
// Valid HTML document structure
// ---------------------------------------------------------------------------
describe('renderToEmailHtml — document structure', () => {
it('starts with ', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
expect(html.trimStart()).toMatch(/^/i)
})
it('contains root element with lang="en"', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
expect(html).toContain(' with charset meta tag', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
expect(html).toContain('')
})
it('contains viewport meta tag', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
expect(html).toContain('name="viewport"')
})
it('contains element', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
expect(html).toContain(' tag', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
expect(html.trimEnd()).toMatch(/<\/html>$/)
})
it('contains closing tag', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
expect(html).toContain('')
})
it('contains role="presentation" table for email wrapper', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
expect(html).toContain('role="presentation"')
})
it('contains email-container class for responsive width', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
expect(html).toContain('class="email-container"')
})
it('contains @media query for max-width:620px', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
expect(html).toContain('max-width:620px')
})
it('contains MSO conditional comment for Outlook', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
expect(html).toContain('[if mso]')
})
})
// ---------------------------------------------------------------------------
// Global styles applied to output
// ---------------------------------------------------------------------------
describe('renderToEmailHtml — global styles', () => {
it('uses globalStyles.backgroundColor in body inline style', () => {
const html = renderToEmailHtml(makeSingleSection(), { ...defaultStyles, backgroundColor: '#abcdef' })
expect(html).toContain('background-color:#abcdef')
})
it('uses globalStyles.contentWidth for container width attribute', () => {
const html = renderToEmailHtml(makeSingleSection(), { ...defaultStyles, contentWidth: 700 })
expect(html).toContain('width="700"')
expect(html).toContain('max-width:700px')
})
it('uses globalStyles.fontFamily in body inline style', () => {
const html = renderToEmailHtml(makeSingleSection(), {
...defaultStyles,
fontFamily: 'Georgia, serif',
})
expect(html).toContain('font-family:Georgia, serif')
})
it('defaults contentWidth to 600 when not provided', () => {
const html = renderToEmailHtml(makeSingleSection(), { ...defaultStyles, contentWidth: 0 })
expect(html).toContain('width="600"')
})
it('defaults backgroundColor when not provided', () => {
const html = renderToEmailHtml(makeSingleSection(), { ...defaultStyles, backgroundColor: '' })
// Falls back to the empty string default; body still renders
expect(html).toContain(' {
it('puts subject in tag', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles, { subject: 'Q1 Update' })
expect(html).toContain('Q1 Update')
})
it('empty subject results in empty ', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles, { subject: '' })
expect(html).toContain('')
})
it('renders preheader div when preheaderText is provided', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles, {
preheaderText: 'Read this important update',
})
expect(html).toContain('Read this important update')
expect(html).toContain('display:none')
})
it('does not render preheader div when preheaderText is absent', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
expect(html).not.toContain('display:none')
})
})
// ---------------------------------------------------------------------------
// Block content included in output
// ---------------------------------------------------------------------------
describe('renderToEmailHtml — block content', () => {
it('includes text block content in output', () => {
const html = renderToEmailHtml(makeSingleSection(), defaultStyles)
expect(html).toContain('Hello email')
})
it('includes content from header block', () => {
const section: Section = {
id: 's1',
rows: [
{
id: 'r1',
layout: '1',
columns: [
[{ id: 'h1', type: 'header', companyName: 'StartSimpli', alignment: 'center' }],
],
},
],
}
const html = renderToEmailHtml([section], defaultStyles)
expect(html).toContain('StartSimpli')
})
it('includes metrics block values', () => {
const section: Section = {
id: 's1',
rows: [
{
id: 'r1',
layout: '1',
columns: [
[
{
id: 'met1',
type: 'metrics',
title: 'My Metrics',
metrics: [{ id: 'm1', label: 'Revenue', value: '$42k', changeType: 'positive' }],
columns: 2,
},
],
],
},
],
}
const html = renderToEmailHtml([section], defaultStyles)
expect(html).toContain('$42k')
expect(html).toContain('Revenue')
})
it('includes CTA button text and URL', () => {
const section: Section = {
id: 's1',
rows: [
{
id: 'r1',
layout: '1',
columns: [
[
{
id: 'cta1',
type: 'cta',
text: 'Click Here',
url: 'https://start.simpli',
alignment: 'center',
buttonColor: '#2563eb',
textColor: '#ffffff',
borderRadius: 6,
paddingH: 24,
paddingV: 12,
},
],
],
},
],
}
const html = renderToEmailHtml([section], defaultStyles)
expect(html).toContain('Click Here')
expect(html).toContain('https://start.simpli')
})
it('includes footer company name', () => {
const section: Section = {
id: 's1',
rows: [
{
id: 'r1',
layout: '1',
columns: [
[
{
id: 'f1',
type: 'footer',
companyName: 'Acme Inc',
showUnsubscribe: false,
alignment: 'center',
},
],
],
},
],
}
const html = renderToEmailHtml([section], defaultStyles)
expect(html).toContain('Acme Inc')
})
})
// ---------------------------------------------------------------------------
// Multi-section and multi-column layout
// ---------------------------------------------------------------------------
describe('renderToEmailHtml — layout', () => {
it('renders multiple sections (both section backgrounds appear)', () => {
const s1: Section = {
id: 's1',
backgroundColor: '#ff0000',
rows: [{ id: 'r1', layout: '1', columns: [[{ id: 't1', type: 'text', content: 'Sec1' }]] }],
}
const s2: Section = {
id: 's2',
backgroundColor: '#00ff00',
rows: [{ id: 'r2', layout: '1', columns: [[{ id: 't2', type: 'text', content: 'Sec2' }]] }],
}
const html = renderToEmailHtml([s1, s2], defaultStyles)
expect(html).toContain('#ff0000')
expect(html).toContain('#00ff00')
expect(html).toContain('Sec1')
expect(html).toContain('Sec2')
})
it('renders multi-column row using nested table with stack-column class', () => {
const section: Section = {
id: 's1',
rows: [
{
id: 'r1',
layout: '2',
columns: [
[{ id: 't1', type: 'text', content: 'Left col' }],
[{ id: 't2', type: 'text', content: 'Right col' }],
],
},
],
}
const html = renderToEmailHtml([section], defaultStyles)
expect(html).toContain('class="stack-column"')
expect(html).toContain('Left col')
expect(html).toContain('Right col')
})
it('section separator spacer row is present between sections', () => {
const s1: Section = {
id: 's1',
rows: [{ id: 'r1', layout: '1', columns: [[{ id: 't1', type: 'text', content: 'A' }]] }],
}
const s2: Section = {
id: 's2',
rows: [{ id: 'r2', layout: '1', columns: [[{ id: 't2', type: 'text', content: 'B' }]] }],
}
const html = renderToEmailHtml([s1, s2], defaultStyles)
// 4px spacer row between sections
expect(html).toContain('height:4px')
})
it('section padding is applied in style attribute', () => {
const section: Section = {
id: 's1',
paddingTop: 32,
paddingBottom: 24,
paddingLeft: 8,
paddingRight: 8,
rows: [{ id: 'r1', layout: '1', columns: [[{ id: 't1', type: 'text', content: 'X' }]] }],
}
const html = renderToEmailHtml([section], defaultStyles)
expect(html).toContain('padding:32px 8px 24px 8px')
})
it('renders empty sections array without crashing', () => {
expect(() => renderToEmailHtml([], defaultStyles)).not.toThrow()
const html = renderToEmailHtml([], defaultStyles)
expect(html).toContain('')
})
it('renders section with empty columns without crashing', () => {
const section: Section = {
id: 's1',
rows: [{ id: 'r1', layout: '1', columns: [[]] }],
}
expect(() => renderToEmailHtml([section], defaultStyles)).not.toThrow()
})
})
// ---------------------------------------------------------------------------
// renderToPreviewHtml delegates to renderToEmailHtml
// ---------------------------------------------------------------------------
describe('renderToPreviewHtml', () => {
it('returns the same output as renderToEmailHtml', () => {
const sections = makeSingleSection()
expect(renderToPreviewHtml(sections, defaultStyles)).toBe(
renderToEmailHtml(sections, defaultStyles)
)
})
it('uses DEFAULT_GLOBAL_STYLES when no styles argument provided', () => {
const sections = makeSingleSection()
const html = renderToPreviewHtml(sections)
expect(html).toContain(`background-color:${DEFAULT_GLOBAL_STYLES.backgroundColor}`)
})
})