open-design/apps/web/tests/runtime/srcdoc-bridge-empty-targets.test.ts

359 lines
14 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#890. The Examples / Tweaks
// preview iframe runs the bridge script generated by `buildSrcdoc`. When
// the agent emits an artifact without `data-od-id` / `data-screen-label`
// (e.g. a freeform PRD → HTML pass through a Claude-Code-compatible CLI
// without a skill), the bridge must:
//
// 1. Still post `od:comment-targets` with `targets: []` so the host
// can detect the empty-annotation state and surface a clearer
// hint than "Click any element with `data-od-id` …" — without
// this, FileViewer's `liveCommentTargets` map never updates and
// the hint banner sticks at its instructive-default state even
// though there's nothing to click.
// 2. Drop click events on unannotated elements without posting an
// `od:comment-target` — the click handler walks up to <html>,
// finds nothing tagged, and must bail. Posting a synthetic id
// here would change save-to-source semantics for inspect
// overrides (the persisted CSS keys off the same elementId), so
// this test pins the no-fallback contract.
//
// The host-side hint switch lives in `apps/web/src/components/FileViewer.tsx`
// (search for `inspect-empty-hint-no-targets`); these tests pin the
// signal it depends on.
function extractBridgeScript(srcdoc: string): string {
// The bridge is wrapped in `<script data-od-selection-bridge>(function(){…})()</script>`.
const match = srcdoc.match(
/<script data-od-selection-bridge>([\s\S]*?)<\/script>/,
);
if (!match || !match[1]) {
throw new Error('selection bridge script not found in srcdoc');
}
return match[1];
}
function extractSelectionBridgeStyle(srcdoc: string): string {
const match = srcdoc.match(
/<style data-od-selection-bridge-style>[\s\S]*?<\/style>/,
);
return match?.[0] ?? '';
}
function markVisible(win: { document: Document }, selector: string): void {
const el = win.document.querySelector(selector);
if (!el) throw new Error(`selector not found: ${selector}`);
Object.defineProperty(el, 'getBoundingClientRect', {
configurable: true,
value: () => ({
x: 10,
y: 20,
width: 120,
height: 48,
top: 20,
right: 130,
bottom: 68,
left: 10,
toJSON: () => ({}),
}),
});
}
function setupBridgeDom(
bodyHtml: string,
mode: 'inspect' | 'comment',
visibleSelectors: string[] = [],
) {
const srcdoc = buildSrcdoc(`<!doctype html><html><body>${bodyHtml}</body></html>`, {
inspectBridge: mode === 'inspect',
commentBridge: mode === 'comment',
});
const script = extractBridgeScript(srcdoc);
const bridgeStyle = extractSelectionBridgeStyle(srcdoc);
// Build a fresh JSDOM that mirrors the iframe runtime: `window.parent`
// is what the bridge calls into, and we spy on its postMessage to
// capture the messages it would emit to the host.
const dom = new JSDOM(
`<!doctype html><html><head>${bridgeStyle}</head><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 },
});
visibleSelectors.forEach((selector) => markVisible(win, selector));
// Run the bridge IIFE inside the jsdom window so its `document` /
// `window` refer to our DOM. We don't use `runScripts: 'dangerously'`
// — the IIFE is our trusted source, evaluated by `Function` in the
// jsdom realm.
const evaluate = new win.Function(script);
evaluate.call(win);
return { dom, win, parentPostMessage };
}
describe('selection bridge — empty annotation surface (#890)', () => {
it('posts od:comment-targets with an empty list when the artifact has no annotated elements', async () => {
const { win, parentPostMessage } = setupBridgeDom(
// PRD-style mockup: real DOM, zero `data-od-id` / `data-screen-label`.
'<header><h1>Acme PRD</h1></header><main><section><p>Goals</p></section></main>',
'inspect',
);
// The bridge schedules the initial postTargets via setTimeout(0)
// after enabling the mode. Drain microtasks + the timer so the
// message lands before we assert.
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
const targetMessages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((message) => message?.type === 'od:comment-targets');
expect(targetMessages.length).toBeGreaterThan(0);
// Every targets-broadcast must be an empty list — there's nothing
// annotated to enumerate. If a future change starts inventing
// synthetic ids in `allTargets()`, this assertion fires before
// the host's empty-state hint silently disappears.
for (const message of targetMessages) {
expect(message.targets).toEqual([]);
}
});
it('does not post od:comment-target when the user clicks an unannotated element', async () => {
const { win, parentPostMessage } = setupBridgeDom(
'<header><h1 id="title">Acme PRD</h1></header>',
'inspect',
);
// Wait for bridge boot.
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
parentPostMessage.mockClear();
// Click directly on the <h1> — it has no `data-od-id` / `data-screen-label`,
// and no ancestor does either. The bridge's `closestTarget()` walks up
// to <html> and returns null; the click handler must bail before
// emitting a comment-target message.
const target = win.document.getElementById('title');
expect(target).not.toBeNull();
target!.dispatchEvent(
new win.MouseEvent('click', { bubbles: true, cancelable: true }),
);
const clickMessages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((message) => message?.type === 'od:comment-target');
expect(clickMessages).toEqual([]);
});
it('still posts od:comment-target when the click traverses up to an annotated ancestor', async () => {
// Sanity check / contract pin: the no-op behavior above is specific
// to the no-annotation case. When ANY ancestor carries `data-od-id`,
// the click must still resolve to that ancestor — this is the
// happy path the gallery has shipped since the bridge landed.
const { win, parentPostMessage } = setupBridgeDom(
'<main data-od-id="hero"><h1 id="title">Hero</h1></main>',
'inspect',
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
parentPostMessage.mockClear();
win.document.getElementById('title')!.dispatchEvent(
new win.MouseEvent('click', { bubbles: true, cancelable: true }),
);
const clickMessages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((message) => message?.type === 'od:comment-target');
expect(clickMessages).toHaveLength(1);
expect(clickMessages[0].elementId).toBe('hero');
expect(clickMessages[0].clickedDescendant).toEqual({
label: 'h1',
text: 'Hero',
});
});
it('does not add clickedDescendant when the annotated target itself is clicked', async () => {
const { win, parentPostMessage } = setupBridgeDom(
'<main data-od-id="hero">Hero</main>',
'inspect',
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
parentPostMessage.mockClear();
win.document.querySelector('[data-od-id="hero"]')!.dispatchEvent(
new win.MouseEvent('click', { bubbles: true, cancelable: true }),
);
const clickMessages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((message) => message?.type === 'od:comment-target');
expect(clickMessages).toHaveLength(1);
expect(clickMessages[0].elementId).toBe('hero');
expect(clickMessages[0].clickedDescendant).toBeUndefined();
});
it('does not invent fallback targets in Inspect mode for unannotated elements', async () => {
const { win, parentPostMessage } = setupBridgeDom(
'<main><button id="cta">Launch</button></main>',
'inspect',
['#cta'],
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
parentPostMessage.mockClear();
win.document.getElementById('cta')!.dispatchEvent(
new win.MouseEvent('click', { bubbles: true, cancelable: true }),
);
const clickMessages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((message) => message?.type === 'od:comment-target');
expect(clickMessages).toEqual([]);
});
it('uses a DOM selector fallback for Picker mode when elements are unannotated', async () => {
const { win, parentPostMessage } = setupBridgeDom(
'<main><button id="cta">Launch</button></main>',
'comment',
['#cta'],
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
parentPostMessage.mockClear();
win.document.getElementById('cta')!.dispatchEvent(
new win.MouseEvent('click', { bubbles: true, cancelable: true }),
);
const clickMessages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((message) => message?.type === 'od:comment-target');
expect(clickMessages).toHaveLength(1);
expect(clickMessages[0].elementId).toBe('dom:body > main:nth-of-type(1) > button:nth-of-type(1)');
expect(clickMessages[0].selector).toBe('body > main:nth-of-type(1) > button:nth-of-type(1)');
expect(clickMessages[0].text).toBe('Launch');
});
it('does not use Picker DOM fallback on mixed annotated and unannotated pages', async () => {
const { win, parentPostMessage } = setupBridgeDom(
'<main><section data-od-id="hero">Hero</section><button id="cta">Launch</button></main>',
'comment',
['#cta'],
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
parentPostMessage.mockClear();
win.document.getElementById('cta')!.dispatchEvent(
new win.MouseEvent('click', { bubbles: true, cancelable: true }),
);
const clickMessages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((message) => message?.type === 'od:comment-target');
expect(clickMessages).toEqual([]);
});
it('broadcasts DOM fallback targets in comment mode so Pods can hit-test unannotated pages', async () => {
const { win, parentPostMessage } = setupBridgeDom(
'<main><section id="card"><h2>Card</h2></section></main>',
'comment',
['#card'],
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
const targetMessages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((message) => message?.type === 'od:comment-targets');
expect(targetMessages.length).toBeGreaterThan(0);
const last = targetMessages.at(-1);
expect(last.targets).toEqual(
expect.arrayContaining([
expect.objectContaining({
elementId: 'dom:body > main:nth-of-type(1) > section:nth-of-type(1)',
selector: 'body > main:nth-of-type(1) > section:nth-of-type(1)',
text: 'Card',
}),
]),
);
});
it('applies pointer-events none to body iframes while Comment picker is active', async () => {
const { win } = setupBridgeDom(
'<article data-od-id="card"><div>Tablet edition</div><iframe id="f" src="about:blank"></iframe></article>',
'comment',
['#f'],
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
const iframe = win.document.getElementById('f');
expect(iframe).not.toBeNull();
expect(win.getComputedStyle(iframe!).pointerEvents).toBe('none');
});
it('applies pointer-events none to body iframes while Inspect picker is active', async () => {
const { win } = setupBridgeDom(
'<article data-od-id="card"><iframe id="f" src="about:blank"></iframe></article>',
'inspect',
['#f'],
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
expect(win.getComputedStyle(win.document.getElementById('f')!).pointerEvents).toBe('none');
});
it('restores iframe pointer events after Comment mode is disabled', async () => {
const { win } = setupBridgeDom(
'<article data-od-id="card"><iframe id="f" src="about:blank"></iframe></article>',
'comment',
['#f'],
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
const iframe = win.document.getElementById('f')!;
expect(win.getComputedStyle(iframe).pointerEvents).toBe('none');
win.dispatchEvent(
new win.MessageEvent('message', {
data: { type: 'od:comment-mode', enabled: false, mode: 'picker' },
}),
);
expect(win.getComputedStyle(iframe).pointerEvents).toBe('auto');
});
it('posts od:comment-target for the annotated card when the device-frame iframe is clicked', async () => {
const { win, parentPostMessage } = setupBridgeDom(
'<article data-od-id="tablet-card" class="frame-card"><div class="meta">Tablet edition</div><iframe id="f" class="tablet-frame" title="Tablet edition" src="about:blank"></iframe></article>',
'comment',
['#f', '.frame-card'],
);
await new Promise<void>((resolve) => win.setTimeout(resolve, 10));
parentPostMessage.mockClear();
win.document.getElementById('f')!.dispatchEvent(
new win.MouseEvent('click', { bubbles: true, cancelable: true }),
);
const clickMessages = parentPostMessage.mock.calls
.map((call) => call[0])
.filter((message) => message?.type === 'od:comment-target');
expect(clickMessages).toHaveLength(1);
expect(clickMessages[0].elementId).toBe('tablet-card');
});
});