mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
973e868ced
* fix(web): make deck thumbnail navigation immediate * feat(deck): standardize viewer navigation protocol * test(daemon): update deck prompt matrix snapshot * test(daemon): assert versioned deck fallback state * test(deck): strengthen navigation regression contract * fix(deck): reject unsupported fallback protocol versions * fix(deck): reject unsupported bridge protocol versions * fix(deck): retry keyboard navigation through transition locks
526 lines
21 KiB
TypeScript
526 lines
21 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { JSDOM } from 'jsdom';
|
|
import { buildSrcdoc } from '../../src/runtime/srcdoc';
|
|
|
|
// Regression coverage for "keyboard paging moves the slide but the page
|
|
// counter never changes" (plane issue 3c507f18, 0.14.1 acceptance).
|
|
//
|
|
// Decks generated outside the fixed framework skeleton track their slide
|
|
// index inside their own keyboard handler and render their own page counter
|
|
// from it. The bridge's slide navigation used to mutate classes/transforms
|
|
// directly, which moved the visible slide while the artifact's handler (and
|
|
// therefore its counter) never ran — the counter stayed on "1" forever. The
|
|
// mirror gap: when the iframe itself has focus, the artifact handler moves a
|
|
// transform track without touching slide attributes, so the bridge's
|
|
// MutationObserver never fired a report and the HOST page counter went stale
|
|
// instead.
|
|
|
|
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];
|
|
}
|
|
|
|
// Tolerant on purpose: returns null when the hook is absent so the fixture
|
|
// also runs against bridge builds that predate the registry hook (used for
|
|
// red/green verification of the regression below).
|
|
function extractDeckKeydownRegistryScript(srcdoc: string): string | null {
|
|
const match = srcdoc.match(/<script data-od-deck-keydown-registry>([\s\S]*?)<\/script>/);
|
|
return match && match[1] ? match[1] : null;
|
|
}
|
|
|
|
interface CustomDeckOptions {
|
|
/** Where the artifact registers its keydown handler. */
|
|
listenOn?: 'document' | 'window';
|
|
/**
|
|
* Defer the handler's state update to a later task (the
|
|
* requestAnimationFrame / setTimeout shape some generated decks use),
|
|
* instead of mutating synchronously inside the keydown listener.
|
|
*/
|
|
deferUpdate?: boolean;
|
|
/**
|
|
* Register the artifact handler before the deck bridge evaluates — the
|
|
* external <script src> runtime shape, where only the head-start keydown
|
|
* registry hook can observe the registration.
|
|
*/
|
|
registerBeforeBridge?: boolean;
|
|
/** Ignore navigation keys while an authored slide transition is running. */
|
|
navigationLockMs?: number;
|
|
/** Expose one artifact-owned direct-index control per slide. */
|
|
directIndexControls?: boolean;
|
|
/** Keep direct-index controls visible but ignore their click handlers. */
|
|
ignoreDirectIndexControls?: boolean;
|
|
/** Advertise a canonical deck protocol version in the source document. */
|
|
protocolVersion?: number;
|
|
}
|
|
|
|
function setupCustomCounterDeck(options: CustomDeckOptions = {}) {
|
|
const bodyHtml = `
|
|
<style>
|
|
html, body { margin: 0; }
|
|
body { overflow-x: hidden; }
|
|
.deck-shell { width: 100vw; overflow: hidden; }
|
|
.deck-track { display: flex; width: 300vw; }
|
|
.slide { flex: 0 0 100vw; }
|
|
.deck-pager { position: fixed; bottom: 20px; left: 50%; }
|
|
</style>
|
|
<div class="deck-shell">
|
|
<div class="deck-track" id="deck-track">
|
|
<section class="slide active">One</section>
|
|
<section class="slide">Two</section>
|
|
<section class="slide">Three</section>
|
|
</div>
|
|
</div>
|
|
<div class="deck-pager"><span id="pager-cur">1</span> / <span id="pager-total">3</span></div>
|
|
`;
|
|
const protocolAttribute = options.protocolVersion == null
|
|
? ''
|
|
: ` data-od-deck-protocol="${options.protocolVersion}"`;
|
|
const srcdoc = buildSrcdoc(`<!doctype html><html${protocolAttribute}><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();
|
|
Object.defineProperty(win, 'parent', {
|
|
configurable: true,
|
|
value: { postMessage: parentPostMessage },
|
|
});
|
|
Object.defineProperty(win, 'innerWidth', {
|
|
configurable: true,
|
|
value: 1000,
|
|
});
|
|
Object.defineProperty(win.document, 'scrollingElement', {
|
|
configurable: true,
|
|
value: win.document.documentElement,
|
|
});
|
|
|
|
// Production order: the keydown registry hook runs at the start of <head>,
|
|
// before any artifact script and before the deck bridge at the end of
|
|
// <body>.
|
|
const hookScript = extractDeckKeydownRegistryScript(srcdoc);
|
|
if (hookScript) new win.Function(hookScript).call(win);
|
|
|
|
// The artifact's own navigation runtime: internal index + self-rendered
|
|
// page counter, driven by its own keydown handler — the shape agent
|
|
// decks author when they do not copy the framework skeleton.
|
|
const slides = Array.from(win.document.querySelectorAll<HTMLElement>('.slide'));
|
|
const track = win.document.getElementById('deck-track') as HTMLElement;
|
|
const pagerCur = win.document.getElementById('pager-cur') as HTMLElement;
|
|
let active = 0;
|
|
let navigationLocked = false;
|
|
const visited: number[] = [];
|
|
function apply(index: number) {
|
|
active = Math.max(0, Math.min(slides.length - 1, index));
|
|
visited.push(active);
|
|
slides.forEach((slide, slideIndex) => {
|
|
slide.classList.toggle('is-active', slideIndex === active);
|
|
});
|
|
track.style.transform = `translateX(-${active * 100}vw)`;
|
|
pagerCur.textContent = String(active + 1);
|
|
}
|
|
function lockNavigation() {
|
|
if (!options.navigationLockMs) return;
|
|
navigationLocked = true;
|
|
win.setTimeout(() => {
|
|
navigationLocked = false;
|
|
}, options.navigationLockMs);
|
|
}
|
|
if (options.directIndexControls) {
|
|
const nav = win.document.createElement('div');
|
|
nav.className = 'deck-dots';
|
|
slides.forEach((_slide, index) => {
|
|
const dot = win.document.createElement('button');
|
|
dot.className = 'nav-dot';
|
|
dot.addEventListener('click', () => {
|
|
if (options.ignoreDirectIndexControls || navigationLocked) return;
|
|
apply(index);
|
|
lockNavigation();
|
|
});
|
|
nav.appendChild(dot);
|
|
});
|
|
win.document.body.appendChild(nav);
|
|
}
|
|
const onKeydown = (event: KeyboardEvent) => {
|
|
const key = event.key;
|
|
if (key !== 'ArrowRight' && key !== 'ArrowLeft' && key !== 'Home' && key !== 'End') return;
|
|
if (navigationLocked) return;
|
|
// Recompute from the live index inside the step, the way generated decks
|
|
// do: when the update is deferred, every extra key event the bridge leaks
|
|
// in becomes a visible extra advance.
|
|
const step = () => {
|
|
if (key === 'ArrowRight') apply(active + 1);
|
|
else if (key === 'ArrowLeft') apply(active - 1);
|
|
else if (key === 'Home') apply(0);
|
|
else apply(slides.length - 1);
|
|
};
|
|
if (options.deferUpdate) win.setTimeout(step, 0);
|
|
else step();
|
|
lockNavigation();
|
|
};
|
|
const registerArtifactHandler = () => {
|
|
const listenTarget = options.listenOn === 'window' ? win : win.document;
|
|
listenTarget.addEventListener('keydown', onKeydown as EventListener);
|
|
apply(0);
|
|
};
|
|
const evaluateBridge = () => new win.Function(script).call(win);
|
|
if (options.registerBeforeBridge) {
|
|
// External-script deck shape: the keyboard runtime registers before the
|
|
// bridge executes, so only the head-start registry hook observes it.
|
|
registerArtifactHandler();
|
|
evaluateBridge();
|
|
} else {
|
|
// Late-initializing deck shape: the registration flows through the
|
|
// bridge's own patched addEventListener.
|
|
evaluateBridge();
|
|
registerArtifactHandler();
|
|
}
|
|
|
|
return { win, parentPostMessage, track, pagerCur, visited };
|
|
}
|
|
|
|
function setupSlashHashDeck() {
|
|
const bodyHtml = `
|
|
<section class="slide active">One</section>
|
|
<section class="slide">Two</section>
|
|
<section class="slide">Three</section>
|
|
<span id="pager-cur">1</span>
|
|
`;
|
|
const artifactScript = `
|
|
window.addEventListener('hashchange', function () {
|
|
var match = location.hash.match(/^#\\/(\\d+)$/);
|
|
if (match) window.__artifactGo(Number(match[1]) - 1);
|
|
});
|
|
function artifactRoute(index) {
|
|
var target = '#/' + (index + 1);
|
|
location.hash = target;
|
|
}
|
|
`;
|
|
const srcdoc = buildSrcdoc(
|
|
`<!doctype html><html><body>${bodyHtml}<script>${artifactScript}</script></body></html>`,
|
|
{ deck: true },
|
|
);
|
|
const bridgeScript = extractDeckBridgeScript(srcdoc);
|
|
const dom = new JSDOM(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
|
|
runScripts: 'outside-only',
|
|
pretendToBeVisual: true,
|
|
url: 'https://preview.test/',
|
|
});
|
|
const win = dom.window;
|
|
const replaceState = vi.spyOn(win.history, 'replaceState');
|
|
const parentPostMessage = vi.fn();
|
|
Object.defineProperty(win, 'parent', {
|
|
configurable: true,
|
|
value: { postMessage: parentPostMessage },
|
|
});
|
|
const slides = Array.from(win.document.querySelectorAll<HTMLElement>('.slide'));
|
|
const pagerCur = win.document.getElementById('pager-cur') as HTMLElement;
|
|
let current = 0;
|
|
const go = (index: number) => {
|
|
current = Math.max(0, Math.min(slides.length - 1, index));
|
|
slides.forEach((slide, slideIndex) => {
|
|
slide.classList.toggle('active', slideIndex === current);
|
|
});
|
|
pagerCur.textContent = String(current + 1);
|
|
};
|
|
Object.defineProperty(win, '__artifactGo', {
|
|
configurable: true,
|
|
value: go,
|
|
});
|
|
win.addEventListener('hashchange', () => {
|
|
const match = win.location.hash.match(/^#\/(\d+)$/);
|
|
if (match) go(Number(match[1]) - 1);
|
|
});
|
|
new win.Function(bridgeScript).call(win);
|
|
|
|
return {
|
|
current: () => current,
|
|
pagerCur,
|
|
parentPostMessage,
|
|
replaceState,
|
|
win,
|
|
};
|
|
}
|
|
|
|
function slideStatesOf(parentPostMessage: ReturnType<typeof vi.fn>) {
|
|
return parentPostMessage.mock.calls
|
|
.map((call) => call[0])
|
|
.filter((message) => message?.type === 'od:slide-state');
|
|
}
|
|
|
|
describe('deck bridge - keyboard paging keeps page counters in sync', () => {
|
|
it('host-driven navigation runs the artifact keyboard handler so the artifact page counter advances', async () => {
|
|
const { win, parentPostMessage, track, pagerCur } = setupCustomCounterDeck();
|
|
|
|
win.dispatchEvent(new win.MessageEvent('message', {
|
|
data: { type: 'od:slide', action: 'next' },
|
|
}));
|
|
await new Promise<void>((resolve) => win.setTimeout(resolve, 360));
|
|
|
|
// The artifact's own handler must have moved the deck (it writes the
|
|
// transform) and updated its own counter — direct class/transform
|
|
// mutation from the bridge leaves the counter stale on "1".
|
|
expect(track.style.transform).toBe('translateX(-100vw)');
|
|
expect(pagerCur.textContent).toBe('2');
|
|
expect(slideStatesOf(parentPostMessage).at(-1)).toMatchObject({ active: 1, count: 3 });
|
|
});
|
|
|
|
it('one host request advances a deferred-handler deck exactly once (no double advance, no direct-DOM fallback)', async () => {
|
|
// The artifact defers its state update to a later task and listens on
|
|
// window — the shape where a synchronous probe is wrong twice over: the
|
|
// bridge sees no immediate movement, so (a) it re-dispatches the key at
|
|
// document, which propagates back to the window listener as a second
|
|
// press, and (b) it falls back to direct DOM mutation while the deferred
|
|
// handler is still pending. The probe must instead wait out a bounded
|
|
// window before concluding the artifact did not respond.
|
|
const { win, parentPostMessage, track, pagerCur } = setupCustomCounterDeck({
|
|
listenOn: 'window',
|
|
deferUpdate: true,
|
|
});
|
|
|
|
win.dispatchEvent(new win.MessageEvent('message', {
|
|
data: { type: 'od:slide', action: 'next' },
|
|
}));
|
|
await new Promise<void>((resolve) => win.setTimeout(resolve, 360));
|
|
|
|
// Exactly one advance: slide 2, not slide 3, and the artifact's own
|
|
// counter agrees with the visible slide.
|
|
expect(track.style.transform).toBe('translateX(-100vw)');
|
|
expect(pagerCur.textContent).toBe('2');
|
|
expect(slideStatesOf(parentPostMessage).at(-1)).toMatchObject({ active: 1, count: 3 });
|
|
});
|
|
|
|
it('iframe-focused keyboard navigation on a transform-track deck still reports slide state to the host', async () => {
|
|
const { win, parentPostMessage } = setupCustomCounterDeck();
|
|
// Let the bridge's delayed initial-load report fire first, so the
|
|
// assertion below can only be satisfied by a report triggered by the
|
|
// keyboard navigation itself.
|
|
await new Promise<void>((resolve) => win.setTimeout(resolve, 360));
|
|
parentPostMessage.mockClear();
|
|
|
|
// Focus lives inside the iframe: the artifact handler consumes the key
|
|
// and moves the track. No slide attribute changes, no scroll event —
|
|
// the bridge must still notice and report, or the host counter goes
|
|
// stale.
|
|
win.document.dispatchEvent(new win.KeyboardEvent('keydown', {
|
|
key: 'ArrowRight',
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}));
|
|
await new Promise<void>((resolve) => win.setTimeout(resolve, 240));
|
|
|
|
expect(slideStatesOf(parentPostMessage).at(-1)).toMatchObject({ active: 1, count: 3 });
|
|
});
|
|
|
|
it('probes artifact keys when the keyboard runtime registered before the bridge (script-src deck shape)', async () => {
|
|
// Decks like design-templates/html-ppt load their keyboard runtime from
|
|
// an external <script src="../assets/runtime.js">: the inline html bytes
|
|
// carry no keydown token (so the build-time seed stays false) and the
|
|
// listener registers before the deck bridge executes (so the bridge's
|
|
// own addEventListener patches install too late). Only the head-start
|
|
// registry hook can classify these decks as keyboard-navigable.
|
|
const { win, parentPostMessage, track, pagerCur } = setupCustomCounterDeck({
|
|
registerBeforeBridge: true,
|
|
});
|
|
|
|
win.dispatchEvent(new win.MessageEvent('message', {
|
|
data: { type: 'od:slide', action: 'next' },
|
|
}));
|
|
await new Promise<void>((resolve) => win.setTimeout(resolve, 360));
|
|
|
|
expect(track.style.transform).toBe('translateX(-100vw)');
|
|
expect(pagerCur.textContent).toBe('2');
|
|
expect(slideStatesOf(parentPostMessage).at(-1)).toMatchObject({ active: 1, count: 3 });
|
|
});
|
|
|
|
it('reaches a selected thumbnail before the direct-index retry window', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const { win, parentPostMessage, track, pagerCur, visited } = setupCustomCounterDeck({
|
|
directIndexControls: true,
|
|
ignoreDirectIndexControls: true,
|
|
listenOn: 'window',
|
|
});
|
|
visited.length = 0;
|
|
|
|
win.dispatchEvent(new win.MessageEvent('message', {
|
|
data: { type: 'od:slide', action: 'go', index: 2 },
|
|
}));
|
|
await vi.advanceTimersByTimeAsync(1499);
|
|
|
|
// The authored dots deliberately ignore clicks, matching the reported
|
|
// legacy deck. Keyboard navigation must still reach the target before
|
|
// the bridge's 1.5s direct-index retry window can expire.
|
|
expect(track.style.transform).toBe('translateX(-200vw)');
|
|
expect(pagerCur.textContent).toBe('3');
|
|
expect(win.document.querySelectorAll('.slide')[2]?.classList.contains('is-active')).toBe(true);
|
|
expect(visited).toEqual([1, 2]);
|
|
expect(slideStatesOf(parentPostMessage).at(-1)).toMatchObject({ active: 2, count: 3 });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('retries a proven keyboard path through an authored transition lock', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const { win, parentPostMessage, track, pagerCur, visited } = setupCustomCounterDeck({
|
|
navigationLockMs: 120,
|
|
directIndexControls: true,
|
|
ignoreDirectIndexControls: true,
|
|
listenOn: 'window',
|
|
});
|
|
visited.length = 0;
|
|
|
|
win.dispatchEvent(new win.MessageEvent('message', {
|
|
data: { type: 'od:slide', action: 'go', index: 2 },
|
|
}));
|
|
await vi.advanceTimersByTimeAsync(499);
|
|
|
|
expect(track.style.transform).toBe('translateX(-200vw)');
|
|
expect(pagerCur.textContent).toBe('3');
|
|
expect(win.document.querySelectorAll('.slide')[2]?.classList.contains('is-active')).toBe(true);
|
|
expect(visited).toEqual([1, 2]);
|
|
expect(slideStatesOf(parentPostMessage).at(-1)).toMatchObject({ active: 2, count: 3 });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('ignores future-version bridge commands while accepting legacy and v1 navigation', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const { win, track, pagerCur, visited } = setupCustomCounterDeck({
|
|
listenOn: 'window',
|
|
protocolVersion: 1,
|
|
});
|
|
visited.length = 0;
|
|
|
|
win.dispatchEvent(new win.MessageEvent('message', {
|
|
data: { type: 'od:slide', action: 'go', index: 2, protocolVersion: 2 },
|
|
}));
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(track.style.transform).toBe('translateX(-0vw)');
|
|
expect(pagerCur.textContent).toBe('1');
|
|
expect(visited).toEqual([]);
|
|
|
|
win.dispatchEvent(new win.MessageEvent('message', {
|
|
data: { type: 'od:slide', action: 'next' },
|
|
}));
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(pagerCur.textContent).toBe('2');
|
|
|
|
win.dispatchEvent(new win.MessageEvent('message', {
|
|
data: { type: 'od:slide', action: 'next', protocolVersion: 1 },
|
|
}));
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(track.style.transform).toBe('translateX(-200vw)');
|
|
expect(pagerCur.textContent).toBe('3');
|
|
expect(visited).toEqual([1, 2]);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('keeps keyboard-only deck state in sync across direct selection and later paging', async () => {
|
|
const { win, track, pagerCur, visited } = setupCustomCounterDeck();
|
|
visited.length = 0;
|
|
|
|
// This artifact deliberately exposes no nav dots, indexed controls, hash
|
|
// route, or public goTo API. Its keyboard handler is the only owner of the
|
|
// private page index that drives both the canvas and its own counter.
|
|
win.dispatchEvent(new win.MessageEvent('message', {
|
|
data: { type: 'od:slide', action: 'go', index: 2 },
|
|
}));
|
|
await vi.waitFor(() => {
|
|
expect(track.style.transform).toBe('translateX(-200vw)');
|
|
expect(pagerCur.textContent).toBe('3');
|
|
});
|
|
expect(visited).toEqual([1, 2]);
|
|
|
|
win.dispatchEvent(new win.MessageEvent('message', {
|
|
data: { type: 'od:slide', action: 'next' },
|
|
}));
|
|
await vi.waitFor(() => {
|
|
expect(track.style.transform).toBe('translateX(-200vw)');
|
|
expect(pagerCur.textContent).toBe('3');
|
|
expect(visited).toEqual([1, 2, 2]);
|
|
});
|
|
|
|
win.dispatchEvent(new win.MessageEvent('message', {
|
|
data: { type: 'od:slide', action: 'prev' },
|
|
}));
|
|
await vi.waitFor(() => {
|
|
expect(track.style.transform).toBe('translateX(-100vw)');
|
|
expect(pagerCur.textContent).toBe('2');
|
|
});
|
|
});
|
|
|
|
it('preserves slash-hash routes for immediate artifact-owned thumbnail jumps', async () => {
|
|
const { current, pagerCur, parentPostMessage, replaceState, win } = setupSlashHashDeck();
|
|
|
|
win.dispatchEvent(new win.MessageEvent('message', {
|
|
data: { type: 'od:slide', action: 'go', index: 2 },
|
|
}));
|
|
await new Promise<void>((resolve) => win.setTimeout(resolve, 100));
|
|
|
|
expect(win.location.hash).toBe('#/3');
|
|
expect(current()).toBe(2);
|
|
expect(replaceState).toHaveBeenCalledWith(null, '', '#/3');
|
|
expect(pagerCur.textContent).toBe('3');
|
|
expect(slideStatesOf(parentPostMessage).at(-1)).toMatchObject({ active: 2, count: 3 });
|
|
});
|
|
|
|
it('retries only the latest direct target while authored navigation is locked', async () => {
|
|
const { win, parentPostMessage, track, pagerCur, visited } = setupCustomCounterDeck({
|
|
navigationLockMs: 120,
|
|
directIndexControls: true,
|
|
});
|
|
visited.length = 0;
|
|
|
|
win.dispatchEvent(new win.MessageEvent('message', {
|
|
data: { type: 'od:slide', action: 'go', index: 2 },
|
|
}));
|
|
await new Promise<void>((resolve) => win.setTimeout(resolve, 20));
|
|
win.dispatchEvent(new win.MessageEvent('message', {
|
|
data: { type: 'od:slide', action: 'go', index: 0 },
|
|
}));
|
|
await new Promise<void>((resolve) => win.setTimeout(resolve, 500));
|
|
|
|
expect(track.style.transform).toBe('translateX(-0vw)');
|
|
expect(pagerCur.textContent).toBe('1');
|
|
expect(win.document.querySelectorAll('.slide')[0]?.classList.contains('is-active')).toBe(true);
|
|
// The second selection arrives before the document-listener probe has
|
|
// moved at all, so it cancels the stale jump and keeps slide 1 selected.
|
|
expect(visited).toEqual([]);
|
|
expect(slideStatesOf(parentPostMessage).at(-1)).toMatchObject({ active: 0, count: 3 });
|
|
});
|
|
|
|
it('seeds the keyboard-navigation flag from the artifact source, ignoring od-injected bridge scripts', () => {
|
|
const slideMarkup = '<section class="slide active">One</section>';
|
|
const navScript =
|
|
'<script>document.addEventListener("keydown", (e) => { if (e.key === "ArrowRight") {} });</script>';
|
|
// previewFocusGuard injects a bridge that itself registers a keydown
|
|
// listener; the seed must come from the artifact source alone.
|
|
const withNav = buildSrcdoc(
|
|
`<!doctype html><html><body>${slideMarkup}${navScript}</body></html>`,
|
|
{ deck: true, previewFocusGuard: true },
|
|
);
|
|
expect(withNav).toContain('odHasArtifactKeydownListener = true');
|
|
|
|
const withoutNav = buildSrcdoc(
|
|
`<!doctype html><html><body>${slideMarkup}</body></html>`,
|
|
{ deck: true, previewFocusGuard: true },
|
|
);
|
|
expect(withoutNav).toContain('odHasArtifactKeydownListener = false');
|
|
});
|
|
});
|