import { describe, expect, test } from "bun:test"; import { parse, type DefaultTreeAdapterMap } from "parse5"; import { emailCoreBlocks, emailKind, renderEmail, validateAmpEmail, validateMjml, importMjml, type EmailRenderBundle, } from "../src/index.ts"; import type { BlockDocument } from "@wrnexus/blocks"; import { ampLayoutClass, ampLayoutCss } from "../src/blocks.ts"; test("AMP section and spacer CSS retains configured layout values", async () => { const draft = structuredClone(document); const section = draft.root.slots.content[0]!; section.props = { background: "#abcdef", padding: "12px 30px" }; const spacer = { id: 'spacer:one"', type: "content.spacer", version: 1, props: { height: "77" }, styles: {}, slots: {} }; section.slots.content = [spacer]; const result = await renderEmail(draft, { amp: true, mjml: true }); expect(result.ok).toBe(true); if (!result.ok) return; const amp = result.value.parts.amp; expect(amp?.validation.valid).toBe(true); expect(amp?.content).toContain(`.${ampLayoutClass(section)}{background:#abcdef;padding:12px 30px}`); expect(amp?.content).toContain(`.${ampLayoutClass(spacer)}{height:77px;line-height:77px;font-size:1px}`); expect(amp?.content).toContain(`class="${ampLayoutClass(spacer)}"`); expect(result.value.parts.html.content).toContain("height:77px"); expect(result.value.exports?.mjml).toContain('height="77px"'); expect(ampLayoutCss({ ...section, props: { background: "red;}body{display:none", padding: "" } })).not.toContain("display:none"); }); const document: BlockDocument = { schemaVersion: 1, kind: "email", meta: { subject: "Welcome", preheader: "Preview", lang: "en" }, theme: { tokens: {} }, root: { id: "root", type: "layout.email-document", version: 1, props: { width: "600", background: "#f3f4f6" }, styles: {}, slots: { content: [ { id: "section", type: "layout.section", version: 1, props: { background: "#ffffff", padding: "24px" }, styles: {}, slots: { content: [ { id: "heading", type: "content.heading", version: 1, props: { text: "Hello ", level: "1" }, styles: {}, slots: {}, }, { id: "button", type: "content.button", version: 1, props: { label: "Open", href: "https://example.com/" }, styles: {}, slots: {}, }, ], }, }, ], }, }, }; describe("email renderer", () => { test("optional AMP validation failures preserve HTML/text fallbacks", async () => { for (const ampValidator of [async () => {throw new Error("network unavailable")}, () => new Promise(() => {})]) { const result=await renderEmail(document,{amp:true,ampValidator,ampValidationTimeoutMs:10}); expect(result.ok).toBe(true); if(!result.ok)continue; expect(result.value.parts.amp).toBeUndefined(); expect(result.value.parts.html.content).toContain("Hello <world>"); expect(result.value.parts.text.content).toContain("Hello "); expect(result.value.warnings.some(item=>item.code==="amp.validatorUnavailable"&&item.severity==="warning")).toBe(true); } }); test("builder column rows export without nesting mj-column inside mj-column", async () => { for (const count of [1,2,3,4]) { const draft=structuredClone(document); const content=draft.root.slots.content[0]!.slots.content; draft.root.slots.content[0]!.slots.content=[{id:"columns",type:"layout.columns",version:1,props:{},styles:{},slots:{columns:Array.from({length:count},(_,index)=>({id:`column-${index}`,type:"layout.column",version:1,props:{width:`${100/count}%`},styles:{},slots:{content:index===0?content:[]}}))}}]; const result=await renderEmail(draft,{mjml:true,amp:true}); expect(result.ok).toBe(true); if(!result.ok)continue; const mjml=result.value.exports!.mjml!; expect(mjml.match(/ { const draft = structuredClone(document); draft.root.props.logoUrl = "https://example.com/logo.png"; draft.root.props.logoAlt = 'Brand & "Company"'; draft.root.props.logoWidth = "180"; draft.root.props.logoHeight = "90"; const result = await renderEmail(draft,{amp:true,mjml:true}); expect(result.ok).toBe(true); if (!result.ok) return; for (const output of [result.value.parts.html.content,result.value.parts.amp?.content,result.value.exports?.mjml]) { expect(output).toContain('src="https://example.com/logo.png"'); expect(output).toContain('alt="Brand & "Company""'); } expect(result.value.parts.amp?.validation.valid).toBe(true); expect(await validateMjml(result.value.exports!.mjml!)).toEqual([]); expect(result.value.parts.html.content).toContain('width="180" height="90"'); expect(result.value.parts.amp?.content).toContain('width="180" height="90"'); expect(result.value.exports?.mjml).toContain('width="180px" height="90px"'); for (const value of ["0", "4001", "180px", "1;display:none"]) { const invalid = structuredClone(draft); invalid.root.props.logoWidth = value; expect((await renderEmail(invalid)).ok).toBe(false); } draft.root.props.logoUrl = ""; const empty = await renderEmail(draft,{amp:true,mjml:true}); expect(empty.ok).toBe(true); if (empty.ok) { expect(empty.value.parts.amp?.content).not.toContain(" { const draft = structuredClone(document); const text = 'First line\r\nSecond line\n\n\rLast line'; draft.root.slots.content[0]!.slots.content = [{id:"text",type:"content.text",version:1,props:{text},styles:{},slots:{}}]; const rendered = await renderEmail(draft,{amp:true,mjml:true}); expect(rendered.ok).toBe(true); if (!rendered.ok) return; for (const output of [rendered.value.parts.html.content,rendered.value.parts.amp?.content,rendered.value.exports?.mjml]) { expect(output).toContain('First line
Second line

<script>alert("x")</script>
Last line'); expect(output).not.toContain('"],["textColor","red;background:url(https://example.com)"],["background","#fff;display:none"],["width","600;position:absolute"],["width","9999"]]) { const draft = structuredClone(document); draft.root.props[key!] = value!; const rendered = await renderEmail(draft, {amp:true}); expect(rendered.ok).toBe(false); if (!rendered.ok) expect(rendered.errors.some(error => error.code === "email.brand")).toBe(true); } }); test("responsive stacking begins below the desktop email width", async () => { const rendered = await renderEmail(document); expect(rendered.ok).toBe(true); if (!rendered.ok) return; expect(emailKind.breakpoints.compact).toBe(480); expect(rendered.value.parts.html.content).toContain("max-width:480px"); expect(rendered.value.parts.html.content).not.toContain("max-width:620px"); }); test("countdown URLs are derived for HTML AMP and MJML without mutating the document", async () => { const draft = structuredClone(document); draft.root.slots.content[0]!.slots.content = [{id:"timer",type:"content.countdown",version:1,props:{label:"Offer ends",deadline:"2026-10-01T00:00:00Z",fallback:"Offer ended"},styles:{},slots:{}}]; const before = JSON.stringify(draft); const result = await renderEmail(draft, {amp:true,mjml:true,countdownImageUrl:"https://email.example.com/api/countdown"}); expect(result.ok).toBe(true); if (!result.ok) return; const image = "https://email.example.com/api/countdown?deadline=1790812800000"; expect(result.value.parts.html.content).toContain(image); expect(result.value.parts.amp?.content).toContain(image); expect(result.value.exports?.mjml).toContain(image); expect(JSON.stringify(draft)).toBe(before); expect((await renderEmail(draft, {countdownImageUrl:"javascript:alert(1)"})).ok).toBe(false); }); test("content inherits the selected brand font instead of overriding it", async () => { const branded = structuredClone(document); branded.root.props.fontFamily = "Georgia, serif"; const result = await renderEmail(branded); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.parts.html.content).toContain("font-family:Georgia, serif"); expect(result.value.parts.html.content).not.toContain("font-family:Arial"); expect(result.value.parts.html.content).toContain('

{ const rendered = await renderEmail(document); expect(rendered.ok).toBe(true); if (!rendered.ok) return; const tree = parse(rendered.value.parts.html.content); const ancestry = new Map(); const walk = (node: DefaultTreeAdapterMap["node"], parents: string[]) => { const id = "attrs" in node ? node.attrs.find(attr => attr.name === "data-block-id")?.value : undefined; if (id) ancestry.set(id, parents); if ("childNodes" in node) for (const child of node.childNodes) walk(child, id ? [...parents, id] : parents); }; walk(tree, []); expect(ancestry.get("heading")).toEqual(["root", "section"]); expect(ancestry.get("button")).toEqual(["root", "section"]); }); test("renders deterministic HTML, text, AMP, and MJML", async () => { const first = await renderEmail(document, { amp: true, mjml: true }); const second = await renderEmail(document, { amp: true, mjml: true }); expect(first).toEqual(second); expect(first.ok).toBe(true); if (!first.ok) return; const value: EmailRenderBundle = first.value; expect(value.parts.html.content).toContain("Hello <world>"); expect(value.parts.html.content).toContain("color:#111827"); expect(value.parts.text.content).toContain("Hello "); expect(value.parts.amp?.validation.valid).toBe(true); expect(value.exports?.mjml).toContain(""); }); test("validates AMP output", async () => { expect((await validateAmpEmail("")).valid).toBe(false); }); test("keeps rendered block rows inside valid column tables", async () => { const columnDocument = structuredClone(document); const content = columnDocument.root.slots.content[0]!.slots.content; columnDocument.root.slots.content[0]!.slots.content = [ { id: "columns", type: "layout.columns", version: 1, props: {}, styles: {}, slots: { columns: [ { id: "column", type: "layout.column", version: 1, props: { width: "100%" }, styles: {}, slots: { content }, }, ], }, }, ]; const rendered = await renderEmail(columnDocument, { amp: true }); expect(rendered.ok).toBe(true); if (!rendered.ok) return; expect(rendered.value.parts.html.content).toContain( 'data-block-id="column" class="wrn-column" width="100%" valign="top">
{ const styled = structuredClone(document); styled.root.slots.content[0]!.slots.content[0]!.styles = { color: { base: "#123456" }, textAlign: { base: "center" }, padding: { base: "12px 8px" }, backgroundColor: { base: "red;position:fixed" }, }; const rendered = await renderEmail(styled); expect(rendered.ok).toBe(true); if (!rendered.ok) return; expect(rendered.value.parts.html.content).toContain( "color:#123456;padding:12px 8px;text-align:center", ); expect(rendered.value.parts.html.content).not.toContain("position:fixed"); }); test("resolves allowlisted personalization values and fallbacks", async () => { const personalized = structuredClone(document); personalized.root.slots.content[0]!.slots.content[0]!.props.text = "Hello {{recipientName|friend}}"; const rendered = await renderEmail(personalized, { variables: { recipientName: "Ada" } }); expect(rendered.ok && rendered.value.parts.html.content).toContain("Hello Ada"); const fallback = await renderEmail(personalized); expect(fallback.ok && fallback.value.parts.text.content).toContain("Hello friend"); const strict = await renderEmail(personalized, { requireVariables: true }); expect(strict.ok).toBe(true); personalized.root.slots.content[0]!.slots.content[0]!.props.text = "Hello {{recipientName}}"; expect((await renderEmail(personalized, { requireVariables: true })).ok).toBe(false); }); test("rejects unsafe MJML and imports supported content", () => { expect( importMjml("x", { id: () => crypto.randomUUID(), subject: "x", }).ok, ).toBe(false); let n = 0; const imported = importMjml( "Hello", { id: () => `n${++n}`, subject: "Imported" }, ); expect(imported.ok).toBe(true); }); test("strict MJML validation catches invalid nesting", async () => { expect( (await validateMjml("x")).length, ).toBeGreaterThan(0); }); test("ships the complete marketing block pack", () => { expect(emailCoreBlocks.map((block) => block.manifest.type)).toEqual( expect.arrayContaining([ "content.video", "content.social", "content.countdown", "content.survey", "content.feed-item", "commerce.product", ]), ); for (const block of emailCoreBlocks) { expect(block.textRenderer).toBeFunction(); expect(block.renderers?.emailHtml).toBeFunction(); expect(block.renderers?.ampEmail).toBeFunction(); expect(block.renderers?.mjml).toBeFunction(); } }); });