import { describe, test, expect } from 'vitest';
import {
exportBodyHtml,
exportToHtml,
buildAnimationScript,
ExportOptions,
injectAttrs,
buildSeoMeta,
RESPONSIVE_CSS_MINIFIED,
VISIBILITY_CSS_MINIFIED,
} from './html-export';
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
/**
* C2: buildDataAttrs (internal to html-export.ts) previously interpolated
* `props.animation` / `props.animationDelay` directly into
* `data-animation="${...}"` / `data-animation-delay="${...}"` with no
* escaping. Runs for EVERY node (via renderNode -> buildDataAttrs ->
* injectAttrs) and is reachable via AI `update_props` + deserialized saved
* state. A malicious value like `x">
` breaks
* out of the attribute and injects a live element.
*/
describe('buildDataAttrs escapes animation/animationDelay (C2)', () => {
const makeState = (props: Record) =>
JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { tag: 'div', style: {}, ...props },
displayName: 'Container',
custom: {},
hidden: false,
nodes: [],
linkedNodes: {},
},
});
test('malicious animation value is escaped -- no raw breakout, no injected
', () => {
const payload = 'x">
';
const state = makeState({ animation: payload });
const { html } = exportBodyHtml(state);
expect(html).not.toContain('
');
expect(html).not.toMatch(/data-animation="x">/);
// The attribute value must be escaped, so no raw `"` or `<` survives
// inside the data-animation attribute region.
expect(html).toContain('data-animation="x"><img src=y onerror=alert(1)>"');
});
test('malicious animationDelay value is escaped -- no raw breakout', () => {
const payload = 'x">';
const state = makeState({ animation: 'fade-in', animationDelay: payload });
const { html } = exportBodyHtml(state);
expect(html).not.toContain('');
expect(html).toContain('data-animation-delay="x"><script>alert(1)</script>"');
});
test('normal animation value still emits data-animation="fade-in" unchanged', () => {
const state = makeState({ animation: 'fade-in' });
const { html } = exportBodyHtml(state);
expect(html).toContain('data-animation="fade-in"');
});
test('normal animationDelay value still emits unchanged', () => {
const state = makeState({ animation: 'fade-in', animationDelay: '0.5s' });
const { html } = exportBodyHtml(state);
expect(html).toContain('data-animation-delay="0.5s"');
});
});
/**
* Adversarial re-review of C1: the `typeName === 'Container' || typeName ===
* 'div'` fallback in renderNode (hit when a node's `resolvedName` isn't in
* the component resolver, e.g. legacy/tampered saved state) interpolated
* `props.tag` raw into `<${tag}` with no validation -- the same breakout
* class as the fixed Container.toHtml bug, reachable via deserialized saved
* state.
*/
describe('renderNode div-fallback allowlists props.tag', () => {
const makeUnresolvedDivState = (tag: unknown) =>
JSON.stringify({
ROOT: {
type: { resolvedName: 'div' }, // not in componentResolver -> hits the fallback branch
isCanvas: true,
props: { tag, style: {} },
displayName: 'div',
custom: {},
hidden: false,
nodes: [],
linkedNodes: {},
},
});
test('a malicious tag value falls back to div -- no injected
', () => {
const state = makeUnresolvedDivState('div>
{
const state = makeUnresolvedDivState('section');
const { html } = exportBodyHtml(state);
expect(html).toContain('');
});
});
/**
* PKG-H contract §2-4: per-page SEO/meta, favicon, and design-token CSS
* variables emitted into `wrapInDocument`'s (exercised via
* `exportToHtml`, since `exportBodyHtml` intentionally stays body-only --
* contract §7). These assert the exact "only emit when set" / sanitization
* / ordering rules the backend implementer's `generateCompiledHTML` must
* mirror byte-for-byte for editor Preview === published output.
*/
describe('PKG-H: SEO/meta + favicon + design-token emission', () => {
const EMPTY_ROOT_STATE = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { style: {}, tag: 'div' },
displayName: 'Container',
custom: {},
hidden: false,
nodes: [],
linkedNodes: {},
},
});
function exportWith(options: ExportOptions): string {
return exportToHtml(EMPTY_ROOT_STATE, options).html;
}
describe('back-compat: no seo, no favicon, no design passed', () => {
const html = exportWith({ title: 'Legacy Page' });
test('title falls back to the passed title, no robots/description/og/twitter/favicon tags appear', () => {
expect(html).toContain('Legacy Page');
expect(html).not.toContain('name="robots"');
expect(html).not.toContain('name="description"');
expect(html).not.toContain('property="og:description"');
expect(html).not.toContain('property="og:image"');
expect(html).not.toContain('rel="icon"');
// og:title still always emits (falls back to title) -- og:type is a
// fixed tag too -- neither depends on seo being set.
expect(html).toContain('');
expect(html).toContain('');
// twitter:card only emits when ogImage or description is set -- neither
// is here, so it must be absent.
expect(html).not.toContain('name="twitter:card"');
});
test('still emits the :root token block + token-base CSS + a11y/perf CSS using design defaults (§7 intended change)', () => {
expect(html).toContain(':root{--wsb-primary:');
expect(html).toContain('--wsb-button-radius:');
expect(html).toContain('body{font-family:var(--wsb-body-font)');
expect(html).toContain('a:focus-visible');
expect(html).toContain('prefers-reduced-motion:reduce');
});
});
describe('meta/og/twitter/robots/favicon emit ONLY when their source is set', () => {
test('metaDescription set -> description + og:description + twitter:card (summary) all appear', () => {
const html = exportWith({ title: 'Page', description: 'A great page.' });
expect(html).toContain('');
expect(html).toContain('');
expect(html).toContain('');
});
test('ogImage set (no description) -> og:image + twitter:card defaults to summary_large_image', () => {
const html = exportWith({ title: 'Page', ogImage: '/uploads/hero.jpg' });
expect(html).toContain('');
expect(html).toContain('');
expect(html).not.toContain('name="description"');
});
test('explicit twitterCard overrides the derived default', () => {
const html = exportWith({ title: 'Page', ogImage: '/uploads/hero.jpg', twitterCard: 'summary' });
expect(html).toContain('');
});
test('ogTitle set -> og:title uses it instead of the page title', () => {
const html = exportWith({ title: 'Page Title', ogTitle: 'Custom Share Title' });
expect(html).toContain('');
expect(html).not.toContain('');
});
test('noindex true -> robots meta appears; false/absent -> it does not', () => {
expect(exportWith({ title: 'Page', noindex: true })).toContain('');
expect(exportWith({ title: 'Page', noindex: false })).not.toContain('name="robots"');
expect(exportWith({ title: 'Page' })).not.toContain('name="robots"');
});
test('favicon set -> icon link appears with the exact URL', () => {
const html = exportWith({ title: 'Page', favicon: '/uploads/favicon.png' });
expect(html).toContain('');
});
test('favicon absent -> no icon link', () => {
expect(exportWith({ title: 'Page' })).not.toContain('rel="icon"');
});
});
describe('sanitization neutralizes malicious values', () => {
test('a javascript: og:image / favicon URL is dropped entirely (no tag emitted)', () => {
const html = exportWith({
title: 'Page',
ogImage: 'javascript:alert(1)',
favicon: 'javascript:alert(1)',
description: 'x', // keep twitter:card path alive to prove ogImage really resolved empty
});
expect(html).not.toContain('javascript:alert');
expect(html).not.toContain('property="og:image"');
expect(html).not.toContain('rel="icon"');
// og:image resolved empty -> twitter:card falls back to 'summary' (description-only path), not 'summary_large_image'.
expect(html).toContain('');
});
test('a data:image/png og:image URL is allowed through unchanged (image sink)', () => {
const html = exportWith({ title: 'Page', ogImage: 'data:image/png;base64,AAAA' });
expect(html).toContain('');
});
test('an attribute-breakout metaDescription is escaped, not left raw', () => {
const payload = '">';
const html = exportWith({ title: 'Page', description: payload });
expect(html).not.toContain('');
expect(html).toContain('"><script>alert(1)</script>');
});
test('a malicious design-token value cannot break out of the :root{} block', () => {
const design: SiteDesign = { ...DEFAULT_SITE_DESIGN, primaryColor: '' };
const html = exportWith({ title: 'Page', design });
expect(html).not.toContain('');
expect(html).not.toContain('