import { expect } from '@open-wc/testing'; import { defaultSchema, htmlToDoc, docToHtml, sanitizeHtml } from './index'; import { looksLikeHtml } from './html-detect'; /** * A compact "bulletproof" email: outer 100% table → 600px container with a * background/border → coloured header cell → body cell with padding + font + * an h1/p carrying margins and inline strong/em/underline spans → a callout * cell with a left border → a bgcolor button cell wrapping an → a footer * cell with a top border → and a display:none preheader div that must drop. */ const EMAIL_FIXTURE = `
Hidden preheader text that should never survive.

Company Name

Your monthly update

This is bold, italic, and underlined.

Action needed: please review your details.
Confirm my details

Company Name, 123 Street Address.

`; /** Parse an HTML string and return the first element matching a selector. */ function parse(html: string, selector: string): HTMLElement | null { const doc = new DOMParser().parseFromString(html, 'text/html'); return doc.querySelector(selector); } /** All elements matching a selector from a parsed HTML string. */ function parseAll(html: string, selector: string): HTMLElement[] { const doc = new DOMParser().parseFromString(html, 'text/html'); return Array.from(doc.querySelectorAll(selector)); } const ON = { emailFidelity: true } as const; describe('email-fidelity: sanitizeHtml layout styles', () => { it('1. keeps td padding when the flag is on', () => { const out = sanitizeHtml('
x
', ON); const td = parse(out, 'td')!; expect(td.style.padding).to.not.equal(''); }); it('2. keeps table width and max-width', () => { const out = sanitizeHtml( 'x
', ON ); const table = parse(out, 'table')!; expect(table.style.width).to.equal('100%'); expect(table.style.maxWidth).to.equal('600px'); }); it('3. keeps a td border-left shorthand', () => { const out = sanitizeHtml( '
x
', ON ); const td = parse(out, 'td')!; expect(td.style.borderLeftStyle).to.equal('solid'); expect(td.style.borderLeftWidth).to.equal('4px'); }); it('4. keeps paragraph/heading margins', () => { const out = sanitizeHtml( '

h

p

', ON ); expect(parse(out, 'h1')!.style.margin).to.not.equal(''); expect(parse(out, 'p')!.style.margin).to.not.equal(''); }); it('5. keeps table border and background-color', () => { const out = sanitizeHtml( 'x
', ON ); const table = parse(out, 'table')!; expect(table.style.backgroundColor).to.match(/rgb\(255, 255, 255\)|#ffffff/); expect(table.style.borderStyle).to.equal('solid'); }); it('6. converts legacy bgcolor/width/valign to styles and removes the raw attrs', () => { const out = sanitizeHtml( '
x
', ON ); const table = parse(out, 'table')!; const td = parse(out, 'td')!; expect(table.hasAttribute('width')).to.equal(false); expect(td.hasAttribute('bgcolor')).to.equal(false); expect(td.hasAttribute('valign')).to.equal(false); expect(td.hasAttribute('width')).to.equal(false); expect(table.style.width).to.not.equal(''); expect(td.style.backgroundColor).to.match(/rgb\(31, 58, 95\)|#1f3a5f/); expect(td.style.verticalAlign).to.equal('top'); expect(td.style.width).to.not.equal(''); }); it('7. drops display:none elements with their content', () => { const out = sanitizeHtml( '
preheader secret

kept

', ON ); expect(out).to.not.include('preheader secret'); expect(out).to.include('kept'); }); it('8. drops mso-hide:all elements with their content', () => { const out = sanitizeHtml( '
hidden msg

kept

', ON ); expect(out).to.not.include('hidden msg'); expect(out).to.include('kept'); }); }); describe('email-fidelity: fixture round-trip (flag on)', () => { it('9. layout styles survive sanitize', () => { const out = sanitizeHtml(EMAIL_FIXTURE, ON); // 600px container width + max-width. const container = parseAll(out, 'table').find(t => t.style.maxWidth === '600px'); expect(container, '600px container').to.exist; // Callout left border. const callout = parseAll(out, 'td').find(t => t.style.borderLeftStyle === 'solid'); expect(callout, 'callout border-left').to.exist; // Footer top border. const footer = parseAll(out, 'td').find(t => t.style.borderTopStyle === 'solid'); expect(footer, 'footer border-top').to.exist; // Some cell keeps padding. expect(parseAll(out, 'td').some(t => t.style.padding !== '' || t.style.paddingTop !== '')).to.equal( true ); }); it('10. layout styles survive a full htmlToDoc→docToHtml round-trip', () => { const doc = htmlToDoc(defaultSchema, EMAIL_FIXTURE, true, ON); const out = docToHtml(doc); expect(parseAll(out, 'table').some(t => t.style.maxWidth === '600px'), '600px width').to.equal( true ); expect(parseAll(out, 'td').some(t => t.style.borderLeftStyle === 'solid'), 'callout').to.equal( true ); expect(parseAll(out, 'td').some(t => t.style.borderTopStyle === 'solid'), 'footer').to.equal( true ); expect(parseAll(out, 'td').some(t => t.style.paddingTop !== '' || t.style.padding !== ''), 'padding') .to.equal(true); // Paragraph margins preserved. expect(parseAll(out, 'p').some(p => p.style.marginBottom !== '' || p.style.margin !== ''), 'p margin') .to.equal(true); }); it('11. no text-run span inherits a table/cell background-color or font-family', () => { const doc = htmlToDoc(defaultSchema, EMAIL_FIXTURE, true, ON); const out = docToHtml(doc); for (const span of parseAll(out, 'span')) { expect(span.style.backgroundColor, `span bg: ${span.outerHTML}`).to.equal(''); // Arial is only ever set on cells in the fixture; Georgia is set on the // header

/

themselves and is allowed to mark their own text. expect(span.style.fontFamily, `span font: ${span.outerHTML}`).to.not.include('Arial'); } }); it('11b. a paragraph/heading keeps its own colour and font', () => { const out = docToHtml(htmlToDoc(defaultSchema, EMAIL_FIXTURE, true, ON)); const header = parseAll(out, 'p').find(p => p.textContent === 'Company Name'); expect(header?.innerHTML).to.include('Georgia'); expect(header?.innerHTML).to.include('24px'); expect(header?.innerHTML).to.match(/color: (?:rgb\(255, 255, 255\)|#ffffff)/); expect(parseAll(out, 'h1')[0].innerHTML).to.include('Georgia'); }); it('11c. a container table centred by its holding cell keeps auto side margins', () => { const out = docToHtml( htmlToDoc( defaultSchema, '
x
', true, ON ) ); const inner = parseAll(out, 'table')[1]; expect(inner.style.marginLeft).to.equal('auto'); expect(inner.style.marginRight).to.equal('auto'); expect(inner.style.maxWidth).to.equal('600px'); }); it('12. serialized output is substantially smaller than flag-off output', () => { const on = docToHtml(htmlToDoc(defaultSchema, EMAIL_FIXTURE, true, ON)); const off = docToHtml(htmlToDoc(defaultSchema, EMAIL_FIXTURE)); expect(on.length).to.be.lessThan(off.length * 0.6); }); it('13. the display:none preheader text is absent from the document', () => { const doc = htmlToDoc(defaultSchema, EMAIL_FIXTURE, true, ON); expect(doc.textContent).to.not.include('Hidden preheader'); expect(docToHtml(doc)).to.not.include('Hidden preheader'); }); it('14. serialize → parse → serialize is a fixpoint', () => { const once = docToHtml(htmlToDoc(defaultSchema, EMAIL_FIXTURE, true, ON)); const twice = docToHtml(htmlToDoc(defaultSchema, once, true, ON)); expect(twice).to.equal(once); }); }); describe('email-fidelity: CSS value validation (flag on)', () => { const stripped: [string, string][] = [ ['padding:url(x)', 'td'], ['width:calc(100% - 2px)', 'td'], ['margin:expression(alert(1))', 'p'], ['border-left:1px solid url(javascript:alert(1))', 'td'], ['padding:var(--x)', 'td'], ['width:10px\\9', 'td'], ]; for (const [decl, tag] of stripped) { const prop = decl.split(':')[0]; it(`15. strips hostile declaration: ${decl}`, () => { const html = tag === 'p' ? `

x

` : `
x
`; const out = sanitizeHtml(html, ON); const el = parse(out, tag)!; expect(el.style.getPropertyValue(prop), `${prop} must be empty`).to.equal(''); expect(out).to.not.match(/url\(|calc\(|expression\(|var\(/); }); } const allowed: [string, string, string, string][] = [ ['padding:12px 24px', 'td', 'padding', 'paddingTop'], ['width:100%', 'td', 'width', 'width'], ['margin:0 0 18pt 0', 'p', 'margin', 'marginBottom'], ['border-left:4px solid #1f3a5f', 'td', 'border-left-style', 'borderLeftStyle'], ]; for (const [decl, tag, , jsProp] of allowed) { it(`16. keeps valid declaration: ${decl}`, () => { const html = tag === 'p' ? `

x

` : `
x
`; const out = sanitizeHtml(html, ON); const el = parse(out, tag)!; expect((el.style as unknown as Record)[jsProp]).to.not.equal(''); }); } }); describe('email-fidelity: back-compat (flag off)', () => { it('17. no options, {}, and {emailFidelity:false} are identical', () => { const a = sanitizeHtml(EMAIL_FIXTURE); const b = sanitizeHtml(EMAIL_FIXTURE, {}); const c = sanitizeHtml(EMAIL_FIXTURE, { emailFidelity: false }); expect(b).to.equal(a); expect(c).to.equal(a); }); it('18. flag-off output strips cell padding (unchanged 2.0.16 behaviour)', () => { const out = sanitizeHtml(EMAIL_FIXTURE); expect(out).to.not.include('padding'); }); }); describe('email-fidelity: looksLikeHtml', () => { const truthy = [ '

x

', ' x', '
x
', '

nested block

', '

a paragraph

', '

heading

', '
  • item
', '
quote
', '

x

', '
after comment
', ]; for (const text of truthy) { it(`19. true for: ${text.slice(0, 32)}`, () => { expect(looksLikeHtml(text)).to.equal(true); }); } const falsy = [ 'Just some plain prose with no markup at all.', 'A markdown line with a stray
in it', 'inline **markdown** with a bold word only', 'a < b > c is a math comparison, not html', '', ' ', ]; for (const text of falsy) { it(`20. false for: ${text.slice(0, 32)}`, () => { expect(looksLikeHtml(text)).to.equal(false); }); } }); // --------------------------------------------------------------------------- // allowedStyles — custom allowlist extension // (spec: "Custom allowlist extension"; plan nile-qa sections A–E, G) // --------------------------------------------------------------------------- import { normalizeEmailAllowedStyles, parseAllowedStylesAttr, isExtensionPropValid, isExtensionValueValid, sanitizeExtraStyleForWrite, } from './email-css'; import { jsonToDoc } from './serializer'; /** email-fidelity-on options carrying a normalized app allowlist. */ const WITH = (styles: Record) => ({ emailFidelity: true, allowedStyles: normalizeEmailAllowedStyles(styles) }) as const; /** The `data-extra-style` string on the first matching element, or ''. */ function extra(html: string, selector: string): string { return parse(html, selector)?.getAttribute('data-extra-style') ?? ''; } describe('allowedStyles: happy path (section A)', () => { it('A1. keeps an extension prop on a td via data-extra-style, not live style', () => { const out = sanitizeHtml( '
x
', WITH({ td: ['letter-spacing'] }) ); expect(extra(out, 'td')).to.include('letter-spacing: 0.5px'); expect(parse(out, 'td')!.style.letterSpacing).to.equal(''); }); it('A2. keeps an extension prop on a

', () => { const out = sanitizeHtml( '

x

', WITH({ p: ['letter-spacing'] }) ); expect(extra(out, 'p')).to.include('letter-spacing: 0.5px'); expect(parse(out, 'p')!.style.letterSpacing).to.equal(''); }); it('A3. keeps an extension prop on an

', () => { const out = sanitizeHtml( '

x

', WITH({ h2: ['letter-spacing'] }) ); expect(extra(out, 'h2')).to.include('letter-spacing: 0.5px'); expect(parse(out, 'h2')!.style.letterSpacing).to.equal(''); }); it('A4. round-trips the prop onto live p/td style via htmlToDoc→docToHtml', () => { const out = docToHtml( htmlToDoc( defaultSchema, '

x

y
', true, WITH({ p: ['letter-spacing'], td: ['letter-spacing'] }) ) ); expect(parse(out, 'p')!.style.letterSpacing).to.equal('0.5px'); expect(parse(out, 'td')!.style.letterSpacing).to.equal('0.5px'); for (const span of parseAll(out, 'span')) { expect(span.style.letterSpacing, span.outerHTML).to.equal(''); } }); it('A5. round-trip is a fixpoint with a config set', () => { const cfg = WITH({ p: ['letter-spacing'], td: ['letter-spacing'] }); const src = '

x

'; const once = docToHtml(htmlToDoc(defaultSchema, src, true, cfg)); const twice = docToHtml(htmlToDoc(defaultSchema, once, true, cfg)); expect(twice).to.equal(once); }); it('A6. multiple props and tags survive (non-built-in names only)', () => { const out = sanitizeHtml( '
x
', WITH({ td: ['letter-spacing', 'text-indent'], table: ['border-spacing'] }) ); expect(extra(out, 'td')).to.include('letter-spacing: 0.5px'); expect(extra(out, 'td')).to.include('text-indent: 4px'); expect(extra(out, 'table')).to.include('border-spacing: 2px'); }); }); describe('allowedStyles: feature-off byte-identical (section B)', () => { const FIX = '
x
'; it('B1. flag off: a config changes nothing vs no config', () => { const noCfg = sanitizeHtml(FIX); const withCfg = sanitizeHtml(FIX, { allowedStyles: normalizeEmailAllowedStyles({ td: ['letter-spacing'] }), } as { allowedStyles: ReturnType }); expect(withCfg).to.equal(noCfg); }); it('B2. flag on + empty config === flag on + no allowedStyles', () => { const emptyCfg = sanitizeHtml(EMAIL_FIXTURE, WITH({})); const noCfg = sanitizeHtml(EMAIL_FIXTURE, ON); expect(emptyCfg).to.equal(noCfg); }); }); describe('allowedStyles: security matrix (section C)', () => { // Each: a config + a declaration that must NOT survive as an extension. // expected = no data-extra-style carrying the prop name. const cases: [string, Record, string, string][] = [ // [label, config, declaration, propName that must be absent from data-extra-style] ['denylist position', { td: ['position'] }, 'position:fixed', 'position'], ['denylist display', { td: ['display'] }, 'display:none', 'display'], ['denylist float', { td: ['float'] }, 'float:left', 'float'], ['denylist overflow', { td: ['overflow'] }, 'overflow:hidden', 'overflow'], ['denylist z-index', { td: ['z-index'] }, 'z-index:5', 'z-index'], ['denylist background', { td: ['background'] }, 'background:red', 'background'], ['builtin color', { td: ['color'] }, 'color:red', 'color'], ['builtin padding', { td: ['padding'] }, 'padding:4px', 'padding'], ['builtin width', { td: ['width'] }, 'width:10px', 'width'], ['value url', { td: ['letter-spacing'] }, 'letter-spacing:url(x)', 'letter-spacing'], ['value expression', { td: ['letter-spacing'] }, 'letter-spacing:expression(alert(1))', 'letter-spacing'], ['value var', { td: ['letter-spacing'] }, 'letter-spacing:var(--x)', 'letter-spacing'], ['value calc', { td: ['letter-spacing'] }, 'letter-spacing:calc(1px)', 'letter-spacing'], ['value attr', { td: ['letter-spacing'] }, 'letter-spacing:attr(data-x)', 'letter-spacing'], ['value env', { td: ['letter-spacing'] }, 'letter-spacing:env(x)', 'letter-spacing'], ['value image-set', { td: ['letter-spacing'] }, 'letter-spacing:image-set(a)', 'letter-spacing'], ['value important', { td: ['letter-spacing'] }, 'letter-spacing:1px !important', 'letter-spacing'], ['name custom prop', { td: ['--x'] }, '--x:1px', '--x'], ['name vendor prefix', { td: ['-webkit-transform'] }, '-webkit-transform:none', 'webkit-transform'], ]; for (const [label, cfg, decl, prop] of cases) { it(`C. drops ${label}`, () => { const out = sanitizeHtml( `
x
`, WITH(cfg) ); expect(extra(out, 'td')).to.not.include(prop); }); } it('C-inject. a ;-injected denylisted decl never reaches data-extra-style', () => { const out = sanitizeHtml( '
x
', WITH({ td: ['letter-spacing'] }) ); expect(extra(out, 'td')).to.include('letter-spacing: 1px'); expect(extra(out, 'td')).to.not.include('position'); expect(parse(out, 'td')!.style.position).to.equal(''); }); it('C-obfus. uppercase / escaped obfuscated names are dropped', () => { const upper = sanitizeHtml( '
x
', WITH({ td: ['LETTER-SPACING'] }) ); expect(extra(upper, 'td')).to.not.include('letter-spacing'); }); it('C-nonascii. a non-ASCII value is dropped', () => { const out = sanitizeHtml( '
x
', WITH({ td: ['letter-spacing'] }) ); expect(extra(out, 'td')).to.not.include('letter-spacing'); }); it('C-length. an over-length (>128) value is dropped', () => { const big = `${'1'.repeat(140)}px`; const out = sanitizeHtml( `
x
`, WITH({ td: ['letter-spacing'] }) ); expect(extra(out, 'td')).to.not.include('letter-spacing'); }); it('C-tag. unknown / text-level tags are dropped from the allowlist', () => { const out = sanitizeHtml( '
x
', WITH({ script: ['letter-spacing'], span: ['letter-spacing'], div: ['letter-spacing'] }) ); expect(extra(out, 'div')).to.not.include('letter-spacing'); expect(extra(out, 'span')).to.not.include('letter-spacing'); }); it('C-mso. mso-line-height-rule is kept', () => { const out = sanitizeHtml( '
x
', WITH({ td: ['mso-line-height-rule'] }) ); expect(extra(out, 'td')).to.include('mso-line-height-rule: exactly'); }); it('C-msohide. an mso-hide:all element is still dropped whole regardless of config', () => { const out = sanitizeHtml( '
secret

kept

', WITH({ p: ['letter-spacing'] }) ); expect(out).to.not.include('secret'); expect(out).to.include('kept'); }); }); describe('allowedStyles: JSON trust boundary (section D)', () => { it('D1. a paragraph extraStyle keeps a safe decl but strips a denylisted one', () => { const doc = jsonToDoc(defaultSchema, { type: 'doc', content: [ { type: 'paragraph', attrs: { extraStyle: 'letter-spacing: 1px; position: fixed' }, content: [{ type: 'text', text: 'x' }], }, ], }); const out = docToHtml(doc); expect(out).to.include('letter-spacing'); expect(out).to.not.include('position'); }); it('D2. a table cell extraStyle keeps a safe decl but strips a denylisted one', () => { const doc = jsonToDoc(defaultSchema, { type: 'doc', content: [ { type: 'table', content: [ { type: 'table_row', content: [ { type: 'table_cell', attrs: { extraStyle: 'letter-spacing: 1px; position: fixed' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }], }, ], }, ], }, ], }); const out = docToHtml(doc); expect(parse(out, 'td')!.style.letterSpacing).to.equal('1px'); expect(parse(out, 'td')!.style.position).to.equal(''); }); }); describe('allowedStyles: cell padding per-side fix (section E)', () => { it('E1. a td with only padding-top survives a round-trip', () => { const out = docToHtml( htmlToDoc(defaultSchema, '
x
', true, ON) ); expect(parse(out, 'td')!.style.paddingTop).to.equal('12px'); }); it('E2. a padding:36px 40px shorthand round-trips per side', () => { const out = docToHtml( htmlToDoc(defaultSchema, '
x
', true, ON) ); const td = parse(out, 'td')!; expect(td.style.paddingTop).to.equal('36px'); expect(td.style.paddingRight).to.equal('40px'); expect(td.style.paddingBottom).to.equal('36px'); expect(td.style.paddingLeft).to.equal('40px'); }); it('E3. a cell border:1px solid #ccc keeps all four sides', () => { const out = docToHtml( htmlToDoc(defaultSchema, '
x
', true, ON) ); const td = parse(out, 'td')!; expect(td.style.borderTopStyle).to.equal('solid'); expect(td.style.borderRightStyle).to.equal('solid'); expect(td.style.borderBottomStyle).to.equal('solid'); expect(td.style.borderLeftStyle).to.equal('solid'); }); it('E4. a uniform table border still round-trips', () => { const out = docToHtml( htmlToDoc(defaultSchema, 'x
', true, ON) ); expect(parse(out, 'table')!.style.borderStyle).to.equal('solid'); }); }); describe('allowedStyles: email-css unit helpers', () => { it('U1. isExtensionPropValid accepts new props, rejects built-in/denylist/grammar', () => { expect(isExtensionPropValid('letter-spacing')).to.equal(true); expect(isExtensionPropValid('mso-line-height-rule')).to.equal(true); expect(isExtensionPropValid('color')).to.equal(false); // built-in expect(isExtensionPropValid('padding-top')).to.equal(false); // built-in expect(isExtensionPropValid('position')).to.equal(false); // denylist expect(isExtensionPropValid('--x')).to.equal(false); // grammar expect(isExtensionPropValid('-webkit-transform')).to.equal(false); // grammar expect(isExtensionPropValid('LETTER-SPACING')).to.equal(false); // grammar }); it('U2. isExtensionValueValid accepts plain values, rejects hostile ones', () => { expect(isExtensionValueValid('0.5px')).to.equal(true); expect(isExtensionValueValid('rgb(1, 2, 3)')).to.equal(true); expect(isExtensionValueValid('url(x)')).to.equal(false); expect(isExtensionValueValid('calc(1px)')).to.equal(false); expect(isExtensionValueValid('var(--x)')).to.equal(false); expect(isExtensionValueValid('1px !important')).to.equal(false); expect(isExtensionValueValid('a/b')).to.equal(false); expect(isExtensionValueValid('é')).to.equal(false); expect(isExtensionValueValid(`${'1'.repeat(140)}px`)).to.equal(false); }); it('U3. parseAllowedStylesAttr parses the attribute string form', () => { expect(parseAllowedStylesAttr('td: letter-spacing, text-indent; p: letter-spacing')).to.deep.equal({ td: ['letter-spacing', 'text-indent'], p: ['letter-spacing'], }); }); it('U4. normalizeEmailAllowedStyles warns once per bad entry and never throws', () => { const warn = console.warn; let count = 0; console.warn = () => { count += 1; }; try { const norm = normalizeEmailAllowedStyles({ td: ['letter-spacing', 'position'], foo: ['x'] }); expect(norm.get('td')?.has('letter-spacing')).to.equal(true); expect(norm.get('td')?.has('position')).to.not.equal(true); } finally { console.warn = warn; } expect(count).to.be.greaterThan(0); // tolerate junk input without throwing expect(() => normalizeEmailAllowedStyles({} as Record)).to.not.throw(); }); it('U5. sanitizeExtraStyleForWrite keeps safe decls and drops unsafe ones', () => { const safe = sanitizeExtraStyleForWrite('letter-spacing: 1px; position: fixed'); expect(safe).to.include('letter-spacing: 1px'); expect(safe).to.not.include('position'); }); }); // --------------------------------------------------------------------------- // Fixes 2–4 from the ZenLeaf email test (plan: fixes-1-4-plan.md) // --------------------------------------------------------------------------- /** docToHtml(htmlToDoc(...)) with email-fidelity on. */ function roundTripOn(html: string): string { return docToHtml(htmlToDoc(defaultSchema, html, true, ON)); } /** docToHtml(htmlToDoc(...)) with the flag off (2.0.16 behaviour). */ function roundTripOff(html: string): string { return docToHtml(htmlToDoc(defaultSchema, html)); } describe('fix 2: cell text-align (email-fidelity only)', () => { it('F2.1. keeps text-align:center on a td', () => { const out = roundTripOn( '

x

' ); expect(parse(out, 'td')!.style.textAlign).to.equal('center'); }); it('F2.2. converts a legacy align="center" on a td to text-align', () => { const out = roundTripOn( '

x

' ); expect(parse(out, 'td')!.style.textAlign).to.equal('center'); }); it('F2.3. keeps text-align:right and text-align:justify', () => { const right = roundTripOn('

x

'); expect(parse(right, 'td')!.style.textAlign).to.equal('right'); const just = roundTripOn('

x

'); expect(parse(just, 'td')!.style.textAlign).to.equal('justify'); }); it('F2.4. drops an invalid text-align value (url/expression)', () => { const url = roundTripOn('

x

'); expect(parse(url, 'td')!.style.textAlign).to.equal(''); expect(url).to.not.include('url('); const expr = roundTripOn( '

x

' ); expect(parse(expr, 'td')!.style.textAlign).to.equal(''); expect(expr).to.not.include('expression('); }); it('F2.5. flag off: a td text-align is dropped (unchanged behaviour)', () => { const out = roundTripOff('

x

'); expect(parse(out, 'td')!.style.textAlign).to.equal(''); }); it('F2.6. table centring via align="center" on the holder cell still works', () => { const out = roundTripOn( '
x
' ); const inner = parseAll(out, 'table')[1]; expect(inner.style.marginLeft).to.equal('auto'); expect(inner.style.marginRight).to.equal('auto'); }); it('F2.6b. align="center" on a cell holding a table centres the table as a block, not the nested body text', () => { const out = roundTripOn( '

body text

' ); // The holder cell's own text IS centred (the browser does centre it)... const holder = parseAll(out, 'td')[0]; expect(holder.style.textAlign).to.equal('center'); // ...the nested table is still centred as a block... expect(parseAll(out, 'table')[1].style.marginLeft).to.equal('auto'); // ...but the nested table's own cell is pinned to left, so its body text is // NOT centred (legacy align does not inherit into a nested table). expect(parseAll(out, 'td')[1].style.textAlign).to.equal('left'); }); it('F2.7. serialize → parse → serialize is a fixpoint', () => { const src = '

x

'; const once = roundTripOn(src); expect(roundTripOn(once)).to.equal(once); }); }); describe('fix 2c: container-level centring propagation (email-fidelity only)', () => { // Case 1: td align="center" holding BOTH a nested table and its own text. const CASE1 = '
' + '
' + '

Powered by Aquera

'; it('C1.1. the holder cell centres its own text', () => { const holder = parseAll(roundTripOn(CASE1), 'td')[0]; expect(holder.style.textAlign).to.equal('center'); }); it('C1.2. the nested table\'s own cell is NOT centred (pinned left)', () => { const nested = parseAll(roundTripOn(CASE1), 'table')[1]; const nestedCell = nested.querySelector('td')!; expect(nestedCell.style.textAlign).to.equal('left'); }); it('C1.3. a nested cell with its own alignment is left untouched', () => { const out = roundTripOn( '
' + '

r

' + '

t

' ); expect(parseAll(out, 'td')[1].style.textAlign).to.equal('right'); }); // Case 2:
. it('C2.1.
centres a block paragraph it wraps', () => { const out = roundTripOn('

Powered by Aquera

'); expect(parse(out, 'p')!.style.textAlign).to.equal('center'); }); it('C2.2.
wraps loose inline text in a centred paragraph', () => { const out = roundTripOn('
Powered by Aquera
'); expect(parse(out, 'p')!.style.textAlign).to.equal('center'); expect(parse(out, 'p')!.textContent).to.contain('Powered by Aquera'); }); it('C2.3.
centres a nested table as a block', () => { const out = roundTripOn( '

x

' ); expect(parse(out, 'table')!.style.marginLeft).to.equal('auto'); }); // Case 3: div[style text-align]. it('C3.1. div style text-align:center centres a loose text child', () => { const out = roundTripOn('
Powered by Aquera
'); expect(parse(out, 'p')!.style.textAlign).to.equal('center'); }); it('C3.2. CSS text-align on a div does NOT centre a nested table as a block', () => { const out = roundTripOn( '

x

' ); expect(parse(out, 'table')!.style.marginLeft).to.not.equal('auto'); }); // Case 4: div[align]. it('C4.1. div align="center" centres a loose text child', () => { const out = roundTripOn('
Powered by Aquera
'); expect(parse(out, 'p')!.style.textAlign).to.equal('center'); }); it('C4.2. div align="center" centres a nested table as a block', () => { const out = roundTripOn( '

x

' ); expect(parse(out, 'table')!.style.marginLeft).to.equal('auto'); }); // Case 5: CSS text-align on a table. it('C5.1. table style text-align:center centres its direct cells', () => { const out = roundTripOn( '

Powered by Aquera

' ); expect(parse(out, 'td')!.style.textAlign).to.equal('center'); }); it('C5.2. a cell with its own alignment overrides the table text-align', () => { const out = roundTripOn( '

x

' ); expect(parse(out, 'td')!.style.textAlign).to.equal('left'); }); // Case 6: tr[align]. it('C6.1. tr align="center" centres its direct cells', () => { const out = roundTripOn( '

Powered by Aquera

' ); expect(parse(out, 'td')!.style.textAlign).to.equal('center'); }); it('C7.1. flag off: none of the container-level centring survives', () => { expect(parse(roundTripOff('

x

'), 'p')!.style.textAlign).to.equal(''); expect( parse(roundTripOff('
x
'), 'p')?.style.textAlign ?? '' ).to.equal(''); expect( parse( roundTripOff( '

x

' ), 'td' )!.style.textAlign ).to.equal(''); }); it('C7.2. each case is a serialize → parse → serialize fixpoint', () => { for (const src of [ CASE1, '

x

', '
x
', '
x
', '

x

', '

x

', ]) { const once = roundTripOn(src); expect(roundTripOn(once), src).to.equal(once); } }); }); describe('fix 3: table-level font/colour (email-fidelity only)', () => { const TABLE = '

hello

'; it('F3.1. keeps font-family/font-size/color/line-height on the table', () => { const table = parse(roundTripOn(TABLE), 'table')!; expect(table.style.fontFamily).to.include('Verdana'); expect(table.style.fontSize).to.equal('16px'); expect(table.style.color).to.not.equal(''); expect(table.style.lineHeight).to.not.equal(''); }); it('F3.2. accepts an hsl() colour', () => { const table = parse(roundTripOn(TABLE), 'table')!; // Browsers normalize hsl() to rgb(). expect(table.style.color).to.match(/rgb\(|hsl\(/); }); it('F3.3. rejects a colour that smuggles a var()', () => { const out = roundTripOn( '

x

' ); expect(parse(out, 'table')!.style.color).to.equal(''); expect(out).to.not.include('var('); }); it('F3.4. no per-run span carries the table font-family or colour', () => { const out = roundTripOn(TABLE); for (const span of parseAll(out, 'span')) { expect(span.style.fontFamily, span.outerHTML).to.not.include('Verdana'); } }); it('F3.5. flag off: table font-family/colour are dropped (unchanged behaviour)', () => { const table = parse(roundTripOff(TABLE), 'table')!; expect(table.style.fontFamily).to.equal(''); expect(table.style.color).to.equal(''); }); it('F3.6. serialize → parse → serialize is a fixpoint', () => { const once = roundTripOn(TABLE); expect(roundTripOn(once)).to.equal(once); }); }); describe('fix 4a: colgroup column widths (email-fidelity only)', () => { const THREE_COL = '' + '

a

b

c

'; it('F4a.1. copies each col width onto the matching cell', () => { const tds = parseAll(roundTripOn(THREE_COL), 'td'); expect(tds[0].style.width).to.equal('33.33%'); expect(tds[1].style.width).to.equal('33.34%'); expect(tds[2].style.width).to.equal('33.33%'); }); it('F4a.2. a cell with its own width keeps it', () => { const out = roundTripOn( '' + '

a

b

c

' ); const tds = parseAll(out, 'td'); expect(tds[0].style.width).to.equal('120px'); expect(tds[1].style.width).to.equal('33.34%'); }); it('F4a.3. a row containing a colspan>1 cell is skipped', () => { const out = roundTripOn( '' + '

a

c

' ); for (const td of parseAll(out, 'td')) { expect(td.style.width).to.equal(''); } }); it('F4a.4. an outer colgroup does not touch a nested table\'s cells', () => { const out = roundTripOn( '' + '

nested

b

c

' ); const nested = parseAll(out, 'table')[1]; const nestedCell = nested.querySelector('td')!; expect(nestedCell.style.width).to.equal(''); }); it('F4a.5. flag off: colgroup widths are not propagated', () => { const tds = parseAll(roundTripOff(THREE_COL), 'td'); expect(tds[0].style.width).to.equal(''); }); it('F4a.6. serialize → parse → serialize is a fixpoint', () => { const once = roundTripOn(THREE_COL); expect(roundTripOn(once)).to.equal(once); }); }); describe('fix 4b: margin shorthand centring (email-fidelity only)', () => { it('F4b.1. margin:0px auto centres the table', () => { const out = roundTripOn('

x

'); const table = parse(out, 'table')!; expect(table.style.marginLeft).to.equal('auto'); expect(table.style.marginRight).to.equal('auto'); }); it('F4b.2. margin:0 10px does not centre the table', () => { const out = roundTripOn('

x

'); const table = parse(out, 'table')!; expect(table.style.marginLeft).to.not.equal('auto'); expect(table.style.marginRight).to.not.equal('auto'); }); it('F4b.3. flag off: margin:0px auto does not centre', () => { const out = roundTripOff('

x

'); expect(parse(out, 'table')!.style.marginLeft).to.not.equal('auto'); }); it('F4b.4. serialize → parse → serialize is a fixpoint', () => { const src = '

x

'; const once = roundTripOn(src); expect(roundTripOn(once)).to.equal(once); }); }); describe('ZenLeaf integration (email-fidelity on)', () => { // A trimmed but representative copy of the CKEditor "ZenLeaf" email: an outer // presentation table carrying Verdana/colour/line-height and margin:0px auto, // a 100%-width hero image, centred heading/offer cells, and a 3-column // colgroup table whose cells must inherit the column widths. const ZENLEAF = `
hero

The Magic of Green Tea

Get 20% off our premium green tea collection!

Boosts energy

Antioxidants

Immune support

`; it('Z1. the first image keeps width: 100%', () => { const out = roundTripOn(ZENLEAF); expect(parseAll(out, 'img')[0].style.width).to.equal('100%'); }); it('Z2. at least one cell is text-align: center', () => { const out = roundTripOn(ZENLEAF); const centred = parseAll(out, 'td').filter(t => t.style.textAlign === 'center'); expect(centred.length).to.be.greaterThan(0); }); it('Z3. the outer table is centred and keeps a Verdana font-family', () => { const out = roundTripOn(ZENLEAF); const outer = parseAll(out, 'table')[0]; expect(outer.style.marginLeft).to.equal('auto'); expect(outer.style.marginRight).to.equal('auto'); expect(outer.style.fontFamily).to.include('Verdana'); }); it('Z4. the 3-column table cells carry 33.33%/33.34% widths', () => { const out = roundTripOn(ZENLEAF); const widths = parseAll(out, 'td').map(t => t.style.width); expect(widths).to.include('33.33%'); expect(widths).to.include('33.34%'); }); it('Z5. the fidelity-on output is still smaller than the flag-off output', () => { const on = roundTripOn(ZENLEAF); const off = roundTripOff(ZENLEAF); expect(on.length).to.be.lessThan(off.length); }); it('Z6. serialize → parse → serialize is a fixpoint', () => { const once = roundTripOn(ZENLEAF); expect(roundTripOn(once)).to.equal(once); }); }); // --------------------------------------------------------------------------- // Button links → one-cell table buttons (email-fidelity only) // (plan: button-links-plan.md — option A, 2026-10-13) // // Email builders make a "button" from ONE styled
(display:inline-block + // background-color/padding/border-radius/width). The editor stores links as a // mark on text, so the box styling is dropped and background-color becomes a // thin highlight strip. The email-fidelity pre-pass converts such a link, when // it is the only content of its block, into: //
text
// with the box styles on the cell and the text styles kept on the link. // --------------------------------------------------------------------------- /** The ZenLeaf "Get discount" button: one display:inline-block styled link in a centred td. */ const BTN_GET_DISCOUNT = `
Get discount
`; /** The ZenLeaf "Shop now" button (darker background, same shape). */ const BTN_SHOP_NOW = `
Shop now
`; /** Find the that was produced for a button link: the innermost td holding the link. */ function buttonCell(html: string): HTMLElement { const link = parse(html, 'a')!; return link.closest('td') as HTMLElement; } describe('button links: ZenLeaf "Get discount" (email-fidelity on)', () => { it('BL1. produces a role=presentation table around the link', () => { const out = roundTripOn(BTN_GET_DISCOUNT); const link = parse(out, 'a')!; const table = link.closest('table'); expect(table, 'link sits inside a table').to.exist; expect(table!.getAttribute('role')).to.equal('presentation'); }); it('BL2. the button cell carries the link background-color', () => { const out = roundTripOn(BTN_GET_DISCOUNT); expect(buttonCell(out).style.backgroundColor).to.equal('rgb(64, 107, 30)'); }); it('BL3. the button cell carries the padding (any per-side equivalent of 12px)', () => { const out = roundTripOn(BTN_GET_DISCOUNT); const td = buttonCell(out); const uniform = td.style.padding === '12px'; const perSide = td.style.paddingTop === '12px' && td.style.paddingRight === '12px' && td.style.paddingBottom === '12px' && td.style.paddingLeft === '12px'; expect(uniform || perSide, `padding was "${td.style.cssText}"`).to.equal(true); }); it('BL4. the button cell carries border-radius and width', () => { const td = buttonCell(roundTripOn(BTN_GET_DISCOUNT)); expect(td.style.borderRadius).to.equal('8px'); expect(td.style.width).to.equal('260px'); }); it('BL5. the button cell is text-align: center', () => { expect(buttonCell(roundTripOn(BTN_GET_DISCOUNT)).style.textAlign).to.equal('center'); }); it('BL6. the table is centred (auto side margins) from the link margin:0 auto', () => { const out = roundTripOn(BTN_GET_DISCOUNT); const table = parse(out, 'a')!.closest('table')!; const centredByMargin = table.style.marginLeft === 'auto' && table.style.marginRight === 'auto'; const centredByAttr = table.getAttribute('align') === 'center'; expect(centredByMargin || centredByAttr, `table was "${table.outerHTML}"`).to.equal(true); }); it('BL7. the link keeps its href and text', () => { const out = roundTripOn(BTN_GET_DISCOUNT); const link = parse(out, 'a')!; expect(link.getAttribute('href')).to.equal('#'); expect(link.textContent).to.equal('Get discount'); }); it('BL8. no span wraps the link text with a background-color highlight', () => { const out = roundTripOn(BTN_GET_DISCOUNT); for (const span of parseAll(out, 'span')) { expect(span.style.backgroundColor, span.outerHTML).to.equal(''); } }); it('BL9. the link keeps white colour, 24px, bold as text styling', () => { const out = roundTripOn(BTN_GET_DISCOUNT); const link = parse(out, 'a')!; // white / 24px / bold may land on the link itself or a mark span inside it. expect(link.innerHTML, 'white').to.match(/rgb\(255, 255, 255\)|#ffffff|#fff\b/i); expect(link.innerHTML, '24px').to.include('24px'); expect(link.innerHTML, 'bold').to.match(/font-weight:\s*(?:700|bold)| { const link = parse(roundTripOn(BTN_GET_DISCOUNT), 'a')!; expect(link.style.display, 'display').to.equal(''); expect(link.style.backgroundColor, 'background').to.equal(''); expect(link.style.padding, 'padding').to.equal(''); expect(link.style.paddingTop, 'padding-top').to.equal(''); expect(link.style.width, 'width').to.equal(''); expect(link.style.borderRadius, 'border-radius').to.equal(''); }); }); describe('button links: ZenLeaf "Shop now" (email-fidelity on)', () => { it('BL11. the button cell carries the darker background-color', () => { expect(buttonCell(roundTripOn(BTN_SHOP_NOW)).style.backgroundColor).to.equal('rgb(20, 21, 23)'); }); it('BL12. the button cell carries border-radius and width', () => { const td = buttonCell(roundTripOn(BTN_SHOP_NOW)); expect(td.style.borderRadius).to.equal('6px'); expect(td.style.width).to.equal('200px'); }); it('BL13. the button cell keeps the 14px/28px padding per side', () => { const td = buttonCell(roundTripOn(BTN_SHOP_NOW)); const uniform = td.style.padding === '14px 28px'; const perSide = td.style.paddingTop === '14px' && td.style.paddingRight === '28px' && td.style.paddingBottom === '14px' && td.style.paddingLeft === '28px'; expect(uniform || perSide, `padding was "${td.style.cssText}"`).to.equal(true); }); it('BL14. no span highlights the link text', () => { for (const span of parseAll(roundTripOn(BTN_SHOP_NOW), 'span')) { expect(span.style.backgroundColor, span.outerHTML).to.equal(''); } }); }); describe('button links: not-a-button cases (email-fidelity on)', () => { it('BL15. a plain coloured link in a paragraph is left inline (no table)', () => { const out = roundTripOn('

link

'); expect(parse(out, 'p a'), 'link stays in the paragraph').to.exist; expect(parse(out, 'p table'), 'no table inside the paragraph').to.not.exist; }); it('BL16. display:inline-block with no bg/padding is not converted', () => { const out = roundTripOn( '

link

' ); expect(parse(out, 'p table'), 'no table').to.not.exist; }); it('BL17. a button-styled link sharing a paragraph with other text stays inline', () => { const out = roundTripOn( '

before btn after

' ); expect(parse(out, 'p table'), 'no table; link is not the only content').to.not.exist; expect(parse(out, 'p')!.textContent).to.include('before'); expect(parse(out, 'p')!.textContent).to.include('after'); }); }); describe('button links: block replacement (email-fidelity on)', () => { it('BL18. a

whose only content is a button link is replaced by the table', () => { const out = roundTripOn( '

Get discount

' ); const link = parse(out, 'a')!; const table = link.closest('table'); expect(table, 'link now inside a table').to.exist; // The original

is replaced by the table (the cell's own paragraph is just // ProseMirror's block+ cell content model). expect(table!.closest('p'), 'table not nested in the old paragraph').to.not.exist; expect(out.trim().startsWith(' { // First-email idiom: a navy cell wrapping an inline-block padded link. const out = roundTripOn( '
Confirm
' ); const link = parse(out, 'a')!; const inner = link.closest('td')!; // The link's immediate cell now carries the padding... const uniform = inner.style.padding === '13px 28px'; const perSide = inner.style.paddingTop === '13px' && inner.style.paddingRight === '28px' && inner.style.paddingBottom === '13px' && inner.style.paddingLeft === '28px'; expect(uniform || perSide, `inner padding was "${inner.style.cssText}"`).to.equal(true); // ...and the outer navy cell background survives somewhere above it. const navy = parseAll(out, 'td').some( t => t.style.backgroundColor === 'rgb(31, 58, 95)' ); expect(navy, 'navy background retained').to.equal(true); }); }); describe('button links: flag off (unchanged 2.0.16 behaviour)', () => { it('BL20. flag off: the button link is NOT turned into a table', () => { const out = roundTripOff(BTN_GET_DISCOUNT); // The only table is the original wrapper; the link is not wrapped in a new one. const link = parse(out, 'a')!; const cell = link.closest('td')!; expect(cell.querySelector('table'), 'no nested button table').to.not.exist; }); it('BL21. flag off: no cell carries the button background-color', () => { const out = roundTripOff(BTN_GET_DISCOUNT); const anyBg = parseAll(out, 'td').some( t => t.style.backgroundColor === 'rgb(64, 107, 30)' ); expect(anyBg).to.equal(false); }); }); describe('button links: security (email-fidelity on)', () => { it('BL22. a hostile padding:url(x) never reaches the button cell', () => { const out = roundTripOn( '

btn

' ); expect(out).to.not.include('url('); const bg = parseAll(out, 'td').some(t => t.style.backgroundColor === 'rgb(64, 107, 30)'); if (bg) { const td = buttonCell(out); expect(td.style.padding, 'padding must be empty').to.equal(''); } }); it('BL23. a background-color:expression(...) is dropped', () => { const out = roundTripOn( '

btn

' ); expect(out).to.not.include('expression('); }); it('BL24. a javascript: href on a button link is stripped', () => { const out = roundTripOn( '

btn

' ); const link = parse(out, 'a'); if (link) { expect((link.getAttribute('href') ?? '').toLowerCase()).to.not.include('javascript:'); } expect(out.toLowerCase()).to.not.include('javascript:'); }); }); describe('button links: fixpoint (email-fidelity on)', () => { it('BL25. "Get discount" is a serialize → parse → serialize fixpoint', () => { const once = roundTripOn(BTN_GET_DISCOUNT); expect(roundTripOn(once)).to.equal(once); }); it('BL26. the reloaded button is not re-detected (link no longer has box styles)', () => { const once = roundTripOn(BTN_GET_DISCOUNT); // After one pass the link has no display/padding, so a second pass must not // add another nested table layer. const tablesOnce = parseAll(once, 'table').length; const tablesTwice = parseAll(roundTripOn(once), 'table').length; expect(tablesTwice).to.equal(tablesOnce); }); it('BL27. "Shop now" is a serialize → parse → serialize fixpoint', () => { const once = roundTripOn(BTN_SHOP_NOW); expect(roundTripOn(once)).to.equal(once); }); });