open-design/apps/web/tests/runtime/srcdoc-deck-bridge-nested-slides.test.ts
chaoxiaoche 831208b823
Refine Studio preview interactions (#3000)
* Refine studio preview interactions

* Fix deck toolbar navigation for transform tracks

* Fix manual edit preview close

* Fix Simple Deck toolbar scrolling

* Fix preview screenshot capture

* Fix deck preview progress sync

* Refine edit target selection for grouped elements (#3068)

* Prefer child edit targets over grouped parents

* Keep edit inspector header and footer fixed

* Shorten floating edit inspector

* Show readable edit target names

* Allow dragging the floating edit inspector

* Add explicit edit inspector actions

* Show preview comment count in toolbar

* Separate annotation and comment toolbar groups

* Remove annotation toolbar divider

* Close edit inspector from footer actions

* Hide edit inspector until target hover

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>

* Fix manual edit iframe regression test

* Fix Studio interaction review feedback

Generated-By: looper 0.9.2 (runner=fixer, agent=codex)

* Fix saved comment link classification

Generated-By: looper 0.9.2 (runner=fixer, agent=codex)

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
Co-authored-by: Siri-Ray <2667192167@qq.com>
2026-05-28 12:52:37 +00:00

201 lines
9.1 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it, vi } from 'vitest';
import { JSDOM } from 'jsdom';
import { buildSrcdoc } from '../../src/runtime/srcdoc';
// Behavioral coverage for nexu-io/open-design#1530. The deck bridge in
// `buildSrcdoc({ deck: true })` counts slides via a DOM selector to drive
// the host preview toolbar's `slideState.count`. Generated HTML decks
// commonly nest `.slide` elements under an extra wrapper rather than
// placing them as direct children of the structured containers the bridge
// recognised (`.deck`, `.deck-stage`, `.deck-shell`, `body`). When that
// happened the bridge reported `count: 0` and the toolbar showed `1 / 0`
// even though the deck visibly contained slides and its own keyboard
// handler navigated them — the host counter did not match what the user
// saw. The fix keeps the structured selector first (so decorative
// `.slide` markup in non-deck pages is not accidentally counted) and
// falls back to all `.slide` only when the structured count is zero.
function extractDeckBridgeScript(srcdoc: string): string {
const match = srcdoc.match(/<script data-od-deck-bridge>([\s\S]*?)<\/script>/);
if (!match || !match[1]) {
throw new Error('deck bridge script not found in srcdoc');
}
return match[1];
}
function setupDeckBridge(bodyHtml: string) {
const srcdoc = buildSrcdoc(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
deck: true,
});
const script = extractDeckBridgeScript(srcdoc);
const dom = new JSDOM(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
runScripts: 'outside-only',
pretendToBeVisual: true,
});
const win = dom.window;
const parentPostMessage = vi.fn();
// jsdom defaults `window.parent` to `window` itself for top-level
// documents; replace it with a stub that has a spied postMessage so
// we can observe what the bridge would send to the embedding host.
Object.defineProperty(win, 'parent', {
configurable: true,
value: { postMessage: parentPostMessage },
});
const evaluate = new win.Function(script);
evaluate.call(win);
// jsdom fires `load` during construction, before the bridge IIFE
// installs its listener. Replay it here so the test exercises the
// same first-paint `report()` path the real preview iframe takes —
// without this the only postMessage we'd capture would come from the
// MutationObserver path inside `observeSlides`, which never fires
// when the structured selector is empty (the pre-fix bug condition).
win.dispatchEvent(new win.Event('load'));
return { dom, win, parentPostMessage };
}
function lastSlideState(parentPostMessage: ReturnType<typeof vi.fn>) {
const messages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((m) => m?.type === 'od:slide-state');
return messages.at(-1);
}
function postSlide(win: ReturnType<typeof setupDeckBridge>['win'], action: 'next' | 'prev') {
win.dispatchEvent(new win.window.MessageEvent('message', {
data: { type: 'od:slide', action },
}));
}
describe('deck bridge — nested slide markup (#1530)', () => {
it('counts nested .slide elements through a fallback when no structured container matches', async () => {
// 8 slides nested two levels deep — none of `.deck > .slide`,
// `.deck-stage > .slide`, `.deck-shell > .slide`, or `body > .slide`
// matches them. The bridge must still count 8 so the host renders
// `1 / 8` instead of the user-reported `1 / 0`.
const slides = Array.from({ length: 8 }, (_, i) =>
`<section class="slide">Slide ${i + 1}</section>`,
).join('');
const { win, parentPostMessage } = setupDeckBridge(
`<div class="deck-wrap"><div class="deck-inner">${slides}</div></div>`,
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 350));
const state = lastSlideState(parentPostMessage);
expect(state).toBeDefined();
expect(state.count).toBe(8);
});
it('still counts slides under the documented containers as direct children and ignores decorative .slide markup outside them', async () => {
// Pin the structured-first contract: direct children of `.deck` /
// `.deck-stage` / `.deck-shell` / `body` keep working as before AND
// decorative `.slide` markup placed outside any structured container
// (e.g. a utility class on a banner graphic) is not pulled in just
// because it shares the class name. Without the structured-first
// pass a broad `.slide` selector would count 4 here, so this fixture
// pins the precedence directly rather than only by docstring.
const slides = Array.from({ length: 3 }, (_, i) =>
`<section class="slide">${i}</section>`,
).join('');
const { win, parentPostMessage } = setupDeckBridge(
`<header><span class="slide" aria-hidden="true">decoy</span></header><div class="deck">${slides}</div>`,
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 350));
const state = lastSlideState(parentPostMessage);
expect(state).toBeDefined();
expect(state.count).toBe(3);
});
it('advances transform-track decks that do not expose active classes or scroll state', async () => {
const { win, parentPostMessage } = setupDeckBridge(`
<style>
html, body { margin: 0; overflow: hidden; }
#deck { display: flex; width: 300vw; transform: translateX(0); }
.slide { flex: 0 0 100vw; width: 100vw; height: 100vh; }
</style>
<div id="deck">
<section class="slide">One</section>
<section class="slide">Two</section>
<section class="slide">Three</section>
</div>
`);
const deck = win.document.getElementById('deck') as HTMLElement;
postSlide(win, 'next');
await new Promise<void>((resolve) => win.setTimeout(resolve, 350));
expect(deck.style.transform).toBe('translateX(-100vw)');
const state = lastSlideState(parentPostMessage);
expect(state).toMatchObject({ active: 1, count: 3 });
});
it('scrolls documentElement when body looks horizontally scrollable in a sandboxed Simple Deck', async () => {
const { win, parentPostMessage } = setupDeckBridge(`
<style>
html, body { margin: 0; height: 100%; }
body { display: flex; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; }
.slide { flex: 0 0 100vw; width: 100vw; height: 100vh; scroll-snap-align: start; }
</style>
<section class="slide">One</section>
<section class="slide">Two</section>
<section class="slide">Three</section>
`);
Object.defineProperty(win, 'innerWidth', { configurable: true, value: 1000 });
Object.defineProperties(win.document.body, {
scrollWidth: { configurable: true, value: 3000 },
clientWidth: { configurable: true, value: 1000 },
});
Object.defineProperties(win.document.documentElement, {
scrollWidth: { configurable: true, value: 3000 },
clientWidth: { configurable: true, value: 1000 },
});
const bodyScrollTo = vi.fn();
const htmlScrollTo = vi.fn((options?: ScrollToOptions | number) => {
const left = typeof options === 'number' ? options : Number(options?.left || 0);
win.document.documentElement.scrollLeft = left;
});
win.document.body.scrollTo = bodyScrollTo;
win.document.documentElement.scrollTo = htmlScrollTo;
postSlide(win, 'next');
await new Promise<void>((resolve) => win.setTimeout(resolve, 450));
expect(bodyScrollTo).toHaveBeenCalledWith({ left: 1000, behavior: 'smooth' });
expect(htmlScrollTo).toHaveBeenCalledWith({ left: 1000, behavior: 'smooth' });
expect(lastSlideState(parentPostMessage)).toMatchObject({ active: 1, count: 3 });
});
it('updates Simple Deck direct progress fill when host navigation drives the slide', async () => {
const { win } = setupDeckBridge(`
<style>
html, body { margin: 0; height: 100%; }
body { display: flex; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; }
.slide { flex: 0 0 100vw; width: 100vw; height: 100vh; scroll-snap-align: start; }
.deck-progress { position: fixed; top: 0; left: 0; height: 3px; width: 0; }
</style>
<section class="slide">One</section>
<section class="slide">Two</section>
<section class="slide">Three</section>
<div class="deck-progress" id="deck-progress" aria-hidden></div>
`);
Object.defineProperty(win, 'innerWidth', { configurable: true, value: 1000 });
Object.defineProperties(win.document.body, {
scrollWidth: { configurable: true, value: 3000 },
clientWidth: { configurable: true, value: 1000 },
});
Object.defineProperties(win.document.documentElement, {
scrollWidth: { configurable: true, value: 3000 },
clientWidth: { configurable: true, value: 1000 },
});
win.document.body.scrollTo = vi.fn();
win.document.documentElement.scrollTo = vi.fn((options?: ScrollToOptions | number) => {
const left = typeof options === 'number' ? options : Number(options?.left || 0);
win.document.documentElement.scrollLeft = left;
});
postSlide(win, 'next');
await new Promise<void>((resolve) => win.setTimeout(resolve, 450));
expect((win.document.getElementById('deck-progress') as HTMLElement).style.width).toBe('66.66666666666666%');
});
});