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.parts.amp?.content).toContain( 'width="180" height="90" layout="responsive"', ); expect(result.value.parts.amp?.content).toContain( ".brand-logo{max-width:180px;width:100%;margin:20px auto}", ); 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(); } }); });