Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,25 @@ jobs:
- name: Run test coverage
run: pnpm test:coverage

# ── CSP safety gate ─────────────────────────────────────────
# Imports + renders the built bundle under V8's
# --disallow-code-generation-from-strings (a CSP without 'unsafe-eval').
# HARD gate: fails if this package OR its pinned @unlayer/exporters
# evaluates a string at import/render. Stays red until the catalog pins a
# CSP-safe (precompiled-template) @unlayer/exporters release.
- name: CSP safety gate
run: pnpm --filter @unlayer/react-elements test:csp

# ── Storybook browser smoke test ────────────────────────────
Comment thread
brunolemos marked this conversation as resolved.
# Opens every story in headless Chromium and asserts each component
# paints visible content with no console / page errors. Runs against the
# production static build (storybook build → http-server).
- name: Install Playwright Chromium
run: pnpm --filter @unlayer/react-elements exec playwright install --with-deps chromium

- name: Storybook smoke test
run: pnpm --filter @unlayer/react-elements test-storybook:ci

# ── Next.js integration test ────────────────────────────────
# Packs the built react-elements as a tarball and installs it
# in a real Next.js app — exactly like a consumer would.
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,17 @@ Example: `<Button fontSize="16px">` → `{ style: { fontSize: "16px" } }` in the
- **Golden template test**: `packages/react/src/golden-template.test.tsx` — full realistic email through all 4 render pipelines
- **Node environment test**: `packages/react/src/node-import.test.ts` — verifies no browser API dependency
- **Next.js integration**: `tests/nextjs-integration/` — real Next.js 15 app build with Server Components
- **Storybook smoke test**: `packages/react/.storybook/test-runner.ts` — opens every story in headless Chromium and asserts each component paints visible content with no console / page errors. Runs against both the dev server (`pnpm test-storybook`) and the production static build (`pnpm test-storybook:ci`). Note: Storybook bundles from `src/` via Vite — the published `dist/` artifact is covered by the Next.js integration and the CSP gate.
- **CSP safety gate**: `packages/react/scripts/csp-probe.mjs` (`pnpm test:csp`) — imports + renders the built `dist/` bundle under V8's `--disallow-code-generation-from-strings` (a Content-Security-Policy without `'unsafe-eval'`). **Hard gate**: fails if this package _or_ its pinned `@unlayer/exporters` evaluates a string (`eval` / `new Function`) at import or render. It stays red until the workspace catalog pins a precompiled / CSP-safe `@unlayer/exporters` release — a green check must mean the package is genuinely CSP-safe.

## CI Quality Gates

- TypeScript strict compilation
- All unit tests pass
- Bundle size < 60KB (ESM)
- Next.js integration build succeeds
- Storybook smoke test passes (every story renders, no console errors)
- CSP safety gate passes (red until the pinned `@unlayer/exporters` is CSP-safe)

## Common Gotchas

Expand Down
83 changes: 83 additions & 0 deletions packages/react/.storybook/test-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { TestRunnerConfig } from '@storybook/test-runner';

/**
* Storybook test-runner config — a browser smoke test over every story.
*
* For each story page it asserts two things:
* 1. No JavaScript errors — uncaught exceptions (`pageerror`) or app-level
* `console.error` calls. Network/resource failures (e.g. the YouTube
* thumbnails and example.com images used in stories, which 404 in CI)
* are deliberately ignored — they aren't component bugs.
* 2. The component actually painted — `#storybook-root` has at least one
* visible descendant with a non-zero box. Catches the failure mode where
* a component throws or renders nothing and the page is silently blank.
*
* Runs against both `storybook dev` and the static `storybook build` output
* (see the `test-storybook` / `test-storybook:ci` scripts).
*/

const ERRORS_KEY = '__unlayerStoryErrors';
const LISTENERS_KEY = '__unlayerListenersAttached';

// Resource-load / network noise that is not a component regression.
const RESOURCE_NOISE =
/(Failed to load resource|net::ERR|ERR_[A-Z_]+|the server responded with a status of [45]\d\d|favicon)/i;

const config: TestRunnerConfig = {
async preVisit(page) {
const anyPage = page as any;

// The test-runner reuses one page per worker across stories, so attach the
// listeners exactly once and just reset the buffer on each visit.
if (!anyPage[LISTENERS_KEY]) {
anyPage[LISTENERS_KEY] = true;

page.on('console', (msg) => {
if (msg.type() !== 'error') return;
const text = msg.text();
if (RESOURCE_NOISE.test(text)) return;
(anyPage[ERRORS_KEY] ??= []).push(`console.error: ${text}`);
});

page.on('pageerror', (err) => {
(anyPage[ERRORS_KEY] ??= []).push(`pageerror: ${err.message}`);
});
}

anyPage[ERRORS_KEY] = [];
},

async postVisit(page, context) {
const id = `${context.title} › ${context.name}`;

const hasVisibleContent = await page.evaluate(() => {
const root = document.querySelector('#storybook-root');
if (!root) return false;
// Media elements count as rendered even when their remote asset 404s in
// CI (no network) and the box collapses to 0×0 — the component still
// emitted its UI. Everything else must actually paint a non-zero box.
const MEDIA = new Set(['IMG', 'IFRAME', 'VIDEO', 'PICTURE', 'SVG', 'CANVAS']);
for (const el of Array.from(root.querySelectorAll('*'))) {
const style = getComputedStyle(el);
if (style.visibility === 'hidden' || style.display === 'none') continue;
if (MEDIA.has(el.tagName)) return true;
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) return true;
}
return false;
});

if (!hasVisibleContent) {
throw new Error(
`[${id}] rendered nothing visible — #storybook-root has no painted content`,
);
}

const errors: string[] = (page as any)[ERRORS_KEY] ?? [];
if (errors.length > 0) {
throw new Error(`[${id}] browser errors:\n ${errors.join('\n ')}`);
}
},
};

