import { describe, expect, test } from "bun:test"; import { emailCoreBlocks, renderEmail, validateAmpEmail, validateMjml, importMjml, type EmailRenderBundle, } from "../src/index.ts"; import type { BlockDocument } from "@wrnexus/blocks"; 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("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("compiles only allowlisted semantic styles", async () => { 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(); } }); });