import { describe, expect, it } from 'vitest';
import { JSDOM } from 'jsdom';
import { buildSrcdoc } from '../../src/runtime/srcdoc';
const deckHtml = `
Deck
`;
describe('buildSrcdoc', () => {
it('injects an initial slide index for deck previews', () => {
const doc = buildSrcdoc(deckHtml, { deck: true, initialSlideIndex: 2 });
expect(doc).toContain('var initialSlideIndex = 2;');
expect(doc).toContain('setTimeout(restoreInitialSlide, 200)');
expect(doc).toContain('setTimeout(restoreInitialSlide, 100)');
});
it('clamps invalid initial slide indices before injecting deck bridge script', () => {
const doc = buildSrcdoc(deckHtml, { deck: true, initialSlideIndex: -4 });
expect(doc).toContain('var initialSlideIndex = 0;');
});
it('injects the snapshot bridge used by draw annotations', () => {
const srcdoc = buildSrcdoc('Hero');
expect(srcdoc).toContain('data-od-snapshot-bridge');
expect(srcdoc).toContain("data.type !== 'od:snapshot'");
expect(srcdoc).toContain("type: 'od:snapshot:result'");
expect(srcdoc).toContain('copyComputedStyle');
expect(srcdoc).toContain('foreignObject');
});
it('renders snapshot SVGs through data URLs so canvas export stays origin-clean', () => {
const srcdoc = buildSrcdoc('Hero');
expect(srcdoc).toContain('function encodedSvgDataUrl()');
expect(srcdoc).toContain('img.src = encodedSvgDataUrl();');
expect(srcdoc).not.toContain('createObjectURL');
expect(srcdoc).not.toContain('snapshot too large');
});
it('crops snapshots with an XHTML wrapper instead of moving foreignObject offscreen', () => {
const srcdoc = buildSrcdoc('Hero');
expect(srcdoc).toContain('function scrollOffset()');
expect(srcdoc).toContain('left:\' + (-scroll.x) + \'px;top:\' + (-scroll.y) + \'px;');
expect(srcdoc).toContain('Hero');
expect(srcdoc).toContain('link[rel~="stylesheet"], link[rel~="preload"], link[rel~="preconnect"]');
expect(srcdoc).toContain('.replace(/@import[^;]+;/gi,');
expect(srcdoc).toContain('.replace(/@font-face\\s*\\{[^}]*\\}/gi,');
});
it('can guard preview iframes against load-time focus stealing', () => {
// This test would fail if injectPreviewFocusGuard were removed from
// buildSrcdoc — the guard script would be absent, and the assertions
// below would not find the data-od-preview-focus-guard marker.
const srcdoc = buildSrcdoc(
'Hero',
{ previewFocusGuard: true },
);
expect(srcdoc).toContain('data-od-preview-focus-guard');
expect(srcdoc).toContain("Object.defineProperty(window, 'focus'");
expect(srcdoc).toContain("Object.defineProperty(HTMLElement.prototype, 'focus'");
expect(srcdoc.indexOf('data-od-preview-focus-guard')).toBeLessThan(
srcdoc.indexOf(''),
);
});
it('only uses directly mutable slide conventions for setActive support', () => {
const srcdoc = buildSrcdoc(
'',
{ deck: true }
);
const canSetActive = srcdoc.match(/function canSetActive\(list\)\{([\s\S]*?)\n \}/)?.[1] ?? '';
expect(canSetActive).toContain('var active = findActiveByClass(list);');
expect(canSetActive).toContain('hasComputedHiddenSibling(list, active)');
expect(canSetActive).toContain("list[i].style.display === 'none'");
expect(canSetActive).toContain("list[i].style.visibility === 'hidden'");
expect(canSetActive).toContain("list[i].hasAttribute('hidden')");
expect(canSetActive).not.toContain('findActiveByVisibility');
});
it('injects the selection bridge for comment mode', () => {
const srcdoc = buildSrcdoc('Hero', {
commentBridge: true,
});
expect(srcdoc).toContain('data-od-selection-bridge');
// The bridge boots with the requested mode already on so a click
// immediately after srcdoc rebuild is not lost to the listener-install
// race against the host's `od:*-mode` postMessage.
expect(srcdoc).toContain('var commentEnabled = true;');
expect(srcdoc).toContain('var inspectEnabled = false;');
expect(srcdoc).toContain("type: 'od:comment-target'");
expect(srcdoc).toContain("type: 'od:comment-hover'");
expect(srcdoc).toContain("type: 'od:comment-leave'");
expect(srcdoc).toContain("type: 'od:comment-targets'");
expect(srcdoc).toContain("postStroke('od:pod-stroke')");
expect(srcdoc).toContain("postStroke('od:pod-select')");
expect(srcdoc).toContain('data-od-comment-mode-kind');
expect(srcdoc).toContain("body * { cursor: crosshair !important; }");
expect(srcdoc).toContain('MutationObserver(schedulePostTargets)');
expect(srcdoc).toContain('schedulePostPreviewScroll');
expect(srcdoc).toContain("type: 'od:preview-scroll'");
expect(srcdoc).toContain("type: 'od:preview-scroll-request'");
expect(srcdoc).toContain('data-od-selection-bridge-style');
expect(srcdoc).toContain('html[data-od-comment-mode] body iframe');
expect(srcdoc).toContain('html[data-od-inspect-mode] body iframe');
expect(srcdoc).toContain('pointer-events: none !important');
});
it('emits free-pin fallback coordinates in viewport space', () => {
const srcdoc = buildSrcdoc('Hero', { commentBridge: true });
const freePinStart = srcdoc.indexOf('var pinX = Math.round(ev.clientX);');
const freePinEnd = srcdoc.indexOf('// Pod drawing', freePinStart);
const freePinBlock = srcdoc.slice(freePinStart, freePinEnd);
expect(freePinBlock).toContain('var pinX = Math.round(ev.clientX);');
expect(freePinBlock).toContain('var pinY = Math.round(ev.clientY);');
expect(freePinBlock).toContain('position: { x: pinX - 12, y: pinY - 12, width: 24, height: 24 }');
expect(freePinBlock).not.toContain('scrollX');
expect(freePinBlock).not.toContain('scrollY');
expect(freePinBlock).not.toContain('pageXOffset');
expect(freePinBlock).not.toContain('pageYOffset');
});
it('injects the selection bridge for inspect mode and exposes override hooks', () => {
const srcdoc = buildSrcdoc('Hero', {
inspectBridge: true,
});
expect(srcdoc).toContain('data-od-selection-bridge');
expect(srcdoc).toContain('var commentEnabled = false;');
expect(srcdoc).toContain('var inspectEnabled = true;');
expect(srcdoc).toContain("type: 'od:inspect-overrides'");
expect(srcdoc).toContain("data.type === 'od:inspect-mode'");
expect(srcdoc).toContain("data.type === 'od:inspect-set'");
expect(srcdoc).toContain("data.type === 'od:inspect-reset'");
expect(srcdoc).toContain("data.type === 'od:inspect-extract'");
expect(srcdoc).toContain("data-od-inspect-overrides");
expect(srcdoc).toContain('html[data-od-inspect-mode]');
});
it('hydrates inspect overrides from a persisted style block on bridge boot', () => {
// Without hydration, the first od:inspect-set rebuilds the override
// sheet from an empty in-memory map and silently drops every previously
// saved rule for other elements — Save-to-source would then erase them
// from the artifact too.
const srcdoc = buildSrcdoc('Hero', {
inspectBridge: true,
});
expect(srcdoc).toContain('function hydrateOverridesFromDom()');
expect(srcdoc).toContain('hydrateOverridesFromDom();');
expect(srcdoc).toContain("document.querySelector('style[data-od-inspect-overrides]')");
// After hydration, the bridge must seed the host's overrides state so a
// Save-to-source before the user has touched any control does not splice
// an empty CSS body that erases the persisted style block.
expect(srcdoc).toContain('if (Object.keys(overrides).length) setTimeout(postOverrides, 0);');
});
it('reflects the requested initial bridge modes on the documentElement attributes', () => {
const commentDoc = buildSrcdoc('Hero', {
commentBridge: true,
});
expect(commentDoc).toContain("document.documentElement.toggleAttribute('data-od-comment-mode', true)");
const inspectDoc = buildSrcdoc('Hero', {
inspectBridge: true,
});
expect(inspectDoc).toContain("document.documentElement.toggleAttribute('data-od-inspect-mode', true)");
});
it('omits the selection bridge entirely when neither comment nor inspect mode is on', () => {
const srcdoc = buildSrcdoc('Hero', {});
expect(srcdoc).not.toContain('data-od-selection-bridge');
});
// Regression for nexu-io/open-design#362: the bridge must accept an
// od:inspect-replay message that replaces its in-memory override map
// with the host's authoritative set. Without this, toggling Inspect
// off/on or switching to Comment mode reloads the iframe from
// previewSource without the host's unsaved style block, leaving
// preview and persisted state out of sync — saveInspectToSource()
// could then commit CSS the user is no longer seeing.
it('accepts od:inspect-replay to rehydrate from the host map after a srcdoc rebuild', () => {
const srcdoc = buildSrcdoc('Hero', {
inspectBridge: true,
});
expect(srcdoc).toContain("data.type === 'od:inspect-replay'");
// Re-validates the inbound payload under the same allow-list and
// value sanitizer used for od:inspect-set. A parent able to post to
// this bridge is otherwise trusted, but applying its payload through
// the bridge's own contract keeps the override sheet under known
// rules instead of whatever the parent sent.
expect(srcdoc).toContain('Object.prototype.hasOwnProperty.call(ALLOWED_PROPS, name)');
// The replay handler installs the host map atomically — clears the
// previous in-memory map first, then re-applies validated entries
// and rebuilds the sheet in a single pass so the user does not see
// a flash of unstyled preview between the two postMessages a
// per-prop replay would require.
expect(srcdoc).toContain('overrides = Object.create(null);');
});
it('hardens inspect overrides with a prop allow-list, value sanitizer, and trusted selector', () => {
const srcdoc = buildSrcdoc('Hero', {
inspectBridge: true,
});
// Allow-list rejects anything off the InspectPanel surface — without
// this a malicious parent could smuggle CSS via od:inspect-set.
expect(srcdoc).toContain('var ALLOWED_PROPS');
expect(srcdoc).toContain("'color': true");
expect(srcdoc).toContain("'background-color': true");
expect(srcdoc).toContain("'border-radius': true");
expect(srcdoc).toContain("Object.prototype.hasOwnProperty.call(ALLOWED_PROPS, prop)");
// Value sanitizer drops any character that could close the declaration,
// the rule, or the