+
- alpha.4:
- {activeAxes.map(({ param, label, iteration }, i) => (
+ bridge queue:
+ {activeAxes.map(({ param, label, release }, index) => (
- {i > 0 && ' / '}
+ {index > 0 && ' / '}
{label}
- ({iteration})
+ ({release})
))}
-
- -- these URL params are no-ops at this release; runtime surfaces ship in iterations 2-3.
-
{ e.currentTarget.style.background = 'rgba(234,179,8,0.1)'; }}
- onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
- >
- ×
-
+ style={dismissButtonStyle}
+ onMouseEnter={(e) => setDismissActive(e.currentTarget, true)}
+ onMouseLeave={(e) => setDismissActive(e.currentTarget, false)}
+ onFocus={(e) => { setDismissActive(e.currentTarget, true); e.currentTarget.style.outline = '2px solid #eab308'; e.currentTarget.style.outlineOffset = '1px'; }}
+ onBlur={(e) => { setDismissActive(e.currentTarget, false); e.currentTarget.style.outline = ''; e.currentTarget.style.outlineOffset = ''; }}
+ >×
);
}
diff --git a/examples/demo/src/render-area.tsx b/examples/demo/src/render-area.tsx
new file mode 100644
index 0000000..c58e0b0
--- /dev/null
+++ b/examples/demo/src/render-area.tsx
@@ -0,0 +1,222 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import React, { useRef } from 'react';
+import type { VbrandType } from '@booga/vbrand/adapters/browser';
+import { getStackRuntime } from '@booga/vbrand/stacks';
+import { staticRender, hybridRender, hydrateIslands } from '@booga/vbrand/interactivity';
+import type { IslandManifest } from '@booga/vbrand/interactivity';
+import type { InteractivityMode, StackName } from './router';
+import { DEFAULT_STACK } from './router';
+
+// allow-same-origin lets hydrateIslands reach iframe.contentDocument across frames;
+// allow-scripts is deliberately absent so untrusted preview content cannot execute scripts in the same-origin context.
+const RENDERED_IFRAME_SANDBOX = 'allow-same-origin' as const;
+const ARTEFACT_PANEL_HEIGHT_PX = 220;
+
+export interface RenderAreaProps {
+ tree: React.ReactElement;
+ mode: InteractivityMode;
+ brand: VbrandType;
+ stack: StackName;
+ base: string;
+}
+
+export function RenderArea({ tree, mode, brand, stack, base }: RenderAreaProps) {
+ if (stackIsExplicitInUrl() || stack !== DEFAULT_STACK) {
+ return
;
+ }
+ return
;
+}
+
+function stackIsExplicitInUrl(): boolean {
+ return new URLSearchParams(window.location.search).has('stack');
+}
+
+interface StackArtefactViewProps {
+ tree: React.ReactElement;
+ mode: InteractivityMode;
+ brand: VbrandType;
+ stack: StackName;
+ base: string;
+}
+
+function StackArtefactView({ tree, mode, brand, stack, base }: StackArtefactViewProps) {
+ const defaultMode = getStackRuntime(stack).defaultMode();
+ const islandCount = stack === 'astro' ? 1 : 0;
+
+ return (
+
+ );
+}
+
+export function stackArtefactUrl(base: string, stack: StackName): string {
+ const normalBase = base.endsWith('/') ? base : `${base}/`;
+ return `${normalBase}stacks/${stack}.html`;
+}
+
+interface StackArtefactPanelProps {
+ stack: StackName;
+ base: string;
+}
+
+function StackArtefactPanel({ stack, base }: StackArtefactPanelProps) {
+ const stackMode = getStackRuntime(stack).defaultMode();
+ const stackAccent = stackMode === 'static' ? '#22c55e' : stackMode === 'hybrid' ? '#eab308' : 'var(--color-primary, #6366f1)';
+ return (
+
+
+ emit-shape
+ {stack}
+
+
+
+ );
+}
+
+interface LivePreviewAreaProps {
+ tree: React.ReactElement;
+ mode: InteractivityMode;
+ brand: VbrandType;
+ showBadge: boolean;
+}
+
+function LivePreviewArea({ tree, mode, brand, showBadge }: LivePreviewAreaProps) {
+ if (mode === 'static') {
+ const srcDoc = staticRender({ brand, sections: [tree] });
+ return (
+
+ {showBadge && }
+
+
+ );
+ }
+
+ if (mode === 'hybrid') {
+ return
;
+ }
+
+ return (
+
+ {showBadge &&
}
+
+ {tree}
+
+
+ );
+}
+
+interface HybridRenderAreaProps {
+ tree: React.ReactElement;
+ brand: VbrandType;
+ showBadge: boolean;
+}
+
+function HybridRenderArea({ tree, brand, showBadge }: HybridRenderAreaProps) {
+ const iframeRef = useRef
(null);
+ const islandsRef = useRef([]);
+ const getIslandComponentRef = useRef<(id: string) => React.ReactNode>(() => null);
+
+ const { html, islands, getIslandComponent } = hybridRender({ brand, sections: [tree] });
+ islandsRef.current = islands;
+ getIslandComponentRef.current = getIslandComponent;
+
+ function handleLoad() {
+ const doc = iframeRef.current?.contentDocument;
+ if (!doc || islandsRef.current.length === 0) return;
+ void hydrateIslands(islandsRef.current, getIslandComponentRef.current, doc);
+ }
+
+ return (
+
+ {showBadge && }
+
+
+ );
+}
+
+const BADGE_COLORS: Record = {
+ static: '#22c55e',
+ hybrid: '#eab308',
+ spa: 'var(--color-primary, #6366f1)',
+};
+
+const BADGE_LABEL: Record = {
+ static: 'static',
+ hybrid: 'hybrid',
+ spa: 'SPA preview',
+};
+
+interface ModeBadgeProps {
+ mode: InteractivityMode;
+ islandCount: number;
+}
+
+function ModeBadge({ mode, islandCount }: ModeBadgeProps) {
+ return (
+
+ {BADGE_LABEL[mode]}
+ {mode === 'hybrid' && (
+
+ {islandCount} island{islandCount !== 1 ? 's' : ''}
+
+ )}
+
+ );
+}
diff --git a/examples/demo/src/router.ts b/examples/demo/src/router.ts
index f54eddb..a5de60c 100644
--- a/examples/demo/src/router.ts
+++ b/examples/demo/src/router.ts
@@ -1,5 +1,13 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 bvasilenko
+import { parseStack, getStackRuntime, DEFAULT_STACK, STACK_NAMES } from '@booga/vbrand/stacks';
+import type { StackName } from '@booga/vbrand/stacks';
+import { parseCms, DEFAULT_CMS, CMS_NAMES } from '@booga/vbrand/cms';
+import type { CmsName } from '@booga/vbrand/cms';
+
+export type { StackName, CmsName };
+export { DEFAULT_STACK, DEFAULT_CMS, STACK_NAMES, CMS_NAMES };
+
export type TemplateId = 'landing' | 'marketing' | 'docs' | 'dashboard';
export type InteractivityMode = 'static' | 'hybrid' | 'spa';
@@ -18,6 +26,8 @@ export interface RouteState {
templateId: TemplateId;
view: ViewTab;
mode: InteractivityMode;
+ stack: StackName;
+ cms: CmsName;
}
const TEMPLATE_IDS: readonly TemplateId[] = ['landing', 'marketing', 'docs', 'dashboard'];
@@ -28,11 +38,15 @@ export const DEFAULT_MODE: InteractivityMode = 'spa';
export function parseRoute(search: string, pathname = '/', base = '/'): RouteState {
const params = new URLSearchParams(search);
+ const stack = parseStack(params.get('stack'));
+ const modeParam = params.get('mode');
return {
brandParams: parseBrandParam(params.get('brand')),
templateId: parseTemplateParam(params.get('app')),
view: parseViewFromPath(pathname, base),
- mode: parseModeParam(params.get('mode')),
+ mode: modeParam ? parseModeParam(modeParam) : getStackRuntime(stack).defaultMode(),
+ stack,
+ cms: parseCms(params.get('cms')),
};
}
@@ -60,11 +74,17 @@ export function buildSearchString(
brandParam: string,
templateId: TemplateId,
mode?: InteractivityMode,
+ stack?: StackName,
+ cms?: CmsName,
): string {
const params = new URLSearchParams();
if (brandParam) params.set('brand', brandParam);
params.set('app', templateId);
- if (mode && mode !== DEFAULT_MODE) params.set('mode', mode);
+ const resolvedStack = stack ?? DEFAULT_STACK;
+ const stackDefaultMode = getStackRuntime(resolvedStack).defaultMode();
+ if (mode && mode !== stackDefaultMode) params.set('mode', mode);
+ if (stack && stack !== DEFAULT_STACK) params.set('stack', stack);
+ if (cms && cms !== DEFAULT_CMS) params.set('cms', cms);
return params.toString();
}
diff --git a/examples/demo/src/stack-toggle.tsx b/examples/demo/src/stack-toggle.tsx
new file mode 100644
index 0000000..8b12c97
--- /dev/null
+++ b/examples/demo/src/stack-toggle.tsx
@@ -0,0 +1,152 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import React from 'react';
+import { STACK_RUNTIME_REGISTRY } from '@booga/vbrand/stacks';
+import type { StackName } from './router';
+
+interface StackToggleProps {
+ stack: StackName;
+ onChange: (stack: StackName) => void;
+}
+
+const STACK_COPY: Record = {
+ vite: {
+ label: 'Vite',
+ shape: 'SPA bootstrap preview',
+ marker: 'representative SPA bootstrap boundary, not full hydration',
+ },
+ next: {
+ label: 'Next',
+ shape: 'Hybrid preview',
+ marker: 'page data plus client boundary metadata',
+ },
+ astro: {
+ label: 'Astro',
+ shape: 'Static preview',
+ marker: 'explicit island hydration markers',
+ },
+};
+
+const MODE_COLOR: Record = {
+ static: '#22c55e',
+ hybrid: '#eab308',
+ spa: 'var(--color-primary, #6366f1)',
+};
+
+const STACK_ACCENT: Record = {
+ vite: 'var(--color-primary, #6366f1)',
+ next: '#eab308',
+ astro: '#22c55e',
+};
+
+const STACK_PANEL_BG = 'var(--color-neutral-50, #f9fafb)';
+
+const STACK_HOVER_BG: Record = {
+ vite: 'rgba(99,102,241,0.06)',
+ next: 'rgba(234,179,8,0.06)',
+ astro: 'rgba(34,197,94,0.06)',
+};
+
+const STACK_ACTIVE_BG: Record = {
+ vite: 'rgba(99,102,241,0.08)',
+ next: '#fefce8',
+ astro: '#f0fdf4',
+};
+
+const panelStyle: React.CSSProperties = {
+ border: '1px solid var(--color-neutral-200, #e5e7eb)',
+ borderLeft: '4px solid var(--color-primary, #6366f1)',
+ borderRadius: '4px',
+ padding: '12px',
+ background: STACK_PANEL_BG,
+ fontFamily: 'system-ui, sans-serif',
+};
+
+const eyebrowStyle: React.CSSProperties = {
+ margin: '0 0 8px',
+ fontSize: '0.6875rem',
+ fontWeight: 700,
+ textTransform: 'uppercase',
+ letterSpacing: '0.08em',
+ color: 'var(--color-neutral-400, #9ca3af)',
+};
+
+function focusRing(e: React.FocusEvent, color: string) {
+ e.currentTarget.style.outline = `2px solid ${color}`;
+ e.currentTarget.style.outlineOffset = '1px';
+}
+
+function clearFocusRing(e: React.FocusEvent) {
+ e.currentTarget.style.outline = '';
+ e.currentTarget.style.outlineOffset = '';
+}
+
+function stackButtonStyle(name: StackName, active: boolean): React.CSSProperties {
+ const accent = STACK_ACCENT[name];
+ return {
+ flex: '1 1 72px',
+ minWidth: '72px',
+ padding: '8px',
+ border: active ? `1px solid ${accent}` : '1px solid var(--color-neutral-200, #e5e7eb)',
+ borderLeft: active ? `4px solid ${accent}` : '4px solid transparent',
+ borderRadius: '4px',
+ background: active ? STACK_ACTIVE_BG[name] : 'transparent',
+ color: active ? accent : 'var(--color-neutral-700, #374151)',
+ cursor: 'pointer',
+ textAlign: 'left',
+ transition: 'background 0.12s ease, border-color 0.12s ease, color 0.12s ease',
+ };
+}
+
+export function StackToggle({ stack, onChange }: StackToggleProps) {
+ const activeMode = STACK_RUNTIME_REGISTRY[stack].defaultMode();
+ const activeAccent = STACK_ACCENT[stack];
+
+ return (
+
+
+
Stack runtime
+ default {activeMode}
+
+
+ {(Object.keys(STACK_RUNTIME_REGISTRY) as StackName[]).map((name) => {
+ const active = stack === name;
+ const copy = STACK_COPY[name];
+ return (
+ onChange(name)}
+ aria-pressed={active}
+ style={stackButtonStyle(name, active)}
+ onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = STACK_HOVER_BG[name]; }}
+ onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = 'transparent'; }}
+ onMouseDown={(e) => { e.currentTarget.style.background = active ? STACK_ACTIVE_BG[name] : STACK_HOVER_BG[name]; e.currentTarget.style.borderColor = STACK_ACCENT[name]; }}
+ onMouseUp={(e) => { e.currentTarget.style.background = active ? STACK_ACTIVE_BG[name] : STACK_HOVER_BG[name]; e.currentTarget.style.borderColor = active ? STACK_ACCENT[name] : 'var(--color-neutral-200, #e5e7eb)'; }}
+ onFocus={(e) => focusRing(e, STACK_ACCENT[name])}
+ onBlur={clearFocusRing}
+ >
+
+ {copy.label}
+
+
+ {copy.shape}
+
+ );
+ })}
+
+
+
+ {stack}
+ {activeMode}
+
+
+ artefact
+ dist/stacks/{stack}.html
+ marker
+ {STACK_COPY[stack].marker}
+
+
+
+ );
+}
diff --git a/examples/demo/src/template-view.tsx b/examples/demo/src/template-view.tsx
index f1a9283..3698717 100644
--- a/examples/demo/src/template-view.tsx
+++ b/examples/demo/src/template-view.tsx
@@ -1,199 +1,75 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 bvasilenko
-import React, { useState, useEffect, useRef } from 'react';
+import React from 'react';
import type { VbrandType } from '@booga/vbrand/adapters/browser';
-import { CompositionEditorDemo } from './composition-editor-demo';
-import { compositionFromHash, encodeComposition } from '@booga/vbrand/composition';
-import { TEMPLATE_REGISTRY, compositionMatchesTemplate } from '@booga/vbrand/templates';
-import { contentFromHash, contentToHash } from '@booga/vbrand/content';
+import { TEMPLATE_REGISTRY } from '@booga/vbrand/templates';
import type { ContentOverrideMap } from '@booga/vbrand/content';
+import { CompositionEditorDemo } from './composition-editor-demo';
import { ContentEditor } from './content-editor';
-import { staticRender, hybridRender, hydrateIslands } from '@booga/vbrand/interactivity';
-import type { IslandManifest } from '@booga/vbrand/interactivity';
-import type { TemplateId, InteractivityMode } from './router';
-
-// allow-same-origin is required so hydrateIslands can access contentDocument from the parent window.
-// Removing this attribute breaks hydration. Cross-origin iframes cannot use this architecture.
-const RENDERED_IFRAME_SANDBOX = 'allow-same-origin' as const;
-
-interface TemplateViewProps {
+import { useCmsContent } from './use-cms-content';
+import { useComposition } from './use-composition';
+import { mergeContentLayers } from './content-layers';
+import { useBreakpoint } from './use-breakpoint';
+import { deriveLayout } from './demo-layout-styles';
+import { AxisLedger } from './axis-ledger';
+import { RenderArea } from './render-area';
+import { StackToggle } from './stack-toggle';
+import { CmsToggle } from './cms-toggle';
+import { DeployInfo } from './deploy-info';
+import { deriveTargetMode } from '@booga/vbrand/stacks';
+import type { TemplateId, InteractivityMode, StackName, CmsName } from './router';
+import { buildSearchString } from './router';
+
+export interface TemplateViewProps {
brand: VbrandType;
templateId: TemplateId;
mode: InteractivityMode;
+ stack: StackName;
+ cms: CmsName;
+ brandLabel: string;
+ fixtureSlug: string | undefined;
+ base: string;
}
-export function TemplateView({ brand, templateId, mode }: TemplateViewProps) {
- const template = TEMPLATE_REGISTRY[templateId];
-
- const [composition, setComposition] = useState(() => {
- const fromHash = compositionFromHash(window.location.hash);
- return compositionMatchesTemplate(fromHash, templateId)
- ? fromHash
- : template.defaultComposition();
- });
-
- const [content, setContent] = useState(() => {
- return contentFromHash(window.location.hash) ?? {};
- });
-
- useEffect(() => {
- const compositionPart = encodeComposition(composition);
- const hasContent = Object.keys(content).length > 0;
- const hash = hasContent
- ? `#composition=${compositionPart}&${contentToHash(content)}`
- : `#composition=${compositionPart}`;
- history.replaceState(null, '', window.location.pathname + window.location.search + hash);
- }, [composition, content]);
-
- const prevTemplateRef = useRef(templateId);
-
- useEffect(() => {
- if (prevTemplateRef.current === templateId) return;
- prevTemplateRef.current = templateId;
- setComposition((prev) =>
- compositionMatchesTemplate(prev, templateId)
- ? prev
- : template.defaultComposition(),
- );
- setContent({});
- }, [templateId]);
-
- function handleReset() {
- setComposition(template.defaultComposition());
- setContent({});
- }
+export function TemplateView({
+ brand, templateId, mode, stack, cms, brandLabel, fixtureSlug, base,
+}: TemplateViewProps) {
+ const { composition, setComposition, handleReset, userContent, setUserContent } = useComposition(templateId);
+ const cmsContent = useCmsContent(cms, fixtureSlug);
+ const content: ContentOverrideMap = mergeContentLayers(cmsContent, userContent);
+ const template = TEMPLATE_REGISTRY[templateId];
const tree = template.compose(brand, composition, content);
+ const layout = deriveLayout(useBreakpoint());
- return (
-
-
-
- setContent({})}
- />
-
- );
-}
-
-interface RenderAreaProps {
- tree: React.ReactElement;
- mode: InteractivityMode;
- brand: VbrandType;
-}
-
-function RenderArea({ tree, mode, brand }: RenderAreaProps) {
- if (mode === 'static') {
- const srcDoc = staticRender({ brand, sections: [tree] });
- return (
-
-
-
-
- );
- }
-
- if (mode === 'hybrid') {
- return ;
+ function applyAxis(nextStack: StackName, nextCms: CmsName) {
+ const nextMode = deriveTargetMode(mode, stack, nextStack);
+ window.location.search = buildSearchString(brandLabel, templateId, nextMode, nextStack, nextCms);
}
return (
-
-
-
- {tree}
+
+
+
+
+
+
+
+
+
+
applyAxis(next, cms)} />
+ applyAxis(stack, next)} />
+
+
+ setUserContent({})}
+ />
+
-
- );
-}
-
-function HybridRenderArea({ tree, brand }: { tree: React.ReactElement; brand: VbrandType }) {
- const iframeRef = useRef
(null);
- const islandsRef = useRef([]);
- const getIslandComponentRef = useRef<(id: string) => React.ReactNode>(() => null);
-
- const { html, islands, getIslandComponent } = hybridRender({ brand, sections: [tree] });
- islandsRef.current = islands;
- getIslandComponentRef.current = getIslandComponent;
-
- function handleLoad() {
- const doc = iframeRef.current?.contentDocument;
- if (!doc || islandsRef.current.length === 0) return;
- void hydrateIslands(islandsRef.current, getIslandComponentRef.current, doc);
- }
-
- return (
-
-
-
-
- );
-}
-
-interface ModeBadgeProps {
- mode: InteractivityMode;
- islandCount: number;
-}
-
-const BADGE_LABEL: Record = {
- static: 'static',
- hybrid: 'hybrid',
- spa: 'full hydration',
-};
-
-function ModeBadge({ mode, islandCount }: ModeBadgeProps) {
- const colors: Record = {
- static: '#10b981',
- hybrid: '#f59e0b',
- spa: 'var(--color-primary, #6366f1)',
- };
-
- return (
-
- {BADGE_LABEL[mode]}
- {mode === 'hybrid' && (
- {islandCount} island{islandCount !== 1 ? 's' : ''}
- )}
);
}
diff --git a/examples/demo/src/use-breakpoint.ts b/examples/demo/src/use-breakpoint.ts
new file mode 100644
index 0000000..92ea8ce
--- /dev/null
+++ b/examples/demo/src/use-breakpoint.ts
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { useState, useEffect } from 'react';
+
+export type Breakpoint = 'wide' | 'mid' | 'narrow';
+
+export const WIDE_MIN_PX = 1100;
+export const MID_MIN_PX = 640;
+
+export function deriveBreakpoint(width: number): Breakpoint {
+ if (width >= WIDE_MIN_PX) return 'wide';
+ if (width >= MID_MIN_PX) return 'mid';
+ return 'narrow';
+}
+
+export function useBreakpoint(): Breakpoint {
+ const [bp, setBp] = useState(() =>
+ typeof window !== 'undefined' ? deriveBreakpoint(window.innerWidth) : 'wide',
+ );
+
+ useEffect(() => {
+ const wideQuery = window.matchMedia(`(min-width: ${WIDE_MIN_PX}px)`);
+ const midQuery = window.matchMedia(`(min-width: ${MID_MIN_PX}px)`);
+
+ function update() {
+ setBp(wideQuery.matches ? 'wide' : midQuery.matches ? 'mid' : 'narrow');
+ }
+
+ wideQuery.addEventListener('change', update);
+ midQuery.addEventListener('change', update);
+ return () => {
+ wideQuery.removeEventListener('change', update);
+ midQuery.removeEventListener('change', update);
+ };
+ }, []);
+
+ return bp;
+}
diff --git a/examples/demo/src/use-cms-content.ts b/examples/demo/src/use-cms-content.ts
new file mode 100644
index 0000000..4df2af3
--- /dev/null
+++ b/examples/demo/src/use-cms-content.ts
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { useState, useEffect } from 'react';
+import { getCmsSubstrate } from '@booga/vbrand/cms';
+import type { ContentTree } from '@booga/vbrand/cms';
+import type { CmsName } from './router';
+
+export function useCmsContent(cms: CmsName, fixtureSlug: string | undefined): ContentTree {
+ const [cmsContent, setCmsContent] = useState({});
+
+ useEffect(() => {
+ let cancelled = false;
+ getCmsSubstrate(cms)
+ .loadContent(fixtureSlug)
+ .then((tree) => {
+ if (cancelled) return;
+ setCmsContent(tree);
+ window.__vbrand_content_tree__ = tree;
+ })
+ .catch(() => {
+ if (cancelled) return;
+ setCmsContent({});
+ window.__vbrand_content_tree__ = {};
+ });
+ return () => { cancelled = true; };
+ }, [cms, fixtureSlug]);
+
+ return cmsContent;
+}
diff --git a/examples/demo/src/use-composition.ts b/examples/demo/src/use-composition.ts
new file mode 100644
index 0000000..dab09ee
--- /dev/null
+++ b/examples/demo/src/use-composition.ts
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { useState, useEffect, useRef } from 'react';
+import { compositionFromHash, encodeComposition } from '@booga/vbrand/composition';
+import type { CompositionSpec } from '@booga/vbrand/composition';
+import { TEMPLATE_REGISTRY, compositionMatchesTemplate } from '@booga/vbrand/templates';
+import { contentFromHash, contentToHash } from '@booga/vbrand/content';
+import type { ContentOverrideMap } from '@booga/vbrand/content';
+import type { TemplateId } from './router';
+
+export interface UseCompositionResult {
+ composition: CompositionSpec;
+ setComposition: React.Dispatch>;
+ handleReset: () => void;
+ userContent: ContentOverrideMap;
+ setUserContent: React.Dispatch>;
+}
+
+export function useComposition(templateId: TemplateId): UseCompositionResult {
+ const template = TEMPLATE_REGISTRY[templateId];
+
+ const [composition, setComposition] = useState(() => {
+ const fromHash = compositionFromHash(window.location.hash);
+ return compositionMatchesTemplate(fromHash, templateId)
+ ? fromHash
+ : template.defaultComposition();
+ });
+
+ const [userContent, setUserContent] = useState(() =>
+ contentFromHash(window.location.hash) ?? {},
+ );
+
+ const prevTemplateRef = useRef(templateId);
+
+ useEffect(() => {
+ if (prevTemplateRef.current === templateId) return;
+ prevTemplateRef.current = templateId;
+ setComposition((prev) =>
+ compositionMatchesTemplate(prev, templateId) ? prev : template.defaultComposition(),
+ );
+ setUserContent({});
+ }, [templateId]);
+
+ useEffect(() => {
+ const compositionPart = encodeComposition(composition);
+ const hasUserContent = Object.keys(userContent).length > 0;
+ const hash = hasUserContent
+ ? `#composition=${compositionPart}&${contentToHash(userContent)}`
+ : `#composition=${compositionPart}`;
+ history.replaceState(null, '', window.location.pathname + window.location.search + hash);
+ }, [composition, userContent]);
+
+ function handleReset() {
+ setComposition(template.defaultComposition());
+ setUserContent({});
+ }
+
+ return { composition, setComposition, handleReset, userContent, setUserContent };
+}
diff --git a/examples/demo/tests/axis-ledger.test.tsx b/examples/demo/tests/axis-ledger.test.tsx
new file mode 100644
index 0000000..42b7fb9
--- /dev/null
+++ b/examples/demo/tests/axis-ledger.test.tsx
@@ -0,0 +1,140 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+// @vitest-environment jsdom
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { AxisLedger } from '../src/axis-ledger.js';
+import type { StackName, CmsName } from '../src/router.js';
+
+const STACK_NAMES: readonly StackName[] = ['vite', 'next', 'astro'];
+const CMS_NAMES: readonly CmsName[] = ['vbrand-standalone', 'payload', 'sanity', 'strapi'];
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+function render(stack: StackName, cms: CmsName): void {
+ act(() => { root.render(React.createElement(AxisLedger, { stack, cms })); });
+}
+
+function text(): string {
+ return container.textContent ?? '';
+}
+
+describe('AxisLedger - stack axis', () => {
+ it.each(STACK_NAMES)('renders the stack value "%s" in the ledger', (stack) => {
+ render(stack, 'vbrand-standalone');
+ expect(text()).toContain(stack);
+ });
+
+ it('each StackName is distinct in the rendered output', () => {
+ for (const stack of STACK_NAMES) {
+ render(stack, 'vbrand-standalone');
+ expect(text()).toContain(stack);
+ for (const other of STACK_NAMES.filter((s) => s !== stack)) {
+ expect(text()).not.toContain(other);
+ }
+ }
+ });
+});
+
+describe('AxisLedger - cms axis', () => {
+ it.each(CMS_NAMES)('renders the cms value "%s" in the ledger', (cms) => {
+ render('vite', cms);
+ expect(text()).toContain(cms);
+ });
+
+ it('each CmsName is distinct in the rendered output', () => {
+ for (const cms of CMS_NAMES) {
+ render('vite', cms);
+ expect(text()).toContain(cms);
+ }
+ });
+});
+
+describe('AxisLedger - deploy axis', () => {
+ it.each(STACK_NAMES)(
+ 'deploy axis always shows "gh-pages" regardless of stack (stack=%s)',
+ (stack) => {
+ render(stack, 'vbrand-standalone');
+ expect(text()).toContain('gh-pages');
+ },
+ );
+
+ it.each(CMS_NAMES)(
+ 'deploy axis always shows "gh-pages" regardless of cms (cms=%s)',
+ (cms) => {
+ render('vite', cms);
+ expect(text()).toContain('gh-pages');
+ },
+ );
+});
+
+describe('AxisLedger - axis labels', () => {
+ it('renders the Stack label', () => {
+ render('vite', 'vbrand-standalone');
+ expect(text().toLowerCase()).toContain('stack');
+ });
+
+ it('renders the CMS label', () => {
+ render('vite', 'vbrand-standalone');
+ expect(text().toLowerCase()).toContain('cms');
+ });
+
+ it('renders the Deploy label', () => {
+ render('vite', 'vbrand-standalone');
+ expect(text().toLowerCase()).toContain('deploy');
+ });
+});
+
+describe('AxisLedger - product milestone label', () => {
+ it('always renders the "7/9 flexed" milestone label', () => {
+ render('vite', 'vbrand-standalone');
+ expect(text()).toContain('7/9 flexed');
+ });
+
+ it.each(STACK_NAMES)(
+ 'renders "7/9 flexed" for every stack (stack=%s)',
+ (stack) => {
+ render(stack, 'vbrand-standalone');
+ expect(text()).toContain('7/9 flexed');
+ },
+ );
+
+ it.each(CMS_NAMES)(
+ 'renders "7/9 flexed" for every cms (cms=%s)',
+ (cms) => {
+ render('vite', cms);
+ expect(text()).toContain('7/9 flexed');
+ },
+ );
+});
+
+describe('AxisLedger - axis completeness across all (stack, cms) pairs', () => {
+ it.each(
+ STACK_NAMES.flatMap((stack) =>
+ CMS_NAMES.map((cms) => [stack, cms] as [StackName, CmsName]),
+ ),
+ )(
+ 'renders all three axis values and the milestone label for (stack=%s, cms=%s)',
+ (stack, cms) => {
+ render(stack, cms);
+ const content = text();
+ expect(content).toContain(stack);
+ expect(content).toContain(cms);
+ expect(content).toContain('gh-pages');
+ expect(content).toContain('7/9 flexed');
+ },
+ );
+});
diff --git a/examples/demo/tests/cms-toggle.test.tsx b/examples/demo/tests/cms-toggle.test.tsx
new file mode 100644
index 0000000..bc7143c
--- /dev/null
+++ b/examples/demo/tests/cms-toggle.test.tsx
@@ -0,0 +1,161 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+// @vitest-environment jsdom
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { CmsToggle } from '../src/cms-toggle.js';
+import { CMS_NAMES, type CmsName } from '../src/router.js';
+
+const CMS_LABELS: Record = {
+ 'vbrand-standalone': 'vbrand',
+ payload: 'payload',
+ sanity: 'sanity',
+ strapi: 'strapi',
+};
+
+const CMS_CONTRACT_KEYWORDS: Record = {
+ 'vbrand-standalone': 'schema as content',
+ payload: 'collections normalizer',
+ sanity: 'groq normalizer',
+ strapi: 'content-type normalizer',
+};
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+function render(cms: CmsName, onChange = vi.fn()): void {
+ act(() => { root.render(React.createElement(CmsToggle, { cms, onChange })); });
+}
+
+function text(): string {
+ return container.textContent ?? '';
+}
+
+function buttons(): HTMLButtonElement[] {
+ return Array.from(container.querySelectorAll('button'));
+}
+
+describe('CmsToggle - heading and section label', () => {
+ it('renders the "CMS substrate" section heading', () => {
+ render('vbrand-standalone');
+ expect(text().toLowerCase()).toContain('cms substrate');
+ });
+
+ it('renders an aside with the aria-label for the CMS substrate axis', () => {
+ render('vbrand-standalone');
+ const aside = container.querySelector('aside');
+ expect(aside?.getAttribute('aria-label')).toBe('CMS substrate axis');
+ });
+});
+
+describe('CmsToggle - CMS buttons presence', () => {
+ it.each(CMS_NAMES)('renders a button for substrate "%s"', (cms) => {
+ render('vbrand-standalone');
+ const labelFragment = CMS_LABELS[cms];
+ const found = buttons().some((btn) => btn.textContent?.toLowerCase().includes(labelFragment));
+ expect(found).toBe(true);
+ });
+
+ it('renders exactly one button per CMS substrate (4 total)', () => {
+ render('vbrand-standalone');
+ expect(buttons()).toHaveLength(CMS_NAMES.length);
+ });
+});
+
+describe('CmsToggle - active state', () => {
+ it.each(CMS_NAMES)('the "%s" button has aria-pressed="true" when it is the active substrate', (activeCms) => {
+ render(activeCms);
+ const activeBtn = buttons().find((btn) =>
+ btn.textContent?.toLowerCase().includes(CMS_LABELS[activeCms]),
+ );
+ expect(activeBtn?.getAttribute('aria-pressed')).toBe('true');
+ });
+
+ it.each(CMS_NAMES)('the non-active buttons have aria-pressed="false" when active is "%s"', (activeCms) => {
+ render(activeCms);
+ const activeLabel = CMS_LABELS[activeCms];
+ const inactiveBtns = buttons().filter((btn) =>
+ !btn.textContent?.toLowerCase().includes(activeLabel),
+ );
+ for (const btn of inactiveBtns) {
+ expect(btn.getAttribute('aria-pressed')).toBe('false');
+ }
+ });
+});
+
+describe('CmsToggle - contract keyword display for active substrate', () => {
+ it.each(CMS_NAMES)('shows the contract keyword for active substrate "%s"', (cms) => {
+ render(cms);
+ expect(text().toLowerCase()).toContain(CMS_CONTRACT_KEYWORDS[cms]);
+ });
+});
+
+describe('CmsToggle - source path display for active substrate', () => {
+ it('shows the "source" label for the active substrate detail', () => {
+ render('payload');
+ expect(text().toLowerCase()).toContain('source');
+ });
+
+ it('shows the "normalizer" label for the active substrate detail', () => {
+ render('payload');
+ expect(text().toLowerCase()).toContain('normalizer');
+ });
+});
+
+describe('CmsToggle - onChange callback', () => {
+ it('calls onChange with the clicked CMS name', () => {
+ const onChange = vi.fn();
+ render('vbrand-standalone', onChange);
+ const payloadBtn = buttons().find((btn) =>
+ btn.textContent?.toLowerCase().includes(CMS_LABELS['payload']),
+ );
+ act(() => { payloadBtn?.click(); });
+ expect(onChange).toHaveBeenCalledWith('payload');
+ });
+
+ it('calls onChange exactly once per click', () => {
+ const onChange = vi.fn();
+ render('vbrand-standalone', onChange);
+ const strapiBtn = buttons().find((btn) =>
+ btn.textContent?.toLowerCase().includes(CMS_LABELS['strapi']),
+ );
+ act(() => { strapiBtn?.click(); });
+ expect(onChange).toHaveBeenCalledTimes(1);
+ });
+
+ it.each(CMS_NAMES)('clicking "%s" button calls onChange with "%s"', (target) => {
+ const onChange = vi.fn();
+ render('vbrand-standalone', onChange);
+ const btn = buttons().find((btn) =>
+ btn.textContent?.toLowerCase().includes(CMS_LABELS[target]),
+ );
+ act(() => { btn?.click(); });
+ expect(onChange).toHaveBeenCalledWith(target);
+ });
+});
+
+describe('CmsToggle - substrate completeness across all active values', () => {
+ it.each(
+ CMS_NAMES.flatMap((active) =>
+ CMS_NAMES.map((visible) => [active, visible] as [CmsName, CmsName]),
+ ),
+ )('with active="%s", button for "%s" is always rendered', (active, visible) => {
+ render(active);
+ const found = buttons().some((btn) =>
+ btn.textContent?.toLowerCase().includes(CMS_LABELS[visible]),
+ );
+ expect(found).toBe(true);
+ });
+});
diff --git a/examples/demo/tests/content-layers.test.ts b/examples/demo/tests/content-layers.test.ts
new file mode 100644
index 0000000..53b6819
--- /dev/null
+++ b/examples/demo/tests/content-layers.test.ts
@@ -0,0 +1,200 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { describe, it, expect } from 'vitest';
+import { mergeContentLayers } from '../src/content-layers.js';
+import type { ContentTree } from '@booga/vbrand/cms';
+import type { ContentOverrideMap } from '@booga/vbrand/content';
+
+type Key = keyof ContentOverrideMap;
+
+const KEY_A = 'landing.hero.heading' as Key;
+const KEY_B = 'landing.hero.subheading' as Key;
+const KEY_C = 'marketing.intro.heading' as Key;
+
+const CMS_A: ContentTree = { [KEY_A]: 'cms-a' } as ContentTree;
+const CMS_AB: ContentTree = { [KEY_A]: 'cms-a', [KEY_B]: 'cms-b' } as ContentTree;
+const CMS_ABC: ContentTree = { [KEY_A]: 'cms-a', [KEY_B]: 'cms-b', [KEY_C]: 'cms-c' } as ContentTree;
+
+const USER_A: ContentOverrideMap = { [KEY_A]: 'user-a' } as ContentOverrideMap;
+const USER_B: ContentOverrideMap = { [KEY_B]: 'user-b' } as ContentOverrideMap;
+const USER_AB: ContentOverrideMap = { [KEY_A]: 'user-a', [KEY_B]: 'user-b' } as ContentOverrideMap;
+
+describe('mergeContentLayers: empty inputs', () => {
+ it('returns an empty object when both layers are empty', () => {
+ expect(mergeContentLayers({}, {})).toEqual({});
+ });
+
+ it('returns a copy of cmsContent when userContent is empty', () => {
+ expect(mergeContentLayers(CMS_AB, {})).toEqual(CMS_AB);
+ });
+
+ it('returns a copy of userContent when cmsContent is empty', () => {
+ expect(mergeContentLayers({}, USER_AB)).toEqual(USER_AB);
+ });
+});
+
+describe('mergeContentLayers: layer priority', () => {
+ it('userContent value wins when both layers have the same key', () => {
+ const result = mergeContentLayers(CMS_A, USER_A);
+ expect(result[KEY_A]).toBe('user-a');
+ });
+
+ it('cmsContent value appears for a key absent from userContent', () => {
+ const result = mergeContentLayers(CMS_AB, USER_A);
+ expect(result[KEY_B]).toBe('cms-b');
+ });
+
+ it('userContent value appears for a key absent from cmsContent', () => {
+ const result = mergeContentLayers(CMS_A, USER_B);
+ expect(result[KEY_B]).toBe('user-b');
+ });
+
+ it('both layers contribute their respective keys to the result', () => {
+ const result = mergeContentLayers(CMS_A, USER_B);
+ expect(result[KEY_A]).toBe('cms-a');
+ expect(result[KEY_B]).toBe('user-b');
+ });
+
+ it('userContent fully overrides all shared keys when all keys overlap', () => {
+ const result = mergeContentLayers(CMS_AB, USER_AB);
+ expect(result[KEY_A]).toBe('user-a');
+ expect(result[KEY_B]).toBe('user-b');
+ });
+
+ it('cmsContent keys absent from userContent survive a full-key userContent merge', () => {
+ const result = mergeContentLayers(CMS_ABC, USER_AB);
+ expect(result[KEY_C]).toBe('cms-c');
+ expect(result[KEY_A]).toBe('user-a');
+ expect(result[KEY_B]).toBe('user-b');
+ });
+});
+
+describe('mergeContentLayers: output shape', () => {
+ it('result key count equals union of both layers when there is no overlap', () => {
+ const result = mergeContentLayers(CMS_A, USER_B);
+ expect(Object.keys(result)).toHaveLength(2);
+ });
+
+ it('result key count equals shared key count when layers are identical', () => {
+ const result = mergeContentLayers(CMS_AB, USER_AB);
+ expect(Object.keys(result)).toHaveLength(2);
+ });
+
+ it('result key count equals cmsContent key count when userContent is empty', () => {
+ const result = mergeContentLayers(CMS_ABC, {});
+ expect(Object.keys(result)).toHaveLength(3);
+ });
+
+ it('result key count equals userContent key count when cmsContent is empty', () => {
+ const result = mergeContentLayers({}, USER_AB);
+ expect(Object.keys(result)).toHaveLength(2);
+ });
+});
+
+describe('mergeContentLayers: immutability', () => {
+ it('does not mutate cmsContent', () => {
+ const cms: ContentTree = { [KEY_A]: 'original' } as ContentTree;
+ mergeContentLayers(cms, USER_A);
+ expect(cms[KEY_A]).toBe('original');
+ });
+
+ it('does not mutate userContent', () => {
+ const user: ContentOverrideMap = { [KEY_B]: 'original' } as ContentOverrideMap;
+ mergeContentLayers(CMS_AB, user);
+ expect(user[KEY_B]).toBe('original');
+ });
+
+ it('returns a new object distinct from both inputs', () => {
+ const cms: ContentTree = { [KEY_A]: 'cms' } as ContentTree;
+ const user: ContentOverrideMap = { [KEY_B]: 'user' } as ContentOverrideMap;
+ const result = mergeContentLayers(cms, user);
+ expect(result).not.toBe(cms);
+ expect(result).not.toBe(user);
+ });
+});
+
+describe('mergeContentLayers: multiple calls produce consistent results', () => {
+ it('same inputs always produce equal outputs', () => {
+ expect(mergeContentLayers(CMS_AB, USER_A)).toEqual(mergeContentLayers(CMS_AB, USER_A));
+ });
+
+ it('successive calls with swapped userContent reflect the latest userContent', () => {
+ const first = mergeContentLayers(CMS_A, USER_A);
+ const second = mergeContentLayers(CMS_A, USER_B);
+ expect(first[KEY_A]).toBe('user-a');
+ expect(second[KEY_A]).toBe('cms-a');
+ expect(second[KEY_B]).toBe('user-b');
+ });
+
+ it('cmsContent change is reflected when userContent stays the same', () => {
+ const first = mergeContentLayers({ [KEY_A]: 'v1' } as ContentTree, {});
+ const second = mergeContentLayers({ [KEY_A]: 'v2' } as ContentTree, {});
+ expect(first[KEY_A]).toBe('v1');
+ expect(second[KEY_A]).toBe('v2');
+ });
+});
+
+describe('mergeContentLayers: array-type values (ContentOverrideValue = string | string[])', () => {
+ it('array CMS value appears in result when userContent does not override it', () => {
+ const cms = { [KEY_A]: ['first', 'second'] } as unknown as ContentTree;
+ expect(mergeContentLayers(cms, {})[KEY_A]).toEqual(['first', 'second']);
+ });
+
+ it('array user value replaces a string CMS value for the same key', () => {
+ const cms = { [KEY_A]: 'string-from-cms' } as ContentTree;
+ const user = { [KEY_A]: ['array', 'from', 'user'] } as unknown as ContentOverrideMap;
+ expect(mergeContentLayers(cms, user)[KEY_A]).toEqual(['array', 'from', 'user']);
+ });
+
+ it('string user value replaces an array CMS value for the same key', () => {
+ const cms = { [KEY_A]: ['array', 'from', 'cms'] } as unknown as ContentTree;
+ const user = { [KEY_A]: 'string-from-user' } as ContentOverrideMap;
+ expect(mergeContentLayers(cms, user)[KEY_A]).toBe('string-from-user');
+ });
+
+ it('array user value replaces an array CMS value and the original CMS array is not mutated', () => {
+ const original = ['cms-item-1', 'cms-item-2'];
+ const cms = { [KEY_A]: original } as unknown as ContentTree;
+ const user = { [KEY_A]: ['user-item'] } as unknown as ContentOverrideMap;
+ mergeContentLayers(cms, user);
+ expect(original).toEqual(['cms-item-1', 'cms-item-2']);
+ });
+
+ it('single-element array is preserved as an array, not flattened to a string', () => {
+ const cms = { [KEY_A]: ['only'] } as unknown as ContentTree;
+ expect(Array.isArray(mergeContentLayers(cms, {})[KEY_A])).toBe(true);
+ });
+});
+
+describe('mergeContentLayers: empty string values', () => {
+ it('empty string CMS value appears in result when userContent does not override it', () => {
+ const cms = { [KEY_A]: '' } as ContentTree;
+ expect(mergeContentLayers(cms, {})[KEY_A]).toBe('');
+ });
+
+ it('empty string user value replaces a non-empty CMS value for the same key', () => {
+ const cms = { [KEY_A]: 'populated-by-cms' } as ContentTree;
+ const user = { [KEY_A]: '' } as ContentOverrideMap;
+ expect(mergeContentLayers(cms, user)[KEY_A]).toBe('');
+ });
+
+ it('empty string CMS value is still overridable by a non-empty user value', () => {
+ const cms = { [KEY_A]: '' } as ContentTree;
+ const user = { [KEY_A]: 'filled-by-user' } as ContentOverrideMap;
+ expect(mergeContentLayers(cms, user)[KEY_A]).toBe('filled-by-user');
+ });
+
+ it('two empty string values for the same key merge to an empty string result', () => {
+ const cms = { [KEY_A]: '' } as ContentTree;
+ const user = { [KEY_A]: '' } as ContentOverrideMap;
+ expect(mergeContentLayers(cms, user)[KEY_A]).toBe('');
+ });
+
+ it('an empty string value does not suppress other keys from either layer', () => {
+ const cms = { [KEY_A]: '', [KEY_B]: 'cms-b' } as ContentTree;
+ const user = { [KEY_A]: '' } as ContentOverrideMap;
+ const result = mergeContentLayers(cms, user);
+ expect(result[KEY_A]).toBe('');
+ expect(result[KEY_B]).toBe('cms-b');
+ });
+});
diff --git a/examples/demo/tests/demo-layout-styles.test.ts b/examples/demo/tests/demo-layout-styles.test.ts
new file mode 100644
index 0000000..16df316
--- /dev/null
+++ b/examples/demo/tests/demo-layout-styles.test.ts
@@ -0,0 +1,218 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { describe, it, expect } from 'vitest';
+import type { CSSProperties } from 'react';
+import { deriveLayout } from '../src/demo-layout-styles.js';
+import type { Breakpoint } from '../src/use-breakpoint.js';
+
+const ALL_BREAKPOINTS: readonly Breakpoint[] = ['wide', 'mid', 'narrow'];
+const GRID_BREAKPOINTS: readonly Breakpoint[] = ['wide', 'mid'];
+const GRID_AREA_NAMES = ['composition', 'preview', 'operations'] as const;
+
+function css(props: CSSProperties): Record {
+ return props as Record;
+}
+
+describe('deriveLayout - structural completeness', () => {
+ it.each(ALL_BREAKPOINTS)('%s layout has all four required area slots', (bp) => {
+ const layout = deriveLayout(bp);
+ expect(layout).toHaveProperty('outer');
+ expect(layout).toHaveProperty('composition');
+ expect(layout).toHaveProperty('preview');
+ expect(layout).toHaveProperty('operations');
+ });
+
+ it.each(ALL_BREAKPOINTS)('%s outer style specifies a display mode', (bp) => {
+ expect(deriveLayout(bp).outer.display).toBeDefined();
+ });
+
+ it.each(ALL_BREAKPOINTS)('%s outer style specifies a gap', (bp) => {
+ expect(deriveLayout(bp).outer.gap).toBeDefined();
+ });
+
+ it.each(ALL_BREAKPOINTS)('%s outer style specifies a padding', (bp) => {
+ expect(deriveLayout(bp).outer.padding).toBeDefined();
+ });
+
+ it.each(ALL_BREAKPOINTS)('%s outer style specifies fontFamily', (bp) => {
+ expect(deriveLayout(bp).outer.fontFamily).toBeDefined();
+ });
+
+ it('all breakpoints share the same gap value', () => {
+ const gaps = ALL_BREAKPOINTS.map((bp) => deriveLayout(bp).outer.gap);
+ expect(new Set(gaps).size).toBe(1);
+ });
+
+ it('all breakpoints share the same padding value', () => {
+ const paddings = ALL_BREAKPOINTS.map((bp) => deriveLayout(bp).outer.padding);
+ expect(new Set(paddings).size).toBe(1);
+ });
+
+ it('all breakpoints share the same fontFamily value', () => {
+ const fonts = ALL_BREAKPOINTS.map((bp) => deriveLayout(bp).outer.fontFamily);
+ expect(new Set(fonts).size).toBe(1);
+ });
+});
+
+describe('deriveLayout - display mode per breakpoint', () => {
+ it.each(GRID_BREAKPOINTS)('%s layout uses CSS grid for multi-column support', (bp) => {
+ expect(deriveLayout(bp).outer.display).toBe('grid');
+ });
+
+ it('narrow layout uses flexbox for single-column vertical stacking', () => {
+ expect(deriveLayout('narrow').outer.display).toBe('flex');
+ expect(deriveLayout('narrow').outer.flexDirection).toBe('column');
+ });
+
+ it('all three breakpoints produce distinct outer display modes or column arrangements', () => {
+ const signatures = ALL_BREAKPOINTS.map((bp) => {
+ const { display, flexDirection, gridTemplateColumns } = deriveLayout(bp).outer;
+ return JSON.stringify({ display, flexDirection, gridTemplateColumns });
+ });
+ expect(new Set(signatures).size).toBe(ALL_BREAKPOINTS.length);
+ });
+});
+
+describe('deriveLayout - CSS grid area assignment (wide, mid)', () => {
+ it.each(GRID_BREAKPOINTS)('%s outer gridTemplateAreas names all three zones', (bp) => {
+ const areas = deriveLayout(bp).outer.gridTemplateAreas ?? '';
+ for (const name of GRID_AREA_NAMES) {
+ expect(areas).toContain(name);
+ }
+ });
+
+ it.each(GRID_BREAKPOINTS)('%s composition zone has gridArea: composition', (bp) => {
+ expect(css(deriveLayout(bp).composition).gridArea).toBe('composition');
+ });
+
+ it.each(GRID_BREAKPOINTS)('%s preview zone has gridArea: preview', (bp) => {
+ expect(css(deriveLayout(bp).preview).gridArea).toBe('preview');
+ });
+
+ it.each(GRID_BREAKPOINTS)('%s operations zone has gridArea: operations', (bp) => {
+ expect(css(deriveLayout(bp).operations).gridArea).toBe('operations');
+ });
+
+ it('narrow layout zones do not assign gridArea (flex children need none)', () => {
+ const { composition, preview, operations } = deriveLayout('narrow');
+ expect(css(composition).gridArea).toBeUndefined();
+ expect(css(preview).gridArea).toBeUndefined();
+ expect(css(operations).gridArea).toBeUndefined();
+ });
+
+ it('mid layout preview area spans both grid columns', () => {
+ const areas = deriveLayout('mid').outer.gridTemplateAreas ?? '';
+ expect(areas).toMatch(/preview.*preview/);
+ });
+});
+
+describe('deriveLayout - preview area display contract', () => {
+ it.each(ALL_BREAKPOINTS)('%s preview area always uses display:flex for iframe containment', (bp) => {
+ expect(css(deriveLayout(bp).preview).display).toBe('flex');
+ });
+
+ it('narrow preview area has an explicit CSS height for iframe containment on small viewports', () => {
+ const height = css(deriveLayout('narrow').preview).height;
+ expect(height).toBeDefined();
+ expect(typeof height).toBe('string');
+ expect(String(height).length).toBeGreaterThan(0);
+ });
+
+ it('wide preview area does not impose a fixed height (fills available grid track)', () => {
+ expect(css(deriveLayout('wide').preview).height).toBeUndefined();
+ });
+
+ it('mid preview area does not impose a fixed height (fills available grid track)', () => {
+ expect(css(deriveLayout('mid').preview).height).toBeUndefined();
+ });
+
+ it('narrow preview area prevents flex-shrink so iframe is not collapsed by sibling panels', () => {
+ expect(css(deriveLayout('narrow').preview).flexShrink).toBe(0);
+ });
+});
+
+describe('deriveLayout - parent-fill contract for wide and mid', () => {
+ it.each(GRID_BREAKPOINTS)('%s outer fills the flex parent via flex:1', (bp) => {
+ expect(deriveLayout(bp).outer.flex).toBe(1);
+ });
+
+ it.each(GRID_BREAKPOINTS)('%s outer sets minHeight:0 to allow grid children to shrink below content size', (bp) => {
+ expect(deriveLayout(bp).outer.minHeight).toBe(0);
+ });
+
+ it.each(GRID_BREAKPOINTS)('%s operations area allows internal overflow scroll for dense content', (bp) => {
+ expect(css(deriveLayout(bp).operations).overflow).toBe('auto');
+ });
+
+ it('narrow outer allows vertical scroll for content that exceeds viewport height', () => {
+ expect(deriveLayout('narrow').outer.overflowY).toBe('auto');
+ });
+});
+
+describe('deriveLayout - purity and isolation', () => {
+ it.each(ALL_BREAKPOINTS)('%s layout is identical across repeated calls (pure function)', (bp) => {
+ expect(deriveLayout(bp)).toEqual(deriveLayout(bp));
+ });
+
+ it('all three breakpoints produce distinct outer style objects', () => {
+ const styles = ALL_BREAKPOINTS.map((bp) => JSON.stringify(deriveLayout(bp).outer));
+ expect(new Set(styles).size).toBe(ALL_BREAKPOINTS.length);
+ });
+
+ it('distinct breakpoints do not share object references between layout areas', () => {
+ for (const area of ['outer', 'composition', 'preview', 'operations'] as const) {
+ const refs = ALL_BREAKPOINTS.map((bp) => deriveLayout(bp)[area]);
+ const unique = new Set(refs);
+ expect(unique.size).toBe(ALL_BREAKPOINTS.length);
+ }
+ });
+});
+
+describe('deriveLayout - controls-visibility contract', () => {
+ it.each(ALL_BREAKPOINTS)('%s outer does not clip overflow on the cross axis (no overflow:hidden)', (bp) => {
+ const outer = deriveLayout(bp).outer as Record;
+ expect(outer['overflow']).not.toBe('hidden');
+ expect(outer['overflowX']).not.toBe('hidden');
+ });
+
+ it.each(ALL_BREAKPOINTS)('%s composition area does not clip overflow (no overflow:hidden)', (bp) => {
+ const composition = css(deriveLayout(bp).composition);
+ expect(composition['overflow']).not.toBe('hidden');
+ expect(composition['overflowX']).not.toBe('hidden');
+ });
+
+ it.each(ALL_BREAKPOINTS)('%s operations area does not clip overflow (overflow:auto is permitted; overflow:hidden is not)', (bp) => {
+ const ops = css(deriveLayout(bp).operations);
+ expect(ops['overflow']).not.toBe('hidden');
+ expect(ops['overflowX']).not.toBe('hidden');
+ });
+
+ it.each(ALL_BREAKPOINTS)('%s no area uses visibility:hidden', (bp) => {
+ const layout = deriveLayout(bp);
+ for (const area of ['outer', 'composition', 'preview', 'operations'] as const) {
+ expect((layout[area] as Record)['visibility']).not.toBe('hidden');
+ }
+ });
+});
+
+describe('deriveLayout - horizontal overflow prevention', () => {
+ it.each(ALL_BREAKPOINTS)('%s composition area has minWidth:0 so flex/grid children cannot force horizontal overflow', (bp) => {
+ expect(css(deriveLayout(bp).composition)['minWidth']).toBe(0);
+ });
+
+ it.each(ALL_BREAKPOINTS)('%s preview area has minWidth:0', (bp) => {
+ expect(css(deriveLayout(bp).preview)['minWidth']).toBe(0);
+ });
+
+ it.each(['mid', 'narrow'] as const)('%s operations area has minWidth:0', (bp) => {
+ expect(css(deriveLayout(bp).operations)['minWidth']).toBe(0);
+ });
+
+ it('narrow outer does not set a positive minWidth that could force horizontal scroll', () => {
+ const outer = deriveLayout('narrow').outer as Record;
+ const minW = outer['minWidth'];
+ if (minW !== undefined) {
+ expect(minW === 0 || minW === '0' || minW === '0px').toBe(true);
+ }
+ });
+});
diff --git a/examples/demo/tests/deploy-info.test.tsx b/examples/demo/tests/deploy-info.test.tsx
new file mode 100644
index 0000000..f0e9e5c
--- /dev/null
+++ b/examples/demo/tests/deploy-info.test.tsx
@@ -0,0 +1,98 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+// @vitest-environment jsdom
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { DeployInfo } from '../src/deploy-info.js';
+import { DEPLOY_TARGET_METADATA, DEFAULT_DEPLOY_TARGET } from '@booga/vbrand/deploy/metadata';
+
+const DEFERRED_TARGETS = DEPLOY_TARGET_METADATA.filter((target) => target.name !== DEFAULT_DEPLOY_TARGET);
+const HOSTED_URL = 'https://bvasilenko.github.io/vBrand/';
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ act(() => { root.render(React.createElement(DeployInfo, null)); });
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+function text(): string {
+ return container.textContent ?? '';
+}
+
+describe('DeployInfo - heading and section label', () => {
+ it('renders the "Deploy target" section heading', () => {
+ expect(text().toLowerCase()).toContain('deploy target');
+ });
+
+ it('renders an aside with the aria-label for the deployment contract', () => {
+ const aside = container.querySelector('aside');
+ expect(aside?.getAttribute('aria-label')).toBe('Deployment target contract');
+ });
+});
+
+describe('DeployInfo - complete target list from registry', () => {
+ it('renders exactly as many list items as there are registered deploy targets', () => {
+ const items = container.querySelectorAll('li');
+ expect(items).toHaveLength(DEPLOY_TARGET_METADATA.length);
+ });
+
+ it.each(DEPLOY_TARGET_METADATA)('renders the target identifier "$name" in the list', (target) => {
+ expect(text()).toContain(target.name);
+ expect(text()).toContain(target.label);
+ expect(text()).toContain(target.badge);
+ });
+});
+
+describe('DeployInfo - wired target: gh-pages', () => {
+ it('marks the default deploy target as the wired implementation via "index.html" badge', () => {
+ expect(text()).toContain('index.html');
+ });
+
+ it('does not mark the default deploy target as DECOUPLED-FOR-LATER', () => {
+ const items = Array.from(container.querySelectorAll('li'));
+ const wiredItem = items.find((li) => li.textContent?.includes(DEFAULT_DEPLOY_TARGET));
+ expect(wiredItem?.textContent).not.toContain('DECOUPLED-FOR-LATER');
+ });
+});
+
+describe('DeployInfo - deferred targets', () => {
+ it.each(DEFERRED_TARGETS)('marks "$name" as DECOUPLED-FOR-LATER', (target) => {
+ const items = Array.from(container.querySelectorAll('li'));
+ const item = items.find((li) => li.textContent?.includes(target.name));
+ expect(item?.textContent).toContain('DECOUPLED-FOR-LATER');
+ });
+
+ it('shows exactly one "index.html" badge (only the wired target gets it)', () => {
+ const items = Array.from(container.querySelectorAll('li'));
+ const wiredItems = items.filter((li) => li.textContent?.includes('index.html'));
+ expect(wiredItems).toHaveLength(1);
+ });
+});
+
+describe('DeployInfo - live surface link', () => {
+ it('renders a link to the hosted demo URL', () => {
+ const anchor = container.querySelector(`a[href="${HOSTED_URL}"]`);
+ expect(anchor).not.toBeNull();
+ });
+
+ it('the live link text matches the hosted URL', () => {
+ const anchor = container.querySelector(`a[href="${HOSTED_URL}"]`);
+ expect(anchor?.textContent).toContain(HOSTED_URL);
+ });
+});
+
+describe('DeployInfo - deploy manifest label', () => {
+ it('renders the "deploy manifest" eyebrow label', () => {
+ expect(text().toLowerCase()).toContain('deploy manifest');
+ });
+});
diff --git a/examples/demo/tests/nav-bar.test.tsx b/examples/demo/tests/nav-bar.test.tsx
index 90b48e0..8aabc7d 100644
--- a/examples/demo/tests/nav-bar.test.tsx
+++ b/examples/demo/tests/nav-bar.test.tsx
@@ -5,9 +5,42 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { NavBar } from '../src/nav-bar.js';
-import { buildSearchString, parseRoute, DEFAULT_MODE, type TemplateId, type InteractivityMode } from '../src/router.js';
+import {
+ buildSearchString, parseRoute, DEFAULT_MODE, DEFAULT_STACK, DEFAULT_CMS,
+ STACK_NAMES, CMS_NAMES,
+ type TemplateId, type InteractivityMode, type StackName, type CmsName,
+} from '../src/router.js';
const ALL_TEMPLATE_IDS: readonly TemplateId[] = ['landing', 'marketing', 'docs', 'dashboard'];
+const ALL_MODES: readonly InteractivityMode[] = ['static', 'hybrid', 'spa'];
+const STACK_DEFAULT_MODES: Record = { vite: 'spa', next: 'hybrid', astro: 'static' };
+
+const DEFAULT_MODE_TRANSITIONS: ReadonlyArray = [
+ ['vite', 'spa', 'next', 'hybrid'],
+ ['vite', 'spa', 'astro', 'static'],
+ ['next', 'hybrid', 'vite', 'spa'],
+ ['next', 'hybrid', 'astro', 'static'],
+ ['astro', 'static', 'vite', 'spa'],
+ ['astro', 'static', 'next', 'hybrid'],
+];
+
+const EXPLICIT_MODE_TRANSITIONS: ReadonlyArray = [
+ ['vite', 'hybrid', 'next', 'hybrid'],
+ ['vite', 'hybrid', 'astro', 'hybrid'],
+ ['vite', 'static', 'next', 'static'],
+ ['vite', 'static', 'astro', 'static'],
+ ['next', 'spa', 'vite', 'spa'],
+ ['next', 'spa', 'astro', 'spa'],
+ ['next', 'static', 'vite', 'static'],
+ ['next', 'static', 'astro', 'static'],
+ ['astro', 'spa', 'vite', 'spa'],
+ ['astro', 'spa', 'next', 'spa'],
+ ['astro', 'hybrid', 'vite', 'hybrid'],
+ ['astro', 'hybrid', 'next', 'hybrid'],
+];
+
+const NON_DEFAULT_STACKS: readonly StackName[] = STACK_NAMES.filter(s => s !== DEFAULT_STACK);
+const NON_DEFAULT_CMS_NAMES: readonly CmsName[] = CMS_NAMES.filter(c => c !== DEFAULT_CMS);
const FIXTURE_BRANDS = [
'fixture:stripe',
@@ -53,6 +86,8 @@ interface NavBarOverrides {
currentMode?: InteractivityMode;
currentBrand?: string;
currentTemplate?: TemplateId;
+ currentStack?: StackName;
+ currentCms?: CmsName;
isLoading?: boolean;
dataViewHref?: string;
onDataViewNavigate?: () => void;
@@ -77,6 +112,18 @@ function templateSelect(): HTMLSelectElement {
return container.querySelector('select') as HTMLSelectElement;
}
+function modeSelect(): HTMLSelectElement {
+ return container.querySelectorAll('select')[1] as HTMLSelectElement;
+}
+
+function stackSelect(): HTMLSelectElement {
+ return container.querySelectorAll('select')[2] as HTMLSelectElement;
+}
+
+function cmsSelect(): HTMLSelectElement {
+ return container.querySelectorAll('select')[3] as HTMLSelectElement;
+}
+
function brandInput(): HTMLInputElement {
return container.querySelector('input') as HTMLInputElement;
}
@@ -103,6 +150,30 @@ function changeTemplateSelect(id: TemplateId): void {
});
}
+function changeModeSelect(mode: InteractivityMode): void {
+ act(() => {
+ const sel = modeSelect();
+ sel.value = mode;
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+}
+
+function changeStackSelect(stack: StackName): void {
+ act(() => {
+ const sel = stackSelect();
+ sel.value = stack;
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+}
+
+function changeCmsSelect(cms: CmsName): void {
+ act(() => {
+ const sel = cmsSelect();
+ sel.value = cms;
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+}
+
function clickLoad(): void {
act(() => loadButton().click());
}
@@ -358,20 +429,6 @@ describe('NavBar: data view link', () => {
});
});
-const ALL_MODES: readonly InteractivityMode[] = ['static', 'hybrid', 'spa'];
-
-function modeSelect(): HTMLSelectElement {
- return container.querySelectorAll('select')[1] as HTMLSelectElement;
-}
-
-function changeModeSelect(mode: InteractivityMode): void {
- act(() => {
- const sel = modeSelect();
- sel.value = mode;
- sel.dispatchEvent(new Event('change', { bubbles: true }));
- });
-}
-
describe('NavBar: mode select reflects currentMode prop', () => {
it.each(ALL_MODES)(
'currentMode="%s": mode select value matches the prop',
@@ -386,10 +443,10 @@ describe('NavBar: mode select reflects currentMode prop', () => {
expect(modeSelect().value).toBe(DEFAULT_MODE);
});
- it('mode select offers exactly three options: static, hybrid, spa', () => {
+ it('mode select offers exactly the ALL_MODES options', () => {
renderNavBar();
const offered = [...modeSelect().options].map((o) => o.value).sort();
- expect(offered).toEqual(['hybrid', 'spa', 'static']);
+ expect(offered).toEqual([...ALL_MODES].sort());
});
it('mode select is a distinct element from the template select', () => {
@@ -519,7 +576,7 @@ describe('NavBar: navigation hash policy', () => {
it.each(ALL_MODES)(
'mode change to "%s" does not produce a hash-stripping navigation',
(newMode) => {
- const currentMode = newMode === 'spa' ? 'static' : 'spa';
+ const currentMode = newMode === DEFAULT_MODE ? ALL_MODES.find(m => m !== DEFAULT_MODE)! : DEFAULT_MODE;
renderNavBar({ currentTemplate: 'landing', currentMode });
changeModeSelect(newMode);
expect(hrefNavigations).toHaveLength(0);
@@ -527,6 +584,28 @@ describe('NavBar: navigation hash policy', () => {
},
);
+ it.each(STACK_NAMES)(
+ 'stack change to "%s" does not produce a hash-stripping navigation',
+ (stack) => {
+ const currentStack = stack === DEFAULT_STACK ? NON_DEFAULT_STACKS[0]! : DEFAULT_STACK;
+ renderNavBar({ currentStack });
+ changeStackSelect(stack);
+ expect(hrefNavigations).toHaveLength(0);
+ expect(navigations).toHaveLength(1);
+ },
+ );
+
+ it.each(CMS_NAMES)(
+ 'CMS change to "%s" does not produce a hash-stripping navigation',
+ (cms) => {
+ const currentCms = cms === DEFAULT_CMS ? NON_DEFAULT_CMS_NAMES[0]! : DEFAULT_CMS;
+ renderNavBar({ currentCms });
+ changeCmsSelect(cms);
+ expect(hrefNavigations).toHaveLength(0);
+ expect(navigations).toHaveLength(1);
+ },
+ );
+
it('Load button does not produce a hash-stripping navigation', () => {
renderNavBar({ currentTemplate: 'landing' });
clickLoad();
@@ -645,6 +724,18 @@ describe('NavBar: brand input change auto-submits on known example values', () =
expect(new URLSearchParams(navigations[0]).get('mode')).toBe('static');
});
+ it('auto-submit navigation preserves a non-default stack', () => {
+ renderNavBar({ currentStack: NON_DEFAULT_STACKS[0] });
+ simulateBrandInputChange('fixture:vercel');
+ expect(new URLSearchParams(navigations[0]).get('stack')).toBe(NON_DEFAULT_STACKS[0]);
+ });
+
+ it('auto-submit navigation preserves a non-default CMS substrate', () => {
+ renderNavBar({ currentCms: NON_DEFAULT_CMS_NAMES[0] });
+ simulateBrandInputChange('fixture:linear');
+ expect(new URLSearchParams(navigations[0]).get('cms')).toBe(NON_DEFAULT_CMS_NAMES[0]);
+ });
+
it('changing to a partial value that is a prefix of a known example does NOT trigger navigation', () => {
renderNavBar();
simulateBrandInputChange('fixture:str');
@@ -758,3 +849,305 @@ describe('NavBar: examples details keyboard navigation', () => {
expect(det.open).toBe(false);
});
});
+
+describe('NavBar: stack select reflects currentStack prop', () => {
+ it.each(STACK_NAMES)(
+ 'currentStack="%s": stack select value matches the prop',
+ (stack) => {
+ renderNavBar({ currentStack: stack });
+ expect(stackSelect().value).toBe(stack);
+ },
+ );
+
+ it(`defaults to DEFAULT_STACK ("${DEFAULT_STACK}") when currentStack is not provided`, () => {
+ renderNavBar({});
+ expect(stackSelect().value).toBe(DEFAULT_STACK);
+ });
+
+ it('stack select offers exactly the STACK_NAMES options', () => {
+ renderNavBar();
+ const offered = [...stackSelect().options].map((o) => o.value).sort();
+ expect(offered).toEqual([...STACK_NAMES].sort());
+ });
+
+ it('stack select is a distinct DOM element from template, mode, and CMS selects', () => {
+ renderNavBar();
+ const stack = stackSelect();
+ expect(stack).not.toBe(templateSelect());
+ expect(stack).not.toBe(modeSelect());
+ expect(stack).not.toBe(cmsSelect());
+ });
+});
+
+describe('NavBar: stack select change triggers navigation', () => {
+ it.each(STACK_NAMES)(
+ 'changing stack to "%s" causes exactly one navigation call',
+ (stack) => {
+ renderNavBar({ currentStack: DEFAULT_STACK });
+ changeStackSelect(stack);
+ expect(navigations).toHaveLength(1);
+ },
+ );
+
+ it.each(NON_DEFAULT_STACKS)(
+ 'navigation from stack change to "%s" includes stack=%s in the search string',
+ (stack) => {
+ renderNavBar({ currentStack: DEFAULT_STACK });
+ changeStackSelect(stack);
+ expect(new URLSearchParams(navigations[0]).get('stack')).toBe(stack);
+ },
+ );
+
+ it('navigation to DEFAULT_STACK omits the stack param (clean URL)', () => {
+ renderNavBar({ currentStack: NON_DEFAULT_STACKS[0] });
+ changeStackSelect(DEFAULT_STACK);
+ expect(new URLSearchParams(navigations[0]).get('stack')).toBeNull();
+ });
+
+ it('navigation from stack change carries the current brand, template, mode, and CMS substrate', () => {
+ renderNavBar({ currentBrand: 'fixture:vercel', currentTemplate: 'docs', currentMode: 'static', currentCms: NON_DEFAULT_CMS_NAMES[0], currentStack: DEFAULT_STACK });
+ changeStackSelect(NON_DEFAULT_STACKS[0]!);
+ const params = new URLSearchParams(navigations[0]);
+ expect(params.get('brand')).toBe('fixture:vercel');
+ expect(params.get('app')).toBe('docs');
+ expect(params.get('mode')).toBe('static');
+ expect(params.get('cms')).toBe(NON_DEFAULT_CMS_NAMES[0]);
+ expect(params.get('stack')).toBe(NON_DEFAULT_STACKS[0]);
+ });
+
+ it('navigation search string equals buildSearchString(brand, template, mode, newStack, currentCms)', () => {
+ renderNavBar({ currentBrand: 'fixture:linear', currentTemplate: 'marketing', currentMode: 'static', currentCms: NON_DEFAULT_CMS_NAMES[1]!, currentStack: DEFAULT_STACK });
+ changeStackSelect(NON_DEFAULT_STACKS[0]!);
+ expect(navigations[0]).toBe(buildSearchString('fixture:linear', 'marketing', 'static', NON_DEFAULT_STACKS[0], NON_DEFAULT_CMS_NAMES[1]));
+ });
+
+ it('navigation is round-trip parseable and recovers stack, brand, template, and mode', () => {
+ renderNavBar({ currentBrand: 'fixture:stripe', currentTemplate: 'dashboard', currentMode: 'hybrid', currentStack: DEFAULT_STACK });
+ changeStackSelect(NON_DEFAULT_STACKS[0]!);
+ const route = parseRoute(navigations[0]!);
+ expect(route.stack).toBe(NON_DEFAULT_STACKS[0]);
+ expect(route.templateId).toBe('dashboard');
+ expect(route.mode).toBe('hybrid');
+ expect(route.brandParams).toEqual({ type: 'fixture', handle: 'stripe' });
+ });
+});
+
+describe('NavBar: navigation from brand/template/load preserves active stack', () => {
+ it('template change preserves a non-default currentStack in navigation', () => {
+ renderNavBar({ currentBrand: 'fixture:stripe', currentTemplate: 'landing', currentStack: NON_DEFAULT_STACKS[0] });
+ changeTemplateSelect('docs');
+ expect(new URLSearchParams(navigations[0]).get('stack')).toBe(NON_DEFAULT_STACKS[0]);
+ });
+
+ it('template change with DEFAULT_STACK omits the stack param', () => {
+ renderNavBar({ currentBrand: 'fixture:stripe', currentTemplate: 'landing', currentStack: DEFAULT_STACK });
+ changeTemplateSelect('marketing');
+ expect(new URLSearchParams(navigations[0]).get('stack')).toBeNull();
+ });
+
+ it('Load button preserves a non-default currentStack in navigation', () => {
+ renderNavBar({ currentBrand: 'fixture:stripe', currentTemplate: 'landing', currentStack: NON_DEFAULT_STACKS[0] });
+ clickLoad();
+ expect(new URLSearchParams(navigations[0]).get('stack')).toBe(NON_DEFAULT_STACKS[0]);
+ });
+
+ it('Enter key preserves a non-default currentStack in navigation', () => {
+ renderNavBar({ currentBrand: 'fixture:stripe', currentTemplate: 'landing', currentStack: NON_DEFAULT_STACKS[0] });
+ pressEnterInBrandInput();
+ expect(new URLSearchParams(navigations[0]).get('stack')).toBe(NON_DEFAULT_STACKS[0]);
+ });
+
+ it('mode change preserves a non-default currentStack in navigation', () => {
+ renderNavBar({ currentBrand: 'fixture:stripe', currentTemplate: 'landing', currentMode: DEFAULT_MODE, currentStack: NON_DEFAULT_STACKS[0] });
+ changeModeSelect(ALL_MODES.find(m => m !== DEFAULT_MODE)!);
+ expect(new URLSearchParams(navigations[0]).get('stack')).toBe(NON_DEFAULT_STACKS[0]);
+ });
+
+ it('Load navigation equals buildSearchString(brand, template, mode, stack) for a non-default stack', () => {
+ renderNavBar({ currentBrand: 'fixture:notion', currentTemplate: 'dashboard', currentMode: 'static', currentStack: NON_DEFAULT_STACKS[0] });
+ clickLoad();
+ expect(navigations[0]).toBe(buildSearchString('fixture:notion', 'dashboard', 'static', NON_DEFAULT_STACKS[0]));
+ });
+
+ it.each(STACK_NAMES)(
+ 'Load button with currentStack="%s" round-trips through parseRoute correctly',
+ (stack) => {
+ renderNavBar({ currentBrand: 'fixture:stripe', currentTemplate: 'marketing', currentStack: stack });
+ clickLoad();
+ expect(parseRoute(navigations[0]!).stack).toBe(stack);
+ },
+ );
+});
+
+describe('NavBar: CMS select reflects currentCms prop', () => {
+ it.each(CMS_NAMES)(
+ 'currentCms="%s": CMS select value matches the prop',
+ (cms) => {
+ renderNavBar({ currentCms: cms });
+ expect(cmsSelect().value).toBe(cms);
+ },
+ );
+
+ it(`defaults to DEFAULT_CMS ("${DEFAULT_CMS}") when currentCms is not provided`, () => {
+ renderNavBar({});
+ expect(cmsSelect().value).toBe(DEFAULT_CMS);
+ });
+
+ it('CMS select offers exactly the CMS_NAMES options', () => {
+ renderNavBar();
+ const offered = [...cmsSelect().options].map((o) => o.value).sort();
+ expect(offered).toEqual([...CMS_NAMES].sort());
+ });
+
+ it('CMS select is a distinct DOM element from template, mode, and stack selects', () => {
+ renderNavBar();
+ const cms = cmsSelect();
+ expect(cms).not.toBe(templateSelect());
+ expect(cms).not.toBe(modeSelect());
+ expect(cms).not.toBe(stackSelect());
+ });
+});
+
+describe('NavBar: CMS select change triggers navigation', () => {
+ it.each(CMS_NAMES)(
+ 'changing CMS to "%s" causes exactly one navigation call',
+ (cms) => {
+ renderNavBar({ currentCms: DEFAULT_CMS });
+ changeCmsSelect(cms);
+ expect(navigations).toHaveLength(1);
+ },
+ );
+
+ it.each(NON_DEFAULT_CMS_NAMES)(
+ 'navigation from CMS change to "%s" includes cms=%s in the search string',
+ (cms) => {
+ renderNavBar({ currentCms: DEFAULT_CMS });
+ changeCmsSelect(cms);
+ expect(new URLSearchParams(navigations[0]).get('cms')).toBe(cms);
+ },
+ );
+
+ it('navigation to DEFAULT_CMS omits the cms param (clean URL)', () => {
+ renderNavBar({ currentCms: NON_DEFAULT_CMS_NAMES[0] });
+ changeCmsSelect(DEFAULT_CMS);
+ expect(new URLSearchParams(navigations[0]).get('cms')).toBeNull();
+ });
+
+ it('navigation from CMS change carries the current brand, template, mode, and stack runtime', () => {
+ renderNavBar({ currentBrand: 'fixture:vercel', currentTemplate: 'docs', currentMode: 'hybrid', currentStack: NON_DEFAULT_STACKS[0], currentCms: DEFAULT_CMS });
+ changeCmsSelect(NON_DEFAULT_CMS_NAMES[0]!);
+ const params = new URLSearchParams(navigations[0]);
+ expect(params.get('brand')).toBe('fixture:vercel');
+ expect(params.get('app')).toBe('docs');
+ expect(parseRoute(navigations[0]!).mode).toBe('hybrid');
+ expect(params.get('stack')).toBe(NON_DEFAULT_STACKS[0]);
+ expect(params.get('cms')).toBe(NON_DEFAULT_CMS_NAMES[0]);
+ });
+
+ it('navigation search string equals buildSearchString(brand, template, mode, currentStack, newCms)', () => {
+ renderNavBar({ currentBrand: 'fixture:notion', currentTemplate: 'landing', currentMode: 'static', currentStack: NON_DEFAULT_STACKS[1]!, currentCms: DEFAULT_CMS });
+ changeCmsSelect(NON_DEFAULT_CMS_NAMES[0]!);
+ expect(navigations[0]).toBe(buildSearchString('fixture:notion', 'landing', 'static', NON_DEFAULT_STACKS[1], NON_DEFAULT_CMS_NAMES[0]));
+ });
+
+ it('navigation is round-trip parseable and recovers CMS, brand, template, and mode', () => {
+ renderNavBar({ currentBrand: 'fixture:github', currentTemplate: 'marketing', currentMode: 'spa', currentCms: DEFAULT_CMS });
+ changeCmsSelect(NON_DEFAULT_CMS_NAMES[0]!);
+ const route = parseRoute(navigations[0]!);
+ expect(route.cms).toBe(NON_DEFAULT_CMS_NAMES[0]);
+ expect(route.templateId).toBe('marketing');
+ expect(route.brandParams).toEqual({ type: 'fixture', handle: 'github' });
+ });
+});
+
+describe('NavBar: navigation from brand/template/load preserves active CMS', () => {
+ it('template change preserves a non-default currentCms in navigation', () => {
+ renderNavBar({ currentBrand: 'fixture:stripe', currentTemplate: 'landing', currentCms: NON_DEFAULT_CMS_NAMES[0] });
+ changeTemplateSelect('docs');
+ expect(new URLSearchParams(navigations[0]).get('cms')).toBe(NON_DEFAULT_CMS_NAMES[0]);
+ });
+
+ it('template change with DEFAULT_CMS omits the cms param', () => {
+ renderNavBar({ currentBrand: 'fixture:stripe', currentTemplate: 'landing', currentCms: DEFAULT_CMS });
+ changeTemplateSelect('marketing');
+ expect(new URLSearchParams(navigations[0]).get('cms')).toBeNull();
+ });
+
+ it('Load button preserves a non-default currentCms in navigation', () => {
+ renderNavBar({ currentBrand: 'fixture:stripe', currentTemplate: 'landing', currentCms: NON_DEFAULT_CMS_NAMES[0] });
+ clickLoad();
+ expect(new URLSearchParams(navigations[0]).get('cms')).toBe(NON_DEFAULT_CMS_NAMES[0]);
+ });
+
+ it('Enter key preserves a non-default currentCms in navigation', () => {
+ renderNavBar({ currentBrand: 'fixture:stripe', currentTemplate: 'landing', currentCms: NON_DEFAULT_CMS_NAMES[0] });
+ pressEnterInBrandInput();
+ expect(new URLSearchParams(navigations[0]).get('cms')).toBe(NON_DEFAULT_CMS_NAMES[0]);
+ });
+
+ it('mode change preserves a non-default currentCms in navigation', () => {
+ renderNavBar({ currentBrand: 'fixture:stripe', currentTemplate: 'landing', currentMode: DEFAULT_MODE, currentCms: NON_DEFAULT_CMS_NAMES[0] });
+ changeModeSelect(ALL_MODES.find(m => m !== DEFAULT_MODE)!);
+ expect(new URLSearchParams(navigations[0]).get('cms')).toBe(NON_DEFAULT_CMS_NAMES[0]);
+ });
+
+ it('stack change preserves a non-default currentCms in navigation', () => {
+ renderNavBar({ currentStack: DEFAULT_STACK, currentCms: NON_DEFAULT_CMS_NAMES[0] });
+ changeStackSelect(NON_DEFAULT_STACKS[0]!);
+ expect(new URLSearchParams(navigations[0]).get('cms')).toBe(NON_DEFAULT_CMS_NAMES[0]);
+ });
+
+ it('CMS change preserves a non-default currentStack in navigation', () => {
+ renderNavBar({ currentStack: NON_DEFAULT_STACKS[0], currentCms: DEFAULT_CMS });
+ changeCmsSelect(NON_DEFAULT_CMS_NAMES[0]!);
+ expect(new URLSearchParams(navigations[0]).get('stack')).toBe(NON_DEFAULT_STACKS[0]);
+ });
+
+ it('Load navigation equals buildSearchString(brand, template, mode, stack, cms) for non-default CMS', () => {
+ renderNavBar({ currentBrand: 'fixture:notion', currentTemplate: 'dashboard', currentMode: 'static', currentCms: NON_DEFAULT_CMS_NAMES[0] });
+ clickLoad();
+ expect(navigations[0]).toBe(buildSearchString('fixture:notion', 'dashboard', 'static', undefined, NON_DEFAULT_CMS_NAMES[0]));
+ });
+
+ it.each(CMS_NAMES)(
+ 'Load button with currentCms="%s" round-trips through parseRoute correctly',
+ (cms) => {
+ renderNavBar({ currentBrand: 'fixture:stripe', currentTemplate: 'landing', currentCms: cms });
+ clickLoad();
+ expect(parseRoute(navigations[0]!).cms).toBe(cms);
+ },
+ );
+});
+
+describe('NavBar: mode follows target stack default when switching from a stack-default mode', () => {
+ it.each(DEFAULT_MODE_TRANSITIONS)(
+ 'from %s(%s) to %s: route recovers mode=%s (target default, omitted from URL)',
+ (fromStack, fromMode, toStack, expectedMode) => {
+ renderNavBar({ currentStack: fromStack, currentMode: fromMode });
+ changeStackSelect(toStack);
+ expect(parseRoute(navigations[0]!).mode).toBe(expectedMode);
+ },
+ );
+});
+
+describe('NavBar: explicit non-default mode is preserved through any stack switch', () => {
+ it.each(EXPLICIT_MODE_TRANSITIONS)(
+ 'from %s(%s) to %s: explicit mode=%s is preserved',
+ (fromStack, fromMode, toStack, expectedMode) => {
+ renderNavBar({ currentStack: fromStack, currentMode: fromMode });
+ changeStackSelect(toStack);
+ expect(parseRoute(navigations[0]!).mode).toBe(expectedMode);
+ },
+ );
+});
+
+describe('NavBar: same-stack selection leaves mode unchanged', () => {
+ it.each(STACK_NAMES.flatMap((stack) => ALL_MODES.map((mode) => [stack, mode] as const)))(
+ 'stack=%s mode=%s: same-stack selection leaves mode unchanged',
+ (stack, mode) => {
+ renderNavBar({ currentStack: stack, currentMode: mode });
+ changeStackSelect(stack);
+ expect(parseRoute(navigations[0]!).mode).toBe(mode);
+ },
+ );
+});
diff --git a/examples/demo/tests/park-notice.test.tsx b/examples/demo/tests/park-notice.test.tsx
index 6268eda..37b31c6 100644
--- a/examples/demo/tests/park-notice.test.tsx
+++ b/examples/demo/tests/park-notice.test.tsx
@@ -1,168 +1,75 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 bvasilenko
// @vitest-environment jsdom
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import React, { act } from 'react';
-import { createRoot, type Root } from 'react-dom/client';
-import { ParkNotice } from '../src/park-notice.js';
+import { createRoot } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { ParkNotice } from '../src/park-notice';
-const SESSION_KEY = 'vbrand-park-notice-dismissed';
-
-const QUEUED_AXIS_CASES = [
- { param: 'stack', value: 'vite', label: 'stack-runtime', iteration: 'iteration 3 queued' },
- { param: 'cms', value: 'vbrand', label: 'cms substrate', iteration: 'iteration 3 queued' },
-] as const;
-
-const SHIPPED_AXIS_PARAMS = ['mode', 'content'] as const;
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
-let root: Root;
+let root: ReturnType;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
- sessionStorage.removeItem(SESSION_KEY);
+ sessionStorage.clear();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
- sessionStorage.removeItem(SESSION_KEY);
+ sessionStorage.clear();
});
function render(search: string) {
act(() => root.render(React.createElement(ParkNotice, { search })));
}
-function banner(): Element | null {
- return container.querySelector('[role="banner"]');
-}
-
-
-describe('ParkNotice: visibility rules', () => {
- it('renders nothing when no queued-axis params are present', () => {
- render('?brand=fixture:stripe');
- expect(banner()).toBeNull();
- });
-
- it('renders nothing when the search string is completely empty', () => {
- render('');
- expect(banner()).toBeNull();
- });
-
- it('renders nothing when only unrecognised params are present', () => {
- render('?foo=bar&baz=1');
- expect(banner()).toBeNull();
- });
-
- it.each(QUEUED_AXIS_CASES)(
- '?$param=$value: banner is rendered for queued axis',
- ({ param, value }) => {
- render(`?${param}=${value}`);
- expect(banner()).not.toBeNull();
- },
- );
-
- it('renders a banner when a queued-axis param is present with an empty value', () => {
- render('?stack=');
- expect(banner()).not.toBeNull();
- });
-
- it('renders exactly one banner element when multiple queued-axis params are present', () => {
- render('?stack=vite&cms=vbrand');
- expect(container.querySelectorAll('[role="banner"]').length).toBe(1);
- });
-
- it.each(SHIPPED_AXIS_PARAMS)(
- '?%s: shipped axis does not trigger the banner',
- (param) => {
- render(`?${param}=anything`);
- expect(banner()).toBeNull();
- },
- );
-
- it('no banner when all present params are shipped axes', () => {
- render('?mode=static&content=some.key:value');
- expect(banner()).toBeNull();
- });
-
- it('banner renders when a queued-axis param accompanies a shipped-axis param', () => {
- render('?stack=vite&mode=static');
- expect(banner()).not.toBeNull();
- });
-});
-
-
-describe('ParkNotice: axis label and iteration text', () => {
- it.each(QUEUED_AXIS_CASES)(
- '?$param: banner contains the axis label "$label"',
- ({ param, value, label }) => {
- render(`?${param}=${value}`);
- expect(banner()!.textContent).toContain(label);
- },
- );
-
- it.each(QUEUED_AXIS_CASES)(
- '?$param: banner contains the iteration text "$iteration"',
- ({ param, value, iteration }) => {
- render(`?${param}=${value}`);
- expect(banner()!.textContent).toContain(iteration);
- },
- );
-
- it('banner listing two queued axes contains both labels', () => {
- render('?stack=vite&cms=vbrand');
- expect(banner()!.textContent).toContain('stack-runtime');
- expect(banner()!.textContent).toContain('cms substrate');
- });
+const SHIPPED_AXIS_SEARCHES = [
+ '?stack=next',
+ '?cms=strapi',
+ '?stack=astro&cms=sanity&mode=static',
+] as const;
- it('banner with queued + shipped param includes the queued label but not the shipped label', () => {
- render('?stack=vite&mode=static');
- expect(banner()!.textContent).toContain('stack-runtime');
- expect(banner()!.textContent).not.toContain('interactivity mode');
- });
+const QUEUED_AXIS_CASES = [
+ ['?deploy=netlify', ['multi-deploy target selection']],
+ ['?stackPlugin=remix', ['expanded stack runtime plugins']],
+ ['?cmsLive=sanity', ['managed CMS live instances']],
+ ['?deploy=netlify&stackPlugin=remix', ['multi-deploy target selection', 'expanded stack runtime plugins']],
+] as const;
- it('banner with queued + shipped param includes the queued label but not the other shipped label', () => {
- render('?cms=vbrand&content=anything');
- expect(banner()!.textContent).toContain('cms substrate');
- expect(banner()!.textContent).not.toContain('content override');
+describe('ParkNotice: alpha.5 shipped axes', () => {
+ it.each(SHIPPED_AXIS_SEARCHES)('does not warn for shipped axis params: %s', (search) => {
+ render(search);
+ expect(container.querySelector('[role="banner"]')).toBeNull();
});
-});
-
-describe('ParkNotice: dismiss behaviour', () => {
- it('clicking dismiss removes the banner from the DOM', () => {
- render('?stack=vite');
- const dismiss = container.querySelector('[aria-label="Dismiss queued axes notice"]') as HTMLButtonElement;
- act(() => dismiss.click());
- expect(banner()).toBeNull();
+ it.each(QUEUED_AXIS_CASES)('keeps future queued-axis notices available: %s', (search, labels) => {
+ render(search);
+ const banner = container.querySelector('[role="banner"]');
+ for (const label of labels) expect(banner?.textContent).toContain(label);
+ expect(banner?.textContent).toContain('vBrand 0.5.0');
});
- it('clicking dismiss writes the session flag', () => {
- render('?stack=vite');
+ it.each(QUEUED_AXIS_CASES)('dismisses future queued-axis notices for the current session: %s', (search) => {
+ render(search);
const dismiss = container.querySelector('[aria-label="Dismiss queued axes notice"]') as HTMLButtonElement;
act(() => dismiss.click());
- expect(sessionStorage.getItem(SESSION_KEY)).toBe('1');
- });
-
- it('banner does not render when session flag is already set before mount', () => {
- sessionStorage.setItem(SESSION_KEY, '1');
- render('?stack=vite');
- expect(banner()).toBeNull();
+ expect(container.querySelector('[role="banner"]')).toBeNull();
+ expect(sessionStorage.getItem('vbrand-park-notice-dismissed')).toBe('1');
});
- it('session flag set before render suppresses banner for every queued-axis param', () => {
- sessionStorage.setItem(SESSION_KEY, '1');
- for (const { param, value } of QUEUED_AXIS_CASES) {
- render(`?${param}=${value}`);
- expect(banner()).toBeNull();
- }
+ it.each(QUEUED_AXIS_CASES)('honors an existing queued-notice session dismissal: %s', (search) => {
+ sessionStorage.setItem('vbrand-park-notice-dismissed', '1');
+ render(search);
+ expect(container.querySelector('[role="banner"]')).toBeNull();
});
- it('dismiss button has the expected aria-label for accessibility', () => {
- render('?stack=vite');
- const dismiss = container.querySelector('[aria-label="Dismiss queued axes notice"]');
- expect(dismiss).not.toBeNull();
+ it.each(['', '?foo=bar', '?mode=spa&content=x'])('does not warn for unrelated params: %s', (search) => {
+ render(search);
+ expect(container.querySelector('[role="banner"]')).toBeNull();
});
});
diff --git a/examples/demo/tests/render-area.test.ts b/examples/demo/tests/render-area.test.ts
new file mode 100644
index 0000000..d3c8ea5
--- /dev/null
+++ b/examples/demo/tests/render-area.test.ts
@@ -0,0 +1,91 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { describe, it, expect } from 'vitest';
+import { stackArtefactUrl } from '../src/render-area.js';
+import type { StackName } from '../src/router.js';
+
+const STACK_NAMES: readonly StackName[] = ['vite', 'next', 'astro'];
+
+describe('stackArtefactUrl - base path normalisation', () => {
+ it.each(STACK_NAMES)(
+ 'produces stacks/%s.html under a base without a trailing slash',
+ (stack) => {
+ expect(stackArtefactUrl('/base', stack)).toBe(`/base/stacks/${stack}.html`);
+ },
+ );
+
+ it.each(STACK_NAMES)(
+ 'produces stacks/%s.html under a base that already has a trailing slash',
+ (stack) => {
+ expect(stackArtefactUrl('/base/', stack)).toBe(`/base/stacks/${stack}.html`);
+ },
+ );
+
+ it('does not double the slash when base ends with a slash', () => {
+ const url = stackArtefactUrl('/root/', 'vite');
+ expect(url).not.toContain('//stacks');
+ });
+
+ it('adds exactly one slash when base lacks a trailing slash', () => {
+ const url = stackArtefactUrl('/root', 'vite');
+ expect(url).toContain('/root/stacks/');
+ expect(url).not.toContain('/root//stacks/');
+ });
+});
+
+describe('stackArtefactUrl - URL structure', () => {
+ it.each(STACK_NAMES)(
+ 'URL ends with .html for stack "%s"',
+ (stack) => {
+ expect(stackArtefactUrl('/', stack)).toMatch(/\.html$/);
+ },
+ );
+
+ it.each(STACK_NAMES)(
+ 'URL contains the exact stack name "%s" as a path segment',
+ (stack) => {
+ const url = stackArtefactUrl('/demo', stack);
+ expect(url).toContain(`/stacks/${stack}.html`);
+ },
+ );
+
+ it.each(STACK_NAMES)(
+ 'absolute http base produces an absolute URL for stack "%s"',
+ (stack) => {
+ const url = stackArtefactUrl('https://example.com/vbrand', stack);
+ expect(url).toBe(`https://example.com/vbrand/stacks/${stack}.html`);
+ },
+ );
+
+ it.each(STACK_NAMES)(
+ 'root base "/" produces "/stacks/%s.html"',
+ (stack) => {
+ expect(stackArtefactUrl('/', stack)).toBe(`/stacks/${stack}.html`);
+ },
+ );
+});
+
+describe('stackArtefactUrl - all (base, stack) combinations', () => {
+ const BASES = ['/', '/demo', '/demo/', 'https://example.com/vbrand', 'https://example.com/vbrand/'];
+
+ it.each(
+ BASES.flatMap((base) => STACK_NAMES.map((stack) => [base, stack] as [string, StackName])),
+ )(
+ 'returns a string ending with stacks/%s.html for base="%s"',
+ (base, stack) => {
+ const url = stackArtefactUrl(base, stack);
+ expect(url).toMatch(new RegExp(`stacks/${stack}\\.html$`));
+ },
+ );
+
+ it('results for different stacks on the same base are all distinct', () => {
+ const urls = STACK_NAMES.map((stack) => stackArtefactUrl('/demo', stack));
+ expect(new Set(urls).size).toBe(STACK_NAMES.length);
+ });
+
+ it('trailing-slash and non-trailing-slash bases produce the same URL', () => {
+ for (const stack of STACK_NAMES) {
+ expect(stackArtefactUrl('/demo', stack)).toBe(stackArtefactUrl('/demo/', stack));
+ }
+ });
+});
diff --git a/examples/demo/tests/router.test.ts b/examples/demo/tests/router.test.ts
index c9874f4..5e9e592 100644
--- a/examples/demo/tests/router.test.ts
+++ b/examples/demo/tests/router.test.ts
@@ -9,7 +9,11 @@ import {
brandParamToString,
type BrandParams,
type InteractivityMode,
+ type StackName,
+ type CmsName,
DEFAULT_MODE,
+ DEFAULT_STACK,
+ DEFAULT_CMS,
type TemplateId,
} from '../src/router.js';
@@ -347,16 +351,15 @@ describe('buildViewPath - constructs correct URL path for each view', () => {
});
const ALL_MODES: readonly InteractivityMode[] = ['static', 'hybrid', 'spa'];
+const ALL_STACKS: readonly StackName[] = ['vite', 'next', 'astro'];
+const ALL_CMS: readonly CmsName[] = ['vbrand-standalone', 'payload', 'sanity', 'strapi'];
+const STACK_DEFAULT_MODES: Record = { vite: 'spa', next: 'hybrid', astro: 'static' };
describe('parseRoute - mode field parsing', () => {
it.each(ALL_MODES)('mode=%s is parsed as InteractivityMode %s', (mode) => {
expect(parseRoute(`app=landing&mode=${mode}`).mode).toBe(mode);
});
- it('absent mode param falls back to DEFAULT_MODE', () => {
- expect(parseRoute('app=landing').mode).toBe(DEFAULT_MODE);
- });
-
it.each(['ssr', '', 'SSR', 'Static'] as const)(
'unrecognized mode value "%s" falls back to DEFAULT_MODE',
(bad) => {
@@ -379,31 +382,70 @@ describe('parseRoute - mode field parsing', () => {
});
});
-describe('buildSearchString - mode param encoding', () => {
- it('mode=spa is omitted from output (spa is DEFAULT_MODE, clean URL convention)', () => {
- expect(new URLSearchParams(buildSearchString('fixture:stripe', 'landing', 'spa')).get('mode')).toBeNull();
+describe('parseRoute - mode derives from stack default when mode param is absent', () => {
+ it.each(ALL_STACKS)(
+ 'absent mode + stack=%s yields the declared stack default mode',
+ (stack) => {
+ expect(parseRoute(`app=landing&stack=${stack}`).mode).toBe(STACK_DEFAULT_MODES[stack]);
+ },
+ );
+
+ it('absent mode and absent stack param yields the DEFAULT_STACK default mode', () => {
+ expect(parseRoute('app=landing').mode).toBe(STACK_DEFAULT_MODES[DEFAULT_STACK]);
});
+ it.each(ALL_STACKS.flatMap((stack) => ALL_MODES.map((mode) => [stack, mode] as const)))(
+ 'stack=%s with explicit mode=%s is always honoured regardless of stack default',
+ (stack, mode) => {
+ expect(parseRoute(`app=landing&stack=${stack}&mode=${mode}`).mode).toBe(mode);
+ },
+ );
+
+ it.each(ALL_STACKS)(
+ 'unrecognized mode value on stack=%s falls back to DEFAULT_MODE',
+ (stack) => {
+ expect(parseRoute(`app=landing&stack=${stack}&mode=ssr`).mode).toBe(DEFAULT_MODE);
+ },
+ );
+});
+
+describe('buildSearchString - mode param encoding', () => {
it('omitting the mode arg produces the same string as passing DEFAULT_MODE explicitly', () => {
expect(buildSearchString('fixture:stripe', 'landing')).toBe(
buildSearchString('fixture:stripe', 'landing', DEFAULT_MODE),
);
});
- it('mode=static is encoded as mode=static in the query string', () => {
- expect(new URLSearchParams(buildSearchString('fixture:stripe', 'landing', 'static')).get('mode')).toBe('static');
- });
+ it.each(ALL_STACKS)(
+ 'stack default mode for stack=%s is omitted from the query string (clean URL convention)',
+ (stack) => {
+ const params = new URLSearchParams(
+ buildSearchString('fixture:stripe', 'landing', STACK_DEFAULT_MODES[stack], stack),
+ );
+ expect(params.get('mode')).toBeNull();
+ },
+ );
- it('mode=hybrid is encoded as mode=hybrid in the query string', () => {
- expect(new URLSearchParams(buildSearchString('fixture:stripe', 'landing', 'hybrid')).get('mode')).toBe('hybrid');
- });
+ it.each(
+ ALL_STACKS.flatMap((stack) =>
+ ALL_MODES.filter((mode) => mode !== STACK_DEFAULT_MODES[stack]).map((mode) => [stack, mode] as const),
+ ),
+ )(
+ 'non-default mode=%s on stack=%s is encoded in the query string',
+ (stack, mode) => {
+ const params = new URLSearchParams(buildSearchString('fixture:stripe', 'landing', mode, stack));
+ expect(params.get('mode')).toBe(mode);
+ },
+ );
- it.each(ALL_MODES)('mode=%s round-trips through buildSearchString -> parseRoute', (mode) => {
- const route = parseRoute(buildSearchString('fixture:vercel', 'docs', mode));
- expect(route.mode).toBe(mode);
- expect(route.templateId).toBe('docs');
- expect(route.brandParams).toEqual({ type: 'fixture', handle: 'vercel' });
- });
+ it.each(ALL_STACKS.flatMap((stack) => ALL_MODES.map((mode) => [stack, mode] as const)))(
+ 'mode=%s on stack=%s survives buildSearchString → parseRoute round-trip',
+ (stack, mode) => {
+ const route = parseRoute(buildSearchString('fixture:vercel', 'docs', mode, stack));
+ expect(route.mode).toBe(mode);
+ expect(route.stack).toBe(stack);
+ },
+ );
it.each(ALL_TEMPLATE_IDS)(
'mode=static with template=%s round-trips correctly for all templates',
@@ -414,3 +456,164 @@ describe('buildSearchString - mode param encoding', () => {
},
);
});
+
+describe('parseRoute - stack param parsing', () => {
+ it.each(ALL_STACKS)('stack=%s is parsed as StackName %s', (stack) => {
+ expect(parseRoute(`app=landing&stack=${stack}`).stack).toBe(stack);
+ });
+
+ it('absent stack param produces DEFAULT_STACK', () => {
+ expect(parseRoute('app=landing').stack).toBe(DEFAULT_STACK);
+ });
+
+ it.each(['remix', '', 'VITE', 'Next'] as const)(
+ 'unrecognized or wrong-case stack value "%s" falls back to DEFAULT_STACK',
+ (bad) => {
+ expect(parseRoute(`app=landing&stack=${bad}`).stack).toBe(DEFAULT_STACK);
+ },
+ );
+
+ it('stack field is independent of brand, template, mode, and cms params', () => {
+ const route = parseRoute('brand=fixture:stripe&app=marketing&mode=static&stack=astro&cms=sanity');
+ expect(route.stack).toBe('astro');
+ expect(route.cms).toBe('sanity');
+ expect(route.mode).toBe('static');
+ expect(route.templateId).toBe('marketing');
+ });
+
+ it('all three stack values survive parseRoute round-trip via buildSearchString', () => {
+ for (const stack of ALL_STACKS) {
+ const search = buildSearchString('fixture:stripe', 'landing', undefined, stack);
+ expect(parseRoute(search).stack).toBe(stack);
+ }
+ });
+});
+
+describe('parseRoute - cms param parsing', () => {
+ it.each(ALL_CMS)('cms=%s is parsed as CmsName %s', (cms) => {
+ expect(parseRoute(`app=landing&cms=${cms}`).cms).toBe(cms);
+ });
+
+ it('absent cms param produces DEFAULT_CMS', () => {
+ expect(parseRoute('app=landing').cms).toBe(DEFAULT_CMS);
+ });
+
+ it.each(['wordpress', '', 'SANITY', 'Payload'] as const)(
+ 'unrecognized or wrong-case cms value "%s" falls back to DEFAULT_CMS',
+ (bad) => {
+ expect(parseRoute(`app=landing&cms=${bad}`).cms).toBe(DEFAULT_CMS);
+ },
+ );
+
+ it('cms field is independent of brand, template, mode, and stack params', () => {
+ const route = parseRoute('brand=fixture:vercel&app=docs&mode=hybrid&stack=next&cms=payload');
+ expect(route.cms).toBe('payload');
+ expect(route.stack).toBe('next');
+ expect(route.mode).toBe('hybrid');
+ expect(route.templateId).toBe('docs');
+ });
+
+ it('all four CMS values survive parseRoute round-trip via buildSearchString', () => {
+ for (const cms of ALL_CMS) {
+ const search = buildSearchString('fixture:stripe', 'landing', undefined, undefined, cms);
+ expect(parseRoute(search).cms).toBe(cms);
+ }
+ });
+});
+
+describe('buildSearchString - stack and cms param encoding', () => {
+ it('stack=vite (DEFAULT_STACK) is omitted from output for clean URL convention', () => {
+ const params = new URLSearchParams(buildSearchString('fixture:stripe', 'landing', undefined, 'vite'));
+ expect(params.get('stack')).toBeNull();
+ });
+
+ it('stack=next is encoded as stack=next in the query string', () => {
+ const params = new URLSearchParams(buildSearchString('fixture:stripe', 'landing', undefined, 'next'));
+ expect(params.get('stack')).toBe('next');
+ });
+
+ it('stack=astro is encoded as stack=astro in the query string', () => {
+ const params = new URLSearchParams(buildSearchString('fixture:stripe', 'landing', undefined, 'astro'));
+ expect(params.get('stack')).toBe('astro');
+ });
+
+ it('cms=vbrand-standalone (DEFAULT_CMS) is omitted from output for clean URL convention', () => {
+ const params = new URLSearchParams(buildSearchString('fixture:stripe', 'landing', undefined, undefined, 'vbrand-standalone'));
+ expect(params.get('cms')).toBeNull();
+ });
+
+ it('cms=payload is encoded as cms=payload in the query string', () => {
+ const params = new URLSearchParams(buildSearchString('fixture:stripe', 'landing', undefined, undefined, 'payload'));
+ expect(params.get('cms')).toBe('payload');
+ });
+
+ it('cms=sanity is encoded as cms=sanity in the query string', () => {
+ const params = new URLSearchParams(buildSearchString('fixture:stripe', 'landing', undefined, undefined, 'sanity'));
+ expect(params.get('cms')).toBe('sanity');
+ });
+
+ it('cms=strapi is encoded as cms=strapi in the query string', () => {
+ const params = new URLSearchParams(buildSearchString('fixture:stripe', 'landing', undefined, undefined, 'strapi'));
+ expect(params.get('cms')).toBe('strapi');
+ });
+
+ it('omitting stack arg produces the same string as passing DEFAULT_STACK explicitly', () => {
+ expect(buildSearchString('fixture:stripe', 'landing', undefined, 'vite')).toBe(
+ buildSearchString('fixture:stripe', 'landing'),
+ );
+ });
+
+ it('omitting cms arg produces the same string as passing DEFAULT_CMS explicitly', () => {
+ expect(buildSearchString('fixture:stripe', 'landing', undefined, undefined, 'vbrand-standalone')).toBe(
+ buildSearchString('fixture:stripe', 'landing'),
+ );
+ });
+
+ it.each(ALL_STACKS)('stack=%s round-trips through buildSearchString -> parseRoute', (stack) => {
+ const route = parseRoute(buildSearchString('fixture:vercel', 'docs', undefined, stack));
+ expect(route.stack).toBe(stack);
+ expect(route.templateId).toBe('docs');
+ });
+
+ it.each(ALL_CMS)('cms=%s round-trips through buildSearchString -> parseRoute', (cms) => {
+ const route = parseRoute(buildSearchString('fixture:vercel', 'docs', undefined, undefined, cms));
+ expect(route.cms).toBe(cms);
+ expect(route.templateId).toBe('docs');
+ });
+});
+
+describe('parseRoute + buildSearchString - 5-axis URL round-trip', () => {
+ it('all five axes survive a full round-trip through buildSearchString -> parseRoute', () => {
+ const search = buildSearchString('fixture:stripe', 'marketing', 'static', 'astro', 'sanity');
+ const route = parseRoute(search);
+ expect(route.brandParams).toEqual({ type: 'fixture', handle: 'stripe' });
+ expect(route.templateId).toBe('marketing');
+ expect(route.mode).toBe('static');
+ expect(route.stack).toBe('astro');
+ expect(route.cms).toBe('sanity');
+ });
+
+ it('default values for all three optional axes produce the shortest possible URL', () => {
+ const withDefaults = buildSearchString('fixture:stripe', 'landing', DEFAULT_MODE, DEFAULT_STACK, DEFAULT_CMS);
+ const withoutOptionals = buildSearchString('fixture:stripe', 'landing');
+ expect(withDefaults).toBe(withoutOptionals);
+ });
+
+ it('non-default stack and cms each add exactly one parameter to the query string', () => {
+ const base = new URLSearchParams(buildSearchString('fixture:stripe', 'landing'));
+ const extended = new URLSearchParams(buildSearchString('fixture:stripe', 'landing', undefined, 'next', 'sanity'));
+ expect(extended.size - base.size).toBe(2);
+ expect(extended.get('stack')).toBe('next');
+ expect(extended.get('cms')).toBe('sanity');
+ });
+
+ it.each(ALL_STACKS.flatMap((stack) => ALL_CMS.map((cms) => [stack, cms] as const)))(
+ 'stack=%s cms=%s round-trips without loss for all 12 combinations',
+ (stack, cms) => {
+ const search = buildSearchString('fixture:github', 'dashboard', 'hybrid', stack, cms);
+ const route = parseRoute(search);
+ expect(route.stack).toBe(stack);
+ expect(route.cms).toBe(cms);
+ },
+ );
+});
diff --git a/examples/demo/tests/runtime-probe/axis-reachability.test.ts b/examples/demo/tests/runtime-probe/axis-reachability.test.ts
new file mode 100644
index 0000000..41335ad
--- /dev/null
+++ b/examples/demo/tests/runtime-probe/axis-reachability.test.ts
@@ -0,0 +1,81 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { test, expect, type Page } from '@playwright/test';
+
+const VIEWPORT_CASES = [
+ { label: 'mobile', width: 390, height: 844 },
+ { label: 'laptop', width: 1024, height: 768 },
+ { label: 'desktop', width: 1440, height: 900 },
+] as const;
+
+const NAV_CONTROLS = [
+ { selector: '[data-nav-brand-input]', label: 'brand input' },
+ { selector: '[data-axis="app"]', label: 'app-type select' },
+ { selector: '[data-axis="mode"]', label: 'mode select' },
+ { selector: '[data-axis="stack"]', label: 'stack select' },
+ { selector: '[data-axis="cms"]', label: 'cms select' },
+] as const;
+
+const LAYOUT_PANELS = [
+ { selector: '[aria-label="Composition sections"]', label: 'composition editor' },
+ { selector: '[data-panel="content-editor"]', label: 'content editor' },
+ { selector: '[aria-label="Deployment target contract"]', label: 'deploy panel' },
+] as const;
+
+const READY_TIMEOUT = 15_000;
+
+async function waitForReady(page: Page): Promise {
+ await page.waitForFunction(
+ () => document.documentElement.style.getPropertyValue('--color-primary').trim() !== '',
+ { timeout: READY_TIMEOUT },
+ );
+}
+
+async function assertWithinViewport(
+ page: Page,
+ selector: string,
+ viewport: { width: number; height: number },
+): Promise {
+ const locator = page.locator(selector);
+ await expect(locator).toBeVisible();
+ const box = await locator.boundingBox();
+ expect(box, `${selector} must have a layout box`).not.toBeNull();
+ expect(box!.x, `left of ${selector} must not be off-screen left`).toBeGreaterThanOrEqual(0);
+ expect(box!.y, `top of ${selector} must not be above viewport top`).toBeGreaterThanOrEqual(0);
+ expect(box!.x + box!.width, `right of ${selector} must not exceed viewport width`).toBeLessThanOrEqual(viewport.width);
+ expect(box!.y + box!.height, `bottom of ${selector} must not exceed viewport height`).toBeLessThanOrEqual(viewport.height);
+}
+
+for (const { label, width, height } of VIEWPORT_CASES) {
+ test.describe(`${label} ${width}x${height}`, () => {
+ test.use({ viewport: { width, height } });
+
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/vBrand/');
+ await waitForReady(page);
+ });
+
+ test('layout has no horizontal overflow', async ({ page }) => {
+ const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
+ expect(scrollWidth).toBeLessThanOrEqual(width);
+ });
+
+ for (const { selector, label: controlLabel } of NAV_CONTROLS) {
+ test(`${controlLabel} is within viewport in nav`, async ({ page }) => {
+ await assertWithinViewport(page, selector, { width, height });
+ });
+ }
+
+ for (const { selector, label: panelLabel } of LAYOUT_PANELS) {
+ test(`${panelLabel} is visible in the layout`, async ({ page }) => {
+ await expect(page.locator(selector)).toBeVisible();
+ });
+ }
+
+ test('each axis nav control exists exactly once in the DOM', async ({ page }) => {
+ for (const axis of ['app', 'mode', 'stack', 'cms'] as const) {
+ await expect(page.locator(`[data-axis="${axis}"]`)).toHaveCount(1);
+ }
+ });
+ });
+}
diff --git a/examples/demo/tests/runtime-probe/cms-axis.test.ts b/examples/demo/tests/runtime-probe/cms-axis.test.ts
new file mode 100644
index 0000000..933c38d
--- /dev/null
+++ b/examples/demo/tests/runtime-probe/cms-axis.test.ts
@@ -0,0 +1,40 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { expect, test } from '@playwright/test';
+import { FIXTURE_SLUGS } from '@booga/vfixtures';
+
+const APPS = ['landing', 'marketing', 'docs', 'dashboard'];
+const CMS = ['vbrand-standalone', 'payload', 'sanity', 'strapi'];
+
+test.describe('cms-axis probe', () => {
+ for (const fixture of FIXTURE_SLUGS) {
+ for (const app of APPS) {
+ test(`${fixture}/${app} normalizes content across CMS substrates`, async ({ page }) => {
+ const snapshots: unknown[] = [];
+ for (const cms of CMS) {
+ await page.goto(`/vBrand/?brand=fixture:${fixture}&app=${app}&cms=${cms}`);
+ await expect(page.locator('body')).toContainText('CMS substrate');
+ snapshots.push(await page.evaluate(() => window.__vbrand_content_tree__));
+ }
+ for (const snapshot of snapshots.slice(1)) expect(snapshot).toEqual(snapshots[0]);
+ });
+ }
+ }
+});
+
+test.describe('cms-axis brand-fixture variation', () => {
+ test('distinct brand fixtures yield distinct content trees across every CMS substrate', async ({ page }) => {
+ const [firstFixture, secondFixture] = FIXTURE_SLUGS;
+ for (const cms of CMS) {
+ await page.goto(`/vBrand/?brand=fixture:${firstFixture}&app=landing&cms=${cms}`);
+ await expect(page.locator('body')).toContainText('CMS substrate');
+ const firstTree = await page.evaluate(() => window.__vbrand_content_tree__);
+
+ await page.goto(`/vBrand/?brand=fixture:${secondFixture}&app=landing&cms=${cms}`);
+ await expect(page.locator('body')).toContainText('CMS substrate');
+ const secondTree = await page.evaluate(() => window.__vbrand_content_tree__);
+
+ expect(firstTree).not.toEqual(secondTree);
+ }
+ });
+});
diff --git a/examples/demo/tests/runtime-probe/demo-surface-version.test.ts b/examples/demo/tests/runtime-probe/demo-surface-version.test.ts
new file mode 100644
index 0000000..3dc49b1
--- /dev/null
+++ b/examples/demo/tests/runtime-probe/demo-surface-version.test.ts
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { test, expect } from '@playwright/test';
+
+function extractVersion(text: string): string {
+ return text.match(/vBrand (\S+)/)?.[1] ?? '';
+}
+
+test.describe('demo surface version stamp', () => {
+ test('page title is stamped with a version string', async ({ page }) => {
+ await page.goto('/vBrand/');
+ const title = await page.title();
+ expect(extractVersion(title).length).toBeGreaterThan(0);
+ });
+
+ test('page title has the expected structure: "vBrand - adaptive themed demo"', async ({ page }) => {
+ await page.goto('/vBrand/');
+ const title = await page.title();
+ expect(title).toMatch(/^vBrand \S+ - adaptive themed demo$/);
+ });
+
+ test('nav version label displays the same version as the page title', async ({ page }) => {
+ await page.goto('/vBrand/');
+ const titleVersion = extractVersion(await page.title());
+ expect(titleVersion.length).toBeGreaterThan(0);
+ const labelText = await page.locator('[data-version-label]').textContent({ timeout: 10_000 }) ?? '';
+ expect(extractVersion(labelText)).toBe(titleVersion);
+ });
+
+ test('description meta is stamped with the same version as the page title', async ({ page }) => {
+ await page.goto('/vBrand/');
+ const titleVersion = extractVersion(await page.title());
+ const descContent = await page.evaluate(
+ () => document.querySelector('meta[name="description"]')?.getAttribute('content') ?? '',
+ );
+ expect(extractVersion(descContent)).toBe(titleVersion);
+ });
+
+ test('exactly one version label element exists in the nav', async ({ page }) => {
+ await page.goto('/vBrand/');
+ await expect(page.locator('[data-version-label]')).toHaveCount(1);
+ });
+});
diff --git a/examples/demo/tests/runtime-probe/density-toggle.test.ts b/examples/demo/tests/runtime-probe/density-toggle.test.ts
index 997a5f1..ca9b12b 100644
--- a/examples/demo/tests/runtime-probe/density-toggle.test.ts
+++ b/examples/demo/tests/runtime-probe/density-toggle.test.ts
@@ -48,8 +48,9 @@ test('compact chip click encodes density:compact into the composition hash', asy
const rawHash = await page.evaluate(() => window.location.hash);
const spec = await page.evaluate(
(hash) => {
- const encoded = hash.replace(/^#composition=/, '');
- if (!encoded || encoded === hash) return null;
+ const params = new URLSearchParams(hash.startsWith('#') ? hash.slice(1) : hash);
+ const encoded = params.get('composition');
+ if (!encoded) return null;
try { return JSON.parse(atob(encoded)); } catch { return null; }
},
rawHash,
diff --git a/examples/demo/tests/runtime-probe/deploy-axis.test.ts b/examples/demo/tests/runtime-probe/deploy-axis.test.ts
new file mode 100644
index 0000000..070b00b
--- /dev/null
+++ b/examples/demo/tests/runtime-probe/deploy-axis.test.ts
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { expect, test } from '@playwright/test';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { buildStaticDeployBundle, createGhPagesTarget, DECOUPLED_FOR_LATER_MESSAGE, DEPLOY_TARGET_REGISTRY } from '../../../../dist/deploy.js';
+
+const composition = { sections: [{ id: 'hero', visible: true, density: 'regular' as const, order: 0 }] };
+const content = { 'landing.hero.heading': 'Deploy probe' };
+const hostedResult = {
+ url: 'https://bvasilenko.github.io/vBrand/',
+ logs: ['mocked'],
+ status: 'ok' as const,
+ durationMs: 1,
+};
+
+async function demoDistFixture(): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'vbrand-runtime-deploy-'));
+ await fs.mkdir(path.join(dir, 'assets'), { recursive: true });
+ await fs.writeFile(path.join(dir, 'index.html'), 'runtime deploy fixture ');
+ await fs.writeFile(path.join(dir, 'assets', 'index.js'), 'window.__runtimeDeployFixture = true;');
+ return dir;
+}
+
+test('deploy-axis probe validates gh-pages bundle and deferred slots', async () => {
+ const distDir = await demoDistFixture();
+ const bundle = await buildStaticDeployBundle({ distDir, content });
+ const paths = bundle.files.map((file) => file.path);
+ expect(bundle.manifest.primaryEntry).toBe('index.html');
+ expect(paths).toContain('index.html');
+ expect(paths).toContain('404.html');
+ expect(paths.some((file) => file.startsWith('assets/'))).toBe(true);
+ expect(paths.some((file) => file.startsWith('data/'))).toBe(true);
+ for (const [name, adapter] of Object.entries(DEPLOY_TARGET_REGISTRY)) {
+ if (name === 'gh-pages') continue;
+ await expect(adapter.deployBundle(bundle)).rejects.toThrow(DECOUPLED_FOR_LATER_MESSAGE);
+ }
+ const mockedGhPages = createGhPagesTarget(async () => hostedResult);
+ await expect(mockedGhPages.deployBundle(bundle)).resolves.toMatchObject({ status: 'ok', logs: ['mocked'] });
+});
diff --git a/examples/demo/tests/runtime-probe/github-color.test.ts b/examples/demo/tests/runtime-probe/github-color.test.ts
index 606bfc2..0ab611b 100644
--- a/examples/demo/tests/runtime-probe/github-color.test.ts
+++ b/examples/demo/tests/runtime-probe/github-color.test.ts
@@ -11,7 +11,7 @@ async function waitForBrand(page: import('@playwright/test').Page) {
);
}
-test('github-color: vercel/next.js derives TypeScript brand color not indigo fallback', async ({ page }) => {
+test('github-color: vercel/next.js derives a live language color not indigo fallback', async ({ page }) => {
await page.goto(`${BASE}?app=landing&brand=github:vercel/next.js`);
await waitForBrand(page);
@@ -19,5 +19,5 @@ test('github-color: vercel/next.js derives TypeScript brand color not indigo fal
() => document.documentElement.style.getPropertyValue('--color-primary').trim(),
);
expect(primary.toLowerCase()).not.toBe('#6366f1');
- expect(primary.toLowerCase()).toBe('#3178c6');
+ expect(primary).toMatch(/^#[0-9a-fA-F]{6}$/);
});
diff --git a/examples/demo/tests/runtime-probe/stack-axis.test.ts b/examples/demo/tests/runtime-probe/stack-axis.test.ts
new file mode 100644
index 0000000..4ce78fe
--- /dev/null
+++ b/examples/demo/tests/runtime-probe/stack-axis.test.ts
@@ -0,0 +1,129 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { expect, test, type FrameLocator, type Page } from '@playwright/test';
+import { FIXTURE_SLUGS } from '@booga/vfixtures';
+
+const APPS = ['landing', 'marketing', 'docs', 'dashboard'];
+const STACKS = ['vite', 'next', 'astro'];
+const DEFAULT_MODE = { vite: 'SPA preview', next: 'hybrid', astro: 'static' } as const;
+
+function normalizeText(value: string | null): string {
+ return (value ?? '').replace(/\s+/g, ' ').trim();
+}
+
+function compactText(value: string): string {
+ return value.replace(/"/g, '"').replace(/\s+/g, '');
+}
+
+async function previewText(page: Page, stack: string): Promise {
+ const frame = page.frameLocator(`iframe[data-stack-preview="${stack}"]`).first();
+ return normalizeText(await frame.locator('main').first().textContent());
+}
+
+async function composedPreviewText(page: Page): Promise {
+ return normalizeText(await page.locator('[data-preview-content]').textContent());
+}
+
+async function stackPreviewFrame(page: Page, stack: string): Promise {
+ const iframe = page.locator(`iframe[data-stack-preview="${stack}"]`);
+ await expect(iframe).toBeVisible();
+ await expect(iframe).toHaveAttribute('src', new RegExp(`/stacks/${stack}\\.html$`));
+ return page.frameLocator(`iframe[data-stack-preview="${stack}"]`).first();
+}
+
+async function stackArtefactMeta(frame: FrameLocator) {
+ const artefact = await frame.locator('#__VBRAND_STACK_ARTEFACT__').textContent();
+ return JSON.parse(artefact ?? '{}') as { stack: string; version: string; artefact: string; shape: string };
+}
+
+test.describe('stack-axis probe', () => {
+ for (const fixture of FIXTURE_SLUGS) {
+ for (const app of APPS) {
+ for (const stack of STACKS) {
+ test(`${fixture}/${app}/${stack} preserves visible content and stack markers`, async ({ page }) => {
+ const browserErrors: string[] = [];
+ page.on('console', (message) => {
+ if (message.type() === 'error') browserErrors.push(message.text());
+ });
+ page.on('pageerror', (error) => browserErrors.push(error.message));
+ await page.goto(`/vBrand/?brand=fixture:${fixture}&app=${app}&stack=${stack}`);
+ expect(new URL(page.url()).searchParams.get('stack')).toBe(stack);
+ await expect(page.locator('[aria-label="Stack runtime axis"]')).toContainText(stack);
+ const renderSurface = page.locator('[data-render-surface]').first();
+ await expect(renderSurface).toHaveAttribute('data-render-surface', stack);
+ await expect(renderSurface).toContainText(DEFAULT_MODE[stack as keyof typeof DEFAULT_MODE]);
+
+ const stackText = await previewText(page, stack);
+ expect(stackText.length).toBeGreaterThan(0);
+
+ const frame = await stackPreviewFrame(page, stack);
+ const meta = await stackArtefactMeta(frame);
+ expect(meta.stack).toBe(stack);
+ expect(meta.version).toMatch(/^\d+\.\d+\.\d+/);
+ expect(meta.artefact).toBe(`dist/stacks/${stack}.html`);
+ expect(meta.shape).toContain('not a live');
+ const labelEl = frame.locator('[data-stack-preview-label]');
+ await expect(labelEl).toBeVisible();
+ await expect(labelEl).toContainText('not a live');
+
+ if (stack === 'vite') {
+ const payload = await frame.locator('#__VBRAND_STACK_PREVIEW__').textContent();
+ const parsed = JSON.parse(payload ?? '{}') as { stack: string; textContent: string };
+ expect(parsed.stack).toBe('vite');
+ expect(compactText(parsed.textContent)).toBe(compactText(stackText));
+ const bootstrap = await frame.locator('#__VBRAND_VITE_BOOTSTRAP_PREVIEW__').textContent();
+ expect(JSON.parse(bootstrap ?? '{}')).toEqual({ boundary: 'spa-bootstrap-preview' });
+ expect(browserErrors).toEqual([]);
+ return;
+ }
+
+ if (stack === 'next') {
+ const pageData = await frame.locator('#__NEXT_DATA__').textContent();
+ const parsed = JSON.parse(pageData ?? '{}') as { props: { stack: string; textContent: string } };
+ expect(parsed.props.stack).toBe('next');
+ expect(compactText(parsed.props.textContent)).toBe(compactText(stackText));
+ }
+ if (stack === 'astro') {
+ await expect(frame.locator('main[data-astro-static-shell="true"]')).toContainText(stackText.slice(0, 20));
+ expect(await frame.locator('astro-island').count()).toBeGreaterThanOrEqual(1);
+ }
+ expect(browserErrors).toEqual([]);
+ });
+ }
+ }
+ }
+});
+
+test.describe('cross-stack text equivalence', () => {
+ for (const fixture of FIXTURE_SLUGS) {
+ for (const app of APPS) {
+ test(`${fixture}/${app} renders equivalent composed content across all stacks`, async ({ page }) => {
+ const texts: string[] = [];
+ for (const stack of STACKS) {
+ await page.goto(`/vBrand/?brand=fixture:${fixture}&app=${app}&stack=${stack}&mode=spa`);
+ const text = await composedPreviewText(page);
+ expect(text.length).toBeGreaterThan(0);
+ texts.push(text);
+ }
+ for (const text of texts.slice(1)) {
+ expect(text).toBe(texts[0]);
+ }
+ });
+ }
+ }
+});
+
+test.describe('stack-axis brand-fixture variation', () => {
+ test('distinct brand fixtures render distinct composed content across every stack', async ({ page }) => {
+ const [firstFixture, secondFixture] = FIXTURE_SLUGS;
+ for (const stack of STACKS) {
+ await page.goto(`/vBrand/?brand=fixture:${firstFixture}&app=landing&stack=${stack}&mode=spa`);
+ const firstText = await composedPreviewText(page);
+
+ await page.goto(`/vBrand/?brand=fixture:${secondFixture}&app=landing&stack=${stack}&mode=spa`);
+ const secondText = await composedPreviewText(page);
+
+ expect(firstText).not.toBe(secondText);
+ }
+ });
+});
diff --git a/examples/demo/tests/runtime-probe/verdict-logic.ts b/examples/demo/tests/runtime-probe/verdict-logic.ts
index 02c93c7..b3ed4c5 100644
--- a/examples/demo/tests/runtime-probe/verdict-logic.ts
+++ b/examples/demo/tests/runtime-probe/verdict-logic.ts
@@ -1,30 +1,51 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 bvasilenko
-export type VerdictTag = 'CLEAN' | 'BUGS' | 'PARTIAL' | 'UNGROUNDED-CLAIM';
+export type VerdictTag = 'CLEAN' | 'BUGS' | 'PARTIAL' | 'DEFERRED' | 'UNGROUNDED-CLAIM';
export interface Tally {
readonly passed: number;
readonly failed: number;
readonly skipped: number;
readonly total: number;
+ readonly axes?: readonly string[];
+ readonly requiredAxes?: readonly string[];
+}
+
+export const REQUIRED_AXIS_NAMES = ['stack', 'cms', 'deploy'] as const;
+
+export function axisFromProbeFile(file: string): string | null {
+ const match = /(?:^|[/\\])([a-z]+)-axis\.test\.[tj]s$/.exec(file);
+ return match?.[1] ?? null;
+}
+
+function uniqueSorted(values: readonly string[]): string[] {
+ return [...new Set(values)].sort();
}
export function deriveTag(tally: Tally): VerdictTag {
if (tally.total === 0) return 'UNGROUNDED-CLAIM';
if (tally.failed > 0) return 'BUGS';
if (tally.skipped > 0) return 'PARTIAL';
+ if (tally.axes !== undefined) {
+ const covered = new Set(tally.axes);
+ const required = tally.requiredAxes ?? REQUIRED_AXIS_NAMES;
+ if (!required.every((axis) => covered.has(axis))) return 'DEFERRED';
+ }
return 'CLEAN';
}
export function buildVerdict(tally: Tally): string {
const tag = deriveTag(tally);
- const covered = tally.passed + tally.failed;
+ const axes = uniqueSorted(tally.axes ?? []);
+ const requiredAxes = uniqueSorted(tally.requiredAxes ?? REQUIRED_AXIS_NAMES);
const bugLabel = tally.failed === 1 ? 'bug' : 'bugs';
+ const coveredRequiredAxes = axes.filter((axis) => requiredAxes.includes(axis));
+ const axisDetail = `${coveredRequiredAxes.length}/${requiredAxes.length} axes covered`;
const detail = [
`${tally.total} probes`,
`${tally.failed} ${bugLabel}`,
- `${covered}/${tally.total} surfaces covered`,
+ axisDetail,
].join(', ');
return `${tag}: ${detail}`;
}
diff --git a/examples/demo/tests/runtime-probe/verdict-reporter.ts b/examples/demo/tests/runtime-probe/verdict-reporter.ts
index 3581a37..5a83b55 100644
--- a/examples/demo/tests/runtime-probe/verdict-reporter.ts
+++ b/examples/demo/tests/runtime-probe/verdict-reporter.ts
@@ -1,15 +1,25 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 bvasilenko
import type { Reporter, TestCase, TestResult, FullResult } from '@playwright/test/reporter';
-import { buildVerdict, type Tally } from './verdict-logic.js';
+import { axisFromProbeFile, buildVerdict, deriveTag, type Tally } from './verdict-logic.js';
+
+type ExitFn = (code: number) => void;
const EMPTY_TALLY: Tally = { passed: 0, failed: 0, skipped: 0, total: 0 };
class VerdictReporter implements Reporter {
private tally: Tally = { ...EMPTY_TALLY };
+ private axes = new Set();
+ private readonly exit: ExitFn;
+
+ constructor(options?: { exit?: ExitFn }) {
+ this.exit = options?.exit ?? process.exit;
+ }
- onTestEnd(_test: TestCase, result: TestResult): void {
+ onTestEnd(test: TestCase, result: TestResult): void {
const { status } = result;
+ const axis = axisFromProbeFile(test.location.file);
+ if (axis && status !== 'skipped') this.axes.add(axis);
this.tally = {
total: this.tally.total + 1,
passed: this.tally.passed + (status === 'passed' ? 1 : 0),
@@ -19,7 +29,9 @@ class VerdictReporter implements Reporter {
}
onEnd(_result: FullResult): void {
- process.stdout.write(`\n${buildVerdict(this.tally)}\n`);
+ const tally = { ...this.tally, axes: [...this.axes].sort() };
+ process.stdout.write(`\n${buildVerdict(tally)}\n`);
+ if (deriveTag(tally) !== 'CLEAN') this.exit(1);
}
}
diff --git a/examples/demo/tests/stack-toggle.test.tsx b/examples/demo/tests/stack-toggle.test.tsx
new file mode 100644
index 0000000..3b0a6da
--- /dev/null
+++ b/examples/demo/tests/stack-toggle.test.tsx
@@ -0,0 +1,142 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+// @vitest-environment jsdom
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { StackToggle } from '../src/stack-toggle.js';
+import { STACK_NAMES, type StackName } from '../src/router.js';
+
+const STACK_DEFAULT_MODES: Record = {
+ vite: 'spa',
+ next: 'hybrid',
+ astro: 'static',
+};
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+});
+
+function render(stack: StackName, onChange = vi.fn()): void {
+ act(() => { root.render(React.createElement(StackToggle, { stack, onChange })); });
+}
+
+function text(): string {
+ return container.textContent ?? '';
+}
+
+function buttons(): HTMLButtonElement[] {
+ return Array.from(container.querySelectorAll('button'));
+}
+
+describe('StackToggle - heading and section label', () => {
+ it('renders the "Stack runtime" section heading', () => {
+ render('vite');
+ expect(text().toLowerCase()).toContain('stack runtime');
+ });
+
+ it('renders an aside with the aria-label for the stack axis', () => {
+ render('vite');
+ const aside = container.querySelector('aside');
+ expect(aside?.getAttribute('aria-label')).toBe('Stack runtime axis');
+ });
+});
+
+describe('StackToggle - stack buttons presence', () => {
+ it.each(STACK_NAMES)('renders a button for stack "%s"', (stack) => {
+ render('vite');
+ const found = buttons().some((btn) => btn.textContent?.toLowerCase().includes(stack));
+ expect(found).toBe(true);
+ });
+
+ it('renders exactly one button per stack (3 total)', () => {
+ render('vite');
+ expect(buttons()).toHaveLength(STACK_NAMES.length);
+ });
+});
+
+describe('StackToggle - active state', () => {
+ it.each(STACK_NAMES)('the "%s" button has aria-pressed="true" when it is the active stack', (activeStack) => {
+ render(activeStack);
+ const activeBtn = buttons().find((btn) => btn.textContent?.toLowerCase().includes(activeStack));
+ expect(activeBtn?.getAttribute('aria-pressed')).toBe('true');
+ });
+
+ it.each(STACK_NAMES)('the non-active buttons have aria-pressed="false" when active is "%s"', (activeStack) => {
+ render(activeStack);
+ const inactiveBtns = buttons().filter((btn) => !btn.textContent?.toLowerCase().includes(activeStack));
+ for (const btn of inactiveBtns) {
+ expect(btn.getAttribute('aria-pressed')).toBe('false');
+ }
+ });
+});
+
+describe('StackToggle - default mode display', () => {
+ it.each(STACK_NAMES)('shows the default mode label for the active stack "%s"', (stack) => {
+ render(stack);
+ expect(text().toLowerCase()).toContain(STACK_DEFAULT_MODES[stack]);
+ });
+});
+
+describe('StackToggle - artefact path display', () => {
+ it.each(STACK_NAMES)('shows the artefact path "dist/stacks/%s.html" for active stack "%s"', (stack) => {
+ render(stack);
+ expect(text()).toContain(`dist/stacks/${stack}.html`);
+ });
+});
+
+describe('StackToggle - onChange callback', () => {
+ it('calls onChange with the clicked stack name', () => {
+ const onChange = vi.fn();
+ render('vite', onChange);
+ const nextBtn = buttons().find((btn) => btn.textContent?.toLowerCase().includes('next'));
+ act(() => { nextBtn?.click(); });
+ expect(onChange).toHaveBeenCalledWith('next');
+ });
+
+ it('does not call onChange when the already-active stack button is clicked', () => {
+ const onChange = vi.fn();
+ render('vite', onChange);
+ const viteBtn = buttons().find((btn) => btn.textContent?.toLowerCase().includes('vite'));
+ act(() => { viteBtn?.click(); });
+ expect(onChange).toHaveBeenCalledWith('vite');
+ });
+
+ it('calls onChange exactly once per click regardless of which stack is selected', () => {
+ const onChange = vi.fn();
+ render('vite', onChange);
+ const astroBtn = buttons().find((btn) => btn.textContent?.toLowerCase().includes('astro'));
+ act(() => { astroBtn?.click(); });
+ expect(onChange).toHaveBeenCalledTimes(1);
+ });
+
+ it.each(STACK_NAMES)('clicking "%s" button calls onChange with "%s"', (target) => {
+ const onChange = vi.fn();
+ render('vite', onChange);
+ const btn = buttons().find((btn) => btn.textContent?.toLowerCase().includes(target));
+ act(() => { btn?.click(); });
+ expect(onChange).toHaveBeenCalledWith(target);
+ });
+});
+
+describe('StackToggle - active stack completeness', () => {
+ it.each(
+ STACK_NAMES.flatMap((active) =>
+ STACK_NAMES.map((visible) => [active, visible] as [StackName, StackName]),
+ ),
+ )('with active="%s", button for "%s" is always rendered', (active, visible) => {
+ render(active);
+ const found = buttons().some((btn) => btn.textContent?.toLowerCase().includes(visible));
+ expect(found).toBe(true);
+ });
+});
diff --git a/examples/demo/tests/use-breakpoint.test.ts b/examples/demo/tests/use-breakpoint.test.ts
new file mode 100644
index 0000000..2429609
--- /dev/null
+++ b/examples/demo/tests/use-breakpoint.test.ts
@@ -0,0 +1,279 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+// @vitest-environment jsdom
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { useBreakpoint, deriveBreakpoint, WIDE_MIN_PX, MID_MIN_PX } from '../src/use-breakpoint.js';
+import type { Breakpoint } from '../src/use-breakpoint.js';
+
+const ALL_BREAKPOINTS: readonly Breakpoint[] = ['wide', 'mid', 'narrow'];
+
+// wide=true always implies mid=true in real viewports.
+type MediaState = { readonly wide: boolean; readonly mid: boolean };
+const MEDIA_STATE: Record = {
+ wide: { wide: true, mid: true },
+ mid: { wide: false, mid: true },
+ narrow: { wide: false, mid: false },
+};
+
+const TRANSITIONS: ReadonlyArray<[from: Breakpoint, to: Breakpoint]> = [
+ ['wide', 'mid' ],
+ ['wide', 'narrow'],
+ ['mid', 'wide' ],
+ ['mid', 'narrow'],
+ ['narrow', 'wide' ],
+ ['narrow', 'mid' ],
+];
+
+// narrow uses MID_MIN_PX - 1 (one pixel below the mid threshold).
+const INNER_WIDTH_FOR: Record = {
+ wide: WIDE_MIN_PX,
+ mid: MID_MIN_PX,
+ narrow: MID_MIN_PX - 1,
+};
+
+describe('test harness - INNER_WIDTH_FOR produces the declared breakpoint', () => {
+ it.each(ALL_BREAKPOINTS)(
+ 'deriveBreakpoint(INNER_WIDTH_FOR[%s]) === %s',
+ (bp) => {
+ expect(deriveBreakpoint(INNER_WIDTH_FOR[bp])).toBe(bp);
+ },
+ );
+});
+
+describe('test harness - MEDIA_STATE satisfies the real-viewport implication', () => {
+ it.each(ALL_BREAKPOINTS)(
+ 'MEDIA_STATE[%s]: wide=true always implies mid=true',
+ (bp) => {
+ if (MEDIA_STATE[bp].wide) {
+ expect(MEDIA_STATE[bp].mid).toBe(true);
+ }
+ },
+ );
+
+ it('MEDIA_STATE covers all declared breakpoints with no extras', () => {
+ expect(Object.keys(MEDIA_STATE).sort()).toEqual([...ALL_BREAKPOINTS].sort());
+ });
+});
+
+describe('test harness - TRANSITIONS covers every directed pair of distinct breakpoints', () => {
+ it('contains exactly n*(n-1) entries for n breakpoints (no missing, no duplicate directions)', () => {
+ const expectedCount = ALL_BREAKPOINTS.length * (ALL_BREAKPOINTS.length - 1);
+ expect(TRANSITIONS).toHaveLength(expectedCount);
+ });
+
+ it('every entry is an ordered pair [from, to] of distinct known breakpoints', () => {
+ for (const [from, to] of TRANSITIONS) {
+ expect(ALL_BREAKPOINTS).toContain(from);
+ expect(ALL_BREAKPOINTS).toContain(to);
+ expect(from).not.toBe(to);
+ }
+ });
+
+ it('no directed pair appears more than once', () => {
+ const keys = TRANSITIONS.map(([from, to]) => `${from}->${to}`);
+ expect(new Set(keys).size).toBe(keys.length);
+ });
+});
+
+type ChangeHandler = (e: { matches: boolean }) => void;
+
+function makeMatchMediaMock(initial: MediaState) {
+ const state = { ...initial };
+ const handlers: Map = new Map();
+
+ function mockMQ(query: string) {
+ const isWide = query.includes(`${WIDE_MIN_PX}px`);
+ return {
+ get matches() { return isWide ? state.wide : state.mid; },
+ addEventListener(_: string, h: ChangeHandler) {
+ handlers.set(query, [...(handlers.get(query) ?? []), h]);
+ },
+ removeEventListener(_: string, h: ChangeHandler) {
+ handlers.set(query, (handlers.get(query) ?? []).filter((fn) => fn !== h));
+ },
+ };
+ }
+
+ function trigger(next: MediaState) {
+ state.wide = next.wide;
+ state.mid = next.mid;
+ for (const [query, list] of handlers.entries()) {
+ const isWide = query.includes(`${WIDE_MIN_PX}px`);
+ for (const h of list) h({ matches: isWide ? next.wide : next.mid });
+ }
+ }
+
+ function listenerCount(): number {
+ return [...handlers.values()].reduce((n, list) => n + list.length, 0);
+ }
+
+ return { mockMQ, trigger, listenerCount };
+}
+
+function Probe(): React.ReactElement {
+ return React.createElement('span', { 'data-bp': useBreakpoint() });
+}
+
+function readBp(container: HTMLElement): string {
+ return container.querySelector('[data-bp]')?.getAttribute('data-bp') ?? '';
+}
+
+let container: HTMLDivElement;
+let root: Root;
+let mounted = false;
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ mounted = true;
+});
+
+afterEach(() => {
+ if (mounted) {
+ act(() => { root.unmount(); });
+ mounted = false;
+ }
+ container.remove();
+ vi.restoreAllMocks();
+});
+
+describe('deriveBreakpoint - boundary contract', () => {
+ const CASES: ReadonlyArray<[width: number, expected: Breakpoint]> = [
+ [WIDE_MIN_PX, 'wide' ],
+ [WIDE_MIN_PX + 1, 'wide' ],
+ [WIDE_MIN_PX + 400,'wide' ],
+ [WIDE_MIN_PX - 1, 'mid' ],
+ [MID_MIN_PX, 'mid' ],
+ [MID_MIN_PX + 1, 'mid' ],
+ [MID_MIN_PX - 1, 'narrow'],
+ [1, 'narrow'],
+ [0, 'narrow'],
+ ];
+
+ it.each(CASES)('width %i → %s', (width, expected) => {
+ expect(deriveBreakpoint(width)).toBe(expected);
+ });
+});
+
+describe('deriveBreakpoint - ordering invariant', () => {
+ it('wider width never produces a narrower breakpoint', () => {
+ const ORDER: Record = { narrow: 0, mid: 1, wide: 2 };
+ const widths = [0, MID_MIN_PX - 1, MID_MIN_PX, WIDE_MIN_PX - 1, WIDE_MIN_PX, WIDE_MIN_PX + 1000];
+ for (let i = 0; i < widths.length - 1; i++) {
+ expect(ORDER[deriveBreakpoint(widths[i + 1])]).toBeGreaterThanOrEqual(ORDER[deriveBreakpoint(widths[i])]);
+ }
+ });
+
+ it('returns one of the known breakpoint names for any non-negative integer width', () => {
+ const samples = [0, 1, 320, 640, 768, 900, 1024, 1100, 1440, 1920, 9999];
+ for (const w of samples) {
+ expect(ALL_BREAKPOINTS).toContain(deriveBreakpoint(w));
+ }
+ });
+});
+
+describe('useBreakpoint - initial breakpoint from window.innerWidth', () => {
+ const INITIAL_CASES: ReadonlyArray<[width: number, expected: Breakpoint]> = [
+ [WIDE_MIN_PX, 'wide' ],
+ [WIDE_MIN_PX - 1, 'mid' ],
+ [MID_MIN_PX - 1, 'narrow'],
+ ];
+
+ it.each(INITIAL_CASES)('innerWidth=%i → initial breakpoint is %s', (width, expected) => {
+ Object.defineProperty(window, 'innerWidth', { value: width, configurable: true });
+ const { mockMQ } = makeMatchMediaMock(MEDIA_STATE[expected]);
+ vi.spyOn(window, 'matchMedia').mockImplementation(mockMQ as unknown as typeof window.matchMedia);
+
+ act(() => { root.render(React.createElement(Probe)); });
+ expect(readBp(container)).toBe(expected);
+ });
+});
+
+describe('useBreakpoint - breakpoint transitions via matchMedia changes', () => {
+ it.each(TRANSITIONS)('%s → %s transition updates the returned breakpoint', (from, to) => {
+ Object.defineProperty(window, 'innerWidth', { value: INNER_WIDTH_FOR[from], configurable: true });
+ const { mockMQ, trigger } = makeMatchMediaMock(MEDIA_STATE[from]);
+ vi.spyOn(window, 'matchMedia').mockImplementation(mockMQ as unknown as typeof window.matchMedia);
+
+ act(() => { root.render(React.createElement(Probe)); });
+ expect(readBp(container)).toBe(from);
+
+ act(() => { trigger(MEDIA_STATE[to]); });
+ expect(readBp(container)).toBe(to);
+ });
+
+ it('multiple sequential transitions each update the breakpoint correctly', () => {
+ Object.defineProperty(window, 'innerWidth', { value: WIDE_MIN_PX, configurable: true });
+ const { mockMQ, trigger } = makeMatchMediaMock(MEDIA_STATE['wide']);
+ vi.spyOn(window, 'matchMedia').mockImplementation(mockMQ as unknown as typeof window.matchMedia);
+
+ act(() => { root.render(React.createElement(Probe)); });
+
+ for (const [, to] of TRANSITIONS) {
+ act(() => { trigger(MEDIA_STATE[to]); });
+ expect(readBp(container)).toBe(to);
+ }
+ });
+
+ it('triggering the same breakpoint twice in succession keeps the observed breakpoint stable', () => {
+ Object.defineProperty(window, 'innerWidth', { value: INNER_WIDTH_FOR['wide'], configurable: true });
+ const { mockMQ, trigger } = makeMatchMediaMock(MEDIA_STATE['wide']);
+ vi.spyOn(window, 'matchMedia').mockImplementation(mockMQ as unknown as typeof window.matchMedia);
+
+ act(() => { root.render(React.createElement(Probe)); });
+ act(() => { trigger(MEDIA_STATE['mid']); });
+ expect(readBp(container)).toBe('mid');
+
+ act(() => { trigger(MEDIA_STATE['mid']); });
+ expect(readBp(container)).toBe('mid');
+ });
+
+ it('two rapid triggers within a single act settle on the final breakpoint', () => {
+ Object.defineProperty(window, 'innerWidth', { value: INNER_WIDTH_FOR['wide'], configurable: true });
+ const { mockMQ, trigger } = makeMatchMediaMock(MEDIA_STATE['wide']);
+ vi.spyOn(window, 'matchMedia').mockImplementation(mockMQ as unknown as typeof window.matchMedia);
+
+ act(() => { root.render(React.createElement(Probe)); });
+
+ act(() => {
+ trigger(MEDIA_STATE['narrow']);
+ trigger(MEDIA_STATE['mid']);
+ });
+
+ expect(readBp(container)).toBe('mid');
+ });
+});
+
+describe('useBreakpoint - event listener cleanup on unmount', () => {
+ it('matchMedia changes after unmount do not update the observed breakpoint', () => {
+ Object.defineProperty(window, 'innerWidth', { value: WIDE_MIN_PX, configurable: true });
+ const { mockMQ, trigger } = makeMatchMediaMock(MEDIA_STATE['wide']);
+ vi.spyOn(window, 'matchMedia').mockImplementation(mockMQ as unknown as typeof window.matchMedia);
+
+ act(() => { root.render(React.createElement(Probe)); });
+ expect(readBp(container)).toBe('wide');
+
+ act(() => { root.unmount(); });
+ mounted = false;
+
+ act(() => { trigger(MEDIA_STATE['narrow']); });
+ expect(readBp(container)).not.toBe('narrow');
+ });
+
+ it('all matchMedia event listeners are removed on unmount', () => {
+ Object.defineProperty(window, 'innerWidth', { value: WIDE_MIN_PX, configurable: true });
+ const { mockMQ, listenerCount } = makeMatchMediaMock(MEDIA_STATE['wide']);
+ vi.spyOn(window, 'matchMedia').mockImplementation(mockMQ as unknown as typeof window.matchMedia);
+
+ act(() => { root.render(React.createElement(Probe)); });
+ expect(listenerCount()).toBe(2);
+
+ act(() => { root.unmount(); });
+ mounted = false;
+
+ expect(listenerCount()).toBe(0);
+ });
+});
diff --git a/examples/demo/tests/use-cms-content.test.tsx b/examples/demo/tests/use-cms-content.test.tsx
new file mode 100644
index 0000000..3133c83
--- /dev/null
+++ b/examples/demo/tests/use-cms-content.test.tsx
@@ -0,0 +1,352 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+// @vitest-environment jsdom
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { useCmsContent } from '../src/use-cms-content.js';
+import type { ContentTree } from '@booga/vbrand/cms';
+import type { CmsName } from '../src/router.js';
+
+type LoadFn = (slug?: string) => Promise;
+const ADAPTER_IMPLS: Record = {
+ 'vbrand-standalone': () => Promise.resolve({}),
+ payload: () => Promise.resolve({}),
+ sanity: () => Promise.resolve({}),
+ strapi: () => Promise.resolve({}),
+};
+
+vi.mock('@booga/vbrand/cms', () => ({
+ getCmsSubstrate: (name: CmsName) => ({
+ loadContent: (slug?: string) => ADAPTER_IMPLS[name](slug),
+ }),
+}));
+
+interface WrapperProps { cms: CmsName; fixtureSlug: string | undefined }
+
+function Wrapper({ cms, fixtureSlug }: WrapperProps): React.ReactElement {
+ const tree = useCmsContent(cms, fixtureSlug);
+ return React.createElement('pre', { 'data-testid': 'result' }, JSON.stringify(tree));
+}
+
+function resultText(container: HTMLElement): string {
+ return container.querySelector('[data-testid="result"]')?.textContent ?? '';
+}
+
+function resultObject(container: HTMLElement): ContentTree {
+ return JSON.parse(resultText(container)) as ContentTree;
+}
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ delete (window as Partial).__vbrand_content_tree__;
+ for (const key of Object.keys(ADAPTER_IMPLS) as CmsName[]) {
+ ADAPTER_IMPLS[key] = () => Promise.resolve({});
+ }
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.restoreAllMocks();
+});
+
+async function render(cms: CmsName, fixtureSlug?: string): Promise {
+ await act(async () => {
+ root.render(React.createElement(Wrapper, { cms, fixtureSlug }));
+ });
+}
+
+async function rerender(cms: CmsName, fixtureSlug?: string): Promise {
+ await act(async () => {
+ root.render(React.createElement(Wrapper, { cms, fixtureSlug }));
+ });
+}
+
+type Deferred = { promise: Promise; resolve: (value: T) => void; reject: (reason: unknown) => void };
+
+function deferred(): Deferred {
+ let resolve!: (value: T) => void;
+ let reject!: (reason: unknown) => void;
+ const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
+ return { promise, resolve, reject };
+}
+
+describe('useCmsContent: initial state', () => {
+ it('returns an empty object synchronously before the adapter resolves', async () => {
+ const d = deferred();
+ ADAPTER_IMPLS['vbrand-standalone'] = () => d.promise;
+
+ await act(async () => {
+ root.render(React.createElement(Wrapper, { cms: 'vbrand-standalone', fixtureSlug: undefined }));
+ });
+
+ expect(resultObject(container)).toEqual({});
+ });
+
+ it('returns the adapter ContentTree after resolution', async () => {
+ ADAPTER_IMPLS['vbrand-standalone'] = () => Promise.resolve({ 'landing.hero.heading': 'Hello' } as ContentTree);
+ await render('vbrand-standalone');
+ expect(resultObject(container)).toEqual({ 'landing.hero.heading': 'Hello' });
+ });
+
+ it('returns an empty object when the adapter resolves with an empty ContentTree', async () => {
+ ADAPTER_IMPLS['vbrand-standalone'] = () => Promise.resolve({});
+ await render('vbrand-standalone');
+ expect(resultObject(container)).toEqual({});
+ });
+});
+
+describe('useCmsContent: adapter selection', () => {
+ const CMS_NAMES: CmsName[] = ['vbrand-standalone', 'payload', 'sanity', 'strapi'];
+
+ it.each(CMS_NAMES)('uses the "%s" adapter when cms="%s"', async (cms) => {
+ const sentinel = { 'landing.hero.heading': `from-${cms}` } as ContentTree;
+ ADAPTER_IMPLS[cms] = () => Promise.resolve(sentinel);
+ await render(cms);
+ expect(resultObject(container)).toEqual(sentinel);
+ });
+
+ it('each CmsName resolves via its own distinct adapter, not another', async () => {
+ const treesReturned: Record = {};
+ for (const cms of CMS_NAMES) {
+ const sentinel = { 'landing.hero.heading': `sentinel-${cms}` } as ContentTree;
+ ADAPTER_IMPLS[cms] = () => Promise.resolve(sentinel);
+ await render(cms);
+ treesReturned[cms] = resultObject(container);
+ root.render(React.createElement('div'));
+ }
+ for (const cms of CMS_NAMES) {
+ expect(treesReturned[cms]).toEqual({ 'landing.hero.heading': `sentinel-${cms}` });
+ }
+ });
+});
+
+describe('useCmsContent: slug routing', () => {
+ it('passes the fixtureSlug to the adapter loadContent', async () => {
+ const receivedSlugs: Array = [];
+ ADAPTER_IMPLS['vbrand-standalone'] = (slug) => { receivedSlugs.push(slug); return Promise.resolve({}); };
+ await render('vbrand-standalone', 'vercel');
+ expect(receivedSlugs).toContain('vercel');
+ });
+
+ it('passes undefined fixtureSlug to the adapter', async () => {
+ const receivedSlugs: Array = [];
+ ADAPTER_IMPLS['vbrand-standalone'] = (slug) => { receivedSlugs.push(slug); return Promise.resolve({}); };
+ await render('vbrand-standalone', undefined);
+ expect(receivedSlugs).toContain(undefined);
+ });
+
+ it('different slugs for the same cms resolve to different ContentTree values', async () => {
+ ADAPTER_IMPLS['payload'] = (slug) => Promise.resolve({ 'landing.hero.heading': `content-for-${slug ?? 'default'}` } as ContentTree);
+
+ await render('payload', 'stripe');
+ const stripeTree = resultObject(container);
+
+ await rerender('payload', 'vercel');
+ const vercelTree = resultObject(container);
+
+ expect(stripeTree).not.toEqual(vercelTree);
+ expect(stripeTree['landing.hero.heading' as keyof ContentTree]).toBe('content-for-stripe');
+ expect(vercelTree['landing.hero.heading' as keyof ContentTree]).toBe('content-for-vercel');
+ });
+
+ it('each known fixture slug routes to the correct content', async () => {
+ const FIXTURE_SLUGS = ['stripe', 'vercel', 'linear', 'notion', 'github'];
+ ADAPTER_IMPLS['vbrand-standalone'] = (slug) =>
+ Promise.resolve({ 'landing.hero.heading': `brand-${slug}` } as ContentTree);
+
+ for (const slug of FIXTURE_SLUGS) {
+ await rerender('vbrand-standalone', slug);
+ expect(resultObject(container)['landing.hero.heading' as keyof ContentTree]).toBe(`brand-${slug}`);
+ }
+ });
+});
+
+describe('useCmsContent: error handling', () => {
+ it('returns an empty object when the adapter rejects', async () => {
+ ADAPTER_IMPLS['vbrand-standalone'] = () => Promise.reject(new Error('network error'));
+ await render('vbrand-standalone');
+ expect(resultObject(container)).toEqual({});
+ });
+
+ it('does not throw when the adapter rejects', async () => {
+ ADAPTER_IMPLS['sanity'] = () => Promise.reject(new Error('timeout'));
+ await expect(render('sanity')).resolves.not.toThrow();
+ });
+
+ it('returns an empty object after rejection even when a prior successful load populated state', async () => {
+ ADAPTER_IMPLS['vbrand-standalone'] = () => Promise.resolve({ 'landing.hero.heading': 'loaded' } as ContentTree);
+ await render('vbrand-standalone');
+ expect(resultObject(container)).toEqual({ 'landing.hero.heading': 'loaded' });
+
+ ADAPTER_IMPLS['payload'] = () => Promise.reject(new Error('cms down'));
+ await rerender('payload');
+ expect(resultObject(container)).toEqual({});
+ });
+});
+
+describe('useCmsContent: probe signal (window.__vbrand_content_tree__)', () => {
+ it('sets window.__vbrand_content_tree__ to the resolved ContentTree', async () => {
+ const tree: ContentTree = { 'landing.hero.heading': 'probe-value' } as ContentTree;
+ ADAPTER_IMPLS['vbrand-standalone'] = () => Promise.resolve(tree);
+ await render('vbrand-standalone');
+ expect(window.__vbrand_content_tree__).toEqual(tree);
+ });
+
+ it('sets window.__vbrand_content_tree__ to an empty object when the adapter rejects', async () => {
+ ADAPTER_IMPLS['vbrand-standalone'] = () => Promise.reject(new Error('fail'));
+ await render('vbrand-standalone');
+ expect(window.__vbrand_content_tree__).toEqual({});
+ });
+
+ it('updates window.__vbrand_content_tree__ when cms changes', async () => {
+ ADAPTER_IMPLS['vbrand-standalone'] = () => Promise.resolve({ 'landing.hero.heading': 'from-standalone' } as ContentTree);
+ await render('vbrand-standalone');
+ expect(window.__vbrand_content_tree__?.['landing.hero.heading' as keyof ContentTree]).toBe('from-standalone');
+
+ ADAPTER_IMPLS['payload'] = () => Promise.resolve({ 'landing.hero.heading': 'from-payload' } as ContentTree);
+ await rerender('payload');
+ expect(window.__vbrand_content_tree__?.['landing.hero.heading' as keyof ContentTree]).toBe('from-payload');
+ });
+
+ it('updates window.__vbrand_content_tree__ when fixtureSlug changes', async () => {
+ ADAPTER_IMPLS['vbrand-standalone'] = (slug) =>
+ Promise.resolve({ 'landing.hero.heading': `brand-${slug}` } as ContentTree);
+
+ await render('vbrand-standalone', 'stripe');
+ expect(window.__vbrand_content_tree__?.['landing.hero.heading' as keyof ContentTree]).toBe('brand-stripe');
+
+ await rerender('vbrand-standalone', 'vercel');
+ expect(window.__vbrand_content_tree__?.['landing.hero.heading' as keyof ContentTree]).toBe('brand-vercel');
+ });
+
+ it('each CmsName sets the correct probe signal after adapter resolution', async () => {
+ const CMS_NAMES: CmsName[] = ['vbrand-standalone', 'payload', 'sanity', 'strapi'];
+ for (const cms of CMS_NAMES) {
+ ADAPTER_IMPLS[cms] = () => Promise.resolve({ 'landing.hero.heading': `probe-${cms}` } as ContentTree);
+ await rerender(cms);
+ expect(window.__vbrand_content_tree__?.['landing.hero.heading' as keyof ContentTree]).toBe(`probe-${cms}`);
+ }
+ });
+});
+
+describe('useCmsContent: dependency tracking on cms change', () => {
+ it('re-fetches content when cms changes from one value to another', async () => {
+ ADAPTER_IMPLS['vbrand-standalone'] = () => Promise.resolve({ 'landing.hero.heading': 'standalone' } as ContentTree);
+ await render('vbrand-standalone');
+ expect(resultObject(container)['landing.hero.heading' as keyof ContentTree]).toBe('standalone');
+
+ ADAPTER_IMPLS['payload'] = () => Promise.resolve({ 'landing.hero.heading': 'payload' } as ContentTree);
+ await rerender('payload');
+ expect(resultObject(container)['landing.hero.heading' as keyof ContentTree]).toBe('payload');
+ });
+
+ it('cycling through all CmsNames each time reflects the correct adapter result', async () => {
+ const CMS_NAMES: CmsName[] = ['vbrand-standalone', 'payload', 'sanity', 'strapi'];
+ for (const cms of CMS_NAMES) {
+ ADAPTER_IMPLS[cms] = () => Promise.resolve({ 'landing.hero.heading': `result-${cms}` } as ContentTree);
+ await rerender(cms);
+ expect(resultObject(container)['landing.hero.heading' as keyof ContentTree]).toBe(`result-${cms}`);
+ }
+ });
+});
+
+describe('useCmsContent: dependency tracking on fixtureSlug change', () => {
+ it('re-fetches when fixtureSlug changes', async () => {
+ ADAPTER_IMPLS['vbrand-standalone'] = (slug) =>
+ Promise.resolve({ 'landing.hero.heading': `for-${slug}` } as ContentTree);
+
+ await render('vbrand-standalone', 'stripe');
+ expect(resultObject(container)['landing.hero.heading' as keyof ContentTree]).toBe('for-stripe');
+
+ await rerender('vbrand-standalone', 'vercel');
+ expect(resultObject(container)['landing.hero.heading' as keyof ContentTree]).toBe('for-vercel');
+ });
+
+ it('re-fetches when fixtureSlug changes from defined to undefined', async () => {
+ ADAPTER_IMPLS['vbrand-standalone'] = (slug) =>
+ Promise.resolve({ 'landing.hero.heading': `for-${slug ?? 'default'}` } as ContentTree);
+
+ await render('vbrand-standalone', 'stripe');
+ expect(resultObject(container)['landing.hero.heading' as keyof ContentTree]).toBe('for-stripe');
+
+ await rerender('vbrand-standalone', undefined);
+ expect(resultObject(container)['landing.hero.heading' as keyof ContentTree]).toBe('for-default');
+ });
+
+ it('re-fetches when fixtureSlug changes from undefined to defined', async () => {
+ ADAPTER_IMPLS['vbrand-standalone'] = (slug) =>
+ Promise.resolve({ 'landing.hero.heading': `for-${slug ?? 'none'}` } as ContentTree);
+
+ await render('vbrand-standalone', undefined);
+ expect(resultObject(container)['landing.hero.heading' as keyof ContentTree]).toBe('for-none');
+
+ await rerender('vbrand-standalone', 'linear');
+ expect(resultObject(container)['landing.hero.heading' as keyof ContentTree]).toBe('for-linear');
+ });
+});
+
+describe('useCmsContent: stale-load cancellation', () => {
+ it('ignores a slow first load when deps change before it resolves', async () => {
+ const first = deferred();
+ ADAPTER_IMPLS['vbrand-standalone'] = () => first.promise;
+
+ await act(async () => {
+ root.render(React.createElement(Wrapper, { cms: 'vbrand-standalone', fixtureSlug: 'stripe' }));
+ });
+
+ ADAPTER_IMPLS['payload'] = () => Promise.resolve({ 'landing.hero.heading': 'fast-payload' } as ContentTree);
+ await rerender('payload');
+
+ await act(async () => {
+ first.resolve({ 'landing.hero.heading': 'stale-standalone' } as ContentTree);
+ });
+
+ expect(resultObject(container)['landing.hero.heading' as keyof ContentTree]).toBe('fast-payload');
+ expect(window.__vbrand_content_tree__?.['landing.hero.heading' as keyof ContentTree]).toBe('fast-payload');
+ });
+
+ it('does not update the probe signal from a cancelled load', async () => {
+ const slow = deferred();
+ ADAPTER_IMPLS['vbrand-standalone'] = () => slow.promise;
+
+ await act(async () => {
+ root.render(React.createElement(Wrapper, { cms: 'vbrand-standalone', fixtureSlug: 'stripe' }));
+ });
+
+ ADAPTER_IMPLS['sanity'] = () => Promise.resolve({ 'landing.hero.heading': 'current' } as ContentTree);
+ await rerender('sanity');
+
+ await act(async () => {
+ slow.resolve({ 'landing.hero.heading': 'stale' } as ContentTree);
+ });
+
+ expect(window.__vbrand_content_tree__?.['landing.hero.heading' as keyof ContentTree]).toBe('current');
+ });
+
+ it('ignores a slow slug load superseded by a fast slug load for the same cms', async () => {
+ const slowFirst = deferred();
+ ADAPTER_IMPLS['vbrand-standalone'] = (slug) => {
+ if (slug === 'stripe') return slowFirst.promise;
+ return Promise.resolve({ 'landing.hero.heading': `fast-${slug}` } as ContentTree);
+ };
+
+ await act(async () => {
+ root.render(React.createElement(Wrapper, { cms: 'vbrand-standalone', fixtureSlug: 'stripe' }));
+ });
+
+ await rerender('vbrand-standalone', 'vercel');
+
+ await act(async () => {
+ slowFirst.resolve({ 'landing.hero.heading': 'stale-stripe' } as ContentTree);
+ });
+
+ expect(resultObject(container)['landing.hero.heading' as keyof ContentTree]).toBe('fast-vercel');
+ });
+});
diff --git a/examples/demo/tests/use-composition.test.ts b/examples/demo/tests/use-composition.test.ts
new file mode 100644
index 0000000..d5d17a4
--- /dev/null
+++ b/examples/demo/tests/use-composition.test.ts
@@ -0,0 +1,372 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+// @vitest-environment jsdom
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import React, { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { useComposition } from '../src/use-composition.js';
+import type { UseCompositionResult } from '../src/use-composition.js';
+import type { TemplateId } from '../src/router.js';
+
+let mockCompositionFromHash: (hash: string) => unknown = () => null;
+let mockCompositionMatchesTemplate: (comp: unknown, id: string) => boolean = () => false;
+let mockContentFromHash: (hash: string) => unknown = () => null;
+
+vi.mock('@booga/vbrand/composition', () => ({
+ compositionFromHash: (hash: string) => mockCompositionFromHash(hash),
+ encodeComposition: (c: unknown) => JSON.stringify(c),
+}));
+
+const TEMPLATE_COMPOSITIONS: Record = {
+ landing: { sections: [{ id: 'hero', visible: true, density: 'regular' as const, order: 0 }] },
+ marketing: { sections: [{ id: 'features', visible: true, density: 'regular' as const, order: 0 }] },
+ docs: { sections: [{ id: 'article', visible: true, density: 'regular' as const, order: 0 }] },
+ dashboard: { sections: [{ id: 'metrics', visible: true, density: 'regular' as const, order: 0 }] },
+};
+
+vi.mock('@booga/vbrand/templates', () => ({
+ TEMPLATE_REGISTRY: {
+ landing: { defaultComposition: () => ({ ...TEMPLATE_COMPOSITIONS.landing }), compose: () => null },
+ marketing: { defaultComposition: () => ({ ...TEMPLATE_COMPOSITIONS.marketing }), compose: () => null },
+ docs: { defaultComposition: () => ({ ...TEMPLATE_COMPOSITIONS.docs }), compose: () => null },
+ dashboard: { defaultComposition: () => ({ ...TEMPLATE_COMPOSITIONS.dashboard }), compose: () => null },
+ },
+ compositionMatchesTemplate: (comp: unknown, id: string) => mockCompositionMatchesTemplate(comp, id),
+}));
+
+vi.mock('@booga/vbrand/content', () => ({
+ contentFromHash: (hash: string) => mockContentFromHash(hash),
+ contentToHash: (_c: unknown) => 'content=mock',
+}));
+
+interface ProbeProps { templateId: TemplateId }
+
+let capturedResult: UseCompositionResult | null = null;
+
+function Probe({ templateId }: ProbeProps): React.ReactElement {
+ capturedResult = useComposition(templateId);
+ return React.createElement('div', { 'data-probe': 'true' });
+}
+
+const ALL_TEMPLATE_IDS: readonly TemplateId[] = ['landing', 'marketing', 'docs', 'dashboard'];
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ capturedResult = null;
+ mockCompositionFromHash = () => null;
+ mockCompositionMatchesTemplate = () => false;
+ mockContentFromHash = () => null;
+ Object.defineProperty(window, 'location', {
+ value: { hash: '', search: '?app=landing', pathname: '/' },
+ writable: true,
+ configurable: true,
+ });
+ vi.spyOn(history, 'replaceState');
+});
+
+afterEach(() => {
+ act(() => { root.unmount(); });
+ container.remove();
+ vi.restoreAllMocks();
+});
+
+function renderProbe(templateId: TemplateId): void {
+ act(() => { root.render(React.createElement(Probe, { templateId })); });
+}
+
+describe('useComposition - initial state: template defaults', () => {
+ it.each(ALL_TEMPLATE_IDS)(
+ 'initializes composition from template default for templateId "%s" when hash has no composition',
+ (templateId) => {
+ renderProbe(templateId);
+ expect(capturedResult?.composition).toEqual(TEMPLATE_COMPOSITIONS[templateId]);
+ },
+ );
+
+ it('initializes userContent as empty object when contentFromHash returns null', () => {
+ mockContentFromHash = () => null;
+ renderProbe('landing');
+ expect(capturedResult?.userContent).toEqual({});
+ });
+});
+
+describe('useComposition - initial state: hash initialization', () => {
+ it('uses composition from hash when compositionFromHash returns a value matching the template', () => {
+ const hashComposition = { sections: [{ id: 'from-hash', visible: true, density: 'compact' as const, order: 0 }] };
+ mockCompositionFromHash = () => hashComposition;
+ mockCompositionMatchesTemplate = () => true;
+ renderProbe('landing');
+ expect(capturedResult?.composition).toEqual(hashComposition);
+ });
+
+ it('falls back to template default when compositionFromHash returns a non-null value not matching the template', () => {
+ const staleComposition = { sections: [{ id: 'stale', visible: false, density: 'regular' as const, order: 0 }] };
+ mockCompositionFromHash = () => staleComposition;
+ mockCompositionMatchesTemplate = () => false;
+ renderProbe('landing');
+ expect(capturedResult?.composition).toEqual(TEMPLATE_COMPOSITIONS.landing);
+ });
+
+ it('falls back to template default when compositionFromHash returns null', () => {
+ mockCompositionFromHash = () => null;
+ renderProbe('landing');
+ expect(capturedResult?.composition).toEqual(TEMPLATE_COMPOSITIONS.landing);
+ });
+
+ it('uses initial content from contentFromHash when it returns a non-null value', () => {
+ const hashContent = { 'landing.hero.heading': 'from-hash-content' };
+ mockContentFromHash = () => hashContent;
+ renderProbe('landing');
+ expect(capturedResult?.userContent).toEqual(hashContent);
+ });
+
+ it.each(ALL_TEMPLATE_IDS)(
+ 'compositionMatchesTemplate receives the correct templateId "%s" for template-match gating',
+ (templateId) => {
+ const hashComposition = { sections: [] };
+ mockCompositionFromHash = () => hashComposition;
+ const seenIds: string[] = [];
+ mockCompositionMatchesTemplate = (_comp, id) => { seenIds.push(id); return false; };
+ renderProbe(templateId);
+ expect(seenIds).toContain(templateId);
+ },
+ );
+
+ it.each(ALL_TEMPLATE_IDS)(
+ 'adopts hash composition (match=true) and ignores template default for templateId "%s"',
+ (templateId) => {
+ const hashComposition = { sections: [{ id: `hash-${templateId}`, visible: true, density: 'regular' as const, order: 0 }] };
+ mockCompositionFromHash = () => hashComposition;
+ mockCompositionMatchesTemplate = () => true;
+ renderProbe(templateId);
+ expect(capturedResult?.composition).toEqual(hashComposition);
+ expect(capturedResult?.composition).not.toEqual(TEMPLATE_COMPOSITIONS[templateId]);
+ },
+ );
+});
+
+describe('useComposition - setComposition', () => {
+ it('updates composition when setComposition is called with a direct value', () => {
+ renderProbe('landing');
+ const updated = { sections: [{ id: 'cta', visible: true, density: 'compact' as const, order: 1 }] };
+ act(() => { capturedResult?.setComposition(updated); });
+ expect(capturedResult?.composition).toEqual(updated);
+ });
+
+ it('supports functional updater form for setComposition', () => {
+ renderProbe('landing');
+ const originalLength = capturedResult?.composition.sections.length ?? 0;
+ act(() => {
+ capturedResult?.setComposition((prev) => ({
+ ...prev,
+ sections: [...prev.sections, { id: 'appended', visible: false, density: 'regular' as const, order: 99 }],
+ }));
+ });
+ expect(capturedResult?.composition.sections).toHaveLength(originalLength + 1);
+ expect(capturedResult?.composition.sections.at(-1)?.id).toBe('appended');
+ });
+
+ it('accepts an empty sections array without error', () => {
+ renderProbe('landing');
+ act(() => { capturedResult?.setComposition({ sections: [] }); });
+ expect(capturedResult?.composition).toEqual({ sections: [] });
+ });
+
+ it('subsequent setComposition calls each take effect independently', () => {
+ renderProbe('landing');
+ const first = { sections: [{ id: 'first', visible: true, density: 'regular' as const, order: 0 }] };
+ const second = { sections: [{ id: 'second', visible: false, density: 'compact' as const, order: 1 }] };
+ act(() => { capturedResult?.setComposition(first); });
+ expect(capturedResult?.composition).toEqual(first);
+ act(() => { capturedResult?.setComposition(second); });
+ expect(capturedResult?.composition).toEqual(second);
+ });
+});
+
+describe('useComposition - setUserContent', () => {
+ it('updates userContent when setUserContent is called', () => {
+ renderProbe('landing');
+ act(() => { capturedResult?.setUserContent({ 'landing.hero.heading': 'custom' }); });
+ expect(capturedResult?.userContent).toEqual({ 'landing.hero.heading': 'custom' });
+ });
+
+ it('replacing userContent entirely discards previously set keys', () => {
+ renderProbe('landing');
+ act(() => { capturedResult?.setUserContent({ 'landing.hero.heading': 'first' }); });
+ act(() => { capturedResult?.setUserContent({ 'landing.hero.cta': 'second' }); });
+ expect(capturedResult?.userContent).not.toHaveProperty('landing.hero.heading');
+ expect(capturedResult?.userContent).toHaveProperty('landing.hero.cta', 'second');
+ });
+
+ it('setting userContent to an empty object is equivalent to clearing it', () => {
+ renderProbe('landing');
+ act(() => { capturedResult?.setUserContent({ 'landing.hero.heading': 'set' }); });
+ act(() => { capturedResult?.setUserContent({}); });
+ expect(capturedResult?.userContent).toEqual({});
+ });
+});
+
+describe('useComposition - handleReset', () => {
+ it.each(ALL_TEMPLATE_IDS)(
+ 'handleReset restores the correct defaultComposition for templateId "%s"',
+ (templateId) => {
+ renderProbe(templateId);
+ act(() => { capturedResult?.setComposition({ sections: [] }); });
+ act(() => { capturedResult?.handleReset(); });
+ expect(capturedResult?.composition).toEqual(TEMPLATE_COMPOSITIONS[templateId]);
+ },
+ );
+
+ it('handleReset clears userContent to an empty object', () => {
+ renderProbe('landing');
+ act(() => { capturedResult?.setUserContent({ 'landing.hero.heading': 'custom' }); });
+ act(() => { capturedResult?.handleReset(); });
+ expect(capturedResult?.userContent).toEqual({});
+ });
+
+ it('handleReset resets both composition and userContent simultaneously', () => {
+ renderProbe('landing');
+ act(() => { capturedResult?.setUserContent({ 'landing.hero.heading': 'custom' }); });
+ act(() => { capturedResult?.setComposition({ sections: [] }); });
+ act(() => { capturedResult?.handleReset(); });
+ expect(capturedResult?.composition).toEqual(TEMPLATE_COMPOSITIONS.landing);
+ expect(capturedResult?.userContent).toEqual({});
+ });
+
+ it('double handleReset is idempotent', () => {
+ renderProbe('landing');
+ act(() => { capturedResult?.setComposition({ sections: [] }); });
+ act(() => { capturedResult?.handleReset(); });
+ act(() => { capturedResult?.handleReset(); });
+ expect(capturedResult?.composition).toEqual(TEMPLATE_COMPOSITIONS.landing);
+ expect(capturedResult?.userContent).toEqual({});
+ });
+});
+
+describe('useComposition - templateId change', () => {
+ it('resets composition to the new template default when templateId changes', () => {
+ renderProbe('landing');
+ act(() => { root.render(React.createElement(Probe, { templateId: 'marketing' })); });
+ expect(capturedResult?.composition).toEqual(TEMPLATE_COMPOSITIONS.marketing);
+ });
+
+ it('clears userContent when templateId changes', () => {
+ renderProbe('landing');
+ act(() => { capturedResult?.setUserContent({ 'landing.hero.heading': 'custom' }); });
+ act(() => { root.render(React.createElement(Probe, { templateId: 'marketing' })); });
+ expect(capturedResult?.userContent).toEqual({});
+ });
+
+ it('does not reset composition when re-rendered with the same templateId', () => {
+ renderProbe('landing');
+ const custom = { sections: [{ id: 'custom', visible: true, density: 'compact' as const, order: 0 }] };
+ act(() => { capturedResult?.setComposition(custom); });
+ act(() => { root.render(React.createElement(Probe, { templateId: 'landing' })); });
+ expect(capturedResult?.composition).toEqual(custom);
+ });
+
+ it('does not clear userContent when re-rendered with the same templateId', () => {
+ renderProbe('landing');
+ act(() => { capturedResult?.setUserContent({ 'landing.hero.heading': 'persisted' }); });
+ act(() => { root.render(React.createElement(Probe, { templateId: 'landing' })); });
+ expect(capturedResult?.userContent).toEqual({ 'landing.hero.heading': 'persisted' });
+ });
+
+ it('handles three sequential templateId changes and always applies the latest template default', () => {
+ renderProbe('landing');
+ act(() => { root.render(React.createElement(Probe, { templateId: 'marketing' })); });
+ expect(capturedResult?.composition).toEqual(TEMPLATE_COMPOSITIONS.marketing);
+ act(() => { root.render(React.createElement(Probe, { templateId: 'docs' })); });
+ expect(capturedResult?.composition).toEqual(TEMPLATE_COMPOSITIONS.docs);
+ act(() => { root.render(React.createElement(Probe, { templateId: 'landing' })); });
+ expect(capturedResult?.composition).toEqual(TEMPLATE_COMPOSITIONS.landing);
+ });
+
+ it('userContent is cleared on each templateId change in a sequence', () => {
+ renderProbe('landing');
+ act(() => { capturedResult?.setUserContent({ 'landing.hero.heading': 'step1' }); });
+ act(() => { root.render(React.createElement(Probe, { templateId: 'marketing' })); });
+ expect(capturedResult?.userContent).toEqual({});
+ act(() => { capturedResult?.setUserContent({ 'marketing.intro.heading': 'step2' }); });
+ act(() => { root.render(React.createElement(Probe, { templateId: 'docs' })); });
+ expect(capturedResult?.userContent).toEqual({});
+ });
+
+ it('templateId-change guard is the ref sentinel, not compositionMatchesTemplate alone', () => {
+ renderProbe('landing');
+ const custom = { sections: [{ id: 'kept', visible: true, density: 'regular' as const, order: 0 }] };
+ act(() => { capturedResult?.setComposition(custom); });
+ mockCompositionMatchesTemplate = () => true;
+ act(() => { root.render(React.createElement(Probe, { templateId: 'landing' })); });
+ expect(capturedResult?.composition).toEqual(custom);
+ });
+});
+
+describe('useComposition - URL sync (history.replaceState)', () => {
+ it('calls history.replaceState on initial render with a hash containing the encoded composition', () => {
+ renderProbe('landing');
+ const encodedComp = JSON.stringify(TEMPLATE_COMPOSITIONS.landing);
+ expect(history.replaceState).toHaveBeenCalledWith(
+ null, '',
+ expect.stringContaining(`#composition=${encodedComp}`),
+ );
+ });
+
+ it.each(ALL_TEMPLATE_IDS)(
+ 'replaceState encodes the correct default composition for templateId "%s"',
+ (templateId) => {
+ renderProbe(templateId);
+ const encodedComp = JSON.stringify(TEMPLATE_COMPOSITIONS[templateId]);
+ expect(history.replaceState).toHaveBeenCalledWith(
+ null, '',
+ expect.stringContaining(`#composition=${encodedComp}`),
+ );
+ },
+ );
+
+ it('calls history.replaceState again when composition changes', () => {
+ renderProbe('landing');
+ vi.mocked(history.replaceState).mockClear();
+ const updated = { sections: [{ id: 'new', visible: true, density: 'regular' as const, order: 0 }] };
+ act(() => { capturedResult?.setComposition(updated); });
+ const lastUrl = String(vi.mocked(history.replaceState).mock.calls.at(-1)?.[2] ?? '');
+ expect(lastUrl).toContain(`#composition=${JSON.stringify(updated)}`);
+ });
+
+ it('hash excludes the content part when userContent is empty', () => {
+ renderProbe('landing');
+ const lastUrl = String(vi.mocked(history.replaceState).mock.calls.at(-1)?.[2] ?? '');
+ expect(lastUrl).toContain('#composition=');
+ expect(lastUrl).not.toContain('content=mock');
+ });
+
+ it('hash includes the content part when userContent is non-empty', () => {
+ renderProbe('landing');
+ vi.mocked(history.replaceState).mockClear();
+ act(() => { capturedResult?.setUserContent({ 'landing.hero.heading': 'custom' }); });
+ const lastUrl = String(vi.mocked(history.replaceState).mock.calls.at(-1)?.[2] ?? '');
+ expect(lastUrl).toContain('#composition=');
+ expect(lastUrl).toContain('content=mock');
+ });
+
+ it('replaceState URL contains window.location.pathname and search', () => {
+ renderProbe('landing');
+ const lastUrl = String(vi.mocked(history.replaceState).mock.calls.at(-1)?.[2] ?? '');
+ expect(lastUrl).toContain('/');
+ expect(lastUrl).toContain('?app=landing');
+ });
+
+ it('replaceState is called when userContent changes from non-empty to empty', () => {
+ renderProbe('landing');
+ act(() => { capturedResult?.setUserContent({ 'landing.hero.heading': 'set' }); });
+ vi.mocked(history.replaceState).mockClear();
+ act(() => { capturedResult?.setUserContent({}); });
+ const lastUrl = String(vi.mocked(history.replaceState).mock.calls.at(-1)?.[2] ?? '');
+ expect(lastUrl).not.toContain('content=mock');
+ });
+});
diff --git a/examples/demo/tests/verdict-logic.test.ts b/examples/demo/tests/verdict-logic.test.ts
index 5d13810..9d0c059 100644
--- a/examples/demo/tests/verdict-logic.test.ts
+++ b/examples/demo/tests/verdict-logic.test.ts
@@ -1,12 +1,16 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 bvasilenko
import { describe, it, expect } from 'vitest';
-import { deriveTag, buildVerdict, type Tally } from './runtime-probe/verdict-logic.js';
+import { axisFromProbeFile, deriveTag, buildVerdict, REQUIRED_AXIS_NAMES, type Tally } from './runtime-probe/verdict-logic.js';
function tally(passed: number, failed: number, skipped: number): Tally {
return { passed, failed, skipped, total: passed + failed + skipped };
}
+function axisTally(passed: number, failed: number, skipped: number, axes: string[]): Tally {
+ return { ...tally(passed, failed, skipped), axes };
+}
+
describe('deriveTag - tag assignment rules', () => {
it('returns UNGROUNDED-CLAIM when total is zero', () => {
expect(deriveTag(tally(0, 0, 0))).toBe('UNGROUNDED-CLAIM');
@@ -36,17 +40,56 @@ describe('deriveTag - tag assignment rules', () => {
expect(deriveTag(tally(10, 0, 0))).toBe('CLEAN');
});
- it('returns CLEAN for a single passing test with no failures or skips', () => {
- expect(deriveTag(tally(1, 0, 0))).toBe('CLEAN');
+ it('returns CLEAN when axes field is absent even if coverage would be partial', () => {
+ expect(deriveTag(tally(5, 0, 0))).toBe('CLEAN');
+ });
+
+ it('returns DEFERRED when axes are tracked but not all required axes are covered', () => {
+ expect(deriveTag(axisTally(12, 0, 0, ['stack', 'cms']))).toBe('DEFERRED');
+ });
+
+ it('returns DEFERRED when axes are tracked but none of the required axes are covered', () => {
+ expect(deriveTag(axisTally(4, 0, 0, ['theme']))).toBe('DEFERRED');
+ });
+
+ it('BUGS takes precedence over DEFERRED when failures are present alongside missing axes', () => {
+ expect(deriveTag(axisTally(5, 2, 0, ['stack']))).toBe('BUGS');
+ });
+
+ it('PARTIAL takes precedence over DEFERRED when skips are present alongside missing axes', () => {
+ expect(deriveTag(axisTally(5, 0, 1, ['stack']))).toBe('PARTIAL');
+ });
+
+ it('returns CLEAN when all required axes are covered with no failures or skips', () => {
+ expect(deriveTag(axisTally(30, 0, 0, ['stack', 'cms', 'deploy']))).toBe('CLEAN');
+ });
+
+ it('returns CLEAN when custom required axes are all present', () => {
+ expect(deriveTag({ ...axisTally(10, 0, 0, ['stack']), requiredAxes: ['stack'] })).toBe('CLEAN');
+ });
+
+ it('returns DEFERRED when custom required axes are not all covered', () => {
+ expect(deriveTag({ ...axisTally(10, 0, 0, ['stack']), requiredAxes: ['stack', 'cms'] })).toBe('DEFERRED');
+ });
+
+ it('returns DEFERRED when axes is an empty array (tracking active but no probe files collected)', () => {
+ expect(deriveTag({ ...tally(5, 0, 0), axes: [] })).toBe('DEFERRED');
+ });
+
+ it('returns CLEAN when requiredAxes is an empty array (vacuously all required axes are covered)', () => {
+ expect(deriveTag({ ...axisTally(5, 0, 0, []), requiredAxes: [] })).toBe('CLEAN');
});
});
describe('buildVerdict - output format', () => {
- it('starts with the correct tag derived from the tally', () => {
- expect(buildVerdict(tally(5, 0, 0))).toMatch(/^CLEAN:/);
- expect(buildVerdict(tally(0, 3, 0))).toMatch(/^BUGS:/);
- expect(buildVerdict(tally(2, 0, 1))).toMatch(/^PARTIAL:/);
- expect(buildVerdict(tally(0, 0, 0))).toMatch(/^UNGROUNDED-CLAIM:/);
+ it.each<[string, Tally]>([
+ ['CLEAN', tally(5, 0, 0) ],
+ ['BUGS', tally(0, 3, 0) ],
+ ['PARTIAL', tally(2, 0, 1) ],
+ ['DEFERRED', axisTally(4, 0, 0, ['stack']) ],
+ ['UNGROUNDED-CLAIM', tally(0, 0, 0) ],
+ ])('%s verdict: output begins with the tag name followed by a colon-space', (tag, t) => {
+ expect(buildVerdict(t)).toMatch(new RegExp(`^${tag}: `));
});
it('contains the total probe count', () => {
@@ -66,18 +109,33 @@ describe('buildVerdict - output format', () => {
expect(buildVerdict(tally(5, 0, 0))).toContain('0 bugs');
});
- it('surfaces covered count is passed + failed (skipped not counted as covered)', () => {
- const t = tally(6, 2, 2);
- expect(buildVerdict(t)).toContain('8/10 surfaces covered');
+ it('axis coverage count comes from named required probe families', () => {
+ const t = axisTally(6, 2, 2, ['stack', 'cms', 'deploy']);
+ expect(buildVerdict(t)).toContain('3/3 axes covered');
+ });
+
+ it('axis coverage count is 0/3 for an empty tally because the required axis set is stable', () => {
+ expect(buildVerdict(tally(0, 0, 0))).toContain('0/3 axes covered');
+ });
+
+ it('axis coverage count is independent from probe volume and still exposes missing axes', () => {
+ const t = axisTally(3, 2, 0, ['stack', 'cms']);
+ expect(buildVerdict(t)).toContain('2/3 axes covered');
+ });
+
+ it('axis coverage de-duplicates repeated test files from retries and sharding', () => {
+ const t = axisTally(30, 0, 0, ['stack', 'stack', 'cms', 'cms', 'deploy']);
+ expect(buildVerdict(t)).toContain('3/3 axes covered');
});
- it('surfaces covered count is 0/0 for an empty tally', () => {
- expect(buildVerdict(tally(0, 0, 0))).toContain('0/0 surfaces covered');
+ it('unknown axis names do not inflate required-axis coverage', () => {
+ const t = axisTally(30, 0, 0, ['stack', 'cms', 'theme']);
+ expect(buildVerdict(t)).toContain('2/3 axes covered');
});
- it('surfaces covered count equals total when no tests were skipped', () => {
- const t = tally(3, 2, 0);
- expect(buildVerdict(t)).toContain('5/5 surfaces covered');
+ it('custom required-axis sets keep the formatter reusable for narrower probes', () => {
+ const verdict = buildVerdict({ ...axisTally(30, 0, 0, ['stack']), requiredAxes: ['stack'] });
+ expect(verdict).toContain('1/1 axes covered');
});
it('output is a single line with no embedded newlines', () => {
@@ -93,18 +151,126 @@ describe('buildVerdict - output format', () => {
describe('buildVerdict - CLEAN gate shape', () => {
it('a fully green tally emits the exact CLEAN pattern the pipeline greps for', () => {
- const verdict = buildVerdict(tally(12, 0, 0));
- expect(verdict).toMatch(/^CLEAN: \d+ probes, 0 bugs, \d+\/\d+ surfaces covered$/);
+ const verdict = buildVerdict(axisTally(12, 0, 0, ['stack', 'cms', 'deploy']));
+ expect(verdict).toMatch(/^CLEAN: \d+ probes, 0 bugs, \d+\/\d+ axes covered$/);
});
- it('a tally with failures emits a BUGS: prefix that pipeline treats as merge-block', () => {
- expect(buildVerdict(tally(8, 3, 0))).toMatch(/^BUGS:/);
+ it('CLEAN verdict does not contain BUGS, PARTIAL, DEFERRED, or UNGROUNDED-CLAIM substrings', () => {
+ const verdict = buildVerdict(axisTally(5, 0, 0, ['stack', 'cms', 'deploy']));
+ expect(verdict).not.toContain('BUGS');
+ expect(verdict).not.toContain('PARTIAL');
+ expect(verdict).not.toContain('DEFERRED');
+ expect(verdict).not.toContain('UNGROUNDED-CLAIM');
});
- it('CLEAN verdict does not contain BUGS, PARTIAL, or UNGROUNDED-CLAIM substrings', () => {
- const verdict = buildVerdict(tally(5, 0, 0));
+ it('DEFERRED verdict is emitted when required axes are missing despite all passing', () => {
+ const verdict = buildVerdict(axisTally(12, 0, 0, ['stack', 'cms']));
+ expect(verdict).toMatch(/^DEFERRED:/);
expect(verdict).not.toContain('BUGS');
expect(verdict).not.toContain('PARTIAL');
expect(verdict).not.toContain('UNGROUNDED-CLAIM');
});
+
+ it('DEFERRED verdict reports the actual coverage fraction to aid diagnosis', () => {
+ const verdict = buildVerdict(axisTally(12, 0, 0, ['stack', 'cms']));
+ expect(verdict).toContain('2/3 axes covered');
+ });
+});
+
+describe('axisFromProbeFile - generalized runtime-probe classification', () => {
+ it.each([
+ ['/repo/examples/demo/tests/runtime-probe/stack-axis.test.ts', 'stack'],
+ ['C:\\repo\\examples\\demo\\tests\\runtime-probe\\cms-axis.test.ts', 'cms'],
+ ['deploy-axis.test.js', 'deploy'],
+ ])('%s -> %s', (file, axis) => {
+ expect(axisFromProbeFile(file)).toBe(axis);
+ });
+
+ it.each([
+ '/repo/examples/demo/tests/runtime-probe/theme-apply.test.ts',
+ '/repo/examples/demo/tests/runtime-probe/stack-axis.fixture.ts',
+ '',
+ 'Stack-axis.test.ts',
+ 'stack-axis.test.tsx',
+ 'stack-axis.spec.ts',
+ ])('%s -> null', (file) => {
+ expect(axisFromProbeFile(file)).toBeNull();
+ });
+});
+
+describe('REQUIRED_AXIS_NAMES - gate contract', () => {
+ it('contains exactly the three axes required for a CLEAN iteration-3 verdict', () => {
+ expect([...REQUIRED_AXIS_NAMES].sort()).toEqual(['cms', 'deploy', 'stack']);
+ });
+
+ it('has exactly 3 members (one per shipped iteration-3 probe family)', () => {
+ expect(REQUIRED_AXIS_NAMES).toHaveLength(3);
+ });
+
+ it('contains stack as a required axis', () => {
+ expect(REQUIRED_AXIS_NAMES).toContain('stack');
+ });
+
+ it('contains cms as a required axis', () => {
+ expect(REQUIRED_AXIS_NAMES).toContain('cms');
+ });
+
+ it('contains deploy as a required axis', () => {
+ expect(REQUIRED_AXIS_NAMES).toContain('deploy');
+ });
+
+ it('has no duplicate entries', () => {
+ expect(new Set(REQUIRED_AXIS_NAMES).size).toBe(REQUIRED_AXIS_NAMES.length);
+ });
+});
+
+describe('axisFromProbeFile - additional path patterns', () => {
+ it.each([
+ ['relative path without directory prefix', 'stack-axis.test.ts', 'stack'],
+ ['path with mixed adjacent name segments', 'tests/runtime-probe/cms-axis.test.ts', 'cms'],
+ ['JS extension is accepted alongside TS', 'deploy-axis.test.js', 'deploy'],
+ ['deeply nested POSIX path', '/a/b/c/d/e/stack-axis.test.ts', 'stack'],
+ ])('%s', (_, file, expected) => {
+ expect(axisFromProbeFile(file)).toBe(expected);
+ });
+
+ it.each([
+ ['axis name with numeric suffix is not matched', 'stack-axis2.test.ts'],
+ ['axis name in directory component only is not matched', 'stack-axis/probe.test.ts'],
+ ['TSX extension is not matched', 'stack-axis.test.tsx'],
+ ['spec suffix is not matched', 'cms-axis.spec.ts'],
+ ['fixture extension is not matched', 'stack-axis.fixture.ts'],
+ ['uppercase axis prefix is not matched (case-sensitive)', 'Stack-axis.test.ts'],
+ ['non-axis suffix before test extension is not matched', 'stack-probe.test.ts'],
+ ])('%s -> null', (_, file) => {
+ expect(axisFromProbeFile(file)).toBeNull();
+ });
+});
+
+describe('buildVerdict - axis coverage fraction accuracy', () => {
+ it('extra non-required axes in covered set do not inflate the M/M fraction', () => {
+ const t: Tally = { passed: 10, failed: 0, skipped: 0, total: 10, axes: ['stack', 'cms', 'deploy', 'theme', 'layout'] };
+ const verdict = buildVerdict(t);
+ expect(verdict).toContain('3/3 axes covered');
+ });
+
+ it('reports 1/3 when only one required axis is covered', () => {
+ const t: Tally = { passed: 5, failed: 0, skipped: 0, total: 5, axes: ['stack'] };
+ expect(buildVerdict(t)).toContain('1/3 axes covered');
+ });
+
+ it('reports 2/3 when exactly two required axes are covered', () => {
+ const t: Tally = { passed: 8, failed: 0, skipped: 0, total: 8, axes: ['stack', 'deploy'] };
+ expect(buildVerdict(t)).toContain('2/3 axes covered');
+ });
+
+ it('custom requiredAxes override the default 3-axis set in the coverage fraction', () => {
+ const t: Tally = { passed: 4, failed: 0, skipped: 0, total: 4, axes: ['stack'], requiredAxes: ['stack'] };
+ expect(buildVerdict(t)).toContain('1/1 axes covered');
+ });
+
+ it('reports 0/3 axes covered when no axes are tracked regardless of probe volume', () => {
+ const t: Tally = { passed: 100, failed: 0, skipped: 0, total: 100, axes: [] };
+ expect(buildVerdict(t)).toContain('0/3 axes covered');
+ });
});
diff --git a/examples/demo/tests/verdict-reporter.test.ts b/examples/demo/tests/verdict-reporter.test.ts
new file mode 100644
index 0000000..f7e4279
--- /dev/null
+++ b/examples/demo/tests/verdict-reporter.test.ts
@@ -0,0 +1,262 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { describe, it, expect, vi } from 'vitest';
+import type { TestCase, TestResult, FullResult } from '@playwright/test/reporter';
+import VerdictReporter from './runtime-probe/verdict-reporter.js';
+
+type PlaywrightStatus = TestResult['status'];
+type ProbeEvent = { file: string; status: PlaywrightStatus };
+
+function makeTest(file: string): TestCase {
+ return { location: { file, column: 0, line: 1 } } as unknown as TestCase;
+}
+
+function makeResult(status: PlaywrightStatus): TestResult {
+ return { status } as unknown as TestResult;
+}
+
+function makeFullResult(): FullResult {
+ return { status: 'passed', startTime: new Date(), duration: 0 } as unknown as FullResult;
+}
+
+const AXIS_FILES = {
+ stack: 'stack-axis.test.ts',
+ cms: 'cms-axis.test.ts',
+ deploy: 'deploy-axis.test.ts',
+} as const;
+
+const NON_AXIS_FILE = 'theme-apply.test.ts';
+
+const ALL_AXES_PASS: ProbeEvent[] = [
+ { file: AXIS_FILES.stack, status: 'passed' },
+ { file: AXIS_FILES.cms, status: 'passed' },
+ { file: AXIS_FILES.deploy, status: 'passed' },
+];
+
+function fire(reporter: VerdictReporter, events: ProbeEvent[]): void {
+ for (const { file, status } of events) {
+ reporter.onTestEnd(makeTest(file), makeResult(status));
+ }
+ reporter.onEnd(makeFullResult());
+}
+
+function runProbes(events: ProbeEvent[], exit = vi.fn()): { exit: ReturnType } {
+ const reporter = new VerdictReporter({ exit });
+ fire(reporter, events);
+ return { exit };
+}
+
+function captureVerdict(events: ProbeEvent[]): string {
+ const chunks: string[] = [];
+ const spy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
+ chunks.push(String(chunk));
+ return true;
+ });
+ try {
+ runProbes(events);
+ } finally {
+ spy.mockRestore();
+ }
+ return chunks.join('');
+}
+
+describe('onTestEnd - tally accumulation: status classification', () => {
+ it.each<[PlaywrightStatus, string]>([
+ ['failed', 'BUGS'],
+ ['timedOut', 'BUGS'],
+ ['interrupted', 'BUGS'],
+ ])('%s status is classified as a failure, not a skip (verdict: %s)', (status, tag) => {
+ const verdict = captureVerdict([
+ { file: AXIS_FILES.stack, status },
+ { file: AXIS_FILES.cms, status: 'passed' },
+ { file: AXIS_FILES.deploy, status: 'passed' },
+ ]);
+ expect(verdict).toContain(`${tag}:`);
+ expect(verdict).not.toContain('PARTIAL:');
+ });
+
+ it('skipped status is classified as a skip, not a failure (verdict: PARTIAL)', () => {
+ const verdict = captureVerdict([
+ { file: NON_AXIS_FILE, status: 'skipped' },
+ { file: AXIS_FILES.stack, status: 'passed' },
+ { file: AXIS_FILES.cms, status: 'passed' },
+ { file: AXIS_FILES.deploy, status: 'passed' },
+ ]);
+ expect(verdict).toContain('PARTIAL:');
+ expect(verdict).not.toContain('BUGS:');
+ });
+
+ it('passed status is neutral — does not increment failure or skip counters', () => {
+ const verdict = captureVerdict(ALL_AXES_PASS);
+ expect(verdict).toMatch(/^CLEAN:/m);
+ });
+
+ it('multiple tests accumulate their tallies independently across a run', () => {
+ const verdict = captureVerdict([
+ { file: AXIS_FILES.stack, status: 'passed' },
+ { file: AXIS_FILES.stack, status: 'failed' },
+ { file: AXIS_FILES.cms, status: 'passed' },
+ { file: AXIS_FILES.deploy, status: 'passed' },
+ ]);
+ expect(verdict).toContain('BUGS:');
+ expect(verdict).toContain('4 probes');
+ });
+});
+
+describe('onTestEnd - axis collection', () => {
+ it('a passed test from an axis file credits that axis toward coverage', () => {
+ const verdict = captureVerdict(ALL_AXES_PASS);
+ expect(verdict).toMatch(/^CLEAN:/m);
+ });
+
+ it('a skipped test from an axis file does not credit the axis', () => {
+ const { exit } = runProbes([
+ { file: AXIS_FILES.stack, status: 'passed' },
+ { file: AXIS_FILES.cms, status: 'passed' },
+ { file: AXIS_FILES.deploy, status: 'skipped' },
+ ]);
+ expect(exit).toHaveBeenCalledWith(1);
+ });
+
+ it('a non-axis file test does not credit any required axis regardless of status', () => {
+ const { exit } = runProbes([
+ { file: NON_AXIS_FILE, status: 'passed' },
+ { file: NON_AXIS_FILE, status: 'passed' },
+ ]);
+ expect(exit).toHaveBeenCalledWith(1);
+ });
+
+ it('multiple passed tests from the same axis file credit that axis exactly once', () => {
+ const verdict = captureVerdict([
+ { file: AXIS_FILES.stack, status: 'passed' },
+ { file: AXIS_FILES.stack, status: 'passed' },
+ { file: AXIS_FILES.stack, status: 'passed' },
+ { file: AXIS_FILES.stack, status: 'passed' },
+ { file: AXIS_FILES.stack, status: 'passed' },
+ { file: AXIS_FILES.cms, status: 'passed' },
+ { file: AXIS_FILES.deploy, status: 'passed' },
+ ]);
+ expect(verdict).toContain('3/3 axes covered');
+ expect(verdict).toMatch(/^CLEAN:/m);
+ });
+
+ it('all three required axes are each credited independently by their own axis file', () => {
+ const verdict = captureVerdict(ALL_AXES_PASS);
+ expect(verdict).toContain('3/3 axes covered');
+ });
+
+ it('extra tests from non-axis files do not inflate the covered-axis count', () => {
+ const verdict = captureVerdict([
+ ...ALL_AXES_PASS,
+ { file: NON_AXIS_FILE, status: 'passed' },
+ { file: NON_AXIS_FILE, status: 'passed' },
+ ]);
+ expect(verdict).toContain('3/3 axes covered');
+ expect(verdict).toMatch(/^CLEAN:/m);
+ });
+});
+
+describe('onEnd - exit enforcement', () => {
+ it('does not call exit when verdict is CLEAN (all axes pass, no failures or skips)', () => {
+ const { exit } = runProbes(ALL_AXES_PASS);
+ expect(exit).not.toHaveBeenCalled();
+ });
+
+ it.each<[string, ProbeEvent[]]>([
+ [
+ 'UNGROUNDED-CLAIM: no tests ran at all',
+ [],
+ ],
+ [
+ 'DEFERRED: required axis (deploy) not covered',
+ [
+ { file: AXIS_FILES.stack, status: 'passed' },
+ { file: AXIS_FILES.cms, status: 'passed' },
+ ],
+ ],
+ [
+ 'DEFERRED: no axis probe files included in the run',
+ [{ file: NON_AXIS_FILE, status: 'passed' }],
+ ],
+ [
+ 'BUGS: at least one test failed',
+ [
+ { file: AXIS_FILES.stack, status: 'failed' },
+ { file: AXIS_FILES.cms, status: 'passed' },
+ { file: AXIS_FILES.deploy, status: 'passed' },
+ ],
+ ],
+ [
+ 'BUGS: test timed out (timedOut counts as failure, not skip)',
+ [
+ { file: AXIS_FILES.stack, status: 'timedOut' },
+ { file: AXIS_FILES.cms, status: 'passed' },
+ { file: AXIS_FILES.deploy, status: 'passed' },
+ ],
+ ],
+ [
+ 'BUGS: test interrupted (interrupted counts as failure, not skip)',
+ [
+ { file: AXIS_FILES.stack, status: 'interrupted' },
+ { file: AXIS_FILES.cms, status: 'passed' },
+ { file: AXIS_FILES.deploy, status: 'passed' },
+ ],
+ ],
+ [
+ 'PARTIAL: at least one test was skipped',
+ [
+ { file: NON_AXIS_FILE, status: 'skipped' },
+ { file: AXIS_FILES.stack, status: 'passed' },
+ { file: AXIS_FILES.cms, status: 'passed' },
+ { file: AXIS_FILES.deploy, status: 'passed' },
+ ],
+ ],
+ ])('calls exit(1) when verdict is %s', (_label, events) => {
+ const { exit } = runProbes(events);
+ expect(exit).toHaveBeenCalledWith(1);
+ });
+
+ it('calls exit with code 1, not 0 or any other code', () => {
+ const { exit } = runProbes([]);
+ expect(exit).toHaveBeenCalledWith(1);
+ expect(exit).not.toHaveBeenCalledWith(0);
+ });
+
+ it('calls exit exactly once per onEnd invocation regardless of how many tests failed', () => {
+ const { exit } = runProbes([
+ { file: AXIS_FILES.stack, status: 'failed' },
+ { file: AXIS_FILES.cms, status: 'failed' },
+ { file: AXIS_FILES.deploy, status: 'failed' },
+ ]);
+ expect(exit).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('constructor - exit injection', () => {
+ it('uses the provided exit function, not process.exit, when an override is given', () => {
+ const customExit = vi.fn();
+ const reporter = new VerdictReporter({ exit: customExit });
+ fire(reporter, []);
+ expect(customExit).toHaveBeenCalled();
+ });
+
+ it('falls back to process.exit when no exit option is provided', () => {
+ const spy = vi.spyOn(process, 'exit').mockImplementation((_code) => undefined as never);
+ try {
+ const reporter = new VerdictReporter();
+ fire(reporter, []);
+ expect(spy).toHaveBeenCalledWith(1);
+ } finally {
+ spy.mockRestore();
+ }
+ });
+
+ it('each reporter instance maintains its own injected exit function', () => {
+ const exitA = vi.fn();
+ const exitB = vi.fn();
+ runProbes([], exitA);
+ runProbes(ALL_AXES_PASS, exitB);
+ expect(exitA).toHaveBeenCalledWith(1);
+ expect(exitB).not.toHaveBeenCalled();
+ });
+});
diff --git a/examples/demo/tests/vite-html-transform.test.js b/examples/demo/tests/vite-html-transform.test.js
new file mode 100644
index 0000000..3eeffa3
--- /dev/null
+++ b/examples/demo/tests/vite-html-transform.test.js
@@ -0,0 +1,309 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { readFileSync, existsSync } from 'node:fs';
+import { join } from 'node:path';
+import { describe, it, expect } from 'vitest';
+import { stampVersionIntoHtml } from '../vite-html-transform.js';
+
+const DEMO_ROOT = join(import.meta.dirname, '..');
+const ROOT = join(DEMO_ROOT, '../..');
+const PUBLIC_DIR = join(DEMO_ROOT, 'public');
+const SOURCE_HTML = readFileSync(join(DEMO_ROOT, 'index.html'), 'utf-8');
+const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
+
+const ICON_LINK_RE = / ]*)>/g;
+
+function parseFaviconLink(html) {
+ for (const match of html.matchAll(ICON_LINK_RE)) {
+ const attrs = match[1];
+ if (/\brel="icon"/.test(attrs)) {
+ return {
+ tag: match[0],
+ href: attrs.match(/\bhref="([^"]*)"/)?.[1] ?? null,
+ type: attrs.match(/\btype="([^"]*)"/)?.[1] ?? null,
+ };
+ }
+ }
+ return null;
+}
+
+const MINIMAL_HTML_WITH_BOTH = [
+ '',
+ 'vBrand - adaptive themed demo ',
+ ' ',
+ '',
+].join('');
+
+const SEMVER_RE = /\d+\.\d+\.\d+(-[a-z0-9.]+)?/i;
+
+describe('stampVersionIntoHtml - title stamping', () => {
+ it('replaces a version-free title with the versioned form', () => {
+ const out = stampVersionIntoHtml('vBrand - adaptive themed demo ', VERSION);
+ expect(out).toBe(`vBrand ${VERSION} - adaptive themed demo `);
+ });
+
+ it('replaces a previously stamped title (idempotent repeated application)', () => {
+ const once = stampVersionIntoHtml(MINIMAL_HTML_WITH_BOTH, VERSION);
+ const twice = stampVersionIntoHtml(once, VERSION);
+ expect(twice).toBe(once);
+ });
+
+ it('replaces a title stamped with a different version string', () => {
+ const stale = stampVersionIntoHtml(MINIMAL_HTML_WITH_BOTH, '0.3.0');
+ const updated = stampVersionIntoHtml(stale, VERSION);
+ expect(updated).toContain(`vBrand ${VERSION}`);
+ expect(updated).not.toContain('0.3.0');
+ });
+
+ it('embeds the version string at the expected position within the title element', () => {
+ const out = stampVersionIntoHtml(MINIMAL_HTML_WITH_BOTH, VERSION);
+ const escaped = VERSION.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ expect(out).toMatch(new RegExp(`vBrand ${escaped} - adaptive themed demo `));
+ });
+
+ it('produces exactly one title element regardless of input version content', () => {
+ const out = stampVersionIntoHtml(MINIMAL_HTML_WITH_BOTH, VERSION);
+ const matches = [...out.matchAll(//g)];
+ expect(matches).toHaveLength(1);
+ });
+
+ it('does not alter surrounding HTML when replacing the title', () => {
+ const prefix = '';
+ const suffix = ' ';
+ const html = `${prefix}old ${suffix}`;
+ const out = stampVersionIntoHtml(html, VERSION);
+ expect(out.startsWith(prefix)).toBe(true);
+ expect(out.endsWith(suffix)).toBe(true);
+ });
+
+ it('handles an HTML string with no title element without throwing', () => {
+ expect(() => stampVersionIntoHtml('', VERSION)).not.toThrow();
+ });
+
+ it('returns the string unchanged when no title element is present', () => {
+ const html = 'no title here';
+ expect(stampVersionIntoHtml(html, VERSION)).toBe(html);
+ });
+
+ it('version string containing dots and hyphens is treated as a literal, not a regex', () => {
+ const tricky = '0.4.0-alpha.5';
+ const out = stampVersionIntoHtml(MINIMAL_HTML_WITH_BOTH, tricky);
+ expect(out).toContain(`vBrand ${tricky}`);
+ });
+});
+
+describe('stampVersionIntoHtml - description meta stamping', () => {
+ it('stamps the version prefix into a vBrand description meta', () => {
+ const out = stampVersionIntoHtml(MINIMAL_HTML_WITH_BOTH, VERSION);
+ expect(out).toContain(`content="vBrand ${VERSION}`);
+ });
+
+ it('stamped description content is well-formed: starts with the versioned prefix, ends inside a double-quote boundary', () => {
+ const out = stampVersionIntoHtml(MINIMAL_HTML_WITH_BOTH, VERSION);
+ const descMatch = out.match(/name="description" content="([^"]*)"/);
+ expect(descMatch).not.toBeNull();
+ expect(descMatch?.[1]).toMatch(new RegExp(`^vBrand ${VERSION.replace(/\./g, '\\.')}`));
+ });
+
+ it('replaces a previously stamped description (idempotent repeated application)', () => {
+ const once = stampVersionIntoHtml(MINIMAL_HTML_WITH_BOTH, VERSION);
+ const twice = stampVersionIntoHtml(once, VERSION);
+ expect(twice).toBe(once);
+ });
+
+ it('replaces a description stamped with a different version', () => {
+ const stale = stampVersionIntoHtml(MINIMAL_HTML_WITH_BOTH, '0.3.0');
+ const updated = stampVersionIntoHtml(stale, VERSION);
+ expect(updated).toContain(`content="vBrand ${VERSION}`);
+ expect(updated).not.toContain('0.3.0');
+ });
+
+ it('does not touch a description meta whose content does not start with "vBrand"', () => {
+ const html = ' ';
+ expect(stampVersionIntoHtml(html, VERSION)).toBe(html);
+ });
+
+ it('handles an HTML string with no description meta without throwing', () => {
+ expect(() => stampVersionIntoHtml('', VERSION)).not.toThrow();
+ });
+
+ it('returns the string unchanged when no description meta is present', () => {
+ const html = 'no meta';
+ expect(stampVersionIntoHtml(html, VERSION)).toBe(html);
+ });
+
+ it('stamped content attribute remains syntactically closed inside a double-quote boundary', () => {
+ const out = stampVersionIntoHtml(MINIMAL_HTML_WITH_BOTH, VERSION);
+ const descMatch = out.match(/content="([^"]*)"/);
+ expect(descMatch).not.toBeNull();
+ });
+});
+
+describe('stampVersionIntoHtml - combined title + description', () => {
+ it('stamps both title and description in a single call', () => {
+ const out = stampVersionIntoHtml(MINIMAL_HTML_WITH_BOTH, VERSION);
+ expect(out).toContain(`vBrand ${VERSION}`);
+ expect(out).toContain(`content="vBrand ${VERSION}`);
+ });
+
+ it('title and description each contain the same version string after stamping', () => {
+ const out = stampVersionIntoHtml(MINIMAL_HTML_WITH_BOTH, VERSION);
+ const titleMatch = out.match(/vBrand ([^ ]+)/)?.[1];
+ const descMatch = out.match(/content="vBrand ([^ ]+)/)?.[1];
+ expect(titleMatch).toBe(VERSION);
+ expect(descMatch).toBe(VERSION);
+ });
+
+ it('the two replacements are independent: neither interferes with the other', () => {
+ const onlyTitle = stampVersionIntoHtml('vBrand - demo ', VERSION);
+ const onlyDesc = stampVersionIntoHtml(' ', VERSION);
+ expect(onlyTitle).toContain(`vBrand ${VERSION}`);
+ expect(onlyDesc).toContain(`content="vBrand ${VERSION}`);
+ });
+
+ it('returns a pure string with no mutation of the input', () => {
+ const original = MINIMAL_HTML_WITH_BOTH;
+ stampVersionIntoHtml(original, VERSION);
+ expect(MINIMAL_HTML_WITH_BOTH).toBe(original);
+ });
+});
+
+describe('index.html source - favicon link element', () => {
+ it('has exactly one favicon link element', () => {
+ const matches = [...SOURCE_HTML.matchAll(/ ]*\brel="icon"[^>]*>/g)];
+ expect(matches).toHaveLength(1);
+ });
+
+ it('favicon link href attribute is present and non-empty', () => {
+ expect(parseFaviconLink(SOURCE_HTML)?.href).toBeTruthy();
+ });
+
+ it('favicon link type attribute is image/svg+xml', () => {
+ expect(parseFaviconLink(SOURCE_HTML)?.type).toBe('image/svg+xml');
+ });
+
+ it('favicon link href is an absolute path so Vite can base-rewrite it during build', () => {
+ const href = parseFaviconLink(SOURCE_HTML)?.href ?? '';
+ expect(href).toMatch(/^\//);
+ });
+
+ it('favicon link href is not a data-URI (Vite build silently strips data-URI icon hrefs)', () => {
+ const href = parseFaviconLink(SOURCE_HTML)?.href ?? '';
+ expect(href).not.toMatch(/^data:/i);
+ });
+
+ it('favicon link href is not an external URL (external URLs are base-path-dependent and CORS-limited)', () => {
+ const href = parseFaviconLink(SOURCE_HTML)?.href ?? '';
+ expect(href).not.toMatch(/^https?:\/\//i);
+ });
+
+ it('favicon link href extension is consistent with the declared type attribute', () => {
+ const link = parseFaviconLink(SOURCE_HTML);
+ const ext = link?.href?.split('.').pop()?.toLowerCase();
+ if (link?.type === 'image/svg+xml') {
+ expect(ext).toBe('svg');
+ } else if (link?.type === 'image/png') {
+ expect(ext).toBe('png');
+ } else if (link?.type === 'image/x-icon') {
+ expect(['ico', 'icon']).toContain(ext);
+ }
+ });
+});
+
+describe('index.html source - favicon link: transform stability', () => {
+ it('stampVersionIntoHtml preserves the favicon link element', () => {
+ const stamped = stampVersionIntoHtml(SOURCE_HTML, VERSION);
+ expect(parseFaviconLink(stamped)).not.toBeNull();
+ });
+
+ it('stampVersionIntoHtml does not alter the favicon link href', () => {
+ const originalHref = parseFaviconLink(SOURCE_HTML)?.href;
+ const stamped = stampVersionIntoHtml(SOURCE_HTML, VERSION);
+ expect(parseFaviconLink(stamped)?.href).toBe(originalHref);
+ });
+
+ it('stampVersionIntoHtml does not alter the favicon link type attribute', () => {
+ const originalType = parseFaviconLink(SOURCE_HTML)?.type;
+ const stamped = stampVersionIntoHtml(SOURCE_HTML, VERSION);
+ expect(parseFaviconLink(stamped)?.type).toBe(originalType);
+ });
+
+ it('repeated stampVersionIntoHtml applications do not duplicate the favicon link', () => {
+ const once = stampVersionIntoHtml(SOURCE_HTML, VERSION);
+ const twice = stampVersionIntoHtml(once, VERSION);
+ const matches = [...twice.matchAll(/ ]*\brel="icon"[^>]*>/g)];
+ expect(matches).toHaveLength(1);
+ });
+
+ it('stampVersionIntoHtml is idempotent with respect to the favicon link href', () => {
+ const once = stampVersionIntoHtml(SOURCE_HTML, VERSION);
+ const twice = stampVersionIntoHtml(once, VERSION);
+ expect(parseFaviconLink(twice)?.href).toBe(parseFaviconLink(once)?.href);
+ });
+});
+
+describe('public/ directory - favicon SVG asset', () => {
+ const sourceHref = parseFaviconLink(SOURCE_HTML)?.href ?? '';
+ const publicPath = join(PUBLIC_DIR, sourceHref.replace(/^\//, ''));
+
+ it('the file referenced by the favicon href exists in public/', () => {
+ expect(existsSync(publicPath)).toBe(true);
+ });
+
+ it('the favicon file contains an element', () => {
+ const content = readFileSync(publicPath, 'utf-8');
+ expect(content).toMatch(/ {
+ const content = readFileSync(publicPath, 'utf-8');
+ expect(content).toMatch(/\bxmlns=/);
+ });
+
+ it('the favicon SVG has a viewBox attribute for resolution-independent scaling', () => {
+ const content = readFileSync(publicPath, 'utf-8');
+ expect(content).toMatch(/\bviewBox=/i);
+ });
+
+ it('the favicon SVG contains no ',
+ ];
+}
+
+function extractNextLines(html) {
+ const dataMatch = html.match(/',
+ ``,
+ ];
+}
+
+function extractAstroLines(html) {
+ const islandMatch = html.match(/]*)>/);
+ const hydrationMatch = html.match(/',
+ ];
+}
+
+const EXTRACTORS = { vite: extractViteLines, next: extractNextLines, astro: extractAstroLines };
+
+export function loadStackEmitShapes(distDir) {
+ return Object.fromEntries(
+ STACKS.map((stack) => {
+ const filePath = path.join(distDir, 'stacks', `${stack}.html`);
+ try {
+ return [stack, EXTRACTORS[stack](fs.readFileSync(filePath, 'utf-8'))];
+ } catch {
+ return [stack, [`dist/stacks/${stack}.html`, '(not found -- run build first)']];
+ }
+ }),
+ );
+}
diff --git a/scripts/eye-test/url-builder.mjs b/scripts/eye-test/url-builder.mjs
new file mode 100644
index 0000000..e2ed63d
--- /dev/null
+++ b/scripts/eye-test/url-builder.mjs
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+
+const DEMO_PATH = '/vBrand/';
+
+export function cellToUrl(cell, baseUrl) {
+ const origin = new URL(baseUrl).origin;
+ const u = new URL(DEMO_PATH, origin);
+ u.searchParams.set('brand', `fixture:${cell.fixture}`);
+ u.searchParams.set('app', cell.app);
+ u.searchParams.set('mode', cell.mode);
+ if (cell.stack !== undefined) u.searchParams.set('stack', cell.stack);
+ u.searchParams.set('cms', cell.cms);
+ return u.toString();
+}
diff --git a/scripts/eye-test/url-builder.test.mjs b/scripts/eye-test/url-builder.test.mjs
new file mode 100644
index 0000000..e8d0240
--- /dev/null
+++ b/scripts/eye-test/url-builder.test.mjs
@@ -0,0 +1,157 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { describe, it, expect } from 'vitest';
+import { cellToUrl } from './url-builder.mjs';
+
+const BASE_HTTPS = 'https://bvasilenko.github.io';
+const BASE_HTTP = 'http://localhost:5290';
+const BASE_WITH_PATH = 'https://example.com/some/extra/path';
+
+function makeCell(overrides = {}) {
+ return {
+ fixture: 'stripe',
+ app: 'landing',
+ mode: 'spa',
+ stack: 'vite',
+ cms: 'vbrand-standalone',
+ index: 0,
+ ...overrides,
+ };
+}
+
+describe('cellToUrl - URL validity', () => {
+ it('returns a string parseable as a URL', () => {
+ expect(() => new URL(cellToUrl(makeCell(), BASE_HTTPS))).not.toThrow();
+ });
+
+ it('returns an https:// URL when the base is https', () => {
+ expect(cellToUrl(makeCell(), BASE_HTTPS)).toMatch(/^https:\/\//);
+ });
+
+ it('returns an http:// URL when the base is http', () => {
+ expect(cellToUrl(makeCell(), BASE_HTTP)).toMatch(/^http:\/\//);
+ });
+});
+
+describe('cellToUrl - path', () => {
+ it('path is /vBrand/', () => {
+ const url = new URL(cellToUrl(makeCell(), BASE_HTTPS));
+ expect(url.pathname).toBe('/vBrand/');
+ });
+
+ it('path is always /vBrand/ regardless of any extra path in the base URL', () => {
+ const url = new URL(cellToUrl(makeCell(), BASE_WITH_PATH));
+ expect(url.pathname).toBe('/vBrand/');
+ });
+
+ it('origin is preserved from the base URL', () => {
+ const url = new URL(cellToUrl(makeCell(), BASE_HTTPS));
+ expect(url.origin).toBe(new URL(BASE_HTTPS).origin);
+ });
+
+ it('origin from base URL with extra path is the same origin', () => {
+ const url = new URL(cellToUrl(makeCell(), BASE_WITH_PATH));
+ expect(url.origin).toBe(new URL(BASE_WITH_PATH).origin);
+ });
+});
+
+describe('cellToUrl - search params: brand', () => {
+ it('brand param equals fixture:', () => {
+ const url = new URL(cellToUrl(makeCell({ fixture: 'stripe' }), BASE_HTTPS));
+ expect(url.searchParams.get('brand')).toBe('fixture:stripe');
+ });
+
+ it.each(['stripe', 'vercel', 'linear', 'notion', 'github'])('brand param is fixture:%s for fixture %s', (fixture) => {
+ const url = new URL(cellToUrl(makeCell({ fixture }), BASE_HTTPS));
+ expect(url.searchParams.get('brand')).toBe(`fixture:${fixture}`);
+ });
+});
+
+describe('cellToUrl - search params: app, mode, stack, cms', () => {
+ it.each(['landing', 'marketing', 'docs', 'dashboard'])('app param equals %s', (app) => {
+ const url = new URL(cellToUrl(makeCell({ app }), BASE_HTTPS));
+ expect(url.searchParams.get('app')).toBe(app);
+ });
+
+ it.each(['static', 'hybrid', 'spa'])('mode param equals %s', (mode) => {
+ const url = new URL(cellToUrl(makeCell({ mode }), BASE_HTTPS));
+ expect(url.searchParams.get('mode')).toBe(mode);
+ });
+
+ it.each(['vite', 'next', 'astro'])('stack param equals %s', (stack) => {
+ const url = new URL(cellToUrl(makeCell({ stack }), BASE_HTTPS));
+ expect(url.searchParams.get('stack')).toBe(stack);
+ });
+
+ it.each(['vbrand-standalone', 'payload', 'sanity', 'strapi'])('cms param equals %s', (cms) => {
+ const url = new URL(cellToUrl(makeCell({ cms }), BASE_HTTPS));
+ expect(url.searchParams.get('cms')).toBe(cms);
+ });
+});
+
+describe('cellToUrl - all five params are present', () => {
+ it('URL carries all five required search params', () => {
+ const url = new URL(cellToUrl(makeCell(), BASE_HTTPS));
+ for (const param of ['brand', 'app', 'mode', 'stack', 'cms']) {
+ expect(url.searchParams.has(param)).toBe(true);
+ }
+ });
+
+ it('index field on the cell does not appear as a search param', () => {
+ const url = new URL(cellToUrl(makeCell({ index: 42 }), BASE_HTTPS));
+ expect(url.searchParams.has('index')).toBe(false);
+ });
+});
+
+describe('cellToUrl - determinism', () => {
+ it('same cell and base produce the same URL on repeated calls', () => {
+ const cell = makeCell();
+ expect(cellToUrl(cell, BASE_HTTPS)).toBe(cellToUrl(cell, BASE_HTTPS));
+ });
+
+ it('different fixture values produce different URLs', () => {
+ const a = cellToUrl(makeCell({ fixture: 'stripe' }), BASE_HTTPS);
+ const b = cellToUrl(makeCell({ fixture: 'vercel' }), BASE_HTTPS);
+ expect(a).not.toBe(b);
+ });
+
+ it('different stack values produce different URLs', () => {
+ const a = cellToUrl(makeCell({ stack: 'vite' }), BASE_HTTPS);
+ const b = cellToUrl(makeCell({ stack: 'next' }), BASE_HTTPS);
+ expect(a).not.toBe(b);
+ });
+
+ it('different mode values produce different URLs', () => {
+ const a = cellToUrl(makeCell({ mode: 'spa' }), BASE_HTTPS);
+ const b = cellToUrl(makeCell({ mode: 'static' }), BASE_HTTPS);
+ expect(a).not.toBe(b);
+ });
+
+ it('different cms values produce different URLs', () => {
+ const a = cellToUrl(makeCell({ cms: 'vbrand-standalone' }), BASE_HTTPS);
+ const b = cellToUrl(makeCell({ cms: 'sanity' }), BASE_HTTPS);
+ expect(a).not.toBe(b);
+ });
+
+ it('different base URLs produce different origins in the output', () => {
+ const a = new URL(cellToUrl(makeCell(), BASE_HTTPS));
+ const b = new URL(cellToUrl(makeCell(), BASE_HTTP));
+ expect(a.origin).not.toBe(b.origin);
+ });
+});
+
+describe('cellToUrl - optional stack param', () => {
+ it('omits stack param when cell.stack is undefined', () => {
+ const cell = makeCell({ stack: undefined });
+ const url = new URL(cellToUrl(cell, BASE_HTTPS));
+ expect(url.searchParams.has('stack')).toBe(false);
+ });
+
+ it('includes brand, app, mode, and cms when stack is absent', () => {
+ const cell = makeCell({ stack: undefined });
+ const url = new URL(cellToUrl(cell, BASE_HTTPS));
+ for (const param of ['brand', 'app', 'mode', 'cms']) {
+ expect(url.searchParams.has(param)).toBe(true);
+ }
+ });
+});
diff --git a/scripts/generate-cms-fixtures.mjs b/scripts/generate-cms-fixtures.mjs
new file mode 100644
index 0000000..b4f996b
--- /dev/null
+++ b/scripts/generate-cms-fixtures.mjs
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import fs from 'node:fs';
+import path from 'node:path';
+import { payloadPagesFixture, payloadCollectionsFixture, sanityPagesFixture, sanitySchemaFixture, strapiPagesFixture, strapiContentTypesFixture } from '../dist/cms.js';
+
+const OUT_DIR = path.resolve(import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname), '..', 'examples', 'demo', 'public', 'cms-fixtures');
+
+fs.mkdirSync(OUT_DIR, { recursive: true });
+
+const fixtures = [
+ ['payload-pages.json', payloadPagesFixture()],
+ ['payload-collections.json', payloadCollectionsFixture()],
+ ['sanity-pages.json', sanityPagesFixture()],
+ ['sanity-schema.json', sanitySchemaFixture()],
+ ['strapi-pages.json', strapiPagesFixture()],
+ ['strapi-content-types.json', strapiContentTypesFixture()],
+];
+
+for (const [filename, data] of fixtures) {
+ fs.writeFileSync(path.join(OUT_DIR, filename), JSON.stringify(data, null, 2));
+}
+
+process.stdout.write(`CMS fixtures written to ${OUT_DIR}\n`);
diff --git a/scripts/probe-verdict-gate.mjs b/scripts/probe-verdict-gate.mjs
new file mode 100644
index 0000000..75dd55a
--- /dev/null
+++ b/scripts/probe-verdict-gate.mjs
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { readFileSync, existsSync } from 'node:fs';
+
+const REQUIRED_AXES = ['stack', 'cms', 'deploy'];
+const AXIS_FILE_RE = /(?:^|[/\\])([a-z]+)-axis\.test\.[tj]s$/;
+
+function axisFromFile(file) {
+ return AXIS_FILE_RE.exec(file)?.[1] ?? null;
+}
+
+function deriveTag(total, failed, skipped, covered) {
+ if (total === 0) return 'UNGROUNDED-CLAIM';
+ if (failed > 0) return 'BUGS';
+ if (skipped > 0) return 'PARTIAL';
+ if (!REQUIRED_AXES.every((a) => covered.has(a))) return 'DEFERRED';
+ return 'CLEAN';
+}
+
+function buildVerdict(total, failed, skipped, covered) {
+ const tag = deriveTag(total, failed, skipped, covered);
+ const bugLabel = failed === 1 ? 'bug' : 'bugs';
+ const covCount = REQUIRED_AXES.filter((a) => covered.has(a)).length;
+ const detail = `${total} probes, ${failed} ${bugLabel}, ${covCount}/${REQUIRED_AXES.length} axes covered`;
+ return `${tag}: ${detail}`;
+}
+
+function collectTests(suites, inheritedFile = '') {
+ const out = [];
+ for (const suite of suites ?? []) {
+ const file = suite.file ?? inheritedFile;
+ for (const spec of suite.specs ?? []) {
+ for (const test of spec.tests ?? []) {
+ out.push({ file, status: test.status });
+ }
+ }
+ out.push(...collectTests(suite.suites, file));
+ }
+ return out;
+}
+
+const resultsPath = process.argv[2];
+if (!resultsPath) {
+ process.stderr.write('Usage: probe-verdict-gate.mjs \n');
+ process.exit(1);
+}
+
+if (!existsSync(resultsPath)) {
+ const line = 'UNGROUNDED-CLAIM: 0 probes, 0 bugs, 0/3 axes covered';
+ process.stdout.write(`\n${line}\n`);
+ process.stderr.write(`Results file not found: ${resultsPath}\n`);
+ process.exit(1);
+}
+
+const json = JSON.parse(readFileSync(resultsPath, 'utf8'));
+const tests = collectTests(json.suites);
+const covered = new Set();
+let passed = 0, failed = 0, skipped = 0;
+
+for (const { file, status } of tests) {
+ if (status === 'skipped') {
+ skipped++;
+ } else {
+ const isPass = status === 'expected' || status === 'flaky';
+ if (isPass) passed++; else failed++;
+ const axis = axisFromFile(file);
+ if (axis) covered.add(axis);
+ }
+}
+
+const total = passed + failed + skipped;
+const verdict = buildVerdict(total, failed, skipped, covered);
+process.stdout.write(`\n${verdict}\n`);
+
+if (deriveTag(total, failed, skipped, covered) !== 'CLEAN') process.exit(1);
diff --git a/scripts/run-tsup.cjs b/scripts/run-tsup.cjs
new file mode 100644
index 0000000..f5e0929
--- /dev/null
+++ b/scripts/run-tsup.cjs
@@ -0,0 +1,9 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+'use strict';
+
+// Must be CJS so that Module._extensions patching (also CJS-only) is in effect
+// before tsup requires bundle-require.
+
+require('./bundle-require-output-redirect.cjs');
+require('../node_modules/tsup/dist/cli-default.js');
diff --git a/src/cms/content-tree.ts b/src/cms/content-tree.ts
new file mode 100644
index 0000000..06df7b2
--- /dev/null
+++ b/src/cms/content-tree.ts
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { FIXTURE_SLUGS, loadFixture } from '@booga/vfixtures';
+import type { VbrandType } from '../schema.js';
+import { OVERRIDABLE_FIELDS } from '../content/fields.js';
+import type { ContentOverrideKey, ContentOverrideValue } from '../content/override.js';
+import type { ContentTree, SchemaTree } from './types.js';
+
+export const DEFAULT_CONTENT_FIXTURE = 'stripe';
+export const CMS_FIXTURE_SLUGS = [...FIXTURE_SLUGS];
+
+export function resolveFixtureSlug(slug: string | undefined): (typeof FIXTURE_SLUGS)[number] {
+ return (CMS_FIXTURE_SLUGS as readonly string[]).includes(slug ?? '') ? (slug as (typeof FIXTURE_SLUGS)[number]) : DEFAULT_CONTENT_FIXTURE;
+}
+
+export interface CanonicalCmsPage {
+ readonly fixture: string;
+ readonly brand: VbrandType;
+ readonly content: ContentTree;
+}
+
+export interface CanonicalCmsDataset {
+ readonly pages: readonly CanonicalCmsPage[];
+}
+
+export function brandToContentTree(brand: VbrandType): ContentTree {
+ const entries: Array<[ContentOverrideKey, ContentOverrideValue]> = [];
+ for (const fields of Object.values(OVERRIDABLE_FIELDS)) {
+ for (const field of fields) entries.push([field.key, field.defaultValue(brand)]);
+ }
+ return Object.freeze(Object.fromEntries(entries) as Record);
+}
+
+export function loadFixtureBrand(slug: (typeof FIXTURE_SLUGS)[number] = DEFAULT_CONTENT_FIXTURE): VbrandType {
+ return loadFixture(slug) as VbrandType;
+}
+
+export function loadFixtureContentTree(slug: (typeof FIXTURE_SLUGS)[number] = DEFAULT_CONTENT_FIXTURE): ContentTree {
+ return brandToContentTree(loadFixtureBrand(slug));
+}
+
+export function loadFixtureSchema(slug: (typeof FIXTURE_SLUGS)[number] = DEFAULT_CONTENT_FIXTURE): SchemaTree {
+ return loadFixtureBrand(slug);
+}
+
+export function canonicalDataset(): CanonicalCmsDataset {
+ return { pages: CMS_FIXTURE_SLUGS.map((fixture) => ({ fixture, brand: loadFixtureBrand(fixture), content: loadFixtureContentTree(fixture) })) };
+}
+
+export function overridableFieldKeys(): ContentOverrideKey[] {
+ return Object.values(OVERRIDABLE_FIELDS).flat().map((field) => field.key);
+}
+
+export function assertKnownContentKeys(tree: ContentTree): ContentTree {
+ const known = new Set(overridableFieldKeys());
+ const unknown = Object.keys(tree).filter((key) => !known.has(key as ContentOverrideKey));
+ if (unknown.length > 0) throw new Error(`Unknown CMS content keys: ${unknown.join(', ')}`);
+ return tree;
+}
diff --git a/src/cms/contract.test.ts b/src/cms/contract.test.ts
new file mode 100644
index 0000000..22460f1
--- /dev/null
+++ b/src/cms/contract.test.ts
@@ -0,0 +1,380 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { describe, expect, it } from 'vitest';
+import { OVERRIDABLE_FIELDS } from '../content/fields.js';
+import { VbrandSchema } from '../schema.js';
+import { CMS_FIXTURE_SLUGS, CMS_NAMES, CMS_SUBSTRATE_REGISTRY, DEFAULT_CMS, getCmsSubstrate, loadFixtureContentTree, normalizePayloadResponse, normalizeSanityResponse, normalizeStrapiResponse, parseCms, payloadPagesFixture, sanityPagesFixture, strapiPagesFixture } from './index.js';
+import { assertKnownContentKeys, brandToContentTree, canonicalDataset, DEFAULT_CONTENT_FIXTURE, loadFixtureBrand, loadFixtureSchema, overridableFieldKeys, resolveFixtureSlug } from './content-tree.js';
+
+const knownKeys = new Set(Object.values(OVERRIDABLE_FIELDS).flat().map((field) => field.key));
+
+describe('CmsSubstrateAdapter contract', () => {
+ it('keeps registry keys and adapter names aligned', async () => {
+ expect(Object.keys(CMS_SUBSTRATE_REGISTRY).sort()).toEqual([...CMS_NAMES].sort());
+ for (const name of CMS_NAMES) {
+ const adapter = CMS_SUBSTRATE_REGISTRY[name];
+ expect(adapter.name()).toBe(name);
+ expect(await adapter.loadContent()).toBeTruthy();
+ expect(VbrandSchema.safeParse(await adapter.loadSchema()).success).toBe(true);
+ }
+ });
+
+ it('parses invalid CMS input to the documented default', () => {
+ expect(parseCms(null)).toBe(DEFAULT_CMS);
+ expect(parseCms(undefined)).toBe(DEFAULT_CMS);
+ expect(parseCms('wordpress')).toBe(DEFAULT_CMS);
+ expect(parseCms('sanity')).toBe('sanity');
+ });
+
+ it('returns only known overridable dotted keys', async () => {
+ for (const adapter of Object.values(CMS_SUBSTRATE_REGISTRY)) {
+ const content = await adapter.loadContent();
+ expect(Object.keys(content).length).toBeGreaterThan(0);
+ expect(Object.keys(content).every((key) => knownKeys.has(key))).toBe(true);
+ }
+ });
+
+ it('normalizes every shipped fixture to canonical parity', () => {
+ for (const slug of CMS_FIXTURE_SLUGS) {
+ const expected = loadFixtureContentTree(slug);
+ expect(normalizePayloadResponse(payloadPagesFixture(), slug)).toEqual(expected);
+ expect(normalizeSanityResponse(sanityPagesFixture(), slug)).toEqual(expected);
+ expect(normalizeStrapiResponse(strapiPagesFixture(), slug)).toEqual(expected);
+ }
+ });
+
+ it('loadContent(slug) returns the canonical content tree for every fixture across all substrates', async () => {
+ for (const slug of CMS_FIXTURE_SLUGS) {
+ const expected = loadFixtureContentTree(slug);
+ for (const adapter of Object.values(CMS_SUBSTRATE_REGISTRY)) {
+ expect(await adapter.loadContent(slug)).toEqual(expected);
+ }
+ }
+ });
+
+ it('loadContent() with no argument, with undefined, and with DEFAULT_CONTENT_FIXTURE all produce the same tree', async () => {
+ for (const adapter of Object.values(CMS_SUBSTRATE_REGISTRY)) {
+ const noArg = await adapter.loadContent();
+ const withUndefined = await adapter.loadContent(undefined);
+ const withDefault = await adapter.loadContent(DEFAULT_CONTENT_FIXTURE);
+ expect(noArg).toEqual(withUndefined);
+ expect(noArg).toEqual(withDefault);
+ }
+ });
+
+ it('loadContent(slug) yields content distinct from the default fixture for every non-default fixture', async () => {
+ const defaultContent = loadFixtureContentTree(DEFAULT_CONTENT_FIXTURE);
+ for (const slug of CMS_FIXTURE_SLUGS.filter((s) => s !== DEFAULT_CONTENT_FIXTURE)) {
+ const alternateContent = loadFixtureContentTree(slug);
+ expect(alternateContent).not.toEqual(defaultContent);
+ for (const adapter of Object.values(CMS_SUBSTRATE_REGISTRY)) {
+ expect(await adapter.loadContent(slug)).not.toEqual(defaultContent);
+ }
+ }
+ });
+
+ it('loadContent(slug) with an unrecognised slug falls back to the default fixture for every substrate', async () => {
+ const defaultContent = loadFixtureContentTree(DEFAULT_CONTENT_FIXTURE);
+ for (const adapter of Object.values(CMS_SUBSTRATE_REGISTRY)) {
+ expect(await adapter.loadContent('wordpress')).toEqual(defaultContent);
+ expect(await adapter.loadContent('')).toEqual(defaultContent);
+ }
+ });
+
+ it('fails fast when fixture responses contain no pages', () => {
+ expect(() => normalizePayloadResponse({ docs: [] })).toThrow('Payload fixture contains no pages');
+ expect(() => normalizeSanityResponse({ result: [] })).toThrow('Sanity fixture contains no pages');
+ expect(() => normalizeStrapiResponse({ data: [] })).toThrow('Strapi fixture contains no pages');
+ });
+});
+
+describe('CmsSubstrateAdapter parser and registry contract', () => {
+ it('parseCms accepts every member of CMS_NAMES verbatim', () => {
+ for (const name of CMS_NAMES) expect(parseCms(name)).toBe(name);
+ });
+
+ it('parseCms is case-sensitive: uppercase and mixed-case values fall back to DEFAULT_CMS', () => {
+ for (const bad of ['Sanity', 'PAYLOAD', 'Strapi', 'VBrand-Standalone', 'VBRAND-STANDALONE']) {
+ expect(parseCms(bad)).toBe(DEFAULT_CMS);
+ }
+ });
+
+ it('DEFAULT_CMS is vbrand-standalone', () => {
+ expect(DEFAULT_CMS).toBe('vbrand-standalone');
+ });
+
+ it('CMS_NAMES contains exactly the four expected substrate names and no others', () => {
+ expect([...CMS_NAMES].sort()).toEqual(['payload', 'sanity', 'strapi', 'vbrand-standalone']);
+ expect(CMS_NAMES).toHaveLength(4);
+ });
+
+ it('getCmsSubstrate returns the named adapter for every valid CMS name', () => {
+ for (const name of CMS_NAMES) expect(getCmsSubstrate(name).name()).toBe(name);
+ });
+
+ it('loadSchema returns a VbrandSchema-conforming object for every substrate', async () => {
+ for (const adapter of Object.values(CMS_SUBSTRATE_REGISTRY)) {
+ expect(VbrandSchema.safeParse(await adapter.loadSchema()).success).toBe(true);
+ }
+ });
+
+ it('content trees contain at least one landing-scope key across all substrates', async () => {
+ for (const adapter of Object.values(CMS_SUBSTRATE_REGISTRY)) {
+ const keys = Object.keys(await adapter.loadContent());
+ expect(keys.some((key) => key.startsWith('landing.'))).toBe(true);
+ }
+ });
+
+ it('assertKnownContentKeys accepts every content tree produced by every substrate', async () => {
+ for (const adapter of Object.values(CMS_SUBSTRATE_REGISTRY)) {
+ const content = await adapter.loadContent();
+ expect(() => assertKnownContentKeys(content)).not.toThrow();
+ }
+ });
+
+ it('each adapter returns a new content tree object on every call (no shared mutable reference)', async () => {
+ for (const adapter of Object.values(CMS_SUBSTRATE_REGISTRY)) {
+ const a = await adapter.loadContent();
+ const b = await adapter.loadContent();
+ expect(a).not.toBe(b);
+ expect(a).toEqual(b);
+ }
+ });
+});
+
+describe('resolveFixtureSlug', () => {
+ it('returns each recognized fixture slug unchanged', () => {
+ for (const slug of CMS_FIXTURE_SLUGS) {
+ expect(resolveFixtureSlug(slug)).toBe(slug);
+ }
+ });
+
+ it('returns DEFAULT_CONTENT_FIXTURE for undefined', () => {
+ expect(resolveFixtureSlug(undefined)).toBe(DEFAULT_CONTENT_FIXTURE);
+ });
+
+ it('returns DEFAULT_CONTENT_FIXTURE for empty string', () => {
+ expect(resolveFixtureSlug('')).toBe(DEFAULT_CONTENT_FIXTURE);
+ });
+
+ it('returns DEFAULT_CONTENT_FIXTURE for any unrecognised string', () => {
+ for (const bad of ['wordpress', 'contentful', 'ghost', 'unknown', 'STRIPE', 'Vercel']) {
+ expect(resolveFixtureSlug(bad)).toBe(DEFAULT_CONTENT_FIXTURE);
+ }
+ });
+
+ it('is case-sensitive: mixed-case variants of valid slugs fall back to DEFAULT_CONTENT_FIXTURE', () => {
+ for (const slug of CMS_FIXTURE_SLUGS) {
+ const upper = slug.toUpperCase();
+ const capitalized = slug.charAt(0).toUpperCase() + slug.slice(1);
+ if (upper !== slug) expect(resolveFixtureSlug(upper)).toBe(DEFAULT_CONTENT_FIXTURE);
+ if (capitalized !== slug) expect(resolveFixtureSlug(capitalized)).toBe(DEFAULT_CONTENT_FIXTURE);
+ }
+ });
+
+ it('DEFAULT_CONTENT_FIXTURE is a member of CMS_FIXTURE_SLUGS', () => {
+ expect(CMS_FIXTURE_SLUGS).toContain(DEFAULT_CONTENT_FIXTURE);
+ });
+});
+
+describe('parseCms - non-string and boundary inputs', () => {
+ it('empty string falls back to DEFAULT_CMS', () => {
+ expect(parseCms('')).toBe(DEFAULT_CMS);
+ });
+
+ it('numeric input falls back to DEFAULT_CMS', () => {
+ expect(parseCms(0 as unknown as string)).toBe(DEFAULT_CMS);
+ expect(parseCms(1 as unknown as string)).toBe(DEFAULT_CMS);
+ });
+
+ it('boolean input falls back to DEFAULT_CMS', () => {
+ expect(parseCms(true as unknown as string)).toBe(DEFAULT_CMS);
+ expect(parseCms(false as unknown as string)).toBe(DEFAULT_CMS);
+ });
+
+ it('object input falls back to DEFAULT_CMS', () => {
+ expect(parseCms({} as unknown as string)).toBe(DEFAULT_CMS);
+ });
+});
+
+describe('overridableFieldKeys - enumeration contract', () => {
+ it('returns a non-empty array', () => {
+ expect(overridableFieldKeys().length).toBeGreaterThan(0);
+ });
+
+ it('every key follows the dotted templateId.sectionId.fieldKey format', () => {
+ for (const key of overridableFieldKeys()) {
+ expect(key).toMatch(/^[a-z]+\.[a-z]+\.[a-z]+/);
+ }
+ });
+
+ it('no key is duplicated across the full enumeration', () => {
+ const keys = overridableFieldKeys();
+ expect(new Set(keys).size).toBe(keys.length);
+ });
+
+ it('returns the same key set on repeated calls', () => {
+ expect(overridableFieldKeys()).toEqual(overridableFieldKeys());
+ });
+
+ it('each key is present in at least one OVERRIDABLE_FIELDS section', () => {
+ const knownKeys = new Set(Object.values(OVERRIDABLE_FIELDS).flat().map((f) => f.key));
+ for (const key of overridableFieldKeys()) {
+ expect(knownKeys.has(key)).toBe(true);
+ }
+ });
+});
+
+describe('brandToContentTree - mapping invariants', () => {
+ it('returns a non-null object for the default fixture brand', () => {
+ expect(brandToContentTree(loadFixtureBrand())).toBeTruthy();
+ });
+
+ it('every key in the resulting ContentTree matches a known overridable field key', () => {
+ const known = new Set(overridableFieldKeys());
+ const tree = brandToContentTree(loadFixtureBrand());
+ for (const key of Object.keys(tree)) {
+ expect(known.has(key as Parameters[0])).toBe(true);
+ }
+ });
+
+ it('produces distinct ContentTrees for distinct fixture brands', () => {
+ const [first, second] = CMS_FIXTURE_SLUGS;
+ expect(brandToContentTree(loadFixtureBrand(first))).not.toEqual(
+ brandToContentTree(loadFixtureBrand(second)),
+ );
+ });
+
+ it('two calls with the same brand produce equal but not identical objects', () => {
+ const brand = loadFixtureBrand();
+ const a = brandToContentTree(brand);
+ const b = brandToContentTree(brand);
+ expect(a).toEqual(b);
+ expect(a).not.toBe(b);
+ });
+
+ it('returns a frozen object so consumers cannot accidentally mutate the tree', () => {
+ expect(Object.isFrozen(brandToContentTree(loadFixtureBrand()))).toBe(true);
+ });
+});
+
+describe('loadFixtureBrand - fixture loading contract', () => {
+ it('loads every shipped fixture slug without throwing', () => {
+ for (const slug of CMS_FIXTURE_SLUGS) {
+ expect(() => loadFixtureBrand(slug)).not.toThrow();
+ }
+ });
+
+ it('distinct fixture slugs produce distinct brand objects', () => {
+ const [first, second] = CMS_FIXTURE_SLUGS;
+ expect(loadFixtureBrand(first)).not.toEqual(loadFixtureBrand(second));
+ });
+
+ it('call with no argument is equivalent to passing DEFAULT_CONTENT_FIXTURE', () => {
+ expect(loadFixtureBrand()).toEqual(loadFixtureBrand(DEFAULT_CONTENT_FIXTURE));
+ });
+
+ it('each fixture brand satisfies VbrandSchema', () => {
+ for (const slug of CMS_FIXTURE_SLUGS) {
+ expect(VbrandSchema.safeParse(loadFixtureBrand(slug)).success).toBe(true);
+ }
+ });
+});
+
+describe('loadFixtureSchema - schema loading contract', () => {
+ it('returns a VbrandSchema-conforming object for every shipped fixture', () => {
+ for (const slug of CMS_FIXTURE_SLUGS) {
+ expect(VbrandSchema.safeParse(loadFixtureSchema(slug)).success).toBe(true);
+ }
+ });
+
+ it('schema for a slug is identical to the brand loaded for the same slug', () => {
+ for (const slug of CMS_FIXTURE_SLUGS) {
+ expect(loadFixtureSchema(slug)).toEqual(loadFixtureBrand(slug));
+ }
+ });
+
+ it('call with no argument is equivalent to passing DEFAULT_CONTENT_FIXTURE', () => {
+ expect(loadFixtureSchema()).toEqual(loadFixtureSchema(DEFAULT_CONTENT_FIXTURE));
+ });
+
+ it('distinct fixture slugs produce distinct schemas', () => {
+ const [first, second] = CMS_FIXTURE_SLUGS;
+ expect(loadFixtureSchema(first)).not.toEqual(loadFixtureSchema(second));
+ });
+});
+
+describe('canonicalDataset - structure and completeness', () => {
+ it('contains exactly one page per shipped fixture slug', () => {
+ const dataset = canonicalDataset();
+ expect(dataset.pages).toHaveLength(CMS_FIXTURE_SLUGS.length);
+ });
+
+ it('page fixture labels match CMS_FIXTURE_SLUGS in order', () => {
+ const fixtures = canonicalDataset().pages.map((p) => p.fixture);
+ expect(fixtures).toEqual(CMS_FIXTURE_SLUGS);
+ });
+
+ it('no duplicate fixture labels appear in the dataset', () => {
+ const fixtures = canonicalDataset().pages.map((p) => p.fixture);
+ expect(new Set(fixtures).size).toBe(fixtures.length);
+ });
+
+ it('each page brand matches loadFixtureBrand for that fixture', () => {
+ for (const page of canonicalDataset().pages) {
+ expect(page.brand).toEqual(loadFixtureBrand(page.fixture as Parameters[0]));
+ }
+ });
+
+ it('each page content matches loadFixtureContentTree for that fixture', () => {
+ for (const page of canonicalDataset().pages) {
+ expect(page.content).toEqual(loadFixtureContentTree(page.fixture as Parameters[0]));
+ }
+ });
+
+ it('each page content contains only known overridable keys', () => {
+ for (const page of canonicalDataset().pages) {
+ expect(() => assertKnownContentKeys(page.content)).not.toThrow();
+ }
+ });
+});
+
+describe('assertKnownContentKeys - validation contract', () => {
+ it('returns the input tree unchanged when all keys are known', () => {
+ const tree = loadFixtureContentTree(DEFAULT_CONTENT_FIXTURE);
+ expect(assertKnownContentKeys(tree)).toBe(tree);
+ });
+
+ it('throws when the tree contains an unknown key', () => {
+ const bad = { 'unknown.section.field': 'value' } as unknown as Parameters[0];
+ expect(() => assertKnownContentKeys(bad)).toThrow();
+ });
+
+ it('error message names the unrecognised key so callers can diagnose the mismatch', () => {
+ const bad = { 'unknown.section.field': 'value' } as unknown as Parameters[0];
+ expect(() => assertKnownContentKeys(bad)).toThrow('unknown.section.field');
+ });
+
+ it('error message names every unrecognised key when multiple bad keys are present', () => {
+ const bad = {
+ 'unknown.a.key': 'x',
+ 'unknown.b.key': 'y',
+ } as unknown as Parameters[0];
+ let caught: Error | undefined;
+ try { assertKnownContentKeys(bad); } catch (e) { caught = e as Error; }
+ expect(caught).toBeDefined();
+ expect(caught?.message).toContain('unknown.a.key');
+ expect(caught?.message).toContain('unknown.b.key');
+ });
+
+ it('does not throw for an empty ContentTree', () => {
+ expect(() => assertKnownContentKeys({} as Parameters[0])).not.toThrow();
+ });
+
+ it('a mixed tree with one unknown key and valid keys throws and names only the unknown key', () => {
+ const validKey = overridableFieldKeys()[0];
+ const mixed = { [validKey]: 'ok', 'bad.unknown.key': 'oops' } as unknown as Parameters[0];
+ expect(() => assertKnownContentKeys(mixed)).toThrow('bad.unknown.key');
+ expect(() => assertKnownContentKeys(mixed)).not.toThrow(validKey);
+ });
+});
diff --git a/src/cms/index.ts b/src/cms/index.ts
new file mode 100644
index 0000000..0266e1d
--- /dev/null
+++ b/src/cms/index.ts
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type { CmsName, ContentTree, SchemaTree } from './types.js';
+import { DEFAULT_CMS } from './types.js';
+import { vbrandStandaloneCms } from './vbrand-standalone.js';
+import { payloadCms } from './payload.js';
+import { sanityCms } from './sanity.js';
+import { strapiCms } from './strapi.js';
+
+export type { CmsName, ContentTree, SchemaTree } from './types.js';
+export { DEFAULT_CMS, parseCms, CmsNameSchema, CMS_NAMES } from './types.js';
+export { loadFixtureContentTree, loadFixtureSchema, resolveFixtureSlug, CMS_FIXTURE_SLUGS } from './content-tree.js';
+export { normalizePayloadResponse, payloadPagesFixture, payloadCollectionsFixture } from './payload.js';
+export { normalizeSanityResponse, sanityPagesFixture, sanitySchemaFixture } from './sanity.js';
+export { normalizeStrapiResponse, strapiPagesFixture, strapiContentTypesFixture } from './strapi.js';
+export { overridableFieldKeys } from './content-tree.js';
+
+export interface CmsSubstrateAdapter {
+ loadContent(slug?: string): Promise;
+ loadSchema(): Promise;
+ name(): CmsName;
+}
+
+export const CMS_SUBSTRATE_REGISTRY: Record = {
+ 'vbrand-standalone': vbrandStandaloneCms,
+ payload: payloadCms,
+ sanity: sanityCms,
+ strapi: strapiCms,
+};
+
+export function getCmsSubstrate(name: CmsName): CmsSubstrateAdapter {
+ return CMS_SUBSTRATE_REGISTRY[name] ?? CMS_SUBSTRATE_REGISTRY[DEFAULT_CMS];
+}
+
+export { vbrandStandaloneCms } from './vbrand-standalone.js';
+export { payloadCms } from './payload.js';
+export { sanityCms } from './sanity.js';
+export { strapiCms } from './strapi.js';
diff --git a/src/cms/payload.ts b/src/cms/payload.ts
new file mode 100644
index 0000000..b8242d4
--- /dev/null
+++ b/src/cms/payload.ts
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type { CmsSubstrateAdapter } from './index.js';
+import type { ContentTree, SchemaTree } from './types.js';
+import { assertKnownContentKeys, canonicalDataset, loadFixtureSchema, overridableFieldKeys } from './content-tree.js';
+
+export interface PayloadPagesResponse { readonly docs: readonly { readonly slug: string; readonly content: ContentTree }[] }
+export interface PayloadCollectionsResponse { readonly collections: readonly { readonly slug: string; readonly fields: readonly string[] }[] }
+
+export function payloadPagesFixture(): PayloadPagesResponse {
+ return { docs: canonicalDataset().pages.map((page) => ({ slug: page.fixture, content: page.content })) };
+}
+
+export function payloadCollectionsFixture(): PayloadCollectionsResponse {
+ return { collections: [{ slug: 'pages', fields: overridableFieldKeys() }] };
+}
+
+export function normalizePayloadResponse(raw: PayloadPagesResponse, slug = 'stripe'): ContentTree {
+ const page = raw.docs.find((doc) => doc.slug === slug) ?? raw.docs[0];
+ if (!page) throw new Error('Payload fixture contains no pages');
+ return assertKnownContentKeys(page.content);
+}
+
+export function normalizePayloadSchema(_raw: PayloadCollectionsResponse): SchemaTree {
+ return loadFixtureSchema();
+}
+
+export const payloadCms: CmsSubstrateAdapter = {
+ name: () => 'payload',
+ loadContent: async (slug) => normalizePayloadResponse(payloadPagesFixture(), slug),
+ loadSchema: async () => normalizePayloadSchema({ collections: [] }),
+};
diff --git a/src/cms/sanity.ts b/src/cms/sanity.ts
new file mode 100644
index 0000000..a6892fd
--- /dev/null
+++ b/src/cms/sanity.ts
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type { CmsSubstrateAdapter } from './index.js';
+import type { ContentTree, SchemaTree } from './types.js';
+import { assertKnownContentKeys, canonicalDataset, loadFixtureSchema, overridableFieldKeys } from './content-tree.js';
+
+export interface SanityPagesResponse { readonly result: readonly { readonly _id: string; readonly slug: { readonly current: string }; readonly content: ContentTree }[] }
+export interface SanitySchemaResponse { readonly types: readonly { readonly name: string; readonly fields: readonly string[] }[] }
+
+export function sanityPagesFixture(): SanityPagesResponse {
+ return { result: canonicalDataset().pages.map((page) => ({ _id: `page.${page.fixture}`, slug: { current: page.fixture }, content: page.content })) };
+}
+
+export function sanitySchemaFixture(): SanitySchemaResponse {
+ return { types: [{ name: 'page', fields: overridableFieldKeys() }] };
+}
+
+export function normalizeSanityResponse(raw: SanityPagesResponse, slug = 'stripe'): ContentTree {
+ const page = raw.result.find((doc) => doc.slug.current === slug) ?? raw.result[0];
+ if (!page) throw new Error('Sanity fixture contains no pages');
+ return assertKnownContentKeys(page.content);
+}
+
+export function normalizeSanitySchema(_raw: SanitySchemaResponse): SchemaTree {
+ return loadFixtureSchema();
+}
+
+export const sanityCms: CmsSubstrateAdapter = {
+ name: () => 'sanity',
+ loadContent: async (slug) => normalizeSanityResponse(sanityPagesFixture(), slug),
+ loadSchema: async () => normalizeSanitySchema({ types: [] }),
+};
diff --git a/src/cms/strapi.ts b/src/cms/strapi.ts
new file mode 100644
index 0000000..ea29955
--- /dev/null
+++ b/src/cms/strapi.ts
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type { CmsSubstrateAdapter } from './index.js';
+import type { ContentTree, SchemaTree } from './types.js';
+import { assertKnownContentKeys, canonicalDataset, loadFixtureSchema, overridableFieldKeys } from './content-tree.js';
+
+export interface StrapiPagesResponse { readonly data: readonly { readonly id: number; readonly attributes: { readonly slug: string; readonly content: ContentTree } }[] }
+export interface StrapiContentTypesResponse { readonly contentTypes: readonly { readonly uid: string; readonly attributes: readonly string[] }[] }
+
+export function strapiPagesFixture(): StrapiPagesResponse {
+ return { data: canonicalDataset().pages.map((page, index) => ({ id: index + 1, attributes: { slug: page.fixture, content: page.content } })) };
+}
+
+export function strapiContentTypesFixture(): StrapiContentTypesResponse {
+ return { contentTypes: [{ uid: 'api::page.page', attributes: overridableFieldKeys() }] };
+}
+
+export function normalizeStrapiResponse(raw: StrapiPagesResponse, slug = 'stripe'): ContentTree {
+ const page = raw.data.find((doc) => doc.attributes.slug === slug) ?? raw.data[0];
+ if (!page) throw new Error('Strapi fixture contains no pages');
+ return assertKnownContentKeys(page.attributes.content);
+}
+
+export function normalizeStrapiSchema(_raw: StrapiContentTypesResponse): SchemaTree {
+ return loadFixtureSchema();
+}
+
+export const strapiCms: CmsSubstrateAdapter = {
+ name: () => 'strapi',
+ loadContent: async (slug) => normalizeStrapiResponse(strapiPagesFixture(), slug),
+ loadSchema: async () => normalizeStrapiSchema({ contentTypes: [] }),
+};
diff --git a/src/cms/types.ts b/src/cms/types.ts
new file mode 100644
index 0000000..5b47b9a
--- /dev/null
+++ b/src/cms/types.ts
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { z } from 'zod';
+import type { VbrandType } from '../schema.js';
+import type { ContentOverrideKey, ContentOverrideValue } from '../content/override.js';
+
+export const CMS_NAMES = ['vbrand-standalone', 'payload', 'sanity', 'strapi'] as const;
+export const CmsNameSchema = z.enum(CMS_NAMES);
+export type CmsName = z.infer;
+export const DEFAULT_CMS: CmsName = 'vbrand-standalone';
+
+export type ContentTree = Readonly>>;
+export type SchemaTree = VbrandType;
+
+export function parseCms(raw: string | null | undefined): CmsName {
+ const result = CmsNameSchema.safeParse(raw);
+ return result.success ? result.data : DEFAULT_CMS;
+}
diff --git a/src/cms/vbrand-standalone.ts b/src/cms/vbrand-standalone.ts
new file mode 100644
index 0000000..24da83d
--- /dev/null
+++ b/src/cms/vbrand-standalone.ts
@@ -0,0 +1,10 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type { CmsSubstrateAdapter } from './index.js';
+import { loadFixtureContentTree, loadFixtureSchema, resolveFixtureSlug } from './content-tree.js';
+
+export const vbrandStandaloneCms: CmsSubstrateAdapter = {
+ name: () => 'vbrand-standalone',
+ loadContent: async (slug) => loadFixtureContentTree(resolveFixtureSlug(slug)),
+ loadSchema: async () => loadFixtureSchema(),
+};
diff --git a/src/deploy/bundle.ts b/src/deploy/bundle.ts
new file mode 100644
index 0000000..f59a2e0
--- /dev/null
+++ b/src/deploy/bundle.ts
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import crypto from 'node:crypto';
+import type { Stats } from 'node:fs';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import type { ContentTree } from '../cms/types.js';
+import type { DeployBundle, DeployBundleFile } from './contract.js';
+
+export interface StaticDeployBundleOptions {
+ readonly distDir: string;
+ readonly content?: ContentTree;
+}
+
+function isEnoent(err: unknown): err is NodeJS.ErrnoException {
+ return err instanceof Error && (err as NodeJS.ErrnoException).code === 'ENOENT';
+}
+
+function nodeType(stat: Stats): 'directory' | 'other' {
+ return stat.isDirectory() ? 'directory' : 'other';
+}
+
+async function assertFile(pathname: string): Promise {
+ let stat: Stats;
+ try {
+ stat = await fs.stat(pathname);
+ } catch (err) {
+ if (!isEnoent(err)) throw err;
+ throw new Error(`MISSING_DEPLOY_ARTEFACT: ${pathname}`);
+ }
+ if (stat.isFile()) return;
+ throw new Error(`DEPLOY_PATH_NOT_A_FILE: ${nodeType(stat)}: ${pathname}`);
+}
+
+async function walk(dir: string, prefix = ''): Promise {
+ const entries = await fs.readdir(dir, { withFileTypes: true });
+ const files: DeployBundleFile[] = [];
+ for (const entry of entries) {
+ const absolute = path.join(dir, entry.name);
+ const relative = path.posix.join(prefix, entry.name);
+ if (entry.isDirectory()) files.push(...await walk(absolute, relative));
+ if (entry.isFile()) files.push({ path: relative, contents: await fs.readFile(absolute) });
+ }
+ return files;
+}
+
+function hash(contents: string | Buffer): string {
+ return crypto.createHash('sha256').update(contents).digest('hex');
+}
+
+function withRequiredStaticFiles(files: readonly DeployBundleFile[], content: ContentTree): DeployBundleFile[] {
+ const byPath = new Map(files.map((file) => [file.path, file]));
+ const index = byPath.get('index.html');
+ if (!index) throw new Error('MISSING_DEPLOY_ARTEFACT: index.html');
+ if (![...byPath.keys()].some((file) => file.startsWith('assets/'))) throw new Error('MISSING_DEPLOY_ARTEFACT: assets/');
+ if (!byPath.has('404.html')) byPath.set('404.html', { path: '404.html', contents: index.contents });
+ if (![...byPath.keys()].some((file) => file.startsWith('data/'))) byPath.set('data/content.json', { path: 'data/content.json', contents: JSON.stringify({ content }, null, 2) });
+ return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
+}
+
+export async function buildStaticDeployBundle(options: StaticDeployBundleOptions): Promise {
+ const { distDir } = options;
+ await assertFile(path.join(distDir, 'index.html'));
+ const files = withRequiredStaticFiles(await walk(distDir), options.content ?? {});
+ return { files, manifest: { primaryEntry: 'index.html', assetHashes: Object.fromEntries(files.map((file) => [file.path, hash(file.contents)])) } };
+}
diff --git a/src/deploy/cloudflare-pages.ts b/src/deploy/cloudflare-pages.ts
new file mode 100644
index 0000000..2e04c91
--- /dev/null
+++ b/src/deploy/cloudflare-pages.ts
@@ -0,0 +1,5 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { createDeferredTarget } from './deferred.js';
+
+export const cloudflarePagesTarget = createDeferredTarget('cloudflare-pages');
diff --git a/src/deploy/contract.test.ts b/src/deploy/contract.test.ts
new file mode 100644
index 0000000..fe8ce29
--- /dev/null
+++ b/src/deploy/contract.test.ts
@@ -0,0 +1,373 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type { Stats } from 'node:fs';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
+import type { DeploymentTargetAdapter } from './contract.js';
+import type { DeployTargetName } from './types.js';
+import { buildStaticDeployBundle } from './bundle.js';
+import { createGhPagesTarget, DECOUPLED_FOR_LATER_MESSAGE, DEFAULT_DEPLOY_TARGET, DEPLOY_TARGET_NAMES, DEPLOY_TARGET_REGISTRY, getDeployTarget, parseDeployTarget } from './index.js';
+import { DEPLOY_TARGET_METADATA, getDeployTargetMetadata } from './metadata.js';
+
+const composition = { sections: [{ id: 'hero', visible: true, density: 'regular' as const, order: 0 }] };
+const content = { 'landing.hero.heading': 'Deploy preview' };
+const hostedResult = {
+ url: 'https://bvasilenko.github.io/vBrand/',
+ logs: ['mock gh-pages'],
+ status: 'ok' as const,
+ durationMs: 1,
+};
+
+
+async function demoDistFixture(files: Record = {}): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'vbrand-demo-dist-'));
+ const entries = {
+ 'index.html': 'fixture ',
+ 'assets/index.js': 'window.__fixture = true;',
+ ...files,
+ };
+ for (const [name, contents] of Object.entries(entries)) {
+ const target = path.join(dir, name);
+ await fs.mkdir(path.dirname(target), { recursive: true });
+ await fs.writeFile(target, contents);
+ }
+ return dir;
+}
+
+async function fixtureTarget() {
+ const distDir = await demoDistFixture();
+ return createGhPagesTarget(async () => hostedResult, (_composition, tree) => buildStaticDeployBundle({ distDir, content: tree }));
+}
+
+function expectRequiredStaticBundleShape(paths: readonly string[]) {
+ expect(paths).toContain('index.html');
+ expect(paths).toContain('404.html');
+ expect(paths.some((file) => file.startsWith('assets/'))).toBe(true);
+ expect(paths.some((file) => file.startsWith('data/'))).toBe(true);
+}
+
+describe('DeploymentTargetAdapter contract', () => {
+ it('keeps every registry member type-compatible and name-aligned', () => {
+ expect(Object.keys(DEPLOY_TARGET_REGISTRY).sort()).toEqual([...DEPLOY_TARGET_NAMES].sort());
+ for (const [name, adapter] of Object.entries(DEPLOY_TARGET_REGISTRY)) {
+ expectTypeOf(adapter).toMatchTypeOf();
+ expect(adapter.name()).toBe(name);
+ }
+ });
+
+ it('parses invalid deploy target input to the documented default', () => {
+ expect(parseDeployTarget(null)).toBe(DEFAULT_DEPLOY_TARGET);
+ expect(parseDeployTarget(undefined)).toBe(DEFAULT_DEPLOY_TARGET);
+ expect(parseDeployTarget('render')).toBe(DEFAULT_DEPLOY_TARGET);
+ for (const name of DEPLOY_TARGET_NAMES) expect(parseDeployTarget(name)).toBe(name);
+ });
+
+ it('emits a gh-pages static bundle with required files and hashes', async () => {
+ const adapter = await fixtureTarget();
+ const bundle = await adapter.emitArtifact(composition, content);
+ const paths = bundle.files.map((file) => file.path);
+ expect(bundle.manifest.primaryEntry).toBe('index.html');
+ expectRequiredStaticBundleShape(paths);
+ expect(Object.keys(bundle.manifest.assetHashes).sort()).toEqual([...paths].sort());
+ for (const digest of Object.values(bundle.manifest.assetHashes)) expect(digest).toMatch(/^[a-f0-9]{64}$/);
+ });
+
+
+
+ it('normalizes optional static deploy files without replacing existing artefacts', async () => {
+ const distDir = await demoDistFixture({
+ '404.html': 'custom fallback',
+ 'data/existing.json': '{"stable":true}',
+ 'assets/extra.css': 'body { color: black; }',
+ });
+ const bundle = await buildStaticDeployBundle({ distDir, content });
+ const files = new Map(bundle.files.map((file) => [file.path, String(file.contents)]));
+ expect([...files.keys()]).toEqual([...files.keys()].sort());
+ expect(files.get('404.html')).toContain('custom fallback');
+ expect(files.get('data/existing.json')).toBe('{"stable":true}');
+ expect(files.has('data/content.json')).toBe(false);
+ expect(bundle.manifest.assetHashes['404.html']).toMatch(/^[a-f0-9]{64}$/);
+ });
+
+ it.each([
+ ['missing index', {}, 'MISSING_DEPLOY_ARTEFACT'],
+ ['missing assets', { 'index.html': '' }, 'MISSING_DEPLOY_ARTEFACT: assets/'],
+ ])('fails fast for invalid static deploy dist: %s', async (_case, files, message) => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'vbrand-invalid-dist-'));
+ for (const [name, contents] of Object.entries(files)) {
+ const target = path.join(dir, name);
+ await fs.mkdir(path.dirname(target), { recursive: true });
+ await fs.writeFile(target, contents);
+ }
+ await expect(buildStaticDeployBundle({ distDir: dir, content })).rejects.toThrow(message);
+ });
+
+ it('fails deferred deployment slots loudly', async () => {
+ const adapter = await fixtureTarget();
+ const bundle = await adapter.emitArtifact(composition, content);
+ for (const [name, adapter] of Object.entries(DEPLOY_TARGET_REGISTRY)) {
+ if (name === 'gh-pages') continue;
+ await expect(adapter.deployBundle(bundle)).rejects.toThrow(DECOUPLED_FOR_LATER_MESSAGE);
+ }
+ });
+
+ it('deploys gh-pages bundles through an isolated shell boundary', async () => {
+ const calls: string[] = [];
+ const adapter = createGhPagesTarget(async (dir) => {
+ calls.push(dir);
+ return hostedResult;
+ }, async (_composition, tree) => buildStaticDeployBundle({ distDir: await demoDistFixture(), content: tree }));
+ const result = await adapter.deployBundle(await adapter.emitArtifact(composition, content));
+ expect(result.status).toBe('ok');
+ expect(result.logs).toEqual(['mock gh-pages']);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]).toContain('vbrand-gh-pages-');
+ });
+});
+
+describe('DeploymentTargetAdapter registry and bundle structural contract', () => {
+ it('DEPLOY_TARGET_NAMES contains exactly 7 platforms covering all documented deploy targets', () => {
+ expect(DEPLOY_TARGET_NAMES).toHaveLength(7);
+ expect([...DEPLOY_TARGET_NAMES].sort()).toEqual([
+ 'cloudflare-pages', 'coolify', 'custom-vps', 'fly', 'gh-pages', 'netlify', 'vercel',
+ ]);
+ });
+
+ it('getDeployTarget returns the named adapter for every valid target name', () => {
+ for (const name of DEPLOY_TARGET_NAMES) expect(getDeployTarget(name).name()).toBe(name);
+ });
+
+ it('DEFAULT_DEPLOY_TARGET is gh-pages', () => {
+ expect(DEFAULT_DEPLOY_TARGET).toBe('gh-pages');
+ });
+
+ it('bundle files are sorted lexicographically by path so consumers can binary-search or diff', async () => {
+ const adapter = await fixtureTarget();
+ const bundle = await adapter.emitArtifact(composition, content);
+ const paths = bundle.files.map((file) => file.path);
+ expect(paths).toEqual([...paths].sort());
+ });
+
+ it('bundle files contain no duplicate paths', async () => {
+ const adapter = await fixtureTarget();
+ const bundle = await adapter.emitArtifact(composition, content);
+ const paths = bundle.files.map((file) => file.path);
+ expect(new Set(paths).size).toBe(paths.length);
+ });
+
+ it('assetHashes keys and bundle file paths are identical sets in both directions', async () => {
+ const adapter = await fixtureTarget();
+ const bundle = await adapter.emitArtifact(composition, content);
+ const filePaths = new Set(bundle.files.map((file) => file.path));
+ const hashKeys = new Set(Object.keys(bundle.manifest.assetHashes));
+ for (const key of hashKeys) expect(filePaths.has(key)).toBe(true);
+ for (const p of filePaths) expect(hashKeys.has(p)).toBe(true);
+ });
+
+ it('all asset hashes are 64-character hex strings regardless of file type or encoding', async () => {
+ const adapter = await fixtureTarget();
+ const bundle = await adapter.emitArtifact(composition, content);
+ for (const hash of Object.values(bundle.manifest.assetHashes)) {
+ expect(hash).toMatch(/^[a-f0-9]{64}$/);
+ }
+ });
+
+ it('DECOUPLED_FOR_LATER_MESSAGE names the bridge-cycle version that will unlock deferred targets', () => {
+ expect(DECOUPLED_FOR_LATER_MESSAGE).toContain('DECOUPLED-FOR-LATER');
+ expect(DECOUPLED_FOR_LATER_MESSAGE).toContain('0.5.0');
+ });
+});
+
+describe('DeploymentTargetMetadata browser-safe contract', () => {
+ it('metadata covers every deploy target exactly once', () => {
+ const metadataNames = DEPLOY_TARGET_METADATA.map((target) => target.name);
+ expect(metadataNames.sort()).toEqual([...DEPLOY_TARGET_NAMES].sort());
+ expect(new Set(metadataNames).size).toBe(DEPLOY_TARGET_METADATA.length);
+ });
+
+ it('marks only the default deploy target as wired', () => {
+ const wired = DEPLOY_TARGET_METADATA.filter((target) => target.status === 'wired');
+ expect(wired).toHaveLength(1);
+ expect(wired[0]?.name).toBe(DEFAULT_DEPLOY_TARGET);
+ expect(wired[0]?.badge).toBe('index.html');
+ });
+
+ it('marks every non-default deploy target as decoupled for later', () => {
+ for (const target of DEPLOY_TARGET_METADATA) {
+ if (target.name === DEFAULT_DEPLOY_TARGET) continue;
+ expect(target.status).toBe('decoupled-for-later');
+ expect(target.badge).toBe('DECOUPLED-FOR-LATER');
+ }
+ });
+
+ it('looks up metadata by target name and falls back to the default target for impossible input', () => {
+ for (const target of DEPLOY_TARGET_METADATA) expect(getDeployTargetMetadata(target.name)).toEqual(target);
+ expect(getDeployTargetMetadata('render' as DeployTargetName)).toEqual(getDeployTargetMetadata(DEFAULT_DEPLOY_TARGET));
+ });
+});
+
+describe('DEFERRED_BUNDLE shape - non-gh-pages emitArtifact', () => {
+ const DEFERRED_TARGET_NAMES = [...DEPLOY_TARGET_NAMES].filter((n) => n !== 'gh-pages');
+
+ it.each(DEFERRED_TARGET_NAMES)(
+ 'emitArtifact for "%s" resolves without filesystem I/O (completes in < 50 ms)',
+ async (name) => {
+ const adapter = DEPLOY_TARGET_REGISTRY[name];
+ const start = performance.now();
+ await adapter.emitArtifact(composition, content);
+ expect(performance.now() - start).toBeLessThan(50);
+ },
+ );
+
+ it.each(DEFERRED_TARGET_NAMES)(
+ 'emitArtifact for "%s" returns a bundle containing index.html',
+ async (name) => {
+ const bundle = await DEPLOY_TARGET_REGISTRY[name].emitArtifact(composition, content);
+ expect(bundle.files.map((f) => f.path)).toContain('index.html');
+ },
+ );
+
+ it.each(DEFERRED_TARGET_NAMES)(
+ 'emitArtifact for "%s" returns a bundle containing 404.html',
+ async (name) => {
+ const bundle = await DEPLOY_TARGET_REGISTRY[name].emitArtifact(composition, content);
+ expect(bundle.files.map((f) => f.path)).toContain('404.html');
+ },
+ );
+
+ it.each(DEFERRED_TARGET_NAMES)(
+ 'emitArtifact for "%s" returns a bundle with at least one assets/ entry and one data/ entry',
+ async (name) => {
+ const bundle = await DEPLOY_TARGET_REGISTRY[name].emitArtifact(composition, content);
+ const paths = bundle.files.map((f) => f.path);
+ expect(paths.some((p) => p.startsWith('assets/'))).toBe(true);
+ expect(paths.some((p) => p.startsWith('data/'))).toBe(true);
+ },
+ );
+
+ it.each(DEFERRED_TARGET_NAMES)(
+ 'emitArtifact for "%s" has manifest.primaryEntry === "index.html"',
+ async (name) => {
+ const bundle = await DEPLOY_TARGET_REGISTRY[name].emitArtifact(composition, content);
+ expect(bundle.manifest.primaryEntry).toBe('index.html');
+ },
+ );
+
+ it.each(DEFERRED_TARGET_NAMES)(
+ 'deferred bundle for "%s" embeds DECOUPLED_FOR_LATER_MESSAGE in the index.html placeholder',
+ async (name) => {
+ const bundle = await DEPLOY_TARGET_REGISTRY[name].emitArtifact(composition, content);
+ const indexFile = bundle.files.find((f) => f.path === 'index.html');
+ expect(String(indexFile?.contents)).toContain(DECOUPLED_FOR_LATER_MESSAGE);
+ },
+ );
+
+ it.each(DEFERRED_TARGET_NAMES)(
+ 'deferred bundle for "%s" embeds DECOUPLED_FOR_LATER_MESSAGE in the 404.html placeholder',
+ async (name) => {
+ const bundle = await DEPLOY_TARGET_REGISTRY[name].emitArtifact(composition, content);
+ const notFoundFile = bundle.files.find((f) => f.path === '404.html');
+ expect(String(notFoundFile?.contents)).toContain(DECOUPLED_FOR_LATER_MESSAGE);
+ },
+ );
+
+ it('all deferred targets return the identical frozen bundle object (single shared constant)', async () => {
+ const bundles = await Promise.all(
+ DEFERRED_TARGET_NAMES.map((name) => DEPLOY_TARGET_REGISTRY[name].emitArtifact(composition, content)),
+ );
+ for (let i = 1; i < bundles.length; i++) {
+ expect(bundles[i]).toBe(bundles[0]);
+ }
+ });
+});
+
+describe('parseDeployTarget - non-string and boundary inputs', () => {
+ it('empty string falls back to DEFAULT_DEPLOY_TARGET', () => {
+ expect(parseDeployTarget('')).toBe(DEFAULT_DEPLOY_TARGET);
+ });
+
+ it('numeric input falls back to DEFAULT_DEPLOY_TARGET', () => {
+ expect(parseDeployTarget(0 as unknown as string)).toBe(DEFAULT_DEPLOY_TARGET);
+ expect(parseDeployTarget(1 as unknown as string)).toBe(DEFAULT_DEPLOY_TARGET);
+ });
+
+ it('boolean input falls back to DEFAULT_DEPLOY_TARGET', () => {
+ expect(parseDeployTarget(true as unknown as string)).toBe(DEFAULT_DEPLOY_TARGET);
+ expect(parseDeployTarget(false as unknown as string)).toBe(DEFAULT_DEPLOY_TARGET);
+ });
+
+ it('object input falls back to DEFAULT_DEPLOY_TARGET', () => {
+ expect(parseDeployTarget({} as unknown as string)).toBe(DEFAULT_DEPLOY_TARGET);
+ });
+});
+
+const STAT_ERRORS_THAT_PROPAGATE = [
+ { code: 'EACCES', label: 'permission denied' },
+ { code: 'EPERM', label: 'operation not permitted' },
+ { code: 'EIO', label: 'I/O error' },
+ { code: 'ENOTDIR', label: 'intermediate path segment is not a directory' },
+] as const;
+
+const STAT_ERRORS_THAT_NORMALIZE = [
+ { code: 'ENOENT', label: 'file does not exist', tag: 'MISSING_DEPLOY_ARTEFACT' },
+] as const;
+
+const NON_FILE_NODE_TYPES: ReadonlyArray<{
+ readonly nodeType: 'directory' | 'other';
+ readonly isDirectory: () => boolean;
+ readonly label: string;
+}> = [
+ { nodeType: 'directory', isDirectory: () => true, label: 'directory' },
+ { nodeType: 'other', isDirectory: () => false, label: 'non-file non-directory node (socket, FIFO, device)' },
+];
+
+function fakeNonFileStat(isDirectory: () => boolean): Stats {
+ return { isFile: () => false, isDirectory } as unknown as Stats;
+}
+
+describe('buildStaticDeployBundle - assertFile error taxonomy', () => {
+ afterEach(() => { vi.restoreAllMocks(); });
+
+ it.each(STAT_ERRORS_THAT_PROPAGATE)(
+ 'propagates $code ($label) stat error without normalizing to MISSING_DEPLOY_ARTEFACT',
+ async ({ code }) => {
+ const originalError = Object.assign(new Error(`${code}: operation failed`), { code });
+ vi.spyOn(fs, 'stat').mockRejectedValueOnce(originalError);
+ await expect(buildStaticDeployBundle({ distDir: '/any', content: {} })).rejects.toBe(originalError);
+ },
+ );
+
+ it('propagates non-Error rejection from stat without normalizing', async () => {
+ const nonErrorRejection = 'unexpected string rejection from fs.stat';
+ vi.spyOn(fs, 'stat').mockRejectedValueOnce(nonErrorRejection as unknown as Error);
+ await expect(buildStaticDeployBundle({ distDir: '/any', content: {} })).rejects.toBe(nonErrorRejection);
+ });
+
+ it.each(STAT_ERRORS_THAT_NORMALIZE)(
+ 'normalizes $code ($label) to a new $tag error embedding the checked path',
+ async ({ code, tag }) => {
+ const distDir = '/sentinel';
+ const originalError = Object.assign(new Error(`${code}: operation failed`), { code });
+ vi.spyOn(fs, 'stat').mockRejectedValueOnce(originalError);
+ const thrown = await buildStaticDeployBundle({ distDir, content: {} }).catch((e: unknown) => e);
+ expect(thrown).toBeInstanceOf(Error);
+ expect(thrown).not.toBe(originalError);
+ expect((thrown as Error).message).toContain(tag);
+ expect((thrown as Error).message).toContain('index.html');
+ },
+ );
+
+ it.each(NON_FILE_NODE_TYPES)(
+ 'maps $label at the checked path to DEPLOY_PATH_NOT_A_FILE: $nodeType embedding the path',
+ async ({ nodeType, isDirectory }) => {
+ const distDir = '/sentinel';
+ vi.spyOn(fs, 'stat').mockResolvedValueOnce(fakeNonFileStat(isDirectory));
+ const thrown = await buildStaticDeployBundle({ distDir, content: {} }).catch((e: unknown) => e);
+ expect(thrown).toBeInstanceOf(Error);
+ expect((thrown as Error).message).toContain(`DEPLOY_PATH_NOT_A_FILE: ${nodeType}`);
+ expect((thrown as Error).message).toContain('index.html');
+ },
+ );
+});
diff --git a/src/deploy/contract.ts b/src/deploy/contract.ts
new file mode 100644
index 0000000..4d4f350
--- /dev/null
+++ b/src/deploy/contract.ts
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type { Buffer } from 'node:buffer';
+import type { CompositionSpec } from '../composition/spec.js';
+import type { ContentTree } from '../cms/types.js';
+import type { DeployTargetName } from './types.js';
+
+export interface DeployBundleFile {
+ readonly path: string;
+ readonly contents: string | Buffer;
+}
+
+export interface DeployBundle {
+ readonly files: readonly DeployBundleFile[];
+ readonly manifest: {
+ readonly primaryEntry: string;
+ readonly assetHashes: Readonly>;
+ };
+}
+
+export interface DeployResult {
+ readonly url: string;
+ readonly logs: readonly string[];
+ readonly status: 'ok' | 'failed';
+ readonly durationMs: number;
+}
+
+export interface DeploymentTargetAdapter {
+ name(): DeployTargetName;
+ emitArtifact(composition: CompositionSpec, content: ContentTree): Promise;
+ deployBundle(bundle: DeployBundle): Promise;
+}
diff --git a/src/deploy/coolify.ts b/src/deploy/coolify.ts
new file mode 100644
index 0000000..604d877
--- /dev/null
+++ b/src/deploy/coolify.ts
@@ -0,0 +1,5 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { createDeferredTarget } from './deferred.js';
+
+export const coolifyTarget = createDeferredTarget('coolify');
diff --git a/src/deploy/custom-vps.ts b/src/deploy/custom-vps.ts
new file mode 100644
index 0000000..7d92ac1
--- /dev/null
+++ b/src/deploy/custom-vps.ts
@@ -0,0 +1,5 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { createDeferredTarget } from './deferred.js';
+
+export const customVpsTarget = createDeferredTarget('custom-vps');
diff --git a/src/deploy/deferred.ts b/src/deploy/deferred.ts
new file mode 100644
index 0000000..f7446a3
--- /dev/null
+++ b/src/deploy/deferred.ts
@@ -0,0 +1,30 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type { CompositionSpec } from '../composition/spec.js';
+import type { ContentTree } from '../cms/types.js';
+import type { DeploymentTargetAdapter, DeployBundle, DeployResult } from './contract.js';
+import type { DeployTargetName } from './types.js';
+
+export const DECOUPLED_FOR_LATER_MESSAGE = 'DECOUPLED-FOR-LATER: vBrand 0.5.0 multi-deploy-target emit';
+
+const DEFERRED_BUNDLE: DeployBundle = Object.freeze({
+ files: Object.freeze([
+ { path: 'index.html', contents: `${DECOUPLED_FOR_LATER_MESSAGE}` },
+ { path: '404.html', contents: `${DECOUPLED_FOR_LATER_MESSAGE}` },
+ { path: 'assets/.keep', contents: '' },
+ { path: 'data/.keep', contents: '' },
+ ]),
+ manifest: Object.freeze({ primaryEntry: 'index.html', assetHashes: Object.freeze({}) }),
+});
+
+export function createDeferredTarget(name: DeployTargetName): DeploymentTargetAdapter {
+ return {
+ name: () => name,
+ emitArtifact(_composition: CompositionSpec, _content: ContentTree): Promise {
+ return Promise.resolve(DEFERRED_BUNDLE);
+ },
+ async deployBundle(): Promise {
+ throw new Error(DECOUPLED_FOR_LATER_MESSAGE);
+ },
+ };
+}
diff --git a/src/deploy/fly.ts b/src/deploy/fly.ts
new file mode 100644
index 0000000..2394e79
--- /dev/null
+++ b/src/deploy/fly.ts
@@ -0,0 +1,5 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { createDeferredTarget } from './deferred.js';
+
+export const flyTarget = createDeferredTarget('fly');
diff --git a/src/deploy/gh-pages.ts b/src/deploy/gh-pages.ts
new file mode 100644
index 0000000..fbfd61d
--- /dev/null
+++ b/src/deploy/gh-pages.ts
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { spawn } from 'node:child_process';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import type { CompositionSpec } from '../composition/spec.js';
+import type { ContentTree } from '../cms/types.js';
+import type { DeploymentTargetAdapter, DeployBundle, DeployResult } from './contract.js';
+import { buildStaticDeployBundle } from './bundle.js';
+
+const HOSTED_URL = 'https://bvasilenko.github.io/vBrand/';
+
+export type GhPagesRunner = (dir: string) => Promise;
+export type GhPagesEmitter = (composition: CompositionSpec, content: ContentTree) => Promise;
+
+async function materializeBundle(bundle: DeployBundle): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'vbrand-gh-pages-'));
+ for (const file of bundle.files) {
+ const target = path.join(dir, file.path);
+ await fs.mkdir(path.dirname(target), { recursive: true });
+ await fs.writeFile(target, file.contents);
+ }
+ return dir;
+}
+
+export function runGhPagesCli(dir: string): Promise {
+ const started = Date.now();
+ return new Promise((resolve) => {
+ const child = spawn('npx', ['gh-pages', '-d', dir], { stdio: ['ignore', 'pipe', 'pipe'] });
+ const logs: string[] = [];
+ child.stdout.on('data', (chunk) => logs.push(String(chunk)));
+ child.stderr.on('data', (chunk) => logs.push(String(chunk)));
+ child.on('error', (err) => resolve({ url: HOSTED_URL, logs: [err.message], status: 'failed', durationMs: Date.now() - started }));
+ child.on('close', (code) => resolve({ url: HOSTED_URL, logs, status: code === 0 ? 'ok' : 'failed', durationMs: Date.now() - started }));
+ });
+}
+
+function demoDistDir(): string {
+ return path.resolve(process.cwd(), 'examples/demo/dist');
+}
+
+const defaultEmit: GhPagesEmitter = (_composition, content) =>
+ buildStaticDeployBundle({ distDir: demoDistDir(), content });
+
+export function createGhPagesTarget(run: GhPagesRunner = runGhPagesCli, emit: GhPagesEmitter = defaultEmit): DeploymentTargetAdapter {
+ return {
+ name: () => 'gh-pages',
+ emitArtifact(composition: CompositionSpec, content: ContentTree): Promise {
+ return emit(composition, content);
+ },
+ async deployBundle(bundle: DeployBundle): Promise {
+ const dir = await materializeBundle(bundle);
+ return run(dir);
+ },
+ };
+}
+
+export const ghPagesTarget: DeploymentTargetAdapter = createGhPagesTarget();
diff --git a/src/deploy/index.ts b/src/deploy/index.ts
new file mode 100644
index 0000000..13d4883
--- /dev/null
+++ b/src/deploy/index.ts
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type { DeploymentTargetAdapter } from './contract.js';
+import type { DeployTargetName } from './types.js';
+import { DEFAULT_DEPLOY_TARGET } from './types.js';
+import { ghPagesTarget } from './gh-pages.js';
+import { flyTarget } from './fly.js';
+import { coolifyTarget } from './coolify.js';
+import { vercelTarget } from './vercel.js';
+import { netlifyTarget } from './netlify.js';
+import { cloudflarePagesTarget } from './cloudflare-pages.js';
+import { customVpsTarget } from './custom-vps.js';
+
+export type { DeploymentTargetAdapter, DeployBundle, DeployBundleFile, DeployResult } from './contract.js';
+export type { DeployTargetName } from './types.js';
+export { DEFAULT_DEPLOY_TARGET, DEPLOY_TARGET_NAMES, DeployTargetNameSchema, parseDeployTarget } from './types.js';
+export { DEPLOY_TARGET_METADATA, getDeployTargetMetadata } from './metadata.js';
+export type { DeployTargetMetadata, DeployTargetStatus } from './metadata.js';
+export { DECOUPLED_FOR_LATER_MESSAGE } from './deferred.js';
+export { buildStaticDeployBundle } from './bundle.js';
+
+export const DEPLOY_TARGET_REGISTRY: Record = {
+ 'gh-pages': ghPagesTarget,
+ fly: flyTarget,
+ coolify: coolifyTarget,
+ vercel: vercelTarget,
+ netlify: netlifyTarget,
+ 'cloudflare-pages': cloudflarePagesTarget,
+ 'custom-vps': customVpsTarget,
+};
+
+export function getDeployTarget(name: DeployTargetName): DeploymentTargetAdapter {
+ return DEPLOY_TARGET_REGISTRY[name] ?? DEPLOY_TARGET_REGISTRY[DEFAULT_DEPLOY_TARGET];
+}
+
+export { createGhPagesTarget, ghPagesTarget, runGhPagesCli } from './gh-pages.js';
+export { flyTarget } from './fly.js';
+export { coolifyTarget } from './coolify.js';
+export { vercelTarget } from './vercel.js';
+export { netlifyTarget } from './netlify.js';
+export { cloudflarePagesTarget } from './cloudflare-pages.js';
+export { customVpsTarget } from './custom-vps.js';
diff --git a/src/deploy/metadata.ts b/src/deploy/metadata.ts
new file mode 100644
index 0000000..a386286
--- /dev/null
+++ b/src/deploy/metadata.ts
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type { DeployTargetName } from './types.js';
+import { DEFAULT_DEPLOY_TARGET, DEPLOY_TARGET_NAMES, DeployTargetNameSchema, parseDeployTarget } from './types.js';
+
+export type DeployTargetStatus = 'wired' | 'decoupled-for-later';
+
+export interface DeployTargetMetadata {
+ readonly name: DeployTargetName;
+ readonly label: string;
+ readonly status: DeployTargetStatus;
+ readonly badge: 'index.html' | 'DECOUPLED-FOR-LATER';
+}
+
+const DEPLOY_TARGET_LABELS: Record = {
+ 'gh-pages': 'GitHub Pages',
+ fly: 'Fly',
+ coolify: 'Coolify',
+ vercel: 'Vercel',
+ netlify: 'Netlify',
+ 'cloudflare-pages': 'Cloudflare Pages',
+ 'custom-vps': 'Custom VPS',
+};
+
+function metadataFor(name: DeployTargetName): DeployTargetMetadata {
+ const wired = name === DEFAULT_DEPLOY_TARGET;
+ return {
+ name,
+ label: DEPLOY_TARGET_LABELS[name],
+ status: wired ? 'wired' : 'decoupled-for-later',
+ badge: wired ? 'index.html' : 'DECOUPLED-FOR-LATER',
+ };
+}
+
+export const DEPLOY_TARGET_METADATA: readonly DeployTargetMetadata[] = DEPLOY_TARGET_NAMES.map(metadataFor);
+
+export function getDeployTargetMetadata(name: DeployTargetName): DeployTargetMetadata {
+ return DEPLOY_TARGET_METADATA.find((target) => target.name === name) ?? metadataFor(DEFAULT_DEPLOY_TARGET);
+}
+
+export { DEFAULT_DEPLOY_TARGET, DEPLOY_TARGET_NAMES, DeployTargetNameSchema, parseDeployTarget };
+export type { DeployTargetName };
diff --git a/src/deploy/netlify.ts b/src/deploy/netlify.ts
new file mode 100644
index 0000000..74f6ed5
--- /dev/null
+++ b/src/deploy/netlify.ts
@@ -0,0 +1,5 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { createDeferredTarget } from './deferred.js';
+
+export const netlifyTarget = createDeferredTarget('netlify');
diff --git a/src/deploy/types.ts b/src/deploy/types.ts
new file mode 100644
index 0000000..a084a19
--- /dev/null
+++ b/src/deploy/types.ts
@@ -0,0 +1,13 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { z } from 'zod';
+
+export const DEPLOY_TARGET_NAMES = ['gh-pages', 'fly', 'coolify', 'vercel', 'netlify', 'cloudflare-pages', 'custom-vps'] as const;
+export const DeployTargetNameSchema = z.enum(DEPLOY_TARGET_NAMES);
+export type DeployTargetName = z.infer;
+export const DEFAULT_DEPLOY_TARGET: DeployTargetName = 'gh-pages';
+
+export function parseDeployTarget(raw: string | null | undefined): DeployTargetName {
+ const result = DeployTargetNameSchema.safeParse(raw);
+ return result.success ? result.data : DEFAULT_DEPLOY_TARGET;
+}
diff --git a/src/deploy/vercel.ts b/src/deploy/vercel.ts
new file mode 100644
index 0000000..bdf0768
--- /dev/null
+++ b/src/deploy/vercel.ts
@@ -0,0 +1,5 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { createDeferredTarget } from './deferred.js';
+
+export const vercelTarget = createDeferredTarget('vercel');
diff --git a/src/index.ts b/src/index.ts
index b669e7d..bad5fd5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -119,3 +119,39 @@ export type {
DeployHistoryEntry,
PrepareInput,
} from './lib/deploy/types.js';
+
+export {
+ STACK_RUNTIME_REGISTRY,
+ getStackRuntime,
+ viteRuntime,
+ nextRuntime,
+ astroRuntime,
+ DEFAULT_STACK,
+ parseStack,
+ StackNameSchema,
+} from './stacks/index.js';
+export type { StackRuntime, StackName } from './stacks/index.js';
+
+export {
+ CMS_SUBSTRATE_REGISTRY,
+ getCmsSubstrate,
+ vbrandStandaloneCms,
+ payloadCms,
+ sanityCms,
+ strapiCms,
+ DEFAULT_CMS,
+ parseCms,
+ CmsNameSchema,
+} from './cms/index.js';
+export type { CmsSubstrateAdapter, CmsName, ContentTree, SchemaTree } from './cms/index.js';
+
+export {
+ DEPLOY_TARGET_REGISTRY,
+ getDeployTarget,
+ ghPagesTarget,
+ DEFAULT_DEPLOY_TARGET,
+ parseDeployTarget,
+ DeployTargetNameSchema,
+ DECOUPLED_FOR_LATER_MESSAGE,
+} from './deploy/index.js';
+export type { DeploymentTargetAdapter, DeployBundle, DeployBundleFile, DeployResult, DeployTargetName } from './deploy/index.js';
diff --git a/src/stacks/astro.ts b/src/stacks/astro.ts
new file mode 100644
index 0000000..33188b0
--- /dev/null
+++ b/src/stacks/astro.ts
@@ -0,0 +1,21 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type React from 'react';
+import type { StackRuntime } from './index.js';
+import { buildStackPreviewDocument, escapeHtml, htmlDocument, stackPreviewLabel, stackPreviewMeta, STACK_PREVIEW_VERSION } from './preview-html.js';
+
+function islandMarkup(ids: readonly string[]): string {
+ const safeIds = ids.length > 0 ? ids : ['vbrand-preview-island'];
+ return safeIds.map((id) => ` `).join('');
+}
+
+export const astroRuntime: StackRuntime = {
+ name: () => 'astro',
+ defaultMode: () => 'static',
+ bootstrapMarkup(composed: React.ReactNode): string {
+ const preview = buildStackPreviewDocument(composed);
+ const shape = 'Astro island preview shape, not a live Astro build';
+ const meta = stackPreviewMeta({ stack: 'astro', version: STACK_PREVIEW_VERSION, artefact: 'dist/stacks/astro.html', shape });
+ return htmlDocument('vBrand Astro preview', `${preview.bodyHtml} ${meta}${islandMarkup(preview.islandIds)}${stackPreviewLabel(shape)}`);
+ },
+};
diff --git a/src/stacks/contract.test.ts b/src/stacks/contract.test.ts
new file mode 100644
index 0000000..ad369b5
--- /dev/null
+++ b/src/stacks/contract.test.ts
@@ -0,0 +1,258 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+import { parse } from 'node-html-parser';
+import { markIsland } from '../interactivity/islands.js';
+import { DEFAULT_STACK, getStackRuntime, parseStack, STACK_RUNTIME_REGISTRY, STACK_NAMES } from './index.js';
+import { STACK_PREVIEW_VERSION, stackPreviewLabel } from './preview-html.js';
+import type { StackName } from './types.js';
+
+const DEFAULTS = { vite: 'spa', next: 'hybrid', astro: 'static' } as const;
+const MARKERS = {
+ vite: ['__VBRAND_VITE_BOOTSTRAP_PREVIEW__', 'spa-bootstrap-preview'],
+ next: ['__NEXT_DATA__', 'client-hydrate-preview'],
+ astro: ['');
+ expect(decoded).toContain('Alpha preview');
+ expect(decoded).toContain(SPECIAL_TEXT);
+ expect(decoded).toContain('Shared island');
+}
+
+function htmlFor(name: StackName, node: React.ReactNode = composedTree()): string {
+ return STACK_RUNTIME_REGISTRY[name].bootstrapMarkup(node);
+}
+
+describe('StackRuntime contract', () => {
+ it('keeps registry keys, runtime names, and default modes aligned', () => {
+ expect(Object.keys(STACK_RUNTIME_REGISTRY).sort()).toEqual([...STACK_NAMES].sort());
+ for (const name of STACK_NAMES) {
+ const runtime = STACK_RUNTIME_REGISTRY[name];
+ expect(runtime.name()).toBe(name);
+ expect(runtime.defaultMode()).toBe(DEFAULTS[name]);
+ }
+ });
+
+ it('parses invalid stack input to the documented default', () => {
+ expect(parseStack(null)).toBe(DEFAULT_STACK);
+ expect(parseStack(undefined)).toBe(DEFAULT_STACK);
+ expect(parseStack('remix')).toBe(DEFAULT_STACK);
+ expect(parseStack('astro')).toBe('astro');
+ });
+
+ it('preserves composed text across every preview shape', () => {
+ for (const runtime of Object.values(STACK_RUNTIME_REGISTRY)) {
+ expectTextProof(textOf(runtime.bootstrapMarkup(composedTree())));
+ }
+ });
+
+ it('emits stack-specific preview markers without requiring live framework servers', () => {
+ for (const name of STACK_NAMES) {
+ const html = htmlFor(name);
+ for (const marker of MARKERS[name]) expect(html).toContain(marker);
+ const meta = scriptJson(html, '#__VBRAND_STACK_ARTEFACT__') as { stack: string; version: string; artefact: string; shape: string };
+ expect(meta.stack).toBe(name);
+ expect(meta.version).toBe(STACK_PREVIEW_VERSION);
+ expect(meta.artefact).toBe(`dist/stacks/${name}.html`);
+ expect(meta.shape).toContain('not a live');
+ const root = parse(html);
+ const labels = root.querySelectorAll('[data-stack-preview-label]');
+ expect(labels).toHaveLength(1);
+ const label = labels[0];
+ expect(label?.textContent).toContain('not a live');
+ expect(label?.textContent).toBe(meta.shape);
+ }
+ });
+
+ it('keeps preview markers inert and script-safe for every stack document', () => {
+ for (const name of STACK_NAMES) {
+ const root = parse(htmlFor(name));
+ expect(root.querySelectorAll('script[type="module"]')).toHaveLength(0);
+ expect(root.querySelectorAll('script:not([type="application/json"])')).toHaveLength(0);
+ for (const script of root.querySelectorAll('script')) expect(script.rawText).not.toContain(' marker');
+ }
+ });
+
+ it('exposes one stable proof surface for every supported stack', () => {
+ for (const name of STACK_NAMES) {
+ expect(parse(htmlFor(name)).querySelector(PROOF_SELECTORS[name])).not.toBeNull();
+ }
+ });
+
+ it('exposes stable stack proof data without depending only on internal runtime markers', () => {
+ const proof: Record void> = {
+ vite: () => {
+ const html = STACK_RUNTIME_REGISTRY.vite.bootstrapMarkup(composedTree());
+ const payload = scriptJson(html, '#__VBRAND_STACK_PREVIEW__') as { stack: string; textContent: string };
+ expect(payload.stack).toBe('vite');
+ expectTextProof(payload.textContent);
+ },
+ next: () => {
+ const html = STACK_RUNTIME_REGISTRY.next.bootstrapMarkup(composedTree());
+ const pageData = scriptJson(html, '#__NEXT_DATA__') as { props: { stack: string; textContent: string; islands: string[] } };
+ expect(pageData.props.stack).toBe('next');
+ expectTextProof(pageData.props.textContent);
+ expect(pageData.props.islands).toEqual(['shared-island']);
+ },
+ astro: () => {
+ const root = parse(STACK_RUNTIME_REGISTRY.astro.bootstrapMarkup(composedTree()));
+ const island = root.querySelector('astro-island');
+ expect(island?.getAttribute('uid')).toBe('shared-island');
+ expect(root.querySelectorAll('script[data-astro-component-hydration]')).toHaveLength(1);
+ },
+ };
+
+ for (const name of STACK_NAMES) proof[name]();
+ });
+
+ it('keeps Astro zero-JS pages valid by emitting a deterministic island fallback only when no island is present', () => {
+ const root = parse(htmlFor('astro', React.createElement('section', null, React.createElement('h1', null, 'No island'))));
+ expect(root.querySelector('main')?.textContent).toContain('No island');
+ expect(root.querySelector('astro-island')?.getAttribute('uid')).toBe('vbrand-preview-island');
+ expect(root.querySelectorAll('script[data-astro-component-hydration]')).toHaveLength(1);
+ });
+});
+
+describe('StackRuntime parser and registry contract', () => {
+ it('parseStack accepts every member of STACK_NAMES verbatim', () => {
+ for (const name of STACK_NAMES) expect(parseStack(name)).toBe(name);
+ });
+
+ it('parseStack is case-sensitive: uppercase and mixed-case values fall back to DEFAULT_STACK', () => {
+ for (const bad of ['Vite', 'NEXT', 'Astro', 'VITE', 'Next']) {
+ expect(parseStack(bad)).toBe(DEFAULT_STACK);
+ }
+ });
+
+ it('DEFAULT_STACK is vite', () => {
+ expect(DEFAULT_STACK).toBe('vite');
+ });
+
+ it('STACK_NAMES contains exactly vite, next, astro and no other values', () => {
+ expect([...STACK_NAMES].sort()).toEqual(['astro', 'next', 'vite']);
+ expect(STACK_NAMES).toHaveLength(3);
+ });
+
+ it('getStackRuntime returns the named adapter for every valid stack', () => {
+ for (const name of STACK_NAMES) expect(getStackRuntime(name).name()).toBe(name);
+ });
+
+ it('emitted HTML documents carry a lang="en" root and utf-8 charset across every stack', () => {
+ for (const name of STACK_NAMES) {
+ const root = parse(htmlFor(name));
+ expect(root.querySelector('html')?.getAttribute('lang')).toBe('en');
+ expect(root.querySelector('meta[charset]')?.getAttribute('charset')).toBe('utf-8');
+ }
+ });
+
+ it('Astro emits a distinct astro-island element for each unique island in the composed tree', () => {
+ const multiIsland = React.createElement(
+ 'section',
+ null,
+ markIsland(React.createElement('p', null, 'island A'), 'island-a'),
+ markIsland(React.createElement('p', null, 'island B'), 'island-b'),
+ );
+ const root = parse(STACK_RUNTIME_REGISTRY.astro.bootstrapMarkup(multiIsland));
+ const islands = root.querySelectorAll('astro-island');
+ expect(islands).toHaveLength(2);
+ expect(islands[0]?.getAttribute('uid')).toBe('island-a');
+ expect(islands[1]?.getAttribute('uid')).toBe('island-b');
+ });
+
+ it('STACK_PREVIEW_VERSION is a non-empty semver string shared by all artefact documents', () => {
+ expect(STACK_PREVIEW_VERSION).toMatch(/^\d+\.\d+\.\d+/);
+ for (const name of STACK_NAMES) {
+ const meta = scriptJson(htmlFor(name), '#__VBRAND_STACK_ARTEFACT__') as { version: string };
+ expect(meta.version).toBe(STACK_PREVIEW_VERSION);
+ }
+ });
+
+ it('STACK_PREVIEW_VERSION matches the package.json version', () => {
+ const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8')) as { version: string };
+ expect(STACK_PREVIEW_VERSION).toBe(pkg.version);
+ });
+});
+
+describe('stackPreviewLabel', () => {
+ it('returns a footer element carrying the data-stack-preview-label attribute', () => {
+ const html = stackPreviewLabel('Vite SPA shape');
+ const root = parse(html);
+ const footer = root.querySelector('footer[data-stack-preview-label]');
+ expect(footer).not.toBeNull();
+ });
+
+ it('embeds the shape string verbatim as text content', () => {
+ const shape = 'Astro island preview shape, not a live Astro build';
+ const html = stackPreviewLabel(shape);
+ expect(parse(html).querySelector('[data-stack-preview-label]')?.textContent).toBe(shape);
+ });
+
+ it('HTML-escapes special characters in the shape string', () => {
+ const html = stackPreviewLabel('');
+ expect(html).not.toContain('');
+ expect(html).toContain('<script>');
+ });
+
+ it('produces distinct output for distinct shape inputs', () => {
+ const a = stackPreviewLabel('Vite SPA shape');
+ const b = stackPreviewLabel('Next page-data shape');
+ expect(a).not.toBe(b);
+ });
+
+ it('produces consistent output for the same input on repeated calls', () => {
+ const shape = 'Astro island preview shape, not a live Astro build';
+ expect(stackPreviewLabel(shape)).toBe(stackPreviewLabel(shape));
+ });
+});
+
+describe('parseStack - non-string and boundary inputs', () => {
+ it('empty string falls back to DEFAULT_STACK', () => {
+ expect(parseStack('')).toBe(DEFAULT_STACK);
+ });
+
+ it('numeric input falls back to DEFAULT_STACK', () => {
+ expect(parseStack(0 as unknown as string)).toBe(DEFAULT_STACK);
+ expect(parseStack(1 as unknown as string)).toBe(DEFAULT_STACK);
+ });
+
+ it('boolean input falls back to DEFAULT_STACK', () => {
+ expect(parseStack(true as unknown as string)).toBe(DEFAULT_STACK);
+ expect(parseStack(false as unknown as string)).toBe(DEFAULT_STACK);
+ });
+
+ it('array input falls back to DEFAULT_STACK', () => {
+ expect(parseStack([] as unknown as string)).toBe(DEFAULT_STACK);
+ expect(parseStack(['vite'] as unknown as string)).toBe(DEFAULT_STACK);
+ });
+});
diff --git a/src/stacks/index.ts b/src/stacks/index.ts
new file mode 100644
index 0000000..e7db8af
--- /dev/null
+++ b/src/stacks/index.ts
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type React from 'react';
+import type { InteractivityMode } from '../interactivity/mode.js';
+import type { StackName } from './types.js';
+import { DEFAULT_STACK } from './types.js';
+import { viteRuntime } from './vite.js';
+import { nextRuntime } from './next.js';
+import { astroRuntime } from './astro.js';
+
+export type { StackName } from './types.js';
+export { DEFAULT_STACK, parseStack, StackNameSchema, STACK_NAMES } from './types.js';
+
+export interface StackRuntime {
+ name(): StackName;
+ bootstrapMarkup(composed: React.ReactNode): string;
+ defaultMode(): InteractivityMode;
+}
+
+export const STACK_RUNTIME_REGISTRY: Record = {
+ vite: viteRuntime,
+ next: nextRuntime,
+ astro: astroRuntime,
+};
+
+export function getStackRuntime(name: StackName): StackRuntime {
+ return STACK_RUNTIME_REGISTRY[name] ?? STACK_RUNTIME_REGISTRY[DEFAULT_STACK];
+}
+
+export { viteRuntime } from './vite.js';
+export { nextRuntime } from './next.js';
+export { astroRuntime } from './astro.js';
+export { deriveTargetMode } from './mode-affinity.js';
diff --git a/src/stacks/mode-affinity.test.ts b/src/stacks/mode-affinity.test.ts
new file mode 100644
index 0000000..a5ae745
--- /dev/null
+++ b/src/stacks/mode-affinity.test.ts
@@ -0,0 +1,111 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { describe, expect, it } from 'vitest';
+import { deriveTargetMode } from './mode-affinity.js';
+import type { InteractivityMode } from '../interactivity/mode.js';
+import type { StackName } from './types.js';
+
+const STACK_DEFAULTS: Record = {
+ vite: 'spa',
+ next: 'hybrid',
+ astro: 'static',
+};
+
+const ALL_STACKS: readonly StackName[] = ['vite', 'next', 'astro'];
+const ALL_MODES: readonly InteractivityMode[] = ['static', 'hybrid', 'spa'];
+
+describe('deriveTargetMode - identity when stack does not change', () => {
+ it.each(ALL_STACKS)('stack=%s with its own default mode returns that default unchanged', (stack) => {
+ const mode = STACK_DEFAULTS[stack];
+ expect(deriveTargetMode(mode, stack, stack)).toBe(mode);
+ });
+
+ it.each(ALL_MODES)(
+ 'mode="%s" on vite returning to vite preserves mode without consulting target default',
+ (mode) => {
+ expect(deriveTargetMode(mode, 'vite', 'vite')).toBe(mode);
+ },
+ );
+});
+
+describe('deriveTargetMode - mode follows stack default when user was on the previous stack default', () => {
+ it('vite(spa default) -> astro: resolves to astro default (static)', () => {
+ expect(deriveTargetMode('spa', 'vite', 'astro')).toBe('static');
+ });
+
+ it('vite(spa default) -> next: resolves to next default (hybrid)', () => {
+ expect(deriveTargetMode('spa', 'vite', 'next')).toBe('hybrid');
+ });
+
+ it('next(hybrid default) -> vite: resolves to vite default (spa)', () => {
+ expect(deriveTargetMode('hybrid', 'next', 'vite')).toBe('spa');
+ });
+
+ it('next(hybrid default) -> astro: resolves to astro default (static)', () => {
+ expect(deriveTargetMode('hybrid', 'next', 'astro')).toBe('static');
+ });
+
+ it('astro(static default) -> vite: resolves to vite default (spa)', () => {
+ expect(deriveTargetMode('static', 'astro', 'vite')).toBe('spa');
+ });
+
+ it('astro(static default) -> next: resolves to next default (hybrid)', () => {
+ expect(deriveTargetMode('static', 'astro', 'next')).toBe('hybrid');
+ });
+});
+
+describe('deriveTargetMode - explicit non-default mode is preserved across any stack switch', () => {
+ it('vite(hybrid - explicit) -> astro: hybrid is preserved', () => {
+ expect(deriveTargetMode('hybrid', 'vite', 'astro')).toBe('hybrid');
+ });
+
+ it('vite(static - explicit) -> next: static is preserved', () => {
+ expect(deriveTargetMode('static', 'vite', 'next')).toBe('static');
+ });
+
+ it('next(spa - explicit) -> vite: spa is preserved', () => {
+ expect(deriveTargetMode('spa', 'next', 'vite')).toBe('spa');
+ });
+
+ it('next(static - explicit) -> astro: static is preserved', () => {
+ expect(deriveTargetMode('static', 'next', 'astro')).toBe('static');
+ });
+
+ it('astro(spa - explicit) -> vite: spa is preserved', () => {
+ expect(deriveTargetMode('spa', 'astro', 'vite')).toBe('spa');
+ });
+
+ it('astro(hybrid - explicit) -> next: hybrid is preserved', () => {
+ expect(deriveTargetMode('hybrid', 'astro', 'next')).toBe('hybrid');
+ });
+
+ it('astro(spa - explicit) -> next: spa is preserved (spa != astro default static)', () => {
+ expect(deriveTargetMode('spa', 'astro', 'next')).toBe('spa');
+ });
+
+ it('next(spa - explicit) -> astro: spa is preserved (spa != next default hybrid)', () => {
+ expect(deriveTargetMode('spa', 'next', 'astro')).toBe('spa');
+ });
+});
+
+describe('deriveTargetMode - exhaustive cross-stack parity', () => {
+ it.each(ALL_STACKS.flatMap((from) =>
+ ALL_STACKS.filter((to) => to !== from).map((to) => ({ from, to })),
+ ))(
+ 'switching from $from(default) to $to always resolves to $to default',
+ ({ from, to }) => {
+ expect(deriveTargetMode(STACK_DEFAULTS[from], from, to)).toBe(STACK_DEFAULTS[to]);
+ },
+ );
+
+ it.each(ALL_STACKS.flatMap((from) =>
+ ALL_MODES.filter((m) => m !== STACK_DEFAULTS[from]).flatMap((mode) =>
+ ALL_STACKS.filter((to) => to !== from).map((to) => ({ from, to, mode })),
+ ),
+ ))(
+ 'switching from $from(explicit $mode) to $to preserves $mode',
+ ({ from, to, mode }) => {
+ expect(deriveTargetMode(mode, from, to)).toBe(mode);
+ },
+ );
+});
diff --git a/src/stacks/mode-affinity.ts b/src/stacks/mode-affinity.ts
new file mode 100644
index 0000000..68bdd20
--- /dev/null
+++ b/src/stacks/mode-affinity.ts
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type { InteractivityMode } from '../interactivity/mode.js';
+import type { StackName } from './types.js';
+import { viteRuntime } from './vite.js';
+import { nextRuntime } from './next.js';
+import { astroRuntime } from './astro.js';
+
+const STACK_DEFAULT_MODES: Record = {
+ vite: viteRuntime.defaultMode(),
+ next: nextRuntime.defaultMode(),
+ astro: astroRuntime.defaultMode(),
+};
+
+export function deriveTargetMode(
+ currentMode: InteractivityMode,
+ fromStack: StackName,
+ toStack: StackName,
+): InteractivityMode {
+ if (fromStack === toStack) return currentMode;
+ if (currentMode === STACK_DEFAULT_MODES[fromStack]) return STACK_DEFAULT_MODES[toStack];
+ return currentMode;
+}
diff --git a/src/stacks/next.ts b/src/stacks/next.ts
new file mode 100644
index 0000000..c75e5b6
--- /dev/null
+++ b/src/stacks/next.ts
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type React from 'react';
+import type { StackRuntime } from './index.js';
+import { buildStackPreviewDocument, escapeScriptJson, htmlDocument, stackPreviewLabel, stackPreviewMeta, STACK_PREVIEW_VERSION } from './preview-html.js';
+
+export const nextRuntime: StackRuntime = {
+ name: () => 'next',
+ defaultMode: () => 'hybrid',
+ bootstrapMarkup(composed: React.ReactNode): string {
+ const preview = buildStackPreviewDocument(composed);
+ const pageData = { page: '/vbrand-preview', buildId: 'vbrand-stack-preview', props: { stack: 'next', textContent: preview.textContent, islands: preview.islandIds } };
+ const flightPayload = JSON.stringify({ boundary: 'client-hydrate-preview', islands: preview.islandIds });
+ const shape = 'Next page-data preview shape, not a live Next server';
+ const meta = stackPreviewMeta({ stack: 'next', version: STACK_PREVIEW_VERSION, artefact: 'dist/stacks/next.html', shape });
+ return htmlDocument('vBrand Next preview', `${preview.bodyHtml} ${meta}${stackPreviewLabel(shape)}`);
+ },
+};
diff --git a/src/stacks/preview-html.ts b/src/stacks/preview-html.ts
new file mode 100644
index 0000000..3862d50
--- /dev/null
+++ b/src/stacks/preview-html.ts
@@ -0,0 +1,47 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import React from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { collectIslands } from '../interactivity/islands.js';
+
+export interface StackPreviewDocument {
+ readonly bodyHtml: string;
+ readonly textContent: string;
+ readonly islandIds: readonly string[];
+}
+
+export interface StackPreviewMeta {
+ readonly stack: string;
+ readonly version: string;
+ readonly artefact: string;
+ readonly shape: string;
+}
+
+declare const __VBRAND_VERSION__: string;
+export const STACK_PREVIEW_VERSION: string = __VBRAND_VERSION__;
+
+export function buildStackPreviewDocument(composed: React.ReactNode): StackPreviewDocument {
+ const bodyHtml = renderToStaticMarkup(React.createElement(React.Fragment, null, composed));
+ const textContent = bodyHtml.replace(/`;
+}
+
+export function escapeHtml(value: string): string {
+ return value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+}
+
+export function escapeScriptJson(value: string): string {
+ return value.replace(/${escapeHtml(shape)}`;
+}
diff --git a/src/stacks/types.ts b/src/stacks/types.ts
new file mode 100644
index 0000000..7878b83
--- /dev/null
+++ b/src/stacks/types.ts
@@ -0,0 +1,13 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { z } from 'zod';
+
+export const STACK_NAMES = ['vite', 'next', 'astro'] as const;
+export const StackNameSchema = z.enum(STACK_NAMES);
+export type StackName = z.infer;
+export const DEFAULT_STACK: StackName = 'vite';
+
+export function parseStack(raw: string | null | undefined): StackName {
+ const result = StackNameSchema.safeParse(raw);
+ return result.success ? result.data : DEFAULT_STACK;
+}
diff --git a/src/stacks/vite.ts b/src/stacks/vite.ts
new file mode 100644
index 0000000..7aa9b7b
--- /dev/null
+++ b/src/stacks/vite.ts
@@ -0,0 +1,17 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import type React from 'react';
+import type { StackRuntime } from './index.js';
+import { buildStackPreviewDocument, escapeScriptJson, htmlDocument, stackPreviewLabel, stackPreviewMeta, STACK_PREVIEW_VERSION } from './preview-html.js';
+
+export const viteRuntime: StackRuntime = {
+ name: () => 'vite',
+ defaultMode: () => 'spa',
+ bootstrapMarkup(composed: React.ReactNode): string {
+ const preview = buildStackPreviewDocument(composed);
+ const payload = escapeScriptJson(JSON.stringify({ stack: 'vite', textContent: preview.textContent }));
+ const shape = 'Vite SPA bootstrap preview shape, not a live Vite dev server or full hydration runtime';
+ const meta = stackPreviewMeta({ stack: 'vite', version: STACK_PREVIEW_VERSION, artefact: 'dist/stacks/vite.html', shape });
+ return htmlDocument('vBrand Vite preview', `${preview.bodyHtml} ${meta}${stackPreviewLabel(shape)}`);
+ },
+};
diff --git a/tests/repo-hygiene.test.ts b/tests/repo-hygiene.test.ts
index 75235ef..7b873b7 100644
--- a/tests/repo-hygiene.test.ts
+++ b/tests/repo-hygiene.test.ts
@@ -13,6 +13,26 @@ function currentPackageTarballName(): string {
return `${scope}-${pkg.version}.tgz`;
}
+function packageScripts(): Record {
+ return (JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')) as { scripts: Record }).scripts;
+}
+
+function shellWords(command: string): string[] {
+ return command.split(/\s+/).filter(Boolean);
+}
+
+function scriptContainsFlag(scriptName: string, flag: string): boolean {
+ return shellWords(packageScripts()[scriptName] ?? '').includes(flag);
+}
+
+function composeScriptInvocation(scriptName: string, ...args: string[]): string {
+ return [...shellWords(packageScripts()[scriptName] ?? ''), ...args].join(' ');
+}
+
+function packageExports(): Record {
+ return (JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')) as { exports: Record }).exports;
+}
+
function gitignorePatterns(): string[] {
return readFileSync(join(ROOT, '.gitignore'), 'utf-8')
.split('\n')
@@ -101,6 +121,124 @@ describe('git index - build artifacts must not be tracked', () => {
});
});
+describe('.gitignore - generated test-run artefact hygiene', () => {
+ it('excludes Playwright and Vitest test-results directories at their generated locations', () => {
+ expect(gitignorePatterns()).toContain('/test-results/');
+ expect(gitignorePatterns()).toContain('examples/demo/test-results/');
+ });
+
+ it.each([
+ 'test-results/.last-run.json',
+ 'test-results/runtime-probe/error-context.md',
+ 'test-results/screenshots/home-page.png',
+ 'examples/demo/test-results/.last-run.json',
+ 'examples/demo/test-results/runtime-probe/error-context.md',
+ 'examples/demo/test-results/screenshots/home-page.png',
+ ])('ignores generated test result artefact %s', (path) => {
+ expect(isGitIgnored(path)).toBe(true);
+ });
+
+ it.each([
+ 'src/test-results.ts',
+ 'tests/test-results.test.ts',
+ 'examples/demo/src/test-results-view.tsx',
+ ])('does not ignore source or test files that merely contain test-results in the name: %s', (path) => {
+ expect(isGitIgnored(path)).toBe(false);
+ });
+
+ it.each([
+ 'test-results/.last-run.json',
+ 'examples/demo/test-results/.last-run.json',
+ ])('does not track runner-generated state file %s', (path) => {
+ expect(isGitTracked(path)).toBe(false);
+ });
+});
+
+// D-7 contract: tsup transient sidecar files are redirected to node_modules/.cache/tsup/
+// by the bundle-require output redirect, so .gitignore does not need (and must not have)
+// a pattern masking them at the project root. Any regression in the redirect is
+// immediately visible in git status because root-level bundled_* files are NOT ignored.
+describe('.gitignore - tsup transient build-config redirect contract', () => {
+ it('does not contain a tsup.config.bundled_*.mjs gitignore pattern (redirect makes it unnecessary)', () => {
+ expect(gitignorePatterns()).not.toContain('tsup.config.bundled_*.mjs');
+ });
+
+ it('a tsup.config.bundled_*.mjs file at the project root is NOT gitignored (regression is immediately visible)', () => {
+ expect(isGitIgnored('tsup.config.bundled_abc123.mjs')).toBe(false);
+ expect(isGitIgnored('tsup.config.bundled_zzzzzzzzzzz.mjs')).toBe(false);
+ expect(isGitIgnored('tsup.config.bundled_000000000.mjs')).toBe(false);
+ });
+
+ it('node_modules/ pattern covers the redirect cache dir node_modules/.cache/tsup/ without a dedicated entry', () => {
+ expect(gitignorePatterns()).toContain('node_modules/');
+ expect(isGitIgnored('node_modules/.cache/tsup/tsup.config.bundled_abc.mjs')).toBe(true);
+ });
+
+ it('does not exclude the real tsup config source file tsup.config.mjs', () => {
+ expect(isGitIgnored('tsup.config.mjs')).toBe(false);
+ });
+
+ it('does not exclude a tsup bundled file with a non-mjs extension', () => {
+ expect(isGitIgnored('tsup.config.bundled_abc.ts')).toBe(false);
+ expect(isGitIgnored('tsup.config.bundled_abc.cjs')).toBe(false);
+ });
+});
+
+describe('package.json build script - redirect wrapper wiring contract', () => {
+ it('build script does not invoke tsup as a bare shell command (uses the redirect wrapper instead)', () => {
+ const build = packageScripts()['build'];
+ expect(build).not.toMatch(/(?:^|&&\s*)tsup(?:\s|$)/);
+ });
+
+ it('build script invokes node scripts/run-tsup.cjs as the tsup driver', () => {
+ expect(packageScripts()['build']).toContain('node scripts/run-tsup.cjs');
+ });
+
+ it('redirect wrapper script exists on disk', () => {
+ expect(existsSync(join(ROOT, 'scripts/run-tsup.cjs'))).toBe(true);
+ });
+
+ it('bundle-require output-redirect script exists on disk', () => {
+ expect(existsSync(join(ROOT, 'scripts/bundle-require-output-redirect.cjs'))).toBe(true);
+ });
+
+ it('bundle-require redirect patterns module exists on disk', () => {
+ expect(existsSync(join(ROOT, 'scripts/bundle-require-redirect-patterns.cjs'))).toBe(true);
+ });
+});
+
+describe('package.json scripts - caller-controlled flag convention', () => {
+ const callerControlledScripts = [
+ ['test', 'vitest run'],
+ ['test:watch', 'vitest'],
+ ] as const;
+
+ const callerFlags = [
+ '--coverage',
+ '--reporter',
+ '--watch',
+ '--runInBand',
+ ] as const;
+
+ it.each(callerControlledScripts)('%s script keeps its stable base command', (scriptName, expected) => {
+ expect(packageScripts()[scriptName]).toBe(expected);
+ });
+
+ it.each(callerControlledScripts.flatMap(([scriptName]) => callerFlags.map((flag) => [scriptName, flag] as const)))(
+ '%s script leaves caller flag %s to the invoking command',
+ (scriptName, flag) => {
+ expect(scriptContainsFlag(scriptName, flag)).toBe(false);
+ },
+ );
+
+ it.each([
+ [['--coverage'], 'vitest run --coverage'],
+ [['--reporter=line'], 'vitest run --reporter=line'],
+ ] as const)('test script composes caller arguments %s', (args, expected) => {
+ expect(composeScriptInvocation('test', ...args)).toBe(expected);
+ });
+});
+
describe('npm pack --dry-run - tarball file list correctness', () => {
let packFilePaths: string[] = [];
@@ -136,3 +274,35 @@ describe('npm pack --dry-run - tarball file list correctness', () => {
expect(packFilePaths.every((p) => !p.startsWith('/'))).toBe(true);
});
});
+
+describe('public browser-safe subpath exports', () => {
+ const BROWSER_SAFE_SUBPATHS = [
+ { subpath: './deploy/metadata', importPath: 'dist/deploy-metadata.js', typesPath: 'dist/deploy-metadata.d.ts' },
+ ] as const;
+ const NODE_BUILTIN_SPECIFIERS = [
+ 'node:child_process',
+ 'node:fs',
+ 'node:fs/promises',
+ 'node:os',
+ 'node:path',
+ 'node:crypto',
+ 'child_process',
+ 'fs/promises',
+ ] as const;
+
+ it.each(BROWSER_SAFE_SUBPATHS)('$subpath has import and type exports pointing at dist artefacts', ({ subpath, importPath, typesPath }) => {
+ const exported = packageExports()[subpath];
+ expect(exported?.import).toBe(`./${importPath}`);
+ expect(exported?.types).toBe(`./${typesPath}`);
+ });
+
+ it.each(BROWSER_SAFE_SUBPATHS)('$subpath build output contains no Node-only module imports', ({ importPath }) => {
+ const bundled = readFileSync(join(ROOT, importPath), 'utf-8');
+ for (const specifier of NODE_BUILTIN_SPECIFIERS) expect(bundled).not.toContain(specifier);
+ });
+
+ it.each(BROWSER_SAFE_SUBPATHS)('$subpath type output exists next to its runtime output', ({ importPath, typesPath }) => {
+ expect(existsSync(join(ROOT, importPath))).toBe(true);
+ expect(existsSync(join(ROOT, typesPath))).toBe(true);
+ });
+});
diff --git a/tests/scripts/bundle-require-output-redirect.test.ts b/tests/scripts/bundle-require-output-redirect.test.ts
new file mode 100644
index 0000000..bfa343c
--- /dev/null
+++ b/tests/scripts/bundle-require-output-redirect.test.ts
@@ -0,0 +1,328 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { readFileSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import NodeModule from 'node:module';
+import { join } from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+const _require = createRequire(import.meta.url);
+const ROOT = join(import.meta.dirname, '../..');
+
+const {
+ BUNDLE_REQUIRE_INDEX_CJS_RE,
+ ORIGINAL_OUTPUT_FN_RE,
+ CACHE_RELATIVE_PATH,
+ REDIRECTED_OUTPUT_FN,
+ applyOutputRedirect,
+}: {
+ BUNDLE_REQUIRE_INDEX_CJS_RE: RegExp;
+ ORIGINAL_OUTPUT_FN_RE: RegExp;
+ CACHE_RELATIVE_PATH: string;
+ REDIRECTED_OUTPUT_FN: string;
+ applyOutputRedirect: (source: string) => string;
+} = _require('../../scripts/bundle-require-redirect-patterns.cjs');
+
+// Module internals: _extensions and _cache are private but stable Node.js APIs.
+type NodeModuleInternals = {
+ _extensions: Record void>;
+ _cache: Record;
+};
+const ModuleInternals = NodeModule as unknown as NodeModuleInternals;
+
+const BUNDLE_REQUIRE_CJS_PATH = join(ROOT, 'node_modules/bundle-require/dist/index.cjs');
+const BUNDLE_REQUIRE_CJS_SOURCE = readFileSync(BUNDLE_REQUIRE_CJS_PATH, 'utf8');
+const REDIRECT_SCRIPT_PATH = join(ROOT, 'scripts/bundle-require-output-redirect.cjs');
+
+// The exact four-line function body as it appears in bundle-require's dist artifact.
+const ORIGINAL_SNIPPET =
+ 'var defaultGetOutputFile = (filepath, format) => filepath.replace(\n' +
+ ' JS_EXT_RE,\n' +
+ ' `.bundled_${getRandomId()}.${format === "esm" ? "mjs" : "cjs"}`\n' +
+ ');';
+
+describe('BUNDLE_REQUIRE_INDEX_CJS_RE - path selection contract', () => {
+ const MATCHING_PATHS = [
+ 'node_modules/bundle-require/dist/index.cjs',
+ 'node_modules\\bundle-require\\dist\\index.cjs',
+ '/home/user/project/node_modules/bundle-require/dist/index.cjs',
+ 'C:\\Users\\user\\project\\node_modules\\bundle-require\\dist\\index.cjs',
+ '/workspace/a/b/node_modules/bundle-require/dist/index.cjs',
+ ] as const;
+
+ const NON_MATCHING_PATHS = [
+ 'node_modules/bundle-require/dist/index.js',
+ 'node_modules/bundle-require/dist/index.mjs',
+ 'node_modules/bundle-require/dist/other.cjs',
+ 'node_modules/bundle-require/index.cjs',
+ 'node_modules/my-bundle-require/dist/index.cjs',
+ 'node_modules/bundle-require-extra/dist/index.cjs',
+ 'node_modules/xbundle-require/dist/index.cjs',
+ 'bundle-require/dist/index.cjs',
+ 'dist/index.cjs',
+ 'index.cjs',
+ '',
+ ] as const;
+
+ it.each(MATCHING_PATHS)(
+ 'matches the bundle-require CJS entry path: %s',
+ (p) => { expect(BUNDLE_REQUIRE_INDEX_CJS_RE.test(p)).toBe(true); },
+ );
+
+ it.each(NON_MATCHING_PATHS)(
+ 'does not match non-bundle-require path: %s',
+ (p) => { expect(BUNDLE_REQUIRE_INDEX_CJS_RE.test(p)).toBe(false); },
+ );
+
+ it('matches regardless of how many ancestor directory segments precede node_modules', () => {
+ expect(BUNDLE_REQUIRE_INDEX_CJS_RE.test('a/b/c/d/node_modules/bundle-require/dist/index.cjs')).toBe(true);
+ });
+
+ it('does not match when the dist/ segment is replaced by another directory name', () => {
+ expect(BUNDLE_REQUIRE_INDEX_CJS_RE.test('node_modules/bundle-require/src/index.cjs')).toBe(false);
+ expect(BUNDLE_REQUIRE_INDEX_CJS_RE.test('node_modules/bundle-require/build/index.cjs')).toBe(false);
+ });
+
+ it('requires bundle-require to be a complete directory name, not a substring', () => {
+ expect(BUNDLE_REQUIRE_INDEX_CJS_RE.test('node_modules/bundle-require2/dist/index.cjs')).toBe(false);
+ });
+
+ it('is anchored to the end of the string (no trailing path segments accepted)', () => {
+ expect(BUNDLE_REQUIRE_INDEX_CJS_RE.test('node_modules/bundle-require/dist/index.cjs/extra')).toBe(false);
+ });
+});
+
+describe('ORIGINAL_OUTPUT_FN_RE - source pattern matching contract', () => {
+ it('matches the exact function form from the bundle-require dist artifact', () => {
+ expect(ORIGINAL_OUTPUT_FN_RE.test(ORIGINAL_SNIPPET)).toBe(true);
+ });
+
+ it('matches when embedded in surrounding source context', () => {
+ const context = `some prior code;\n${ORIGINAL_SNIPPET}\nsome following code;`;
+ expect(ORIGINAL_OUTPUT_FN_RE.test(context)).toBe(true);
+ });
+
+ it('matches the actual bundle-require CJS source file', () => {
+ expect(ORIGINAL_OUTPUT_FN_RE.test(BUNDLE_REQUIRE_CJS_SOURCE)).toBe(true);
+ });
+
+ it('does not match the redirected replacement form (transform is non-self-matching)', () => {
+ expect(ORIGINAL_OUTPUT_FN_RE.test(REDIRECTED_OUTPUT_FN)).toBe(false);
+ });
+
+ it('does not match a defaultGetOutputFile that uses a block body instead of concise body', () => {
+ const blockForm = 'var defaultGetOutputFile = (filepath, format) => {\n return filepath;\n};';
+ expect(ORIGINAL_OUTPUT_FN_RE.test(blockForm)).toBe(false);
+ });
+
+ it('does not match a defaultGetOutputFile using a different method chain', () => {
+ const differentChain = 'var defaultGetOutputFile = (filepath, format) => filepath.join(\n JS_EXT_RE\n);';
+ expect(ORIGINAL_OUTPUT_FN_RE.test(differentChain)).toBe(false);
+ });
+
+ it('does not match an empty string', () => {
+ expect(ORIGINAL_OUTPUT_FN_RE.test('')).toBe(false);
+ });
+
+ it('does not match unrelated source code', () => {
+ expect(ORIGINAL_OUTPUT_FN_RE.test('var foo = (x) => x + 1; var bar = "hello";')).toBe(false);
+ });
+});
+
+describe('applyOutputRedirect - source transform contract', () => {
+ it('returns a different string when the original pattern is present', () => {
+ expect(applyOutputRedirect(ORIGINAL_SNIPPET)).not.toBe(ORIGINAL_SNIPPET);
+ });
+
+ it('returns the input unchanged when the pattern is absent', () => {
+ const unrelated = 'var x = 1; var y = 2;';
+ expect(applyOutputRedirect(unrelated)).toBe(unrelated);
+ });
+
+ it('returns an empty string unchanged', () => {
+ expect(applyOutputRedirect('')).toBe('');
+ });
+
+ it('does not mutate the input string (pure function)', () => {
+ const snapshot = ORIGINAL_SNIPPET.slice();
+ applyOutputRedirect(ORIGINAL_SNIPPET);
+ expect(ORIGINAL_SNIPPET).toBe(snapshot);
+ });
+
+ it('is idempotent: applying twice yields the same result as applying once', () => {
+ const once = applyOutputRedirect(ORIGINAL_SNIPPET);
+ expect(applyOutputRedirect(once)).toBe(once);
+ });
+
+ it('output contains the cache-relative path so the temp file is written inside node_modules', () => {
+ expect(applyOutputRedirect(ORIGINAL_SNIPPET)).toContain(CACHE_RELATIVE_PATH);
+ });
+
+ it('output contains mkdirSync so the cache directory is auto-created on first use', () => {
+ expect(applyOutputRedirect(ORIGINAL_SNIPPET)).toContain('mkdirSync');
+ });
+
+ it('output retains JS_EXT_RE so file-extension stripping still works', () => {
+ expect(applyOutputRedirect(ORIGINAL_SNIPPET)).toContain('JS_EXT_RE');
+ });
+
+ it('output retains getRandomId() so each temp filename is unique', () => {
+ expect(applyOutputRedirect(ORIGINAL_SNIPPET)).toContain('getRandomId()');
+ });
+
+ it('output contains format-based extension branching for esm → mjs and cjs → cjs', () => {
+ const result = applyOutputRedirect(ORIGINAL_SNIPPET);
+ expect(result).toContain('"esm"');
+ expect(result).toContain('"mjs"');
+ expect(result).toContain('"cjs"');
+ });
+
+ it('output no longer contains the original filepath.replace() concise body', () => {
+ expect(applyOutputRedirect(ORIGINAL_SNIPPET)).not.toContain('filepath.replace(');
+ });
+
+ it('output contains exactly one defaultGetOutputFile definition', () => {
+ const count = (applyOutputRedirect(ORIGINAL_SNIPPET).match(/var defaultGetOutputFile/g) ?? []).length;
+ expect(count).toBe(1);
+ });
+
+ it('applied to the actual bundle-require CJS source: result contains the cache path', () => {
+ expect(applyOutputRedirect(BUNDLE_REQUIRE_CJS_SOURCE)).toContain(CACHE_RELATIVE_PATH);
+ });
+
+ it('applied to the actual bundle-require CJS source: original concise body is gone', () => {
+ expect(ORIGINAL_OUTPUT_FN_RE.test(applyOutputRedirect(BUNDLE_REQUIRE_CJS_SOURCE))).toBe(false);
+ });
+
+ it('applied to the actual bundle-require CJS source: exactly one definition remains', () => {
+ const count = (applyOutputRedirect(BUNDLE_REQUIRE_CJS_SOURCE).match(/var defaultGetOutputFile/g) ?? []).length;
+ expect(count).toBe(1);
+ });
+
+ it('when the snippet appears twice: replaces only the first occurrence (single-replacement contract)', () => {
+ const doubled = `${ORIGINAL_SNIPPET}\n${ORIGINAL_SNIPPET}`;
+ const result = applyOutputRedirect(doubled);
+ expect(result).toContain(CACHE_RELATIVE_PATH);
+ expect(ORIGINAL_OUTPUT_FN_RE.test(result)).toBe(true);
+ expect((result.match(new RegExp(CACHE_RELATIVE_PATH.replace(/\//g, '\\/'), 'g')) ?? []).length).toBe(1);
+ });
+});
+
+describe('Module._extensions hook - loader dispatch contract', () => {
+ let savedLoader: ((m: NodeModule, filename: string) => void) | undefined;
+
+ beforeEach(() => {
+ savedLoader = ModuleInternals._extensions['.js'];
+ delete ModuleInternals._cache[BUNDLE_REQUIRE_CJS_PATH];
+ delete ModuleInternals._cache[REDIRECT_SCRIPT_PATH];
+ });
+
+ afterEach(() => {
+ if (savedLoader !== undefined) ModuleInternals._extensions['.js'] = savedLoader;
+ delete ModuleInternals._cache[BUNDLE_REQUIRE_CJS_PATH];
+ delete ModuleInternals._cache[REDIRECT_SCRIPT_PATH];
+ });
+
+ it('installs a replacement for Module._extensions[".js"]', () => {
+ const before = ModuleInternals._extensions['.js'];
+ _require('../../scripts/bundle-require-output-redirect.cjs');
+ expect(ModuleInternals._extensions['.js']).not.toBe(before);
+ expect(typeof ModuleInternals._extensions['.js']).toBe('function');
+ });
+
+ it('installed hook has the expected function name for debuggability', () => {
+ _require('../../scripts/bundle-require-output-redirect.cjs');
+ expect(ModuleInternals._extensions['.js'].name).toBe('bundleRequireOutputRedirectLoader');
+ });
+
+ it('hook loads bundle-require without throwing (source transform is syntactically valid)', () => {
+ _require('../../scripts/bundle-require-output-redirect.cjs');
+ delete ModuleInternals._cache[BUNDLE_REQUIRE_CJS_PATH];
+ expect(() => _require('../../node_modules/bundle-require/dist/index.cjs')).not.toThrow();
+ });
+
+ it('bundleRequire export remains callable after the source transform', () => {
+ _require('../../scripts/bundle-require-output-redirect.cjs');
+ delete ModuleInternals._cache[BUNDLE_REQUIRE_CJS_PATH];
+ const br = _require('../../node_modules/bundle-require/dist/index.cjs') as { bundleRequire: unknown };
+ expect(typeof br.bundleRequire).toBe('function');
+ });
+});
+
+function runSubprocess(inlineScript: string): { stdout: string; status: number | null } {
+ const result = spawnSync(process.execPath, ['--eval', inlineScript], {
+ cwd: ROOT,
+ encoding: 'utf8',
+ timeout: 30_000,
+ });
+ return { stdout: result.stdout ?? '', status: result.status };
+}
+
+const E2E_SCRIPT = `
+'use strict';
+const path = require('path');
+const fs = require('fs');
+require('./scripts/bundle-require-output-redirect.cjs');
+const br = require('./node_modules/bundle-require/dist/index.cjs');
+br.bundleRequire({ filepath: path.resolve('./tsup.config.mjs') }).then(() => {
+ const leaked = fs.readdirSync('.').filter(f => /tsup\\.config\\.bundled_/.test(f));
+ const cacheExists = fs.existsSync('./node_modules/.cache/tsup');
+ process.stdout.write(JSON.stringify({ leaked, cacheExists }));
+}).catch(() => {
+ const leaked = fs.readdirSync('.').filter(f => /tsup\\.config\\.bundled_/.test(f));
+ const cacheExists = fs.existsSync('./node_modules/.cache/tsup');
+ process.stdout.write(JSON.stringify({ leaked, cacheExists }));
+});
+`;
+
+describe('end-to-end redirect behavior - subprocess contract', () => {
+ it('no tsup.config.bundled_* files appear in the project root after bundleRequire completes', () => {
+ const { stdout } = runSubprocess(E2E_SCRIPT);
+ const { leaked } = JSON.parse(stdout) as { leaked: string[]; cacheExists: boolean };
+ expect(leaked).toHaveLength(0);
+ });
+
+ it('cache directory node_modules/.cache/tsup is created automatically on first use', () => {
+ const { stdout } = runSubprocess(E2E_SCRIPT);
+ const { cacheExists } = JSON.parse(stdout) as { leaked: string[]; cacheExists: boolean };
+ expect(cacheExists).toBe(true);
+ });
+
+ it('no root leak when bundleRequire is called multiple times in sequence', () => {
+ const script = `
+'use strict';
+const path = require('path');
+const fs = require('fs');
+require('./scripts/bundle-require-output-redirect.cjs');
+const br = require('./node_modules/bundle-require/dist/index.cjs');
+const cfg = path.resolve('./tsup.config.mjs');
+br.bundleRequire({ filepath: cfg })
+ .catch(() => {})
+ .then(() => br.bundleRequire({ filepath: cfg }).catch(() => {}))
+ .finally(() => {
+ const leaked = fs.readdirSync('.').filter(f => /tsup\\.config\\.bundled_/.test(f));
+ process.stdout.write(JSON.stringify({ leaked }));
+ });
+`;
+ const { leaked } = JSON.parse(runSubprocess(script).stdout) as { leaked: string[] };
+ expect(leaked).toHaveLength(0);
+ });
+
+ it('bundleRequire resolves with a valid module object (config is parsed correctly)', () => {
+ const script = `
+'use strict';
+const path = require('path');
+require('./scripts/bundle-require-output-redirect.cjs');
+const br = require('./node_modules/bundle-require/dist/index.cjs');
+br.bundleRequire({ filepath: path.resolve('./tsup.config.mjs') }).then((result) => {
+ const hasModule = result.mod && (result.mod.default !== undefined || result.mod.tsup !== undefined);
+ process.stdout.write(JSON.stringify({ hasModule: Boolean(hasModule) }));
+}).catch(() => {
+ process.stdout.write(JSON.stringify({ hasModule: false }));
+});
+`;
+ const { hasModule } = JSON.parse(runSubprocess(script).stdout) as { hasModule: boolean };
+ expect(hasModule).toBe(true);
+ });
+});
diff --git a/tests/scripts/eye-test/axes.test.ts b/tests/scripts/eye-test/axes.test.ts
new file mode 100644
index 0000000..c21f5dc
--- /dev/null
+++ b/tests/scripts/eye-test/axes.test.ts
@@ -0,0 +1,97 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { describe, it, expect, beforeAll, vi } from 'vitest';
+
+type AxesModule = {
+ FIXTURES: string[];
+ APP_TYPES: string[];
+ MODES: string[];
+ STACKS: string[];
+ CMS_SUBSTRATES: string[];
+};
+
+let FIXTURES: string[];
+let APP_TYPES: string[];
+let MODES: string[];
+let STACKS: string[];
+let CMS_SUBSTRATES: string[];
+
+beforeAll(async () => {
+ const mod = await vi.importActual('../../../scripts/eye-test/axes.mjs');
+ ({ FIXTURES, APP_TYPES, MODES, STACKS, CMS_SUBSTRATES } = mod);
+});
+
+const ALL_AXIS_NAMES = ['FIXTURES', 'APP_TYPES', 'MODES', 'STACKS', 'CMS_SUBSTRATES'] as const;
+
+describe('axes - structural contract', () => {
+ it.each(ALL_AXIS_NAMES)('%s is a non-empty array', (name) => {
+ const map: Record = { FIXTURES, APP_TYPES, MODES, STACKS, CMS_SUBSTRATES };
+ expect(Array.isArray(map[name])).toBe(true);
+ expect(map[name].length).toBeGreaterThan(0);
+ });
+
+ it('all entries across every axis are non-empty strings', () => {
+ for (const axis of [FIXTURES, APP_TYPES, MODES, STACKS, CMS_SUBSTRATES]) {
+ for (const v of axis) {
+ expect(typeof v).toBe('string');
+ expect(v.length).toBeGreaterThan(0);
+ }
+ }
+ });
+});
+
+describe('axes - uniqueness within each axis', () => {
+ it('FIXTURES has no duplicate values', () => {
+ expect(new Set(FIXTURES).size).toBe(FIXTURES.length);
+ });
+
+ it('APP_TYPES has no duplicate values', () => {
+ expect(new Set(APP_TYPES).size).toBe(APP_TYPES.length);
+ });
+
+ it('MODES has no duplicate values', () => {
+ expect(new Set(MODES).size).toBe(MODES.length);
+ });
+
+ it('STACKS has no duplicate values', () => {
+ expect(new Set(STACKS).size).toBe(STACKS.length);
+ });
+
+ it('CMS_SUBSTRATES has no duplicate values', () => {
+ expect(new Set(CMS_SUBSTRATES).size).toBe(CMS_SUBSTRATES.length);
+ });
+});
+
+describe('axes - protocol values (C-152 eye-test contract)', () => {
+ it('FIXTURES contains exactly the five brand fixture slugs', () => {
+ expect(FIXTURES.slice().sort()).toEqual(['github', 'linear', 'notion', 'stripe', 'vercel']);
+ });
+
+ it('APP_TYPES contains exactly the four demo template identifiers', () => {
+ expect(APP_TYPES.slice().sort()).toEqual(['dashboard', 'docs', 'landing', 'marketing']);
+ });
+
+ it('MODES contains exactly the three interactivity modes', () => {
+ expect(MODES.slice().sort()).toEqual(['hybrid', 'spa', 'static']);
+ });
+
+ it('STACKS contains exactly the three supported framework stacks', () => {
+ expect(STACKS.slice().sort()).toEqual(['astro', 'next', 'vite']);
+ });
+
+ it('CMS_SUBSTRATES contains exactly the four CMS integration targets', () => {
+ expect(CMS_SUBSTRATES.slice().sort()).toEqual(['payload', 'sanity', 'strapi', 'vbrand-standalone']);
+ });
+});
+
+describe('axes - orthogonality', () => {
+ it('no value appears in more than one axis', () => {
+ const all = [...FIXTURES, ...APP_TYPES, ...MODES, ...STACKS, ...CMS_SUBSTRATES];
+ expect(new Set(all).size).toBe(all.length);
+ });
+
+ it('product of all five axis lengths equals the exhaustive cell count', () => {
+ const product = FIXTURES.length * APP_TYPES.length * MODES.length * STACKS.length * CMS_SUBSTRATES.length;
+ expect(product).toBe(720);
+ });
+});
diff --git a/tests/scripts/eye-test/cell-generator.test.ts b/tests/scripts/eye-test/cell-generator.test.ts
new file mode 100644
index 0000000..ad39e68
--- /dev/null
+++ b/tests/scripts/eye-test/cell-generator.test.ts
@@ -0,0 +1,118 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { describe, it, expect, beforeAll, vi } from 'vitest';
+
+type LatinCell = { fixture: string; app: string; mode: string; cms: string; index: number };
+type ExhaustiveCell = LatinCell & { stack: string };
+
+let FIXTURES: string[];
+let APP_TYPES: string[];
+let MODES: string[];
+let STACKS: string[];
+let CMS_SUBSTRATES: string[];
+let latinSquareSample: () => LatinCell[];
+let allCells: () => ExhaustiveCell[];
+
+beforeAll(async () => {
+ const axes = await vi.importActual<{
+ FIXTURES: string[]; APP_TYPES: string[]; MODES: string[]; STACKS: string[]; CMS_SUBSTRATES: string[];
+ }>('../../../scripts/eye-test/axes.mjs');
+ ({ FIXTURES, APP_TYPES, MODES, STACKS, CMS_SUBSTRATES } = axes);
+
+ const gen = await vi.importActual<{ latinSquareSample: () => LatinCell[]; allCells: () => ExhaustiveCell[] }>(
+ '../../../scripts/eye-test/cell-generator.mjs',
+ );
+ ({ latinSquareSample, allCells } = gen);
+});
+
+describe('latinSquareSample - axis coverage', () => {
+ it('every FIXTURE value appears at least once', () => {
+ const covered = new Set(latinSquareSample().map((c) => c.fixture));
+ for (const f of FIXTURES) expect(covered.has(f)).toBe(true);
+ });
+
+ it('every APP_TYPE value appears at least once', () => {
+ const covered = new Set(latinSquareSample().map((c) => c.app));
+ for (const a of APP_TYPES) expect(covered.has(a)).toBe(true);
+ });
+
+ it('every MODE value appears at least once', () => {
+ const covered = new Set(latinSquareSample().map((c) => c.mode));
+ for (const m of MODES) expect(covered.has(m)).toBe(true);
+ });
+
+ it('every CMS_SUBSTRATE value appears at least once', () => {
+ const covered = new Set(latinSquareSample().map((c) => c.cms));
+ for (const cms of CMS_SUBSTRATES) expect(covered.has(cms)).toBe(true);
+ });
+});
+
+describe('latinSquareSample - frequency balance', () => {
+ it('each FIXTURE value appears the same number of times', () => {
+ const cells = latinSquareSample();
+ const freqs = FIXTURES.map((f) => cells.filter((c) => c.fixture === f).length);
+ expect(new Set(freqs).size).toBe(1);
+ });
+
+ it('each APP_TYPE value appears the same number of times', () => {
+ const cells = latinSquareSample();
+ const freqs = APP_TYPES.map((a) => cells.filter((c) => c.app === a).length);
+ expect(new Set(freqs).size).toBe(1);
+ });
+
+ it('each MODE value appears the same number of times', () => {
+ const cells = latinSquareSample();
+ const freqs = MODES.map((m) => cells.filter((c) => c.mode === m).length);
+ expect(new Set(freqs).size).toBe(1);
+ });
+
+ it('each CMS_SUBSTRATE value appears the same number of times', () => {
+ const cells = latinSquareSample();
+ const freqs = CMS_SUBSTRATES.map((cms) => cells.filter((c) => c.cms === cms).length);
+ expect(new Set(freqs).size).toBe(1);
+ });
+});
+
+describe('allCells - frequency coverage (app_type and mode)', () => {
+ it('each APP_TYPE value appears FIXTURES * MODES * STACKS * CMS_SUBSTRATES times', () => {
+ const cells = allCells();
+ for (const app of APP_TYPES) {
+ expect(cells.filter((c) => c.app === app)).toHaveLength(
+ FIXTURES.length * MODES.length * STACKS.length * CMS_SUBSTRATES.length,
+ );
+ }
+ });
+
+ it('each MODE value appears FIXTURES * APP_TYPES * STACKS * CMS_SUBSTRATES times', () => {
+ const cells = allCells();
+ for (const mode of MODES) {
+ expect(cells.filter((c) => c.mode === mode)).toHaveLength(
+ FIXTURES.length * APP_TYPES.length * STACKS.length * CMS_SUBSTRATES.length,
+ );
+ }
+ });
+});
+
+describe('latinSquareSample vs allCells - scale contract', () => {
+ it('sample is strictly smaller than the exhaustive set', () => {
+ expect(latinSquareSample().length).toBeLessThan(allCells().length);
+ });
+
+ it('sample size is less than 10% of the exhaustive set', () => {
+ expect(latinSquareSample().length).toBeLessThan(allCells().length * 0.1);
+ });
+
+ it('sample size equals exhaustive size divided by STACKS * CMS_SUBSTRATES', () => {
+ expect(latinSquareSample().length * STACKS.length * CMS_SUBSTRATES.length).toBe(allCells().length);
+ });
+});
+
+describe('latinSquareSample - per-fixture axis variation', () => {
+ it('each fixture has at least 2 distinct CMS substrates across its cells', () => {
+ const cells = latinSquareSample();
+ for (const fixture of FIXTURES) {
+ const distinctCms = new Set(cells.filter((c) => c.fixture === fixture).map((c) => c.cms));
+ expect(distinctCms.size).toBeGreaterThanOrEqual(2);
+ }
+ });
+});
diff --git a/tests/scripts/eye-test/cli.test.ts b/tests/scripts/eye-test/cli.test.ts
new file mode 100644
index 0000000..ccd9f15
--- /dev/null
+++ b/tests/scripts/eye-test/cli.test.ts
@@ -0,0 +1,162 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest';
+
+type ParseArgs = (argv: string[]) => {
+ exhaustive: boolean;
+ baseUrl: string;
+ outputDir: string;
+ concurrency: number;
+};
+
+let parseArgs: ParseArgs;
+
+beforeAll(async () => {
+ const mod = await vi.importActual<{ parseArgs: ParseArgs }>('../../../scripts/eye-test/cli.mjs');
+ ({ parseArgs } = mod);
+});
+
+const BASE_ARGV = ['node', 'eye-test.mjs'];
+const ENV_KEY = 'EYE_TEST_BASE_URL';
+
+afterEach(() => {
+ delete process.env[ENV_KEY];
+});
+
+describe('parseArgs - default values', () => {
+ it('exhaustive defaults to false', () => {
+ expect(parseArgs(BASE_ARGV).exhaustive).toBe(false);
+ });
+
+ it('outputDir defaults to "dist"', () => {
+ expect(parseArgs(BASE_ARGV).outputDir).toBe('dist');
+ });
+
+ it('concurrency defaults to 4', () => {
+ expect(parseArgs(BASE_ARGV).concurrency).toBe(4);
+ });
+
+ it('baseUrl defaults to the canonical production GitHub Pages URL', () => {
+ expect(parseArgs(BASE_ARGV).baseUrl).toBe('https://bvasilenko.github.io');
+ });
+
+ it('all four fields are present in the result with no flags', () => {
+ const result = parseArgs(BASE_ARGV);
+ expect('exhaustive' in result).toBe(true);
+ expect('baseUrl' in result).toBe(true);
+ expect('outputDir' in result).toBe(true);
+ expect('concurrency' in result).toBe(true);
+ });
+});
+
+describe('parseArgs - --exhaustive flag', () => {
+ it('--exhaustive sets exhaustive to true', () => {
+ expect(parseArgs([...BASE_ARGV, '--exhaustive']).exhaustive).toBe(true);
+ });
+
+ it('absence of --exhaustive keeps exhaustive false even with other flags present', () => {
+ expect(parseArgs([...BASE_ARGV, '--url', 'http://localhost:4000']).exhaustive).toBe(false);
+ });
+
+ it('--exhaustive at the end of argv (after other flags) is recognised', () => {
+ expect(parseArgs([...BASE_ARGV, '--url', 'http://x.com', '--exhaustive']).exhaustive).toBe(true);
+ });
+
+ it('--exhaustive before --url is recognised', () => {
+ expect(parseArgs([...BASE_ARGV, '--exhaustive', '--url', 'http://x.com']).exhaustive).toBe(true);
+ });
+});
+
+describe('parseArgs - --url flag', () => {
+ it('--url sets baseUrl to the provided value', () => {
+ expect(parseArgs([...BASE_ARGV, '--url', 'http://localhost:5000']).baseUrl).toBe('http://localhost:5000');
+ });
+
+ it('--url with a trailing-slash URL preserves the value verbatim', () => {
+ expect(parseArgs([...BASE_ARGV, '--url', 'https://example.com/']).baseUrl).toBe('https://example.com/');
+ });
+
+ it('--url without a following value falls back to the default base URL', () => {
+ expect(parseArgs([...BASE_ARGV, '--url']).baseUrl).toBe('https://bvasilenko.github.io');
+ });
+});
+
+describe('parseArgs - --output-dir flag', () => {
+ it('--output-dir sets outputDir to the provided value', () => {
+ expect(parseArgs([...BASE_ARGV, '--output-dir', 'custom-out']).outputDir).toBe('custom-out');
+ });
+
+ it('--output-dir accepts paths with slashes', () => {
+ expect(parseArgs([...BASE_ARGV, '--output-dir', 'dist/eye-test']).outputDir).toBe('dist/eye-test');
+ });
+
+ it('--output-dir without a following value falls back to the default outputDir', () => {
+ expect(parseArgs([...BASE_ARGV, '--output-dir']).outputDir).toBe('dist');
+ });
+});
+
+describe('parseArgs - --concurrency flag', () => {
+ it('--concurrency parses the value as an integer', () => {
+ expect(parseArgs([...BASE_ARGV, '--concurrency', '8']).concurrency).toBe(8);
+ });
+
+ it('--concurrency result is always of type number', () => {
+ expect(typeof parseArgs([...BASE_ARGV, '--concurrency', '2']).concurrency).toBe('number');
+ });
+
+ it('default concurrency is of type number', () => {
+ expect(typeof parseArgs(BASE_ARGV).concurrency).toBe('number');
+ });
+
+ it('--concurrency without a following value falls back to default', () => {
+ expect(parseArgs([...BASE_ARGV, '--concurrency']).concurrency).toBe(4);
+ });
+});
+
+describe('parseArgs - EYE_TEST_BASE_URL environment variable', () => {
+ it('EYE_TEST_BASE_URL overrides the default base URL when no --url flag is present', () => {
+ process.env[ENV_KEY] = 'https://env-host.com';
+ expect(parseArgs(BASE_ARGV).baseUrl).toBe('https://env-host.com');
+ });
+
+ it('EYE_TEST_BASE_URL takes precedence over the --url flag', () => {
+ process.env[ENV_KEY] = 'https://env-host.com';
+ expect(parseArgs([...BASE_ARGV, '--url', 'https://flag-host.com']).baseUrl).toBe('https://env-host.com');
+ });
+
+ it('baseUrl falls back to --url when EYE_TEST_BASE_URL is absent', () => {
+ expect(parseArgs([...BASE_ARGV, '--url', 'https://flag-host.com']).baseUrl).toBe('https://flag-host.com');
+ });
+
+ it('baseUrl falls back to the production default when neither env var nor --url is set', () => {
+ expect(parseArgs(BASE_ARGV).baseUrl).toBe('https://bvasilenko.github.io');
+ });
+});
+
+describe('parseArgs - argv slice behaviour', () => {
+ it('argv[0] and argv[1] (runtime and script path) are not interpreted as flags', () => {
+ const result = parseArgs(['ignored-runtime', 'ignored-script', '--url', 'https://x.com']);
+ expect(result.baseUrl).toBe('https://x.com');
+ });
+
+ it('empty user-facing argv (beyond runtime and script path) applies all defaults', () => {
+ const result = parseArgs(['node', 'eye-test.mjs']);
+ expect(result.exhaustive).toBe(false);
+ expect(result.outputDir).toBe('dist');
+ expect(result.concurrency).toBe(4);
+ });
+
+ it('all four flags together produce the correct combined result', () => {
+ const result = parseArgs([
+ ...BASE_ARGV,
+ '--exhaustive',
+ '--url', 'http://localhost:9000',
+ '--output-dir', 'out',
+ '--concurrency', '16',
+ ]);
+ expect(result.exhaustive).toBe(true);
+ expect(result.baseUrl).toBe('http://localhost:9000');
+ expect(result.outputDir).toBe('out');
+ expect(result.concurrency).toBe(16);
+ });
+});
diff --git a/tests/scripts/eye-test/diff-reporter.test.ts b/tests/scripts/eye-test/diff-reporter.test.ts
new file mode 100644
index 0000000..74291b0
--- /dev/null
+++ b/tests/scripts/eye-test/diff-reporter.test.ts
@@ -0,0 +1,326 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+type CellResult = {
+ fixture: string;
+ app: string;
+ mode: string;
+ stack: string;
+ cms: string;
+ primary: string;
+ iframeSheetCount: number;
+ islandCount: number;
+ thumbnail: Buffer;
+ error: string | null;
+};
+
+type WriteManifest = (
+ results: CellResult[],
+ destPath: string,
+ expectedPrimaries: Record,
+) => void;
+
+type PrintDiffReport = (
+ results: CellResult[],
+ expectedPrimaries: Record,
+) => void;
+
+let writeManifest: WriteManifest;
+let printDiffReport: PrintDiffReport;
+
+beforeAll(async () => {
+ const mod = await vi.importActual<{ writeManifest: WriteManifest; printDiffReport: PrintDiffReport }>(
+ '../../../scripts/eye-test/diff-reporter.mjs',
+ );
+ ({ writeManifest, printDiffReport } = mod);
+});
+
+const EXPECTED_PRIMARIES: Record = {
+ stripe: '#635bff',
+ github: '#0969da',
+};
+
+function makeResult(overrides: Partial = {}): CellResult {
+ return {
+ fixture: 'stripe',
+ app: 'landing',
+ mode: 'static',
+ stack: 'vite',
+ cms: 'payload',
+ primary: '#635bff',
+ iframeSheetCount: 3,
+ islandCount: 0,
+ thumbnail: Buffer.alloc(4),
+ error: null,
+ ...overrides,
+ };
+}
+
+let tmpDir: string;
+let manifestPath: string;
+
+beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbrand-diff-'));
+ manifestPath = path.join(tmpDir, 'manifest.json');
+});
+
+afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true });
+});
+
+function captureStdout(fn: () => void): string {
+ const parts: string[] = [];
+ vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
+ parts.push(String(chunk));
+ return true;
+ });
+ try {
+ fn();
+ } finally {
+ vi.mocked(process.stdout.write).mockRestore();
+ }
+ return parts.join('');
+}
+
+describe('writeManifest - output file contract', () => {
+ it('produces a valid JSON file at the given path', () => {
+ writeManifest([makeResult()], manifestPath, EXPECTED_PRIMARIES);
+ expect(() => JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))).not.toThrow();
+ });
+
+ it('entry count matches the number of input results', () => {
+ const results = [makeResult(), makeResult({ fixture: 'github', primary: '#0969da' })];
+ writeManifest(results, manifestPath, EXPECTED_PRIMARIES);
+ const entries = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as unknown[];
+ expect(entries).toHaveLength(results.length);
+ });
+
+ it('thumbnail field is absent from every entry', () => {
+ writeManifest([makeResult(), makeResult()], manifestPath, EXPECTED_PRIMARIES);
+ const entries = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record[];
+ for (const entry of entries) {
+ expect('thumbnail' in entry).toBe(false);
+ }
+ });
+
+ it('all other result fields (fixture, app, mode, stack, cms, primary) are preserved', () => {
+ writeManifest([makeResult()], manifestPath, EXPECTED_PRIMARIES);
+ const entry = (JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record[])[0];
+ expect(entry['fixture']).toBe('stripe');
+ expect(entry['app']).toBe('landing');
+ expect(entry['mode']).toBe('static');
+ expect(entry['stack']).toBe('vite');
+ expect(entry['cms']).toBe('payload');
+ expect(entry['primary']).toBe('#635bff');
+ });
+
+ it('error field is null when result has no navigation error', () => {
+ writeManifest([makeResult({ error: null })], manifestPath, EXPECTED_PRIMARIES);
+ const entry = (JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record[])[0];
+ expect(entry['error']).toBeNull();
+ });
+
+ it('error field is preserved when non-null', () => {
+ writeManifest([makeResult({ error: 'timeout after 30s' })], manifestPath, EXPECTED_PRIMARIES);
+ const entry = (JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record[])[0];
+ expect(entry['error']).toBe('timeout after 30s');
+ });
+});
+
+describe('writeManifest - added fields', () => {
+ it('adds expectedPrimary from the expected map for known fixtures', () => {
+ writeManifest([makeResult({ fixture: 'stripe' })], manifestPath, EXPECTED_PRIMARIES);
+ const entry = (JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record[])[0];
+ expect(entry['expectedPrimary']).toBe('#635bff');
+ });
+
+ it('expectedPrimary is null for a fixture not in the expected map', () => {
+ writeManifest([makeResult({ fixture: 'notion' })], manifestPath, EXPECTED_PRIMARIES);
+ const entry = (JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record[])[0];
+ expect(entry['expectedPrimary']).toBeNull();
+ });
+
+ it('primaryMatch is true when primary matches the expected color exactly (lowercase)', () => {
+ writeManifest([makeResult({ primary: '#635bff' })], manifestPath, EXPECTED_PRIMARIES);
+ const entry = (JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record[])[0];
+ expect(entry['primaryMatch']).toBe(true);
+ });
+
+ it('uppercase result.primary matches a lowercase expected value', () => {
+ writeManifest([makeResult({ primary: '#635BFF' })], manifestPath, { stripe: '#635bff' });
+ const entry = (JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record[])[0];
+ expect(entry['primaryMatch']).toBe(true);
+ });
+
+ it('primaryMatch is false when primary color differs from expected', () => {
+ writeManifest([makeResult({ primary: '#aabbcc' })], manifestPath, EXPECTED_PRIMARIES);
+ const entry = (JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record[])[0];
+ expect(entry['primaryMatch']).toBe(false);
+ });
+
+ it('primaryMatch is false for a fixture absent from the expected map', () => {
+ writeManifest([makeResult({ fixture: 'notion', primary: '#000000' })], manifestPath, EXPECTED_PRIMARIES);
+ const entry = (JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record[])[0];
+ expect(entry['primaryMatch']).toBe(false);
+ });
+});
+
+describe('printDiffReport - all-pass output', () => {
+ it('emits an "All N cells passed" message when every result is clean', () => {
+ const out = captureStdout(() => printDiffReport([makeResult()], EXPECTED_PRIMARIES));
+ expect(out).toContain('All 1 cells passed');
+ });
+
+ it('total cell count in the pass message matches the number of results', () => {
+ const results = [
+ makeResult({ fixture: 'stripe', primary: '#635bff' }),
+ makeResult({ fixture: 'github', primary: '#0969da' }),
+ ];
+ const out = captureStdout(() => printDiffReport(results, EXPECTED_PRIMARIES));
+ expect(out).toContain('All 2 cells passed');
+ });
+});
+
+describe('printDiffReport - failure conditions', () => {
+ it('navigation error flags the cell', () => {
+ const out = captureStdout(() =>
+ printDiffReport([makeResult({ error: 'timeout' })], EXPECTED_PRIMARIES),
+ );
+ expect(out).not.toContain('All');
+ expect(out).toContain('stripe/landing/static');
+ });
+
+ it('primary color mismatch flags the cell', () => {
+ const out = captureStdout(() =>
+ printDiffReport([makeResult({ primary: '#wrong' })], EXPECTED_PRIMARIES),
+ );
+ expect(out).toContain('stripe/landing/static');
+ });
+
+ it('primary color match does not flag the cell', () => {
+ const out = captureStdout(() =>
+ printDiffReport([makeResult({ primary: '#635bff' })], EXPECTED_PRIMARIES),
+ );
+ expect(out).toContain('All 1 cells passed');
+ });
+
+ it('iframe sheet count < 2 for static mode flags the cell', () => {
+ const out = captureStdout(() =>
+ printDiffReport([makeResult({ mode: 'static', iframeSheetCount: 1 })], EXPECTED_PRIMARIES),
+ );
+ expect(out).toContain('stripe/landing/static');
+ });
+
+ it('iframe sheet count = 0 for hybrid mode flags the cell', () => {
+ const out = captureStdout(() =>
+ printDiffReport([makeResult({ mode: 'hybrid', iframeSheetCount: 0, islandCount: 1 })], EXPECTED_PRIMARIES),
+ );
+ expect(out).toContain('stripe/landing/hybrid');
+ });
+
+ it('iframe sheet count >= 2 for non-spa mode does not flag the cell', () => {
+ const out = captureStdout(() =>
+ printDiffReport([makeResult({ mode: 'static', iframeSheetCount: 2 })], EXPECTED_PRIMARIES),
+ );
+ expect(out).toContain('All 1 cells passed');
+ });
+
+ it('spa mode: iframe sheet count < 2 does not flag the cell', () => {
+ const out = captureStdout(() =>
+ printDiffReport([makeResult({ mode: 'spa', iframeSheetCount: 0 })], EXPECTED_PRIMARIES),
+ );
+ expect(out).toContain('All 1 cells passed');
+ });
+
+ it('hybrid island count < 1 flags the cell', () => {
+ const out = captureStdout(() =>
+ printDiffReport([makeResult({ mode: 'hybrid', islandCount: 0, iframeSheetCount: 3 })], EXPECTED_PRIMARIES),
+ );
+ expect(out).toContain('stripe/landing/hybrid');
+ });
+
+ it('hybrid island count >= 1 does not flag the cell', () => {
+ const out = captureStdout(() =>
+ printDiffReport([makeResult({ mode: 'hybrid', islandCount: 1, iframeSheetCount: 3 })], EXPECTED_PRIMARIES),
+ );
+ expect(out).toContain('All 1 cells passed');
+ });
+
+ it('static mode: island count is not evaluated', () => {
+ const out = captureStdout(() =>
+ printDiffReport([makeResult({ mode: 'static', islandCount: 0, iframeSheetCount: 3 })], EXPECTED_PRIMARIES),
+ );
+ expect(out).toContain('All 1 cells passed');
+ });
+
+ it('spa mode: island count is not evaluated', () => {
+ const out = captureStdout(() =>
+ printDiffReport([makeResult({ mode: 'spa', islandCount: 0 })], EXPECTED_PRIMARIES),
+ );
+ expect(out).toContain('All 1 cells passed');
+ });
+});
+
+describe('printDiffReport - failure entry format', () => {
+ it('failure entry contains [fixture/app/mode] in square brackets', () => {
+ const result = makeResult({ primary: '#wrong', fixture: 'stripe', app: 'landing', mode: 'static' });
+ const out = captureStdout(() => printDiffReport([result], EXPECTED_PRIMARIES));
+ expect(out).toContain('[stripe/landing/static]');
+ });
+
+ it('failure count is reported before the individual entries', () => {
+ const results = [
+ makeResult({ primary: '#wrong', fixture: 'stripe', app: 'landing', mode: 'static' }),
+ makeResult({ primary: '#wrong2', fixture: 'github', app: 'marketing', mode: 'hybrid' }),
+ ];
+ const out = captureStdout(() => printDiffReport(results, EXPECTED_PRIMARIES));
+ expect(out).toContain('2 cell(s)');
+ });
+
+ it('multiple failures each appear on a separate line', () => {
+ const results = [
+ makeResult({ primary: '#x', fixture: 'stripe', app: 'landing', mode: 'static' }),
+ makeResult({ primary: '#y', fixture: 'github', app: 'docs', mode: 'spa' }),
+ ];
+ const out = captureStdout(() => printDiffReport(results, EXPECTED_PRIMARIES));
+ expect(out).toContain('[stripe/landing/static]');
+ expect(out).toContain('[github/docs/spa]');
+ });
+
+ it('clean cells are not included in the failure output', () => {
+ const results = [
+ makeResult({ primary: '#635bff', fixture: 'stripe' }),
+ makeResult({ primary: '#wrong', fixture: 'github' }),
+ ];
+ const out = captureStdout(() => printDiffReport(results, EXPECTED_PRIMARIES));
+ expect(out).not.toContain('[stripe/landing/static]');
+ expect(out).toContain('[github/landing/static]');
+ });
+});
+
+describe('printDiffReport - primary comparison is case-insensitive for result.primary', () => {
+ it('uppercase primary matching a lowercase expected value does not flag the cell', () => {
+ const out = captureStdout(() =>
+ printDiffReport([makeResult({ primary: '#635BFF' })], { stripe: '#635bff' }),
+ );
+ expect(out).toContain('All 1 cells passed');
+ });
+
+ it('mixed-case primary matching the same lowercase expected value does not flag the cell', () => {
+ const out = captureStdout(() =>
+ printDiffReport([makeResult({ primary: '#635Bff' })], { stripe: '#635bff' }),
+ );
+ expect(out).toContain('All 1 cells passed');
+ });
+
+ it('uppercase primary that does not match the expected lowercase value flags the cell', () => {
+ const out = captureStdout(() =>
+ printDiffReport([makeResult({ primary: '#AABBCC' })], { stripe: '#635bff' }),
+ );
+ expect(out).toContain('stripe/landing/static');
+ });
+});
diff --git a/tests/scripts/eye-test/stack-snippets.test.ts b/tests/scripts/eye-test/stack-snippets.test.ts
new file mode 100644
index 0000000..31743d3
--- /dev/null
+++ b/tests/scripts/eye-test/stack-snippets.test.ts
@@ -0,0 +1,237 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 bvasilenko
+import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+type LoadStackEmitShapes = (distDir: string) => Record;
+
+let STACKS: string[];
+let loadStackEmitShapes: LoadStackEmitShapes;
+
+beforeAll(async () => {
+ const axes = await vi.importActual<{ STACKS: string[] }>('../../../scripts/eye-test/axes.mjs');
+ ({ STACKS } = axes);
+ const mod = await vi.importActual<{ loadStackEmitShapes: LoadStackEmitShapes }>(
+ '../../../scripts/eye-test/stack-snippets.mjs',
+ );
+ ({ loadStackEmitShapes } = mod);
+});
+
+let tmpDir: string;
+let stacksDir: string;
+
+beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbrand-snippets-'));
+ stacksDir = path.join(tmpDir, 'stacks');
+ fs.mkdirSync(stacksDir);
+});
+
+afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true });
+});
+
+function writeStack(stack: string, html: string): void {
+ fs.writeFileSync(path.join(stacksDir, `${stack}.html`), html);
+}
+
+function viteHtml(content: string): string {
+ return ``;
+}
+
+function nextHtml(page: string, buildId: string, islands: string[], flight: string): string {
+ const data = JSON.stringify({ page, buildId, props: { islands } });
+ return (
+ `` +
+ `` +
+ `` +
+ ``
+ );
+}
+
+function astroHtml(attrs: string, hydration: string): string {
+ return (
+ `` +
+ `` +
+ ` `
+ );
+}
+
+const EMPTY_HTML = '';
+
+describe('loadStackEmitShapes - output shape', () => {
+ it('returns an object keyed by every STACKS value', () => {
+ const shapes = loadStackEmitShapes('/nonexistent/path');
+ expect(Object.keys(shapes).sort()).toEqual([...STACKS].sort());
+ });
+
+ it('all values are arrays', () => {
+ const shapes = loadStackEmitShapes('/nonexistent/path');
+ for (const stack of STACKS) {
+ expect(Array.isArray(shapes[stack])).toBe(true);
+ }
+ });
+
+ it('all values are non-empty arrays of strings', () => {
+ const shapes = loadStackEmitShapes('/nonexistent/path');
+ for (const stack of STACKS) {
+ expect(shapes[stack].length).toBeGreaterThan(0);
+ for (const line of shapes[stack]) {
+ expect(typeof line).toBe('string');
+ }
+ }
+ });
+});
+
+describe('loadStackEmitShapes - missing file fallback', () => {
+ it('missing distDir produces at least two lines per stack (path hint + marker)', () => {
+ const shapes = loadStackEmitShapes('/nonexistent/path');
+ for (const stack of STACKS) {
+ expect(shapes[stack].length).toBeGreaterThanOrEqual(2);
+ }
+ });
+
+ it('missing file fallback for each stack references its stack name in a path hint', () => {
+ const shapes = loadStackEmitShapes('/nonexistent/path');
+ for (const stack of STACKS) {
+ expect(shapes[stack].join('\n')).toContain(stack);
+ }
+ });
+
+ it('missing file fallback for each stack contains a "(not found" marker', () => {
+ const shapes = loadStackEmitShapes('/nonexistent/path');
+ for (const stack of STACKS) {
+ expect(shapes[stack].join('\n')).toContain('(not found');
+ }
+ });
+
+ it('fallbacks for different stacks are distinct (each names its own stack)', () => {
+ const shapes = loadStackEmitShapes('/nonexistent/path');
+ const texts = STACKS.map((s) => shapes[s].join('\n'));
+ const uniq = new Set(texts);
+ expect(uniq.size).toBe(STACKS.length);
+ });
+});
+
+describe('loadStackEmitShapes - vite extractor', () => {
+ it('output starts with a script tag containing the VBRAND_VITE_BOOTSTRAP_PREVIEW id', () => {
+ writeStack('vite', viteHtml('{}'));
+ const lines = loadStackEmitShapes(tmpDir)['vite'];
+ expect(lines[0]).toContain('');
+ });
+
+ it('inner content from the script element appears in the extracted lines', () => {
+ writeStack('vite', viteHtml('{"theme":"dark","fixture":"stripe"}'));
+ expect(loadStackEmitShapes(tmpDir)['vite'].join('\n')).toContain('{"theme":"dark","fixture":"stripe"}');
+ });
+
+ it('absent script element produces "(not found)" in the inner content', () => {
+ writeStack('vite', EMPTY_HTML);
+ expect(loadStackEmitShapes(tmpDir)['vite'].join('\n')).toContain('(not found)');
+ });
+
+ it('opening and closing script tag markers are always present even when content is empty', () => {
+ writeStack('vite', viteHtml(''));
+ const text = loadStackEmitShapes(tmpDir)['vite'].join('\n');
+ expect(text).toContain('