export default config;
12 changes: 10 additions & 2 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,16 @@
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test": "vitest",
"test:coverage": "vitest --coverage"
"test:coverage": "vitest --coverage",
"test:csp": "node --disallow-code-generation-from-strings scripts/csp-probe.mjs",
"test-storybook": "test-storybook",
"test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"pnpm build-storybook --quiet && http-server storybook-static --port 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && pnpm test-storybook\""
},
"devDependencies": {
"@storybook/addon-docs": "^9.1.1",
"@storybook/addon-onboarding": "^9.1.1",
"@storybook/react-vite": "^9.1.1",
"@storybook/test-runner": "0.23.0",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.0.0",
"@types/node": "^24.9.1",
Expand All @@ -70,9 +74,13 @@
"@unlayer-internal/shared-elements": "workspace:*",
"@vitejs/plugin-react": "^4.2.0",
"@vitest/coverage-v8": "^3.2.4",
"concurrently": "9.2.1",
"http-server": "14.1.1",
"jsdom": "^24.1.3",
"playwright": "1.60.0",
"storybook": "^9.1.1",
"tsup": "^8.0.1",
"vitest": "^3.2.4"
"vitest": "^3.2.4",
"wait-on": "9.0.10"
}
}
103 changes: 103 additions & 0 deletions packages/react/scripts/csp-probe.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env node
// Run with: node --disallow-code-generation-from-strings scripts/csp-probe.mjs
//
// CSP safety gate for @unlayer/react-elements.
//
// renderToHtml() pulls in @unlayer/exporters. If any reachable code evaluates a
// string (eval / new Function) at import or render time, a strict
// Content-Security-Policy without 'unsafe-eval' blocks it and the package throws
// in browsers, iframes, and CSP'd SSR — before any render runs. V8's
// --disallow-code-generation-from-strings is the engine-level equivalent of such
// a CSP, so importing + rendering the built bundle under it proves the shipped
// output is CSP-safe.
//
// This is a HARD gate. It fails if importing or rendering the built `dist/`
// evaluates a string anywhere — whether in this package's own code OR in its
// pinned @unlayer/exporters dependency. It will stay red until the workspace
// catalog pins an @unlayer/exporters release that precompiles its templates
// (no `new Function` at import / render). That is intentional: a green check
// must mean "@unlayer/react-elements is genuinely CSP-safe."

const CSP_ERROR = /Code generation from strings disallowed/;

try {
const React = (await import('react')).default;
const {
renderToHtml,
renderToPlainText,
Body,
Row,
Column,
Button,
Heading,
Image,
Divider,
Paragraph,
Html,
Social,
Menu,
Video,
Table,
ColumnLayouts,
} = await import('../dist/index.js');

// Exercise every component, including the template-backed paths (links, menu,
// social) that historically compiled lodash templates at render time.
const tree = React.createElement(
Body,
null,
React.createElement(
Row,
{ layout: ColumnLayouts.OneColumn },
React.createElement(
Column,
null,
React.createElement(Heading, { mode: 'web' }, 'Heading'),
React.createElement(Paragraph, { mode: 'web' }, 'Paragraph'),
React.createElement(Button, { mode: 'web', href: 'https://example.com' }, 'Button'),
React.createElement(Divider, { mode: 'web' }),
React.createElement(Video, {
mode: 'web',
videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
}),
React.createElement(Image, { mode: 'web', src: { url: 'https://example.com/i.png' } }),
React.createElement(Table, { mode: 'web' }),
React.createElement(Social, {
mode: 'web',
icons: [{ name: 'Facebook', url: 'https://example.com' }],
}),
React.createElement(Menu, {
mode: 'web',
items: [{ text: 'Home', link: { name: 'web', values: { href: 'https://example.com' } } }],
}),
React.createElement(Html, { mode: 'web' }, React.createElement('p', null, 'x')),
),
),
);

for (const mode of ['web', 'email', 'document']) {
const html = renderToHtml(tree, { mode });
if (typeof html !== 'string' || html.length === 0) {
console.error(`CSP_FAIL: renderToHtml(${mode}) produced no output`);
process.exit(2);
}
}
renderToPlainText(tree);

console.info(
`CSP_OK: @unlayer/react-elements imports + renders every component (web / ` +
`email / document / plaintext) under strict CSP — no eval / new Function`,
);
} catch (err) {
const message = String((err && err.message) || err);
if (CSP_ERROR.test(message)) {
console.error(
`CSP_FAIL: @unlayer/react-elements (or its pinned @unlayer/exporters ` +
`dependency) evaluates a string (eval / new Function) at import or render — ` +
`it would throw under a Content-Security-Policy without 'unsafe-eval'.\n${message}`,
);
} else {
console.error(`CSP probe failed to run: ${message}`);
}
process.exit(3);
}
Loading
Loading