mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
359 lines
14 KiB
TypeScript
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');
|
|
});
|
|
});
|