mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
7d6dd29c08
Opt-in bridgeScriptUrl on HtmlViewer so multi-chunk hosts can serve the 185 KB bridge as a hashed asset instead of an inlined string; the inline bridge stays the default and Plannotator's own builds, the Pi and OpenCode copies, and the live-app proxy are unchanged apart from a protocolVersion field on the bridge's ready message. The parent checks the version (one warning naming both versions; on the URL path a dismissible banner plus onBridgeUnavailable while the old bridge keeps working), arms a ready timeout on the URL path only, and resolves the URL against the parent document before it reaches the frame so a page's own base href cannot redirect the load. A prepack-generated bridge-script.asset.js (byte-for-byte the inline string) and a bridge-script.lite.ts alias target ship in the tarball. CSP and CORP requirements for hosts are documented. AI-assisted (Claude) under maintainer direction.
1527 lines
62 KiB
TypeScript
1527 lines
62 KiB
TypeScript
/**
|
|
* Bridge-protocol contract for HTML pinpoint mode (DOM-gated).
|
|
*
|
|
* The bridge script runs inside a sandboxed iframe rendering arbitrary HTML,
|
|
* so everything it posts must be validated and size-capped before it reaches
|
|
* React state or the annotation model. These tests cover the pinpoint
|
|
* additions: the element-anchor DTO, the pinpoint click-to-pin flow (straight
|
|
* to the comment composer, skipping the toolbar), and anchor propagation onto
|
|
* created annotations.
|
|
*/
|
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import React from 'react';
|
|
import { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import type { Annotation } from '../../types';
|
|
import { AnnotationType } from '../../types';
|
|
import { BRIDGE_PROTOCOL_VERSION } from './bridge-script';
|
|
|
|
const hasDom = typeof document !== 'undefined';
|
|
const hookModule = hasDom ? await import('./useHtmlAnnotation') : null;
|
|
const htmlViewerModule = hasDom ? await import('./HtmlViewer') : null;
|
|
|
|
// Unmount every root before clearing the DOM: leaving HtmlViewer instances
|
|
// mounted would keep their window message listeners alive and leak into other
|
|
// test files sharing this process.
|
|
const mountedRoots: Array<{ unmount: () => void }> = [];
|
|
|
|
afterEach(async () => {
|
|
if (!hasDom) return;
|
|
await act(async () => {
|
|
for (const root of mountedRoots.splice(0)) root.unmount();
|
|
});
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
describe.if(hasDom)('parseHtmlElementAnchor (validated DTO)', () => {
|
|
test('accepts a well-formed anchor', () => {
|
|
expect(hookModule!.parseHtmlElementAnchor({
|
|
selector: '#hero > p:nth-of-type(2)',
|
|
tagName: 'p',
|
|
text: 'Some text',
|
|
})).toEqual({ selector: '#hero > p:nth-of-type(2)', tagName: 'p', text: 'Some text' });
|
|
});
|
|
|
|
test('accepts an anchor without a text snapshot', () => {
|
|
expect(hookModule!.parseHtmlElementAnchor({ selector: 'main', tagName: 'main' }))
|
|
.toEqual({ selector: 'main', tagName: 'main' });
|
|
});
|
|
|
|
test('accepts an empty text snapshot (stable-identity text-less anchors)', () => {
|
|
// Text-less elements anchor only through a stable-identity selector
|
|
// (#id / data-* identity attrs) and carry an empty snapshot; the bridge
|
|
// treats an empty snapshot on a WEAK selector as a rejection at restore
|
|
// time, so passing it through the DTO is safe.
|
|
expect(hookModule!.parseHtmlElementAnchor({
|
|
selector: 'span[data-testid="close-btn"]',
|
|
tagName: 'span',
|
|
text: '',
|
|
})).toEqual({ selector: 'span[data-testid="close-btn"]', tagName: 'span', text: '' });
|
|
});
|
|
|
|
test('rejects non-records and missing/empty fields', () => {
|
|
expect(hookModule!.parseHtmlElementAnchor(null)).toBeNull();
|
|
expect(hookModule!.parseHtmlElementAnchor('main')).toBeNull();
|
|
expect(hookModule!.parseHtmlElementAnchor({})).toBeNull();
|
|
expect(hookModule!.parseHtmlElementAnchor({ selector: '', tagName: 'p' })).toBeNull();
|
|
expect(hookModule!.parseHtmlElementAnchor({ selector: 'p' })).toBeNull();
|
|
expect(hookModule!.parseHtmlElementAnchor({ selector: 'p', tagName: 42 })).toBeNull();
|
|
});
|
|
|
|
test('rejects oversized fields (size caps)', () => {
|
|
expect(hookModule!.parseHtmlElementAnchor({
|
|
selector: 'x'.repeat(1025),
|
|
tagName: 'p',
|
|
})).toBeNull();
|
|
expect(hookModule!.parseHtmlElementAnchor({
|
|
selector: 'p',
|
|
tagName: 'x'.repeat(65),
|
|
})).toBeNull();
|
|
expect(hookModule!.parseHtmlElementAnchor({
|
|
selector: 'p',
|
|
tagName: 'p',
|
|
text: 'x'.repeat(401),
|
|
})).toBeNull();
|
|
});
|
|
|
|
test('carries a valid normalized marker point through validation', () => {
|
|
expect(hookModule!.parseHtmlElementAnchor({
|
|
selector: '#geo',
|
|
tagName: 'div',
|
|
point: { x: 0.25, y: 0.5 },
|
|
})).toEqual({ selector: '#geo', tagName: 'div', point: { x: 0.25, y: 0.5 } });
|
|
});
|
|
|
|
test('clamps out-of-range point values into 0..1', () => {
|
|
expect(hookModule!.parseHtmlElementAnchor({
|
|
selector: '#geo',
|
|
tagName: 'div',
|
|
point: { x: 7, y: -3 },
|
|
})).toEqual({ selector: '#geo', tagName: 'div', point: { x: 1, y: 0 } });
|
|
});
|
|
|
|
test('drops a malformed point without rejecting the anchor it rides on', () => {
|
|
for (const point of [
|
|
{ x: 'left', y: 0.5 },
|
|
{ x: Infinity, y: 0.5 },
|
|
{ x: NaN, y: 0.5 },
|
|
{ x: 0.5 },
|
|
'center',
|
|
42,
|
|
null,
|
|
]) {
|
|
const parsed = hookModule!.parseHtmlElementAnchor({
|
|
selector: '#geo',
|
|
tagName: 'div',
|
|
point,
|
|
});
|
|
expect(parsed).toEqual({ selector: '#geo', tagName: 'div' });
|
|
expect((parsed as { point?: unknown }).point).toBeUndefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.if(hasDom)('parseBridgeMessage selection additions', () => {
|
|
const rect = { top: 10, left: 10, width: 100, height: 20 };
|
|
|
|
test('carries a validated anchor and the pinpoint flag', () => {
|
|
const parsed = hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-selection',
|
|
text: 'Hello',
|
|
rect,
|
|
anchor: { selector: 'p.intro', tagName: 'p', text: 'Hello' },
|
|
pinpoint: true,
|
|
});
|
|
expect(parsed).toMatchObject({
|
|
text: 'Hello',
|
|
pinpoint: true,
|
|
anchor: { selector: 'p.intro', tagName: 'p', text: 'Hello' },
|
|
});
|
|
});
|
|
|
|
test('a malformed anchor is dropped without rejecting the selection', () => {
|
|
const parsed = hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-selection',
|
|
text: 'Hello',
|
|
rect,
|
|
anchor: { selector: 42 },
|
|
pinpoint: 'yes',
|
|
});
|
|
expect(parsed).toMatchObject({ text: 'Hello', pinpoint: false });
|
|
expect((parsed as { anchor?: unknown }).anchor).toBeUndefined();
|
|
});
|
|
|
|
test('truncation never splits a surrogate pair at the cap boundary', () => {
|
|
// An astral character straddling the cut would leave a lone high
|
|
// surrogate that turns into U+FFFD once UTF-8-encoded (drafts, feedback,
|
|
// share URLs) — the cut must back off one unit instead.
|
|
const cap = hookModule!.MAX_SELECTION_TEXT_LENGTH;
|
|
const straddling = 'x'.repeat(cap - 1) + '\u{1F600}' + 'tail';
|
|
const parsed = hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-selection',
|
|
text: straddling,
|
|
rect,
|
|
}) as { text: string };
|
|
expect(parsed.text.length).toBe(cap - 1);
|
|
expect(parsed.text.endsWith('x')).toBe(true);
|
|
expect(/[\uD800-\uDBFF]$/.test(parsed.text)).toBe(false);
|
|
// A pair that fits entirely under the cap is untouched.
|
|
const fitting = 'y'.repeat(cap - 2) + '\u{1F600}';
|
|
const kept = hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-selection',
|
|
text: fitting,
|
|
rect,
|
|
}) as { text: string };
|
|
expect(kept.text).toBe(fitting);
|
|
});
|
|
|
|
test('selection text is truncated at the parse boundary, not rejected', () => {
|
|
// The page controls element text entirely, so one pinpoint click on a huge
|
|
// <pre> could otherwise ship an unbounded string into React state, drafts,
|
|
// exported feedback, and share URLs.
|
|
const cap = hookModule!.MAX_SELECTION_TEXT_LENGTH;
|
|
const parsed = hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-selection',
|
|
text: 'x'.repeat(cap + 590_000),
|
|
rect,
|
|
});
|
|
expect(parsed).not.toBeNull();
|
|
expect((parsed as { text: string }).text.length).toBe(cap);
|
|
// At or under the cap passes through untouched.
|
|
const exact = hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-selection',
|
|
text: 'y'.repeat(cap),
|
|
rect,
|
|
});
|
|
expect((exact as { text: string }).text).toBe('y'.repeat(cap));
|
|
});
|
|
});
|
|
|
|
describe.if(hasDom)('pinpoint click-to-pin flow', () => {
|
|
async function mountViewer(options: {
|
|
mode: 'selection' | 'redline' | 'comment';
|
|
onAdd: (ann: Annotation) => void;
|
|
}) {
|
|
if (!htmlViewerModule) throw new Error('DOM test environment is not registered');
|
|
const HtmlViewer = htmlViewerModule.HtmlViewer;
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
mountedRoots.push(root);
|
|
await act(async () => {
|
|
root.render(
|
|
<HtmlViewer
|
|
rawHtml="<html><body><p>Pinpoint target</p></body></html>"
|
|
annotations={[]}
|
|
onAddAnnotation={options.onAdd}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode={options.mode}
|
|
inputMethod="pinpoint"
|
|
/>,
|
|
);
|
|
});
|
|
const iframe = host.querySelector<HTMLIFrameElement>('iframe');
|
|
if (!iframe?.contentWindow) throw new Error('HTML iframe missing');
|
|
const postSelection = async (data: Record<string, unknown>) => {
|
|
await act(async () => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data,
|
|
}));
|
|
});
|
|
};
|
|
return { postSelection };
|
|
}
|
|
|
|
const selectionMessage = {
|
|
type: 'plannotator-bridge-selection',
|
|
text: 'Pinpoint target',
|
|
rect: { top: 10, left: 10, width: 120, height: 24 },
|
|
anchor: { selector: 'p:nth-of-type(1)', tagName: 'p', text: 'Pinpoint target' },
|
|
};
|
|
|
|
async function typeComment(value: string) {
|
|
const el = document.querySelector<HTMLTextAreaElement>('[data-comment-popover] textarea');
|
|
if (!el) throw new Error('composer textarea missing');
|
|
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value')?.set;
|
|
await act(async () => {
|
|
if (setter) setter.call(el, value);
|
|
else el.value = value;
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
});
|
|
}
|
|
|
|
async function saveComment() {
|
|
const button = Array.from(
|
|
document.querySelectorAll<HTMLButtonElement>('[data-comment-popover] button'),
|
|
).find((b) => b.textContent === 'Save');
|
|
if (!button) throw new Error('Save button missing');
|
|
await act(async () => {
|
|
button.click();
|
|
});
|
|
}
|
|
|
|
test('a pinpoint selection opens the comment composer, not the toolbar', async () => {
|
|
const { postSelection } = await mountViewer({ mode: 'selection', onAdd: () => {} });
|
|
await postSelection({ ...selectionMessage, pinpoint: true });
|
|
expect(document.querySelector('[data-comment-popover]')).not.toBeNull();
|
|
expect(document.querySelector('.annotation-toolbar')).toBeNull();
|
|
});
|
|
|
|
test('a plain drag selection still opens the markup toolbar', async () => {
|
|
const { postSelection } = await mountViewer({ mode: 'selection', onAdd: () => {} });
|
|
await postSelection({ ...selectionMessage, anchor: undefined });
|
|
expect(document.querySelector('.annotation-toolbar')).not.toBeNull();
|
|
expect(document.querySelector('[data-comment-popover]')).toBeNull();
|
|
});
|
|
|
|
test('the HTML selection toolbar is comment-only plus the restored thumbs-up: no Delete, no label picker', async () => {
|
|
// The restricted restore: exactly ONE label affordance (the hardcoded
|
|
// 👍 "Looks good") came back to HTML surfaces. Delete and the Zap
|
|
// picker staying gone is what preserves the comment-only ruling.
|
|
const { postSelection } = await mountViewer({ mode: 'selection', onAdd: () => {} });
|
|
await postSelection({ ...selectionMessage, anchor: undefined });
|
|
const toolbar = document.querySelector('.annotation-toolbar');
|
|
if (!toolbar) throw new Error('toolbar missing');
|
|
const titles = Array.from(toolbar.querySelectorAll('button')).map((b) => b.title);
|
|
expect(titles).toContain('Comment');
|
|
expect(titles).toContain('Looks good');
|
|
expect(titles).not.toContain('Delete');
|
|
expect(titles).not.toContain('Quick label');
|
|
});
|
|
|
|
test('the toolbar thumbs-up creates an isQuickLabel comment annotation', async () => {
|
|
// Guards the restricted handler wiring in HtmlViewer: the 👍 must
|
|
// produce the same labeled-comment shape the markdown surface produces,
|
|
// never a DELETION and never a bare comment.
|
|
const added: Annotation[] = [];
|
|
const { postSelection } = await mountViewer({ mode: 'selection', onAdd: (ann) => added.push(ann) });
|
|
await postSelection({ ...selectionMessage, anchor: undefined });
|
|
const toolbar = document.querySelector('.annotation-toolbar');
|
|
if (!toolbar) throw new Error('toolbar missing');
|
|
const thumbs = Array.from(toolbar.querySelectorAll<HTMLButtonElement>('button')).find(
|
|
(b) => b.title === 'Looks good',
|
|
);
|
|
if (!thumbs) throw new Error('thumbs-up missing');
|
|
await act(async () => thumbs.click());
|
|
expect(added).toHaveLength(1);
|
|
expect(added[0]?.type).toBe(AnnotationType.COMMENT);
|
|
expect(added[0]?.isQuickLabel).toBe(true);
|
|
expect(added[0]?.text).toBe('Looks good');
|
|
});
|
|
|
|
test('redline mode is CLAMPED on HTML surfaces: a pinpoint selection opens the composer instead of auto-deleting', async () => {
|
|
const added: Annotation[] = [];
|
|
const { postSelection } = await mountViewer({
|
|
mode: 'redline',
|
|
onAdd: (ann) => added.push(ann),
|
|
});
|
|
await postSelection({ ...selectionMessage, pinpoint: true });
|
|
// No DELETION was created; the comment composer owns the draft.
|
|
expect(added.length).toBe(0);
|
|
expect(document.querySelector('[data-comment-popover]')).not.toBeNull();
|
|
});
|
|
|
|
test('a bridge-posted modeOverride "redline" cannot force a DELETION (trust-boundary clamp)', async () => {
|
|
// A hostile page controls modeOverride, so the clamp must sit on the
|
|
// message path, not just on the host-passed mode.
|
|
const added: Annotation[] = [];
|
|
const { postSelection } = await mountViewer({
|
|
mode: 'selection',
|
|
onAdd: (ann) => added.push(ann),
|
|
});
|
|
await postSelection({ ...selectionMessage, pinpoint: true, modeOverride: 'redline' });
|
|
expect(added.length).toBe(0);
|
|
expect(document.querySelector('[data-comment-popover]')).not.toBeNull();
|
|
await typeComment('clamped');
|
|
await saveComment();
|
|
expect(added.length).toBe(1);
|
|
expect(added[0]!.type).toBe('COMMENT' as Annotation['type']);
|
|
});
|
|
|
|
test('a selection with a malformed anchor commits without one', async () => {
|
|
const added: Annotation[] = [];
|
|
const { postSelection } = await mountViewer({
|
|
mode: 'comment',
|
|
onAdd: (ann) => added.push(ann),
|
|
});
|
|
await postSelection({
|
|
...selectionMessage,
|
|
anchor: { selector: 'x'.repeat(2000), tagName: 'p' },
|
|
pinpoint: true,
|
|
});
|
|
await typeComment('no anchor');
|
|
await saveComment();
|
|
expect(added.length).toBe(1);
|
|
expect(added[0]!.htmlAnchor).toBeUndefined();
|
|
});
|
|
|
|
test('the anchor point (selected relative point) rides onto the committed annotation', async () => {
|
|
const added: Annotation[] = [];
|
|
const { postSelection } = await mountViewer({
|
|
mode: 'comment',
|
|
onAdd: (ann) => added.push(ann),
|
|
});
|
|
await postSelection({
|
|
...selectionMessage,
|
|
anchor: { ...selectionMessage.anchor, point: { x: 0.75, y: 0.1 } },
|
|
pinpoint: true,
|
|
});
|
|
await typeComment('with point');
|
|
await saveComment();
|
|
expect(added.length).toBe(1);
|
|
expect(added[0]!.originalText).toBe('Pinpoint target');
|
|
expect(added[0]!.htmlAnchor?.point).toEqual({ x: 0.75, y: 0.1 });
|
|
});
|
|
|
|
test('a hostile anchor point is dropped while the anchor itself commits', async () => {
|
|
const added: Annotation[] = [];
|
|
const { postSelection } = await mountViewer({
|
|
mode: 'comment',
|
|
onAdd: (ann) => added.push(ann),
|
|
});
|
|
await postSelection({
|
|
...selectionMessage,
|
|
anchor: { ...selectionMessage.anchor, point: { x: 'evil', y: [1] } },
|
|
pinpoint: true,
|
|
});
|
|
await typeComment('hostile point');
|
|
await saveComment();
|
|
expect(added.length).toBe(1);
|
|
expect(added[0]!.htmlAnchor).toEqual({
|
|
selector: 'p:nth-of-type(1)',
|
|
tagName: 'p',
|
|
text: 'Pinpoint target',
|
|
});
|
|
expect(added[0]!.htmlAnchor?.point).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe.if(hasDom)('ordered saved-annotation sync (placed-marker numbering)', () => {
|
|
async function mountWithAnnotations(annotations: Annotation[]) {
|
|
if (!htmlViewerModule) throw new Error('DOM test environment is not registered');
|
|
const HtmlViewer = htmlViewerModule.HtmlViewer;
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
mountedRoots.push(root);
|
|
await act(async () => {
|
|
root.render(
|
|
<HtmlViewer
|
|
rawHtml="<html><body><p>Sync target</p></body></html>"
|
|
annotations={annotations}
|
|
onAddAnnotation={() => {}}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode="selection"
|
|
inputMethod="pinpoint"
|
|
/>,
|
|
);
|
|
});
|
|
const iframe = host.querySelector<HTMLIFrameElement>('iframe');
|
|
if (!iframe?.contentWindow) throw new Error('HTML iframe missing');
|
|
const postedToIframe: Array<Record<string, unknown>> = [];
|
|
const realPost = iframe.contentWindow.postMessage.bind(iframe.contentWindow);
|
|
(iframe.contentWindow as unknown as { postMessage: (data: unknown) => void }).postMessage =
|
|
((data: unknown, ...rest: unknown[]) => {
|
|
if (data && typeof data === 'object') postedToIframe.push(data as Record<string, unknown>);
|
|
return (realPost as (...args: unknown[]) => unknown)(data, ...rest);
|
|
}) as typeof iframe.contentWindow.postMessage;
|
|
const postReady = async () => {
|
|
await act(async () => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data: { type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION },
|
|
}));
|
|
});
|
|
};
|
|
return { postReady, postedToIframe };
|
|
}
|
|
|
|
function ann(id: string, createdA: number, type = 'COMMENT' as Annotation['type']): Annotation {
|
|
return {
|
|
id,
|
|
blockId: '',
|
|
startOffset: 0,
|
|
endOffset: 0,
|
|
type,
|
|
originalText: 'x',
|
|
createdA,
|
|
} as Annotation;
|
|
}
|
|
|
|
test('on bridge ready, the ordered collection syncs export-matching numbers (globals occupy slots)', async () => {
|
|
const { AnnotationType } = await import('../../types');
|
|
const annotations = [
|
|
ann('global-1', 5, AnnotationType.GLOBAL_COMMENT),
|
|
ann('ann-late', 20),
|
|
ann('ann-early', 10),
|
|
];
|
|
const { postReady, postedToIframe } = await mountWithAnnotations(annotations);
|
|
await postReady();
|
|
const syncs = postedToIframe.filter(
|
|
(m) => m.type === 'plannotator-bridge-sync-annotations',
|
|
);
|
|
expect(syncs.length).toBeGreaterThanOrEqual(1);
|
|
// Numbered by ARRAY position of the full list INCLUDING globals — the
|
|
// effective ordering exportAnnotations numbers the feedback with (its
|
|
// sort keys tie for every raw-HTML annotation, so its stable sort keeps
|
|
// array order; createdA is deliberately ignored because external
|
|
// annotations interleave server-stamped timestamps). The global (array
|
|
// position 1) has no page location and ships no entry: the on-page
|
|
// numbers start at 2, leaving the gap where the global sits.
|
|
expect(syncs.at(-1)!.annotations).toEqual([
|
|
{ id: 'ann-late', number: 2 },
|
|
{ id: 'ann-early', number: 3 },
|
|
]);
|
|
});
|
|
|
|
test('no sync is posted before the bridge is ready', async () => {
|
|
const { postedToIframe } = await mountWithAnnotations([ann('a', 1)]);
|
|
expect(
|
|
postedToIframe.some((m) => m.type === 'plannotator-bridge-sync-annotations'),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('the sync feed truncates to 512 entries in export (array) order (m2)', async () => {
|
|
// The bridge caps its numbering map at 512 entries; the sender ships the
|
|
// FIRST 512 non-global entries in array order — the same order
|
|
// exportAnnotations numbers — so both sides keep the same set. createdA
|
|
// descends here and must NOT reorder anything (external annotations
|
|
// interleave server-stamped timestamps).
|
|
const annotations = Array.from({ length: 520 }, (_, i) => ann(`bulk-${i}`, 1000 - i));
|
|
const { postReady, postedToIframe } = await mountWithAnnotations(annotations);
|
|
await postReady();
|
|
const syncs = postedToIframe.filter(
|
|
(m) => m.type === 'plannotator-bridge-sync-annotations',
|
|
);
|
|
const list = syncs.at(-1)!.annotations as Array<{ id: string; number: number }>;
|
|
expect(list.length).toBe(512);
|
|
expect(list[0]).toEqual({ id: 'bulk-0', number: 1 });
|
|
expect(list[511]).toEqual({ id: 'bulk-511', number: 512 });
|
|
});
|
|
});
|
|
|
|
describe.if(hasDom)('mark-click validation (trust boundary)', () => {
|
|
test('mark-click ids are capped at 256 chars like the bridge sync cap (m1)', () => {
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-mark-click',
|
|
id: 'x'.repeat(256),
|
|
})).toEqual({ type: 'plannotator-bridge-mark-click', id: 'x'.repeat(256) });
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-mark-click',
|
|
id: 'x'.repeat(257),
|
|
})).toBeNull();
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-mark-click',
|
|
id: 42,
|
|
})).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe.if(hasDom)('multi-target bridge message validation (trust boundary)', () => {
|
|
test('multi-target-added: well-formed DTO passes with validated anchor', () => {
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-multi-target-added',
|
|
key: 'ht-2',
|
|
label: 'Button',
|
|
text: 'Create',
|
|
anchor: { selector: 'span.btn', tagName: 'span', text: 'Create' },
|
|
})).toEqual({
|
|
type: 'plannotator-bridge-multi-target-added',
|
|
key: 'ht-2',
|
|
label: 'Button',
|
|
text: 'Create',
|
|
anchor: { selector: 'span.btn', tagName: 'span', text: 'Create' },
|
|
});
|
|
});
|
|
|
|
test('multi-target-added: missing/oversized key or text rejects the message', () => {
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-multi-target-added',
|
|
text: 'Create',
|
|
})).toBeNull();
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-multi-target-added',
|
|
key: 'x'.repeat(65),
|
|
text: 'Create',
|
|
})).toBeNull();
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-multi-target-added',
|
|
key: 'ht-2',
|
|
text: 42,
|
|
})).toBeNull();
|
|
});
|
|
|
|
test('multi-target-added: anchor point validates like the primary anchor point', () => {
|
|
const good = hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-multi-target-added',
|
|
key: 'ht-6',
|
|
text: 'Create',
|
|
anchor: { selector: 'span.btn', tagName: 'span', text: 'Create', point: { x: 0.9, y: 0.2 } },
|
|
}) as { anchor?: { point?: { x: number; y: number } } };
|
|
expect(good.anchor?.point).toEqual({ x: 0.9, y: 0.2 });
|
|
const bad = hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-multi-target-added',
|
|
key: 'ht-7',
|
|
text: 'Create',
|
|
anchor: { selector: 'span.btn', tagName: 'span', text: 'Create', point: { x: 'evil', y: 0.2 } },
|
|
}) as { anchor?: { selector: string; tagName: string; text?: string; point?: unknown } };
|
|
expect(bad.anchor).toEqual({ selector: 'span.btn', tagName: 'span', text: 'Create' });
|
|
expect(bad.anchor?.point).toBeUndefined();
|
|
});
|
|
|
|
test('multi-target-added: hostile label is truncated, hostile anchor dropped, text capped', () => {
|
|
const parsed = hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-multi-target-added',
|
|
key: 'ht-3',
|
|
label: 'L'.repeat(500),
|
|
text: 'x'.repeat(hookModule!.MAX_SELECTION_TEXT_LENGTH + 5000),
|
|
anchor: { selector: 'x'.repeat(2000), tagName: 'p' },
|
|
}) as { label?: string; text: string; anchor?: unknown };
|
|
expect(parsed).not.toBeNull();
|
|
expect(parsed.label!.length).toBe(64);
|
|
expect(parsed.text.length).toBe(hookModule!.MAX_SELECTION_TEXT_LENGTH);
|
|
expect(parsed.anchor).toBeUndefined();
|
|
});
|
|
|
|
test('multi-target-removed and pointer messages validate their fields', () => {
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-multi-target-removed',
|
|
key: 'ht-2',
|
|
})).toEqual({ type: 'plannotator-bridge-multi-target-removed', key: 'ht-2' });
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-multi-target-removed',
|
|
key: 7,
|
|
})).toBeNull();
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-pointer',
|
|
x: 12,
|
|
y: 34,
|
|
shift: true,
|
|
})).toEqual({ type: 'plannotator-bridge-pointer', x: 12, y: 34, shift: true });
|
|
// shift is a strict boolean: absent or truthy-but-not-true reads false.
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-pointer',
|
|
x: 12,
|
|
y: 34,
|
|
})).toEqual({ type: 'plannotator-bridge-pointer', x: 12, y: 34, shift: false });
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-pointer',
|
|
x: 12,
|
|
y: 34,
|
|
shift: 1,
|
|
})).toEqual({ type: 'plannotator-bridge-pointer', x: 12, y: 34, shift: false });
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-pointer',
|
|
x: Infinity,
|
|
y: 1,
|
|
})).toBeNull();
|
|
});
|
|
|
|
test('hostile labels with newlines are collapsed at the trust boundary (D2)', () => {
|
|
const parsed = hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-multi-target-added',
|
|
key: 'ht-4',
|
|
label: 'Save\n## INJECTED HEADING',
|
|
text: 'Save',
|
|
}) as { label?: string };
|
|
expect(parsed.label).toBe('Save ## INJECTED HEADING');
|
|
const selection = hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-selection',
|
|
text: 'Save',
|
|
rect: { top: 1, left: 1, width: 10, height: 10 },
|
|
pinpoint: true,
|
|
targetKey: 'ht-1',
|
|
targetLabel: ' a\r\n\tb ',
|
|
}) as { targetLabel?: string };
|
|
expect(selection.targetLabel).toBe('a b');
|
|
// Whitespace-only labels vanish instead of becoming empty brackets.
|
|
const blank = hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-multi-target-added',
|
|
key: 'ht-5',
|
|
label: ' \n\t ',
|
|
text: 'x',
|
|
}) as { label?: string };
|
|
expect(blank.label).toBeUndefined();
|
|
});
|
|
|
|
test('selection: targetKey validated, targetLabel truncated', () => {
|
|
const rect = { top: 10, left: 10, width: 100, height: 20 };
|
|
const parsed = hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-selection',
|
|
text: 'Hello',
|
|
rect,
|
|
pinpoint: true,
|
|
targetKey: 'ht-1',
|
|
targetLabel: 'Z'.repeat(200),
|
|
}) as { targetKey?: string; targetLabel?: string };
|
|
expect(parsed.targetKey).toBe('ht-1');
|
|
expect(parsed.targetLabel!.length).toBe(64);
|
|
const badKey = hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-selection',
|
|
text: 'Hello',
|
|
rect,
|
|
pinpoint: true,
|
|
targetKey: 'x'.repeat(65),
|
|
}) as { targetKey?: string };
|
|
expect(badKey.targetKey).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe.if(hasDom)('multi-target composer flow (chips, promotion, submit)', () => {
|
|
async function mountViewer(
|
|
onAdd: (ann: Annotation) => void,
|
|
mode: 'selection' | 'quickLabel' = 'selection',
|
|
extraProps: { maxAdditionalTargets?: number } = {},
|
|
) {
|
|
if (!htmlViewerModule) throw new Error('DOM test environment is not registered');
|
|
const HtmlViewer = htmlViewerModule.HtmlViewer;
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
mountedRoots.push(root);
|
|
await act(async () => {
|
|
root.render(
|
|
<HtmlViewer
|
|
{...extraProps}
|
|
rawHtml="<html><body><p>Pinpoint target</p></body></html>"
|
|
annotations={[]}
|
|
onAddAnnotation={onAdd}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode={mode}
|
|
inputMethod="pinpoint"
|
|
/>,
|
|
);
|
|
});
|
|
const iframe = host.querySelector<HTMLIFrameElement>('iframe');
|
|
if (!iframe?.contentWindow) throw new Error('HTML iframe missing');
|
|
// Record everything the parent posts INTO the iframe (arm-multi-select,
|
|
// remove-target echoes, ...) by wrapping contentWindow.postMessage.
|
|
const postedToIframe: Array<Record<string, unknown>> = [];
|
|
const realPost = iframe.contentWindow.postMessage.bind(iframe.contentWindow);
|
|
(iframe.contentWindow as unknown as { postMessage: (data: unknown) => void }).postMessage =
|
|
((data: unknown, ...rest: unknown[]) => {
|
|
if (data && typeof data === 'object') postedToIframe.push(data as Record<string, unknown>);
|
|
return (realPost as (...args: unknown[]) => unknown)(data, ...rest);
|
|
}) as typeof iframe.contentWindow.postMessage;
|
|
const post = async (data: Record<string, unknown>) => {
|
|
await act(async () => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data,
|
|
}));
|
|
});
|
|
};
|
|
return { post, postedToIframe };
|
|
}
|
|
|
|
const rect = { top: 10, left: 10, width: 120, height: 24 };
|
|
|
|
function primarySelection(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
type: 'plannotator-bridge-selection',
|
|
text: 'Primary text',
|
|
rect,
|
|
pinpoint: true,
|
|
targetKey: 'ht-1',
|
|
targetLabel: 'Paragraph',
|
|
anchor: { selector: 'p.primary', tagName: 'p', text: 'Primary text' },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function addedTarget(key: string, text: string) {
|
|
return {
|
|
type: 'plannotator-bridge-multi-target-added',
|
|
key,
|
|
label: 'Button',
|
|
text,
|
|
anchor: { selector: `span[data-testid="${key}"]`, tagName: 'span', text },
|
|
};
|
|
}
|
|
|
|
function chips(): HTMLElement[] {
|
|
return Array.from(document.querySelectorAll<HTMLElement>('[data-target-chip]'));
|
|
}
|
|
|
|
async function typeComment(value: string) {
|
|
const el = document.querySelector<HTMLTextAreaElement>('[data-comment-popover] textarea');
|
|
if (!el) throw new Error('composer textarea missing');
|
|
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value')?.set;
|
|
await act(async () => {
|
|
if (setter) setter.call(el, value);
|
|
else el.value = value;
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
});
|
|
}
|
|
|
|
async function save() {
|
|
const button = Array.from(
|
|
document.querySelectorAll<HTMLButtonElement>('[data-comment-popover] button'),
|
|
).find((b) => b.textContent === 'Save');
|
|
if (!button) throw new Error('Save button missing');
|
|
await act(async () => {
|
|
button.click();
|
|
});
|
|
}
|
|
|
|
test('pinpoint draft renders a primary chip; shift-adds append chips and submit as ONE comment', async () => {
|
|
const added: Annotation[] = [];
|
|
const { post } = await mountViewer((ann) => added.push(ann));
|
|
await post(primarySelection());
|
|
expect(document.querySelector('[data-comment-popover]')).not.toBeNull();
|
|
expect(chips().length).toBe(1);
|
|
expect(chips()[0]!.getAttribute('data-target-chip-primary')).toBe('true');
|
|
|
|
await post(addedTarget('ht-2', 'Create'));
|
|
await post(addedTarget('ht-3', 'Cancel'));
|
|
expect(chips().length).toBe(3);
|
|
|
|
await typeComment('Unify these buttons');
|
|
await save();
|
|
|
|
expect(added.length).toBe(1);
|
|
const ann = added[0]!;
|
|
expect(ann.text).toBe('Unify these buttons');
|
|
expect(ann.originalText).toBe('Primary text');
|
|
expect(ann.htmlAnchor).toEqual({ selector: 'p.primary', tagName: 'p', text: 'Primary text' });
|
|
expect(ann.htmlAdditionalTargets).toEqual([
|
|
{
|
|
label: 'Button',
|
|
text: 'Create',
|
|
anchor: { selector: 'span[data-testid="ht-2"]', tagName: 'span', text: 'Create' },
|
|
},
|
|
{
|
|
label: 'Button',
|
|
text: 'Cancel',
|
|
anchor: { selector: 'span[data-testid="ht-3"]', tagName: 'span', text: 'Cancel' },
|
|
},
|
|
]);
|
|
// Draft state cleared with the submit.
|
|
expect(document.querySelector('[data-comment-popover]')).toBeNull();
|
|
});
|
|
|
|
test('single-target pinpoint submit carries NO additional-targets array', async () => {
|
|
const added: Annotation[] = [];
|
|
const { post } = await mountViewer((ann) => added.push(ann));
|
|
await post(primarySelection());
|
|
await typeComment('Just this one');
|
|
await save();
|
|
expect(added.length).toBe(1);
|
|
expect(added[0]!.htmlAdditionalTargets).toBeUndefined();
|
|
});
|
|
|
|
test('removing the primary promotes the next target onto the comment', async () => {
|
|
const added: Annotation[] = [];
|
|
const { post } = await mountViewer((ann) => added.push(ann));
|
|
await post(primarySelection());
|
|
await post(addedTarget('ht-2', 'Create'));
|
|
expect(chips().length).toBe(2);
|
|
|
|
// Bridge-echoed removal of the primary (shift-click toggle-off).
|
|
await post({ type: 'plannotator-bridge-multi-target-removed', key: 'ht-1' });
|
|
expect(chips().length).toBe(1);
|
|
expect(chips()[0]!.getAttribute('data-target-chip')).toBe('ht-2');
|
|
expect(chips()[0]!.getAttribute('data-target-chip-primary')).toBe('true');
|
|
|
|
await typeComment('Promoted');
|
|
await save();
|
|
expect(added.length).toBe(1);
|
|
expect(added[0]!.originalText).toBe('Create'); // the promoted target's text
|
|
expect(added[0]!.htmlAnchor).toEqual({
|
|
selector: 'span[data-testid="ht-2"]',
|
|
tagName: 'span',
|
|
text: 'Create',
|
|
});
|
|
expect(added[0]!.htmlAdditionalTargets).toBeUndefined();
|
|
});
|
|
|
|
test('removing the final target cancels the draft (composer closes)', async () => {
|
|
const { post } = await mountViewer(() => {});
|
|
await post(primarySelection());
|
|
expect(document.querySelector('[data-comment-popover]')).not.toBeNull();
|
|
await post({ type: 'plannotator-bridge-multi-target-removed', key: 'ht-1' });
|
|
expect(document.querySelector('[data-comment-popover]')).toBeNull();
|
|
expect(chips().length).toBe(0);
|
|
});
|
|
|
|
test('chip remove button removes that target from the draft', async () => {
|
|
const added: Annotation[] = [];
|
|
const { post } = await mountViewer((ann) => added.push(ann));
|
|
await post(primarySelection());
|
|
await post(addedTarget('ht-2', 'Create'));
|
|
expect(chips().length).toBe(2);
|
|
|
|
const removeButton = document.querySelector<HTMLButtonElement>(
|
|
'[data-target-chip-remove="ht-2"]',
|
|
);
|
|
if (!removeButton) throw new Error('chip remove button missing');
|
|
await act(async () => {
|
|
removeButton.click();
|
|
});
|
|
expect(chips().length).toBe(1);
|
|
|
|
await typeComment('Back to one');
|
|
await save();
|
|
expect(added[0]!.htmlAdditionalTargets).toBeUndefined();
|
|
});
|
|
|
|
test('the additional-target array is capped at 16 at the trust boundary', async () => {
|
|
const added: Annotation[] = [];
|
|
const { post } = await mountViewer((ann) => added.push(ann));
|
|
await post(primarySelection());
|
|
for (let i = 0; i < 25; i++) {
|
|
await post(addedTarget(`flood-${i}`, `Target ${i}`));
|
|
}
|
|
expect(chips().length).toBe(17); // primary + 16
|
|
|
|
await typeComment('Capped');
|
|
await save();
|
|
expect(added[0]!.htmlAdditionalTargets!.length).toBe(16);
|
|
});
|
|
|
|
test('maxAdditionalTargets lowers the cap end to end: chips, the saved array, and the bridge arm', async () => {
|
|
// A host with a product cap of 3 must see the cap in the draft (chips
|
|
// stop growing), in the saved annotation, and in what the bridge is
|
|
// armed with, so the in-page toggle stops at the same number.
|
|
const added: Annotation[] = [];
|
|
const { post, postedToIframe } = await mountViewer((ann) => added.push(ann), 'selection', { maxAdditionalTargets: 3 });
|
|
await post(primarySelection());
|
|
expect(postedToIframe).toContainEqual({
|
|
type: 'plannotator-bridge-arm-multi-select',
|
|
key: 'ht-1',
|
|
max: 3,
|
|
});
|
|
for (let i = 0; i < 8; i++) {
|
|
await post(addedTarget(`host-cap-${i}`, `Target ${i}`));
|
|
}
|
|
expect(chips().length).toBe(4); // primary + 3
|
|
|
|
await typeComment('Host capped');
|
|
await save();
|
|
expect(added[0]!.htmlAdditionalTargets!.length).toBe(3);
|
|
|
|
// The composer's one-click "Looks good" path submits under the same cap.
|
|
await post(primarySelection({ targetKey: 'ht-2' }));
|
|
for (let i = 0; i < 8; i++) {
|
|
await post(addedTarget(`host-cap-b-${i}`, `Target ${i}`));
|
|
}
|
|
const looksGood = Array.from(
|
|
document.querySelectorAll<HTMLButtonElement>('[data-comment-popover] button'),
|
|
).find((b) => b.title.startsWith('Add "Looks good"'));
|
|
if (!looksGood) throw new Error('Looks good button missing');
|
|
await act(async () => { looksGood.click(); });
|
|
expect(added[1]!.isQuickLabel).toBe(true);
|
|
expect(added[1]!.htmlAdditionalTargets!.length).toBe(3);
|
|
});
|
|
|
|
test('without maxAdditionalTargets the arm message and the 16 cap are unchanged', async () => {
|
|
const added: Annotation[] = [];
|
|
const { post, postedToIframe } = await mountViewer((ann) => added.push(ann));
|
|
await post(primarySelection());
|
|
const arm = postedToIframe.find((m) => m.type === 'plannotator-bridge-arm-multi-select');
|
|
expect(arm).toEqual({ type: 'plannotator-bridge-arm-multi-select', key: 'ht-1' });
|
|
expect('max' in arm!).toBe(false);
|
|
});
|
|
|
|
test('adds are ignored when no pinpoint draft is open (drag selections stay single-target)', async () => {
|
|
const { post, postedToIframe } = await mountViewer(() => {});
|
|
// Drag selection (no pinpoint flag): opens the toolbar, arms nothing.
|
|
await post({
|
|
type: 'plannotator-bridge-selection',
|
|
text: 'Dragged text',
|
|
rect,
|
|
});
|
|
await post(addedTarget('ht-9', 'Stray'));
|
|
expect(chips().length).toBe(0);
|
|
expect(postedToIframe.some((m) => m.type === 'plannotator-bridge-arm-multi-select')).toBe(false);
|
|
});
|
|
|
|
test('the composer flow arms the bridge with the primary key (D1)', async () => {
|
|
const armed = await mountViewer(() => {});
|
|
await armed.post(primarySelection());
|
|
expect(armed.postedToIframe).toContainEqual({
|
|
type: 'plannotator-bridge-arm-multi-select',
|
|
key: 'ht-1',
|
|
});
|
|
});
|
|
|
|
test('quickLabel mode is CLAMPED on HTML surfaces: the pinpoint draft opens the composer and arms like any pinpoint draft', async () => {
|
|
// Quick labels are a markdown-surface feature. On HTML surfaces the mode
|
|
// clamps to the plain selection flow, so a pinpoint draft opens the
|
|
// composer (which mirrors targets) — never the label picker. This also
|
|
// keeps the D1 invariant vacuously safe: every pinpoint draft that arms
|
|
// the bridge is mirrored by the composer.
|
|
const quick = await mountViewer(() => {}, 'quickLabel');
|
|
await quick.post(primarySelection());
|
|
expect(document.querySelector('[data-comment-popover]')).not.toBe(null);
|
|
expect(quick.postedToIframe).toContainEqual({
|
|
type: 'plannotator-bridge-arm-multi-select',
|
|
key: 'ht-1',
|
|
});
|
|
await quick.post(addedTarget('ht-2', 'Create'));
|
|
expect(chips().length).toBe(2);
|
|
});
|
|
|
|
test('a forged multi-target-removed still echoes remove-target to resync the bridge (D4)', async () => {
|
|
const added: Annotation[] = [];
|
|
const { post, postedToIframe } = await mountViewer((ann) => added.push(ann));
|
|
await post(primarySelection());
|
|
await post(addedTarget('ht-2', 'Create'));
|
|
expect(chips().length).toBe(2);
|
|
|
|
// Hostile page forges the removal of the primary — the bridge never
|
|
// performed it. The parent promotes AND echoes remove-target so the
|
|
// bridge converges on the same promotion (idempotent if it already had).
|
|
await post({ type: 'plannotator-bridge-multi-target-removed', key: 'ht-1' });
|
|
expect(chips().length).toBe(1);
|
|
expect(chips()[0]!.getAttribute('data-target-chip')).toBe('ht-2');
|
|
expect(postedToIframe).toContainEqual({
|
|
type: 'plannotator-bridge-remove-target',
|
|
key: 'ht-1',
|
|
});
|
|
});
|
|
|
|
test('bridge pointer messages with shift drive the composer yield (D3)', async () => {
|
|
const { post } = await mountViewer(() => {});
|
|
await post(primarySelection());
|
|
const popover = document.querySelector<HTMLElement>('[data-comment-popover]');
|
|
if (!popover) throw new Error('composer missing');
|
|
expect(popover.className).toContain('pn-composer-yieldable');
|
|
expect(popover.className).not.toContain('pn-composer-yield-over');
|
|
|
|
// Pointer over the composer (happy-dom rects are 0x0 at the origin) with
|
|
// shift held — relayed FROM THE BRIDGE, no parent keydown involved.
|
|
await post({ type: 'plannotator-bridge-pointer', x: 0, y: 0, shift: true });
|
|
expect(popover.className).toContain('pn-composer-yield-over');
|
|
|
|
// Shift released (still reported by the bridge): the composer restores.
|
|
await post({ type: 'plannotator-bridge-pointer', x: 0, y: 0, shift: false });
|
|
expect(popover.className).not.toContain('pn-composer-yield-over');
|
|
expect(popover.className).not.toContain('pn-composer-yield-near');
|
|
});
|
|
});
|
|
|
|
describe.if(hasDom)('readOnly view-only contract', () => {
|
|
// The promise made to hosts (HANDOFF.md 0.29.0): readOnly disables every
|
|
// authoring entry point but is NOT blank — committed annotations restore,
|
|
// markers paint with export-matching numbers, and marker clicks still
|
|
// navigate. View-only review contexts depend on all three.
|
|
async function mountReadOnly(
|
|
annotations: Annotation[],
|
|
onSelect: (id: string | null) => void = () => {},
|
|
) {
|
|
if (!htmlViewerModule) throw new Error('DOM test environment is not registered');
|
|
const HtmlViewer = htmlViewerModule.HtmlViewer;
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
mountedRoots.push(root);
|
|
await act(async () => {
|
|
root.render(
|
|
<HtmlViewer
|
|
rawHtml="<html><body><p>Read-only target</p></body></html>"
|
|
annotations={annotations}
|
|
onAddAnnotation={() => {}}
|
|
onSelectAnnotation={onSelect}
|
|
selectedAnnotationId={null}
|
|
mode="selection"
|
|
inputMethod="pinpoint"
|
|
readOnly
|
|
/>,
|
|
);
|
|
});
|
|
const iframe = host.querySelector<HTMLIFrameElement>('iframe');
|
|
if (!iframe?.contentWindow) throw new Error('HTML iframe missing');
|
|
const postedToIframe: Array<Record<string, unknown>> = [];
|
|
const realPost = iframe.contentWindow.postMessage.bind(iframe.contentWindow);
|
|
(iframe.contentWindow as unknown as { postMessage: (data: unknown) => void }).postMessage =
|
|
((data: unknown, ...rest: unknown[]) => {
|
|
if (data && typeof data === 'object') postedToIframe.push(data as Record<string, unknown>);
|
|
return (realPost as (...args: unknown[]) => unknown)(data, ...rest);
|
|
}) as typeof iframe.contentWindow.postMessage;
|
|
const post = async (data: Record<string, unknown>) => {
|
|
await act(async () => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data,
|
|
}));
|
|
});
|
|
};
|
|
return { post, postedToIframe };
|
|
}
|
|
|
|
function committed(id: string): Annotation {
|
|
return {
|
|
id,
|
|
blockId: '',
|
|
startOffset: 0,
|
|
endOffset: 0,
|
|
type: 'COMMENT',
|
|
text: 'noted',
|
|
originalText: 'Read-only target',
|
|
createdA: 1,
|
|
htmlAnchor: { selector: 'p', tagName: 'p', text: 'Read-only target' },
|
|
} as Annotation;
|
|
}
|
|
|
|
test('scroll-to carries the host scrollBehavior only when one is passed', async () => {
|
|
// Reduced motion cannot be observed inside the sandbox, so the host
|
|
// passes it. Absent, the message must be exactly today's shape so the
|
|
// bridge keeps scrolling smoothly for hosts that never set it.
|
|
if (!htmlViewerModule) throw new Error('DOM test environment is not registered');
|
|
const HtmlViewer = htmlViewerModule.HtmlViewer;
|
|
const mountWith = async (scrollBehavior?: 'smooth' | 'auto') => {
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
mountedRoots.push(root);
|
|
await act(async () => {
|
|
root.render(
|
|
<HtmlViewer
|
|
rawHtml="<html><body><p>Read-only target</p></body></html>"
|
|
annotations={[committed('sel-1')]}
|
|
onAddAnnotation={() => {}}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode="selection"
|
|
inputMethod="pinpoint"
|
|
scrollBehavior={scrollBehavior}
|
|
/>,
|
|
);
|
|
});
|
|
const iframe = host.querySelector<HTMLIFrameElement>('iframe');
|
|
if (!iframe?.contentWindow) throw new Error('HTML iframe missing');
|
|
const posted: Array<Record<string, unknown>> = [];
|
|
const realPost = iframe.contentWindow.postMessage.bind(iframe.contentWindow);
|
|
(iframe.contentWindow as unknown as { postMessage: (data: unknown) => void }).postMessage =
|
|
((data: unknown, ...rest: unknown[]) => {
|
|
if (data && typeof data === 'object') posted.push(data as Record<string, unknown>);
|
|
return (realPost as (...args: unknown[]) => unknown)(data, ...rest);
|
|
}) as typeof iframe.contentWindow.postMessage;
|
|
await act(async () => {
|
|
root.render(
|
|
<HtmlViewer
|
|
rawHtml="<html><body><p>Read-only target</p></body></html>"
|
|
annotations={[committed('sel-1')]}
|
|
onAddAnnotation={() => {}}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId="sel-1"
|
|
mode="selection"
|
|
inputMethod="pinpoint"
|
|
scrollBehavior={scrollBehavior}
|
|
/>,
|
|
);
|
|
});
|
|
return posted.filter((m) => m.type === 'plannotator-bridge-scroll-to');
|
|
};
|
|
expect(await mountWith(undefined)).toEqual([{ type: 'plannotator-bridge-scroll-to', id: 'sel-1' }]);
|
|
expect(await mountWith('auto')).toEqual([{ type: 'plannotator-bridge-scroll-to', id: 'sel-1', behavior: 'auto' }]);
|
|
});
|
|
|
|
test('readOnly still restores markers and syncs export-matching numbers on ready', async () => {
|
|
const { post, postedToIframe } = await mountReadOnly([committed('ro-1'), committed('ro-2')]);
|
|
await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION });
|
|
|
|
const restores = postedToIframe.filter((m) => m.type === 'plannotator-bridge-find-and-mark');
|
|
expect(restores.map((m) => m.id)).toEqual(['ro-1', 'ro-2']);
|
|
// Anchor-first restore must survive readOnly: the anchor is forwarded so
|
|
// the bridge can pin the marker at the element, not just text-search.
|
|
expect((restores[0]!.anchor as { selector: string }).selector).toBe('p');
|
|
|
|
const syncs = postedToIframe.filter((m) => m.type === 'plannotator-bridge-sync-annotations');
|
|
expect(syncs.at(-1)!.annotations).toEqual([
|
|
{ id: 'ro-1', number: 1 },
|
|
{ id: 'ro-2', number: 2 },
|
|
]);
|
|
});
|
|
|
|
test('readOnly marker clicks still navigate via onSelectAnnotation', async () => {
|
|
const selected: Array<string | null> = [];
|
|
const { post } = await mountReadOnly([committed('ro-1')], (id) => selected.push(id));
|
|
await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION });
|
|
await post({ type: 'plannotator-bridge-mark-click', id: 'ro-1' });
|
|
expect(selected).toEqual(['ro-1']);
|
|
});
|
|
|
|
test('readOnly ignores selection messages: no toolbar, no composer', async () => {
|
|
const { post } = await mountReadOnly([committed('ro-1')]);
|
|
await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION });
|
|
await post({
|
|
type: 'plannotator-bridge-selection',
|
|
text: 'Read-only target',
|
|
rect: { top: 10, left: 10, width: 120, height: 24 },
|
|
pinpoint: true,
|
|
targetKey: 'ht-ro',
|
|
targetLabel: 'Paragraph',
|
|
anchor: { selector: 'p', tagName: 'p', text: 'Read-only target' },
|
|
});
|
|
expect(document.querySelector('[data-comment-popover]')).toBeNull();
|
|
expect(document.querySelector('[data-annotation-toolbar]')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe.if(hasDom)('unanchored report (trust boundary + delivery)', () => {
|
|
const MSG = 'plannotator-bridge-unanchored';
|
|
|
|
test('accepts a bounded report, including the empty recovery set', () => {
|
|
expect(hookModule!.parseBridgeMessage({ type: MSG, ids: ['a-1', 'b-2'] }))
|
|
.toEqual({ type: MSG, ids: ['a-1', 'b-2'] });
|
|
expect(hookModule!.parseBridgeMessage({ type: MSG, ids: [] }))
|
|
.toEqual({ type: MSG, ids: [] });
|
|
});
|
|
|
|
test('out-of-contract reports are rejected whole (forged-message posture)', () => {
|
|
// The real bridge caps at 512 ids of <=256 chars; anything past that is a
|
|
// hostile page forging the message, so nothing salvageable is delivered.
|
|
expect(hookModule!.parseBridgeMessage({ type: MSG, ids: 'a' })).toBeNull();
|
|
expect(hookModule!.parseBridgeMessage({ type: MSG })).toBeNull();
|
|
expect(hookModule!.parseBridgeMessage({ type: MSG, ids: [42] })).toBeNull();
|
|
expect(hookModule!.parseBridgeMessage({ type: MSG, ids: ['ok', 'x'.repeat(257)] })).toBeNull();
|
|
expect(
|
|
hookModule!.parseBridgeMessage({ type: MSG, ids: Array.from({ length: 513 }, () => 'a') }),
|
|
).toBeNull();
|
|
});
|
|
|
|
test('delivery reaches onUnanchoredChange even in readOnly mode', async () => {
|
|
// View-only surfaces are exactly where silently missing markers would go
|
|
// unnoticed, so the report must bypass the enabled gate like mark-click.
|
|
if (!htmlViewerModule) throw new Error('DOM test environment is not registered');
|
|
const HtmlViewer = htmlViewerModule.HtmlViewer;
|
|
const received: string[][] = [];
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
mountedRoots.push(root);
|
|
await act(async () => {
|
|
root.render(
|
|
<HtmlViewer
|
|
rawHtml="<html><body><p>Page</p></body></html>"
|
|
annotations={[]}
|
|
onAddAnnotation={() => {}}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode="selection"
|
|
inputMethod="pinpoint"
|
|
readOnly
|
|
onUnanchoredChange={(ids) => received.push(ids)}
|
|
/>,
|
|
);
|
|
});
|
|
const iframe = host.querySelector<HTMLIFrameElement>('iframe');
|
|
if (!iframe?.contentWindow) throw new Error('HTML iframe missing');
|
|
await act(async () => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data: { type: MSG, ids: ['lost-1'] },
|
|
}));
|
|
});
|
|
expect(received).toEqual([['lost-1']]);
|
|
});
|
|
|
|
function pageRow(id: string, overrides: Partial<Annotation> = {}): Annotation {
|
|
return {
|
|
id,
|
|
blockId: '',
|
|
startOffset: 0,
|
|
endOffset: 0,
|
|
type: AnnotationType.COMMENT,
|
|
text: 'noted',
|
|
originalText: 'Page',
|
|
createdA: 1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function mountUnion(initial: Annotation[], received: string[][]) {
|
|
if (!htmlViewerModule) throw new Error('DOM test environment is not registered');
|
|
const HtmlViewer = htmlViewerModule.HtmlViewer;
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
mountedRoots.push(root);
|
|
const added: Annotation[] = [];
|
|
const render = async (annotations: Annotation[], rawHtml = '<html><body><p>Page</p></body></html>') => {
|
|
await act(async () => {
|
|
root.render(
|
|
<HtmlViewer
|
|
rawHtml={rawHtml}
|
|
annotations={annotations}
|
|
onAddAnnotation={(ann) => added.push(ann)}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode="comment"
|
|
inputMethod="pinpoint"
|
|
onUnanchoredChange={(ids) => received.push(ids)}
|
|
/>,
|
|
);
|
|
});
|
|
};
|
|
await render(initial);
|
|
const iframe = host.querySelector<HTMLIFrameElement>('iframe');
|
|
if (!iframe?.contentWindow) throw new Error('HTML iframe missing');
|
|
const postedToIframe: Array<Record<string, unknown>> = [];
|
|
const realPost = iframe.contentWindow.postMessage.bind(iframe.contentWindow);
|
|
(iframe.contentWindow as unknown as { postMessage: (data: unknown) => void }).postMessage =
|
|
((data: unknown, ...rest: unknown[]) => {
|
|
if (data && typeof data === 'object') postedToIframe.push(data as Record<string, unknown>);
|
|
return (realPost as (...args: unknown[]) => unknown)(data, ...rest);
|
|
}) as typeof iframe.contentWindow.postMessage;
|
|
const post = async (data: Record<string, unknown>) => {
|
|
await act(async () => {
|
|
window.dispatchEvent(new MessageEvent('message', { source: iframe.contentWindow, data }));
|
|
});
|
|
};
|
|
const ready = () => post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION });
|
|
return { render, post, ready, added, postedToIframe };
|
|
}
|
|
|
|
test('on ready the viewer asks the bridge for one complete report AFTER its restore batch', async () => {
|
|
// The bridge only posts when its set changes from empty, so a fresh
|
|
// document where everything restores would stay silent. The parent asks
|
|
// for the post-restore set explicitly, after the find-and-marks, so the
|
|
// answering pass sees every restore.
|
|
const received: string[][] = [];
|
|
const { ready, postedToIframe } = await mountUnion([pageRow('a-1'), pageRow('a-2')], received);
|
|
await ready();
|
|
const types = postedToIframe.map((m) => m.type);
|
|
const restores = types.filter((t) => t === 'plannotator-bridge-find-and-mark');
|
|
expect(restores.length).toBe(2);
|
|
expect(types.lastIndexOf('plannotator-bridge-find-and-mark')).toBeLessThan(
|
|
types.indexOf('plannotator-bridge-report-unanchored'),
|
|
);
|
|
// Nothing is delivered until the bridge answers.
|
|
expect(received).toEqual([]);
|
|
});
|
|
|
|
test('textless page rows join the FIRST bridge report; document-level comments do not; nothing is delivered before it', async () => {
|
|
// A page row with no quoted text and no element anchor is never posted
|
|
// to the bridge (nothing to find it by), so the bridge can never name
|
|
// it; the viewer completes the bridge's report with it. Delivery waits
|
|
// for the bridge's post-restore answer: a host acknowledging "the first
|
|
// report after a reload" must get the restore set, not a set computed
|
|
// at mount. A GLOBAL_COMMENT has no page location and is never flagged.
|
|
const received: string[][] = [];
|
|
const { post, ready } = await mountUnion([
|
|
pageRow('anchored-1'),
|
|
pageRow('textless-1', { originalText: '' }),
|
|
pageRow('global-1', { originalText: '', type: AnnotationType.GLOBAL_COMMENT }),
|
|
], received);
|
|
await ready();
|
|
expect(received).toEqual([]);
|
|
await post({ type: MSG, ids: [] });
|
|
expect(received).toEqual([['textless-1']]);
|
|
|
|
// Later bridge reports union with it, pass-through timing kept.
|
|
await post({ type: MSG, ids: ['anchored-1'] });
|
|
expect(received.at(-1)).toEqual(['anchored-1', 'textless-1']);
|
|
await post({ type: MSG, ids: [] });
|
|
expect(received.at(-1)).toEqual(['textless-1']);
|
|
});
|
|
|
|
test('one textless row plus one real orphan: the first delivered report carries the orphan', async () => {
|
|
// The refresh acknowledgement consumes the first report per reload, so
|
|
// that report must be the post-restore one, with the orphan in it.
|
|
const received: string[][] = [];
|
|
const { post, ready } = await mountUnion([
|
|
pageRow('textless-1', { originalText: '' }),
|
|
pageRow('orphan-1'),
|
|
], received);
|
|
await ready();
|
|
await post({ type: MSG, ids: ['orphan-1'] });
|
|
expect(received).toEqual([['orphan-1', 'textless-1']]);
|
|
});
|
|
|
|
test('re-anchoring: a document whose restore succeeds reports the empty set once, clearing a prior orphan', async () => {
|
|
// Delete, refresh: the bridge names the orphan. Restore, refresh: a new
|
|
// document restores everything; the forced report is the empty set, and
|
|
// the host clears its chip from it.
|
|
const received: string[][] = [];
|
|
const { post, ready } = await mountUnion([pageRow('a-1')], received);
|
|
await ready();
|
|
await post({ type: MSG, ids: ['a-1'] });
|
|
expect(received).toEqual([['a-1']]);
|
|
// A new document (srcdoc reload): the bridge starts empty and answers
|
|
// the parent's request with its complete, empty set.
|
|
await ready();
|
|
await post({ type: MSG, ids: [] });
|
|
expect(received).toEqual([['a-1'], []]);
|
|
});
|
|
|
|
test('an in-place rawHtml swap drops old-document reports until the new ready, then delivers afresh', async () => {
|
|
// One instance, new srcdoc: the old document can still emit through the
|
|
// same contentWindow before the new one is ready. Those reports belong
|
|
// to a document that no longer exists and must not reach the host; the
|
|
// new document's first answer is delivered even when it equals the last
|
|
// set the old document delivered.
|
|
const received: string[][] = [];
|
|
const { render, post, ready } = await mountUnion([pageRow('a-1')], received);
|
|
await ready();
|
|
await post({ type: MSG, ids: ['a-1'] });
|
|
expect(received).toEqual([['a-1']]);
|
|
|
|
await render([pageRow('a-1')], '<html><body><p>Page v2</p></body></html>');
|
|
// Stale emission from the old document, before the new ready.
|
|
await post({ type: MSG, ids: [] });
|
|
expect(received).toEqual([['a-1']]);
|
|
// A prop-side change before the new ready delivers nothing either.
|
|
await render([pageRow('a-1'), pageRow('textless-1', { originalText: '' })], '<html><body><p>Page v2</p></body></html>');
|
|
expect(received).toEqual([['a-1']]);
|
|
|
|
await ready();
|
|
await post({ type: MSG, ids: ['a-1'] });
|
|
expect(received).toEqual([['a-1'], ['a-1', 'textless-1']]);
|
|
});
|
|
|
|
test('a locally minted id the host swapped out of annotations never reaches the report', async () => {
|
|
// The create swap: the composer mints a local id (create-mark), the host
|
|
// persists the comment under its own id and lists only that one. If the
|
|
// kept local mark later dies, the bridge names the local id, which the
|
|
// host holds no card for. The union drops it; the host id passes.
|
|
const received: string[][] = [];
|
|
const { render, post, added } = await mountUnion([], received);
|
|
await post({
|
|
type: 'plannotator-bridge-selection',
|
|
text: 'Page',
|
|
rect: { top: 10, left: 10, width: 120, height: 24 },
|
|
pinpoint: true,
|
|
targetKey: 'ht-1',
|
|
anchor: { selector: 'p', tagName: 'p', text: 'Page' },
|
|
});
|
|
const textarea = document.querySelector<HTMLTextAreaElement>('[data-comment-popover] textarea');
|
|
if (!textarea) throw new Error('composer textarea missing');
|
|
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(textarea), 'value')?.set;
|
|
await act(async () => {
|
|
if (setter) setter.call(textarea, 'swap me');
|
|
else textarea.value = 'swap me';
|
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
});
|
|
const save = Array.from(document.querySelectorAll<HTMLButtonElement>('[data-comment-popover] button'))
|
|
.find((b) => b.textContent === 'Save');
|
|
if (!save) throw new Error('Save button missing');
|
|
await act(async () => { save.click(); });
|
|
const localId = added[0]?.id;
|
|
if (!localId) throw new Error('no created annotation');
|
|
|
|
// Host swapped: its list carries the server row only.
|
|
await render([pageRow('srv-1')]);
|
|
await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION });
|
|
await post({ type: MSG, ids: [localId, 'srv-1'] });
|
|
expect(received.at(-1)).toEqual(['srv-1']);
|
|
|
|
// A minted id the host DOES keep in its list is a real row and passes.
|
|
await render([pageRow('srv-1'), pageRow(localId)]);
|
|
expect(received.at(-1)).toEqual([localId, 'srv-1'].sort());
|
|
});
|
|
});
|
|
|
|
describe.if(hasDom)('Interact/Annotate mode on static (srcdoc) surfaces', () => {
|
|
async function mountModeViewer(options: {
|
|
annotateModeActive?: boolean;
|
|
onAnnotateModeExit?: () => void;
|
|
onAnnotateModeToggle?: () => void;
|
|
} = {}) {
|
|
if (!htmlViewerModule) throw new Error('DOM test environment is not registered');
|
|
const HtmlViewer = htmlViewerModule.HtmlViewer;
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
mountedRoots.push(root);
|
|
await act(async () => {
|
|
root.render(
|
|
<HtmlViewer
|
|
rawHtml="<html><body><p>Mode target</p></body></html>"
|
|
annotations={[]}
|
|
onAddAnnotation={() => {}}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode="selection"
|
|
inputMethod="pinpoint"
|
|
annotateModeActive={options.annotateModeActive}
|
|
onAnnotateModeExit={options.onAnnotateModeExit}
|
|
onAnnotateModeToggle={options.onAnnotateModeToggle}
|
|
/>,
|
|
);
|
|
});
|
|
const iframe = host.querySelector<HTMLIFrameElement>('iframe');
|
|
if (!iframe?.contentWindow) throw new Error('HTML iframe missing');
|
|
const postedToIframe: Array<Record<string, unknown>> = [];
|
|
const realPost = iframe.contentWindow.postMessage.bind(iframe.contentWindow);
|
|
(iframe.contentWindow as unknown as { postMessage: (data: unknown) => void }).postMessage =
|
|
((data: unknown, ...rest: unknown[]) => {
|
|
if (data && typeof data === 'object') postedToIframe.push(data as Record<string, unknown>);
|
|
return (realPost as (...args: unknown[]) => unknown)(data, ...rest);
|
|
}) as typeof iframe.contentWindow.postMessage;
|
|
const post = async (data: Record<string, unknown>) => {
|
|
await act(async () => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data,
|
|
}));
|
|
});
|
|
};
|
|
return { post, postedToIframe };
|
|
}
|
|
|
|
test('static surfaces default to Annotate armed: set-annotate-mode active:true rides every ready', async () => {
|
|
const { post, postedToIframe } = await mountModeViewer();
|
|
await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION });
|
|
const modePosts = postedToIframe.filter((m) => m.type === 'plannotator-bridge-set-annotate-mode');
|
|
expect(modePosts.length).toBe(1);
|
|
expect(modePosts[0]!.active).toBe(true);
|
|
});
|
|
|
|
test('a host-driven Interact state is pushed instead of the default', async () => {
|
|
const { post, postedToIframe } = await mountModeViewer({ annotateModeActive: false });
|
|
await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION });
|
|
const modePosts = postedToIframe.filter((m) => m.type === 'plannotator-bridge-set-annotate-mode');
|
|
expect(modePosts.length).toBe(1);
|
|
expect(modePosts[0]!.active).toBe(false);
|
|
});
|
|
|
|
test('parent-side Esc with the draft composer open belongs to the composer, never the mode', async () => {
|
|
let exits = 0;
|
|
const { post } = await mountModeViewer({
|
|
annotateModeActive: true,
|
|
onAnnotateModeExit: () => { exits += 1; },
|
|
});
|
|
await post({
|
|
type: 'plannotator-bridge-selection',
|
|
text: 'Mode target',
|
|
rect: { top: 10, left: 10, width: 120, height: 24 },
|
|
anchor: { selector: 'p:nth-of-type(1)', tagName: 'p', text: 'Mode target' },
|
|
pinpoint: true,
|
|
});
|
|
expect(document.querySelector('[data-comment-popover]')).not.toBeNull();
|
|
await act(async () => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
});
|
|
expect(exits).toBe(0);
|
|
});
|
|
|
|
test('a bare parent-side Esc exits Annotate (the ladder final rung)', async () => {
|
|
let exits = 0;
|
|
await mountModeViewer({
|
|
annotateModeActive: true,
|
|
onAnnotateModeExit: () => { exits += 1; },
|
|
});
|
|
await act(async () => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
});
|
|
expect(exits).toBe(1);
|
|
});
|
|
|
|
test('a bridge annotate-toggle message invokes the host toggle callback', async () => {
|
|
let toggles = 0;
|
|
const { post } = await mountModeViewer({
|
|
annotateModeActive: false,
|
|
onAnnotateModeToggle: () => { toggles += 1; },
|
|
});
|
|
await post({ type: 'plannotator-bridge-annotate-toggle' });
|
|
expect(toggles).toBe(1);
|
|
});
|
|
});
|