2026-07-12 17:27:22 -07:00
import { describe , test , expect } from 'vitest' ;
2026-07-14 12:12:01 -07:00
import { exportBodyHtml , exportToHtml , buildAnimationScript , ExportOptions } from './html-export' ;
2026-07-14 07:14:56 -07:00
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' );
});
});
});
2026-07-14 12:12:01 -07:00
/**
* FIX: entrance-animation broken in Preview. Root causes (see
* .superpowers/sdd/fix-animation-contract.md):
* 1. injectAttrs inserted data-attrs before the tag's first `>`, so a void
* tag (`<img ... />`) became malformed (`<img ... / data-animation="...">`,
* attrs landing AFTER the self-close slash, outside the tag).
* 2. wrapInDocument's in-body reveal <script> was destroyed by TopBar's
* handlePreview, which replaces the whole <body> inner with a
* recomposed header+body+footer string that never carried the script.
*/
describe ( 'injectAttrs well-formed void-tag attrs (animation fix)' , () => {
// ImageBlock.toHtml renders `<img src="..." ... />` -- a real void-tag
// producer that goes through injectAttrs via renderNode.
const imageState = ( props : Record < string , unknown >) =>
JSON . stringify ({
ROOT : {
type : { resolvedName : 'ImageBlock' },
isCanvas : false ,
props : { src : '/uploads/photo.jpg' , style : {}, ... props },
displayName : 'ImageBlock' ,
custom : {},
hidden : false ,
nodes : [],
linkedNodes : {},
},
});
test ( 'void <img/> gets attrs INSIDE the tag, no " / " sequence before the final >' , () => {
const { html } = exportBodyHtml ( imageState ({ animation : 'bounce' }));
expect ( html ). toContain ( 'data-animation="bounce"' );
// Well-formed: the attribute sits before the self-close slash.
expect ( html ). toMatch ( /data-animation="bounce"\s*\/>/ );
// Malformed shape from the bug: attrs landing after the slash.
expect ( html ). not . toMatch ( /\/\s*data-animation="bounce"/ );
expect ( html ). not . toContain ( '/ data-animation' );
});
test ( 'non-void tag (Container div) is unaffected -- attrs still inserted before its only >' , () => {
const state = JSON . stringify ({
ROOT : {
type : { resolvedName : 'Container' },
isCanvas : true ,
props : { tag : 'div' , style : {}, animation : 'fade-in' },
displayName : 'Container' ,
custom : {},
hidden : false ,
nodes : [],
linkedNodes : {},
},
});
const { html } = exportBodyHtml ( state );
expect ( html ). toMatch ( /^<div[^>]*data-animation="fade-in"[^>]*>/ );
expect ( html ). not . toContain ( '/>' );
});
});
describe ( 'buildAnimationScript (animation fix)' , () => {
test ( 'returns the IntersectionObserver reveal script when body contains data-animation' , () => {
const body = '<div data-animation="fade-in">Hi</div>' ;
const script = buildAnimationScript ( body );
expect ( script ). toContain ( '<script>' );
expect ( script ). toContain ( 'IntersectionObserver' );
expect ( script ). toContain ( "querySelectorAll('[data-animation]')" );
});
test ( 'returns empty string when body has no data-animation' , () => {
expect ( buildAnimationScript ( '<div>Hi</div>' )). toBe ( '' );
});
2026-07-14 12:34:51 -07:00
test ( 'reveal script coerces a bare-number delay to a valid CSS time (e.g. "2" -> "2s")' , () => {
// animationDelay is stored as a plain seconds string ("2"); assigning that raw
// to el.style.animationDelay is invalid CSS and no-ops. The script must suffix a
// unit onto bare numbers while leaving unit-bearing values ("2s"/"200ms") alone.
const script = buildAnimationScript ( '<div data-animation="fade-in" data-animation-delay="2">Hi</div>' );
expect ( script ). toContain ( "/^-?[0-9.]+$/.test(delay) ? delay + 's' : delay" );
// guard against regressing to the raw (invalid) assignment
expect ( script ). not . toContain ( 'animationDelay = delay;' );
// sanity-check the coercion logic itself against representative inputs
const coerce = ( delay : string ) => ( /^-?[0-9.]+$/ . test ( delay ) ? delay + 's' : delay );
expect ( coerce ( '2' )). toBe ( '2s' );
expect ( coerce ( '0.5' )). toBe ( '0.5s' );
expect ( coerce ( '2s' )). toBe ( '2s' );
expect ( coerce ( '200ms' )). toBe ( '200ms' );
});
2026-07-14 12:12:01 -07:00
});
describe ( 'Preview body-replacement keeps exactly one reveal script (animation fix)' , () => {
const animatedState = JSON . stringify ({
ROOT : {
type : { resolvedName : 'Container' },
isCanvas : true ,
props : { tag : 'div' , style : {}, animation : 'fade-in' },
displayName : 'Container' ,
custom : {},
hidden : false ,
nodes : [],
linkedNodes : {},
},
});
test ( 'wrapped doc alone already contains exactly one script + the CSS + the noscript fallback' , () => {
const { html } = exportToHtml ( animatedState , { title : 'Page' });
const scriptCount = ( html . match ( /IntersectionObserver/g ) || []). length ;
expect ( scriptCount ). toBe ( 1 );
expect ( html ). toContain ( '[data-animation]{opacity:0}' );
expect ( html ). toContain ( '<noscript><style>[data-animation]{opacity:1}</style></noscript>' );
});
test ( 'simulated handlePreview body-replacement: composed body built WITH buildAnimationScript still yields exactly one reveal script and the head CSS survives' , () => {
// Mirror TopBar.tsx handlePreview: exportToHtml gives the wrapped doc
// (head CSS/noscript + its own in-body script); a "composedBody" of
// header+body+footer (no script of its own) is what actually replaces
// the <body> inner. Without appending buildAnimationScript to
// composedBody, the wrapped doc's script would be clobbered and the
// element would never reveal.
const { html : wrapped } = exportToHtml ( animatedState , { title : 'Page' });
const headerHtml = '' ;
const { html : bodyHtml } = exportBodyHtml ( animatedState );
const footerHtml = '' ;
const composedBody =
headerHtml + bodyHtml + footerHtml +
buildAnimationScript ( headerHtml + bodyHtml + footerHtml );
const bodyMatch = wrapped . match ( /<body[^>]*>([\s\S]*)<\/body>/i );
expect ( bodyMatch ). toBeTruthy ();
const finalHtml = wrapped . replace ( bodyMatch ! [ 1 ], () => composedBody );
const scriptCount = ( finalHtml . match ( /IntersectionObserver/g ) || []). length ;
expect ( scriptCount ). toBe ( 1 );
expect ( finalHtml ). toContain ( '[data-animation]{opacity:0}' );
expect ( finalHtml ). toContain ( 'data-animation="fade-in"' );
// No malformed void-tag artifact should leak into the final assembly.
expect ( finalHtml ). not . toContain ( '/ data-animation' );
});
test ( 'non-animated body: no animation CSS, no noscript, no reveal script anywhere' , () => {
const plainState = JSON . stringify ({
ROOT : {
type : { resolvedName : 'Container' },
isCanvas : true ,
props : { tag : 'div' , style : {} },
displayName : 'Container' ,
custom : {},
hidden : false ,
nodes : [],
linkedNodes : {},
},
});
const { html } = exportToHtml ( plainState , { title : 'Page' });
expect ( html ). not . toContain ( '[data-animation]' );
expect ( html ). not . toContain ( '<noscript>' );
expect ( html ). not . toContain ( 'IntersectionObserver' );
expect ( buildAnimationScript ( exportBodyHtml ( plainState ). html )). toBe ( '' );
});
});