2026-07-12 17:27:22 -07:00
import { describe , test , expect } from 'vitest' ;
2026-10-07 11:28:44 -07:00
import {
exportBodyHtml ,
exportToHtml ,
buildAnimationScript ,
ExportOptions ,
injectAttrs ,
RESPONSIVE_CSS_MINIFIED ,
VISIBILITY_CSS_MINIFIED ,
} 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 ( '' );
});
});
2026-07-14 16:04:50 -07:00
/**
* FIX: bounce entrance-animation disappears after finishing + reads like a
* fade (see .superpowers/sdd/fix-anim-image-contract.md, section A). Root
* cause: the old `@keyframes bounce` set opacity at 0% and 60% but NOT at
* 100% -- with `animation-fill-mode: both`, on finish the element reverted
* to the base `[data-animation]{opacity:0}` rule and vanished. The fix is a
* springier keyframe that ends at `opacity:1`.
*/
describe ( 'bounce keyframe ends at opacity:1 (fix-anim-image A)' , () => {
const animatedState = ( animation : string ) => JSON . stringify ({
ROOT : {
type : { resolvedName : 'Container' },
isCanvas : true ,
props : { tag : 'div' , style : {}, animation },
displayName : 'Container' ,
custom : {},
hidden : false ,
nodes : [],
linkedNodes : {},
},
});
const NEW_BOUNCE_MINIFIED = '@keyframes bounce{0%{opacity:0;transform:translateY(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}' ;
const OLD_BOUNCE_TAIL = '100%{transform:translateY(0)}}' ;
test ( 'minified export contains the new springier bounce substring, byte-identical to the shared contract' , () => {
const { html } = exportToHtml ( animatedState ( 'bounce' ), { title : 'Page' });
expect ( html ). toContain ( NEW_BOUNCE_MINIFIED );
});
test ( 'minified export does NOT contain the old bounce tail (100% with no opacity)' , () => {
const { html } = exportToHtml ( animatedState ( 'bounce' ), { title : 'Page' });
expect ( html ). not . toContain ( OLD_BOUNCE_TAIL );
// Every keyframe's 100% frame in this doc must carry opacity:1 now.
expect ( html ). toContain ( '100%{opacity:1;transform:translateY(0)}}' );
});
test ( 'pretty (non-minified) export ends the bounce keyframe at 100% { opacity: 1; transform: translateY(0); }' , () => {
const { html } = exportToHtml ( animatedState ( 'bounce' ), { title : 'Page' , minifyCss : false });
const NEW_BOUNCE_PRETTY = '@keyframes bounce { 0% { opacity: 0; transform: translateY(40px); } 40% { opacity: 1; transform: translateY(-12px); } 60% { transform: translateY(6px); } 80% { transform: translateY(-3px); } 100% { opacity: 1; transform: translateY(0); } }' ;
expect ( html ). toContain ( NEW_BOUNCE_PRETTY );
expect ( html ). not . toContain ( '100% { transform: translateY(0); } }' );
});
test ( 'other keyframes (fadeIn/slideUp/zoomIn) are unchanged' , () => {
const { html } = exportToHtml ( animatedState ( 'bounce' ), { title : 'Page' });
expect ( html ). toContain ( '@keyframes fadeIn{from{opacity:0}to{opacity:1}}' );
expect ( html ). toContain ( '@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}' );
expect ( html ). toContain ( '@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}' );
});
});
2026-10-07 11:16:29 -07:00
/**
* W0-tokens-core: TOKEN_VARS moved from this module into utils/tokens.ts.
* The export head must be byte-identical before/after that move -- the
* hashes below were captured from the pre-move code (commit 97ccbee) for the
* same inputs, so any drift in the :root token block, its order, or the
* cssValue() sanitising fails here.
*/
describe ( 'export head unchanged by the TOKEN_VARS move (W0-tokens-core)' , () => {
const sha = async ( s : string ) => {
const buf = await crypto . subtle . digest ( 'SHA-256' , new TextEncoder (). encode ( s ));
return Array . from ( new Uint8Array ( buf ), ( b ) => b . toString ( 16 ). padStart ( 2 , '0' )). join ( '' );
};
const emptyRoot = JSON . stringify ({
ROOT : {
type : { resolvedName : 'Container' }, isCanvas : true , props : { tag : 'div' , style : {} },
displayName : 'Container' , custom : {}, hidden : false , nodes : [], linkedNodes : {},
},
});
2026-10-07 11:28:44 -07:00
// W0-breakpoints deliberately moved the flex-stacking rule from 768 to the
// 767 mobile boundary. Undo exactly that one change before hashing so these
// baselines still prove nothing ELSE in the head moved.
const undo767 = ( html : string ) => {
const from = '@media(max-width:767px){[style*=' ;
expect ( html . split ( from ). length ). toBe ( 2 );
return html . replace ( from , '@media(max-width:768px){[style*=' );
};
2026-10-07 11:16:29 -07:00
test ( 'default design document hash matches the pre-move baseline' , async () => {
2026-10-07 11:28:44 -07:00
const html = undo767 ( exportToHtml ( emptyRoot , { title : 'T' }). html );
2026-10-07 11:16:29 -07:00
expect ( html . length ). toBe ( 2164 );
expect ( await sha ( html )). toBe ( 'f50f2940798bda064c11e65d111004355034acd240e03cfd7e6fd5cfe98bf3b6' );
});
test ( 'custom + hostile design document hash matches the pre-move baseline' , async () => {
const design : SiteDesign = {
... DEFAULT_SITE_DESIGN ,
primaryColor : '#ff0000' ,
headingFont : '"Playfair Display", serif' ,
accentColor : 'red}</style><script>' ,
};
2026-10-07 11:28:44 -07:00
const html = undo767 ( exportToHtml ( emptyRoot , { title : 'T' , design }). html );
2026-10-07 11:16:29 -07:00
expect ( html . length ). toBe ( 2452 );
expect ( await sha ( html )). toBe ( 'c561538b7ca64c8bac8b2cd02ff65053a866f7891ceeed97c956ea79153d1338' );
});
test ( 'default :root token block is the exact pre-move string' , () => {
const html = exportToHtml ( emptyRoot , { title : 'T' }). html ;
expect ( html ). toContain (
':root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}' ,
);
});
});
2026-10-07 11:28:44 -07:00
describe ( 'per-breakpoint overrides + hidden nodes (W0-breakpoints)' , () => {
const node = ( type : string , props : Record < string , unknown >, nodes : string [] = [], extra : Record < string , unknown > = {}) => ({
type : { resolvedName : type },
isCanvas : type === 'Container' ,
props ,
displayName : type ,
custom : {},
hidden : false ,
nodes ,
linkedNodes : {},
... extra ,
});
const makeState = ( headingProps : Record < string , unknown >, headingExtra : Record < string , unknown > = {}) =>
JSON . stringify ({
ROOT : node ( 'Container' , { tag : 'div' , style : {} }, [ 'sec1' ]),
sec1 : node ( 'Container' , { tag : 'section' , style : { padding : '20px' } }, [ 'head1' ], { parent : 'ROOT' }),
head1 : node ( 'Heading' , { text : 'Hello' , level : 'h2' , style : { fontSize : '40px' }, ... headingProps }, [], {
parent : 'sec1' ,
... headingExtra ,
}),
});
test ( 'a mobile override exports one scoped body <style> block + data-wsb-r on the <h2>' , () => {
const { html , css } = exportBodyHtml ( makeState ({ styleMobile : { fontSize : '24px' } }));
expect ( html . startsWith (
'<style data-wsb-responsive>@media (max-width:767px){[data-wsb-r="head1"]{font-size:24px!important}}</style>' ,
)). toBe ( true );
expect ( html ). toContain ( '<h2 style="font-size:40px" data-wsb-r="head1">Hello</h2>' );
expect ( css ). toBe ( '' );
});
test ( 'the full document carries the block in the BODY, not the head' , () => {
const { html } = exportToHtml ( makeState ({ styleTablet : { textAlign : 'center' } }));
const head = html . slice ( 0 , html . indexOf ( '</head>' ));
const body = html . slice ( html . indexOf ( '<body>' ));
expect ( head ). not . toContain ( 'data-wsb' );
expect ( body ). toContain (
'<style data-wsb-responsive>@media (max-width:991px){[data-wsb-r="head1"]{text-align:center!important}}</style>' ,
);
});
test ( 'a state without overrides exports byte-identically to the pre-change exporter' , () => {
// Captured from the exporter BEFORE this task.
const { html } = exportBodyHtml ( makeState ({}));
expect ( html ). toBe ( '<div><section style="padding:20px"><h2 style="font-size:40px">Hello</h2></section></div>' );
expect ( exportBodyHtml ( makeState ({ styleTablet : {}, styleMobile : { fontSize : '' } })). html ). toBe ( html );
});
test ( 'a node with hidden:true is omitted together with its subtree' , () => {
const state = JSON . parse ( makeState ({ styleMobile : { fontSize : '24px' } }));
state . sec1 . hidden = true ;
const { html } = exportBodyHtml ( JSON . stringify ( state ));
expect ( html ). toBe ( '<div></div>' );
// ...and its overrides do not leak a style block either.
expect ( html ). not . toContain ( 'data-wsb' );
});
test ( 'a hidden leaf is omitted, siblings survive' , () => {
const { html } = exportBodyHtml ( makeState ({}, { hidden : true }));
expect ( html ). toBe ( '<div><section style="padding:20px"></section></div>' );
});
test ( 'RESPONSIVE_CSS_MINIFIED stacks flex rows at the 767 mobile boundary' , () => {
expect ( RESPONSIVE_CSS_MINIFIED ). toContain ( '@media(max-width:767px){' );
expect ( RESPONSIVE_CSS_MINIFIED ). not . toContain ( '768' );
expect ( VISIBILITY_CSS_MINIFIED ). toBe (
'@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}' ,
);
const pretty = exportToHtml ( makeState ({}), { minifyCss : false }). html ;
expect ( pretty ). toContain ( '@media (max-width: 767px) {\n [style*="display: flex"]' );
expect ( pretty ). not . toContain ( 'max-width: 768px' );
});
});
describe ( 'injectAttrs skips leading <style> blocks (W0-breakpoints)' , () => {
test ( 'attrs land on the element after a leading <style>' , () => {
expect ( injectAttrs ( '<style>.b:hover{color:red}</style><a href="#">x</a>' , ' data-x' )). toBe (
'<style>.b:hover{color:red}</style><a href="#" data-x>x</a>' ,
);
});
test ( 'multiple leading style blocks are skipped' , () => {
expect ( injectAttrs ( '<style>a{}</style><style media="x">b{}</style><div>y</div>' , ' data-x' )). toBe (
'<style>a{}</style><style media="x">b{}</style><div data-x>y</div>' ,
);
});
test ( 'void tag after a style block stays well-formed' , () => {
expect ( injectAttrs ( '<style>i{}</style><img src="a" />' , ' data-x' )). toBe ( '<style>i{}</style><img src="a" data-x/>' );
});
test ( 'plain html unchanged in behaviour' , () => {
expect ( injectAttrs ( '<div>x</div>' , ' data-x' )). toBe ( '<div data-x>x</div>' );
expect ( injectAttrs ( '<img src="a" />' , ' data-x' )). toBe ( '<img src="a" data-x/>' );
expect ( injectAttrs ( '<div>x</div>' , '' )). toBe ( '<div>x</div>' );
});
test ( 'ButtonLink with hover + override: data-wsb-r lands on the <a>, not the <style>' , () => {
const state = JSON . stringify ({
ROOT : {
type : { resolvedName : 'Container' }, isCanvas : true , props : { tag : 'div' , style : {} },
displayName : 'Container' , custom : {}, hidden : false , nodes : [ 'btn1' ], linkedNodes : {},
},
btn1 : {
type : { resolvedName : 'ButtonLink' }, isCanvas : false , parent : 'ROOT' ,
props : { text : 'Go' , href : '#' , hoverBg : '#000' , style : {}, styleMobile : { width : '100%' } },
displayName : 'ButtonLink' , custom : {}, hidden : false , nodes : [], linkedNodes : {},
},
});
const { html } = exportBodyHtml ( state );
expect ( html ). toMatch ( /<a href="#"[^>]* data-wsb-r="btn1">Go<\/a>/ );
expect ( html ). not . toContain ( '<style data-wsb-r=' );
expect ( html ). toContain ( '</style><a href="#"' );
});
});