2026-07-12 17:27:22 -07:00
|
|
|
import { describe, test, expect } from 'vitest';
|
2026-07-14 07:14:56 -07:00
|
|
|
import { exportBodyHtml, exportToHtml, ExportOptions } from './html-export';
|
|
|
|
|
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
|
2026-07-12 17:27:22 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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"><img src=y onerror=alert(1)>` breaks
|
|
|
|
|
* out of the attribute and injects a live element.
|
|
|
|
|
*/
|
|
|
|
|
describe('buildDataAttrs escapes animation/animationDelay (C2)', () => {
|
|
|
|
|
const makeState = (props: Record<string, unknown>) =>
|
|
|
|
|
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 <img>', () => {
|
|
|
|
|
const payload = 'x"><img src=y onerror=alert(1)>';
|
|
|
|
|
const state = makeState({ animation: payload });
|
|
|
|
|
const { html } = exportBodyHtml(state);
|
|
|
|
|
|
|
|
|
|
expect(html).not.toContain('<img src=y onerror=alert(1)>');
|
|
|
|
|
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"><script>alert(1)</script>';
|
|
|
|
|
const state = makeState({ animation: 'fade-in', animationDelay: payload });
|
|
|
|
|
const { html } = exportBodyHtml(state);
|
|
|
|
|
|
|
|
|
|
expect(html).not.toContain('<script>alert(1)</script>');
|
|
|
|
|
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"');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-12 17:44:59 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 <img>', () => {
|
|
|
|
|
const state = makeUnresolvedDivState('div><img src=x onerror=alert(1)');
|
|
|
|
|
const { html } = exportBodyHtml(state);
|
|
|
|
|
expect(html).not.toContain('<img');
|
|
|
|
|
expect(html).not.toContain('onerror');
|
|
|
|
|
expect(html.startsWith('<div')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a valid tag (section) still emits <section', () => {
|
|
|
|
|
const state = makeUnresolvedDivState('section');
|
|
|
|
|
const { html } = exportBodyHtml(state);
|
|
|
|
|
expect(html).toContain('<section');
|
|
|
|
|
expect(html).toContain('</section>');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-14 07:14:56 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* PKG-H contract §2-4: per-page SEO/meta, favicon, and design-token CSS
|
|
|
|
|
* variables emitted into `wrapInDocument`'s <head> (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 <head> 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('<title>Legacy Page</title>');
|
|
|
|
|
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('<meta property="og:title" content="Legacy Page">');
|
|
|
|
|
expect(html).toContain('<meta property="og:type" content="website">');
|
|
|
|
|
// 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('<meta name="description" content="A great page.">');
|
|
|
|
|
expect(html).toContain('<meta property="og:description" content="A great page.">');
|
|
|
|
|
expect(html).toContain('<meta name="twitter:card" content="summary">');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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('<meta property="og:image" content="/uploads/hero.jpg">');
|
|
|
|
|
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
|
|
|
|
|
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('<meta name="twitter:card" content="summary">');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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('<meta property="og:title" content="Custom Share Title">');
|
|
|
|
|
expect(html).not.toContain('<meta property="og:title" content="Page Title">');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('noindex true -> robots meta appears; false/absent -> it does not', () => {
|
|
|
|
|
expect(exportWith({ title: 'Page', noindex: true })).toContain('<meta name="robots" content="noindex, nofollow">');
|
|
|
|
|
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('<link rel="icon" href="/uploads/favicon.png">');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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('<meta name="twitter:card" content="summary">');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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('<meta property="og:image" content="data:image/png;base64,AAAA">');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('an attribute-breakout metaDescription is escaped, not left raw', () => {
|
|
|
|
|
const payload = '"><script>alert(1)</script>';
|
|
|
|
|
const html = exportWith({ title: 'Page', description: payload });
|
|
|
|
|
expect(html).not.toContain('<script>alert(1)</script>');
|
|
|
|
|
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: '</style><script>alert(1)</script>' };
|
|
|
|
|
const html = exportWith({ title: 'Page', design });
|
|
|
|
|
expect(html).not.toContain('<script>alert(1)</script>');
|
|
|
|
|
expect(html).not.toContain('</style><script>');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe(':root token block', () => {
|
|
|
|
|
test('emits all 16 contract-named CSS variables with sanitized values, in order', () => {
|
|
|
|
|
const design: SiteDesign = {
|
|
|
|
|
...DEFAULT_SITE_DESIGN,
|
|
|
|
|
primaryColor: '#111111',
|
|
|
|
|
secondaryColor: '#222222',
|
|
|
|
|
accentColor: '#333333',
|
|
|
|
|
linkColor: '#444444',
|
|
|
|
|
successColor: '#555555',
|
|
|
|
|
warningColor: '#666666',
|
|
|
|
|
errorColor: '#777777',
|
|
|
|
|
backgroundColor: '#888888',
|
|
|
|
|
textColor: '#999999',
|
|
|
|
|
mutedTextColor: '#aaaaaa',
|
|
|
|
|
borderColor: '#bbbbbb',
|
|
|
|
|
borderRadius: '12px',
|
|
|
|
|
headingFont: 'Georgia, serif',
|
|
|
|
|
bodyFont: 'Verdana, sans-serif',
|
|
|
|
|
buttonFont: 'Tahoma, sans-serif',
|
|
|
|
|
buttonRadius: '4px',
|
|
|
|
|
};
|
|
|
|
|
const html = exportWith({ title: 'Page', design });
|
|
|
|
|
|
|
|
|
|
const rootMatch = html.match(/:root\{([^}]*)\}/);
|
|
|
|
|
expect(rootMatch).not.toBeNull();
|
|
|
|
|
const rootBlock = rootMatch![1];
|
|
|
|
|
|
|
|
|
|
const expectedOrder = [
|
|
|
|
|
['--wsb-primary', '#111111'],
|
|
|
|
|
['--wsb-secondary', '#222222'],
|
|
|
|
|
['--wsb-accent', '#333333'],
|
|
|
|
|
['--wsb-link', '#444444'],
|
|
|
|
|
['--wsb-success', '#555555'],
|
|
|
|
|
['--wsb-warning', '#666666'],
|
|
|
|
|
['--wsb-error', '#777777'],
|
|
|
|
|
['--wsb-bg', '#888888'],
|
|
|
|
|
['--wsb-text', '#999999'],
|
|
|
|
|
['--wsb-muted', '#aaaaaa'],
|
|
|
|
|
['--wsb-border', '#bbbbbb'],
|
|
|
|
|
['--wsb-radius', '12px'],
|
|
|
|
|
['--wsb-heading-font', 'Georgia, serif'],
|
|
|
|
|
['--wsb-body-font', 'Verdana, sans-serif'],
|
|
|
|
|
['--wsb-button-font', 'Tahoma, sans-serif'],
|
|
|
|
|
['--wsb-button-radius', '4px'],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let lastIndex = -1;
|
|
|
|
|
for (const [varName, value] of expectedOrder) {
|
|
|
|
|
const decl = `${varName}:${value}`;
|
|
|
|
|
expect(rootBlock).toContain(decl);
|
|
|
|
|
const idx = rootBlock.indexOf(decl);
|
|
|
|
|
expect(idx).toBeGreaterThan(lastIndex);
|
|
|
|
|
lastIndex = idx;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('a11y/perf CSS', () => {
|
|
|
|
|
test('focus-visible outline rule is present', () => {
|
|
|
|
|
const html = exportWith({ title: 'Page' });
|
|
|
|
|
expect(html).toContain('a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('prefers-reduced-motion rule is present', () => {
|
|
|
|
|
const html = exportWith({ title: 'Page' });
|
|
|
|
|
expect(html).toContain('@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Google Fonts link', () => {
|
|
|
|
|
test('always includes display=swap, with no design passed (fallback full link)', () => {
|
|
|
|
|
const html = exportWith({ title: 'Page', includeFonts: true });
|
|
|
|
|
expect(html).toContain('display=swap');
|
|
|
|
|
expect(html).toContain('fonts.googleapis.com');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('subsets to only the used families when all 3 design fonts are known presets', () => {
|
|
|
|
|
const design: SiteDesign = {
|
|
|
|
|
...DEFAULT_SITE_DESIGN,
|
|
|
|
|
headingFont: 'Playfair Display, serif',
|
|
|
|
|
bodyFont: 'Inter, sans-serif',
|
|
|
|
|
buttonFont: 'Inter, sans-serif',
|
|
|
|
|
};
|
|
|
|
|
const html = exportWith({ title: 'Page', includeFonts: true, design });
|
|
|
|
|
expect(html).toContain('display=swap');
|
|
|
|
|
expect(html).toContain('family=Playfair+Display');
|
|
|
|
|
expect(html).toContain('family=Inter');
|
|
|
|
|
// Not one of the 3 used fonts -- subset link must not pull it in.
|
|
|
|
|
expect(html).not.toContain('family=Merriweather');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('falls back to the full multi-font link when a design font is not a known preset', () => {
|
|
|
|
|
const design: SiteDesign = {
|
|
|
|
|
...DEFAULT_SITE_DESIGN,
|
|
|
|
|
headingFont: 'Comic Sans MS, cursive',
|
|
|
|
|
};
|
|
|
|
|
const html = exportWith({ title: 'Page', includeFonts: true, design });
|
|
|
|
|
expect(html).toContain('display=swap');
|
|
|
|
|
// Full fallback link carries every preset family, including ones the
|
|
|
|
|
// design object doesn't reference -- a component using ANY preset
|
|
|
|
|
// never silently loses its font.
|
|
|
|
|
expect(html).toContain('family=Merriweather');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|