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.
877 lines
40 KiB
TypeScript
877 lines
40 KiB
TypeScript
/**
|
|
* Live-session protocol contract (DOM-gated).
|
|
*
|
|
* Parent side: with a live session configured, messages from a wrong origin
|
|
* or without the session token never reach parseBridgeMessage effects, and
|
|
* every outbound post carries the token with the live targetOrigin.
|
|
*
|
|
* Bridge side: executes the composed live body (JSON config prelude +
|
|
* LIVE_BRIDGE_BOOTSTRAP + BRIDGE_SCRIPT) in the test window with a stubbed
|
|
* parent/top pair so the frame gate passes, then asserts the live gate:
|
|
* pinpoint-only clamp, vim ignored, ready pageUrl, coalesced page-change,
|
|
* inbound token checks, and the bootstrap-installed CSS. The srcdoc suites
|
|
* run the SAME script with no config and pass unmodified; that is the
|
|
* regression proof that live behavior is inert without it.
|
|
*/
|
|
import { afterAll, afterEach, beforeAll, 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 { 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;
|
|
const bridgeModule = hasDom ? await import('./bridge-script') : null;
|
|
|
|
const LIVE_ORIGIN = 'http://127.0.0.1:4567';
|
|
const LIVE_TOKEN = 'live-token-1234';
|
|
|
|
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)('page-change message validation (trust boundary)', () => {
|
|
test('accepts a bounded pageUrl string', () => {
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-page-change',
|
|
pageUrl: '/settings?tab=git',
|
|
})).toEqual({ type: 'plannotator-bridge-page-change', pageUrl: '/settings?tab=git' });
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-page-change',
|
|
pageUrl: 'x'.repeat(hookModule!.MAX_PAGE_URL_LENGTH),
|
|
})).not.toBeNull();
|
|
});
|
|
|
|
test('rejects oversize, empty, and non-string pageUrls', () => {
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-page-change',
|
|
pageUrl: 'x'.repeat(hookModule!.MAX_PAGE_URL_LENGTH + 1),
|
|
})).toBeNull();
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-page-change',
|
|
pageUrl: '',
|
|
})).toBeNull();
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-page-change',
|
|
pageUrl: 42,
|
|
})).toBeNull();
|
|
expect(hookModule!.parseBridgeMessage({
|
|
type: 'plannotator-bridge-page-change',
|
|
})).toBeNull();
|
|
});
|
|
|
|
test('rejectsLiveMessage keys on exact origin and token', () => {
|
|
const live = { origin: LIVE_ORIGIN, token: LIVE_TOKEN };
|
|
expect(hookModule!.rejectsLiveMessage(live, LIVE_ORIGIN, { token: LIVE_TOKEN })).toBe(false);
|
|
expect(hookModule!.rejectsLiveMessage(live, 'http://evil.example', { token: LIVE_TOKEN })).toBe(true);
|
|
expect(hookModule!.rejectsLiveMessage(live, LIVE_ORIGIN, { token: 'wrong' })).toBe(true);
|
|
expect(hookModule!.rejectsLiveMessage(live, LIVE_ORIGIN, {})).toBe(true);
|
|
expect(hookModule!.rejectsLiveMessage(live, LIVE_ORIGIN, null)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe.if(hasDom)('live parent side (HtmlViewer with src + liveSession)', () => {
|
|
async function mountLiveViewer(options: {
|
|
onAdd?: (ann: Annotation) => void;
|
|
onPageChange?: (pageUrl: string) => void;
|
|
annotations?: Annotation[];
|
|
currentPageUrl?: string;
|
|
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=""
|
|
src="about:blank"
|
|
liveSession={{ origin: LIVE_ORIGIN, token: LIVE_TOKEN }}
|
|
currentPageUrl={options.currentPageUrl ?? '/'}
|
|
onPageChange={options.onPageChange}
|
|
annotations={options.annotations ?? []}
|
|
onAddAnnotation={options.onAdd ?? (() => {})}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode="selection"
|
|
inputMethod="pinpoint"
|
|
annotateModeActive={options.annotateModeActive}
|
|
onAnnotateModeExit={options.onAnnotateModeExit}
|
|
onAnnotateModeToggle={options.onAnnotateModeToggle}
|
|
fullViewport
|
|
/>,
|
|
);
|
|
});
|
|
const iframe = host.querySelector<HTMLIFrameElement>('iframe');
|
|
if (!iframe?.contentWindow) throw new Error('live iframe missing');
|
|
const postedToIframe: Array<{ data: Record<string, unknown>; targetOrigin: unknown }> = [];
|
|
const realPost = iframe.contentWindow.postMessage.bind(iframe.contentWindow);
|
|
(iframe.contentWindow as unknown as { postMessage: (data: unknown, origin?: unknown) => void }).postMessage =
|
|
((data: unknown, targetOrigin?: unknown, ...rest: unknown[]) => {
|
|
if (data && typeof data === 'object') {
|
|
postedToIframe.push({ data: data as Record<string, unknown>, targetOrigin });
|
|
}
|
|
return (realPost as (...args: unknown[]) => unknown)(data, targetOrigin, ...rest);
|
|
}) as typeof iframe.contentWindow.postMessage;
|
|
const post = async (data: Record<string, unknown>, origin: string = LIVE_ORIGIN) => {
|
|
await act(async () => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
origin,
|
|
data,
|
|
}));
|
|
});
|
|
};
|
|
return { iframe, post, postedToIframe };
|
|
}
|
|
|
|
const selectionMessage = {
|
|
type: 'plannotator-bridge-selection',
|
|
text: 'Live target',
|
|
rect: { top: 10, left: 10, width: 120, height: 24 },
|
|
anchor: { selector: 'p:nth-of-type(1)', tagName: 'p', text: 'Live target' },
|
|
pinpoint: true,
|
|
};
|
|
|
|
test('renders a src iframe with no sandbox and no srcdoc', async () => {
|
|
const { iframe } = await mountLiveViewer();
|
|
expect(iframe.getAttribute('src')).toBe('about:blank');
|
|
expect(iframe.hasAttribute('sandbox')).toBe(false);
|
|
expect(iframe.hasAttribute('srcdoc')).toBe(false);
|
|
});
|
|
|
|
test('a message from the wrong origin never reaches the selection flow', async () => {
|
|
const { post } = await mountLiveViewer();
|
|
await post({ ...selectionMessage, token: LIVE_TOKEN }, 'http://evil.example');
|
|
expect(document.querySelector('[data-comment-popover]')).toBeNull();
|
|
});
|
|
|
|
test('a message without (or with a wrong) token is ignored', async () => {
|
|
const { post } = await mountLiveViewer();
|
|
await post(selectionMessage);
|
|
expect(document.querySelector('[data-comment-popover]')).toBeNull();
|
|
await post({ ...selectionMessage, token: 'forged' });
|
|
expect(document.querySelector('[data-comment-popover]')).toBeNull();
|
|
});
|
|
|
|
test('a correctly authenticated pinpoint selection opens the composer', async () => {
|
|
const { post } = await mountLiveViewer();
|
|
await post({ ...selectionMessage, token: LIVE_TOKEN });
|
|
expect(document.querySelector('[data-comment-popover]')).not.toBeNull();
|
|
});
|
|
|
|
test('outbound posts carry the token and the live targetOrigin', async () => {
|
|
const { post, postedToIframe } = await mountLiveViewer({
|
|
annotations: [],
|
|
});
|
|
await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION, pageUrl: '/', token: LIVE_TOKEN });
|
|
expect(postedToIframe.length).toBeGreaterThan(0);
|
|
for (const posted of postedToIframe) {
|
|
expect(posted.data.token).toBe(LIVE_TOKEN);
|
|
expect(posted.targetOrigin).toBe(LIVE_ORIGIN);
|
|
}
|
|
// The bridge-config posts a ready surface always sends.
|
|
const types = postedToIframe.map((p) => p.data.type);
|
|
expect(types).toContain('plannotator-bridge-set-input-method');
|
|
});
|
|
|
|
test('an unauthenticated ready is ignored; an authenticated one forwards its pageUrl', async () => {
|
|
const pages: string[] = [];
|
|
const { post, postedToIframe } = await mountLiveViewer({ onPageChange: (p) => pages.push(p) });
|
|
await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION, pageUrl: '/spoofed' }, LIVE_ORIGIN);
|
|
expect(pages).toEqual([]);
|
|
expect(postedToIframe.length).toBe(0);
|
|
await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION, pageUrl: '/dashboard?x=1', token: LIVE_TOKEN });
|
|
expect(pages).toEqual(['/dashboard?x=1']);
|
|
});
|
|
|
|
test('page-change messages update the parent through onPageChange', async () => {
|
|
const pages: string[] = [];
|
|
const { post } = await mountLiveViewer({ onPageChange: (p) => pages.push(p) });
|
|
await post({ type: 'plannotator-bridge-page-change', pageUrl: '/about', token: LIVE_TOKEN });
|
|
expect(pages).toEqual(['/about']);
|
|
// Oversize pageUrl is rejected at the parse boundary.
|
|
await post({
|
|
type: 'plannotator-bridge-page-change',
|
|
pageUrl: 'x'.repeat(3000),
|
|
token: LIVE_TOKEN,
|
|
});
|
|
expect(pages).toEqual(['/about']);
|
|
});
|
|
|
|
test('restore filters to the current page; other pages annotations are held back', async () => {
|
|
const pageAnn = (id: string, pageUrl: string): Annotation => ({
|
|
id,
|
|
blockId: '',
|
|
startOffset: 0,
|
|
endOffset: 0,
|
|
type: 'COMMENT' as Annotation['type'],
|
|
text: 'c',
|
|
originalText: 'o',
|
|
createdA: 1,
|
|
pageUrl,
|
|
} as Annotation);
|
|
const { post, postedToIframe } = await mountLiveViewer({
|
|
annotations: [pageAnn('on-home', '/'), pageAnn('on-about', '/about')],
|
|
currentPageUrl: '/',
|
|
});
|
|
await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION, pageUrl: '/', token: LIVE_TOKEN });
|
|
const restores = postedToIframe.filter((p) => p.data.type === 'plannotator-bridge-find-and-mark');
|
|
expect(restores.map((p) => p.data.id)).toEqual(['on-home']);
|
|
// Numbering still ships the FULL list (global numbers across pages).
|
|
const syncs = postedToIframe.filter((p) => p.data.type === 'plannotator-bridge-sync-annotations');
|
|
expect(syncs.length).toBeGreaterThan(0);
|
|
expect((syncs.at(-1)!.data.annotations as Array<{ id: string }>).map((a) => a.id)).toEqual([
|
|
'on-home',
|
|
'on-about',
|
|
]);
|
|
});
|
|
|
|
test('a page change delivers no unanchored set until that page\'s forced answer arrives', async () => {
|
|
// Between the page change and the bridge's answer to the parent's
|
|
// report request, a prop-side change must not deliver a union computed
|
|
// against the previous page's bridge report.
|
|
if (!htmlViewerModule) throw new Error('DOM test environment is not registered');
|
|
const HtmlViewer = htmlViewerModule.HtmlViewer;
|
|
const row = (id: string, overrides: Partial<Annotation> = {}): Annotation => ({
|
|
id,
|
|
blockId: '',
|
|
startOffset: 0,
|
|
endOffset: 0,
|
|
type: 'COMMENT' as Annotation['type'],
|
|
text: 'c',
|
|
originalText: 'o',
|
|
createdA: 1,
|
|
...overrides,
|
|
} as Annotation);
|
|
const received: string[][] = [];
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
mountedRoots.push(root);
|
|
const render = async (currentPageUrl: string, annotations: Annotation[]) => {
|
|
await act(async () => {
|
|
root.render(
|
|
<HtmlViewer
|
|
rawHtml=""
|
|
src="about:blank"
|
|
liveSession={{ origin: LIVE_ORIGIN, token: LIVE_TOKEN }}
|
|
currentPageUrl={currentPageUrl}
|
|
annotations={annotations}
|
|
onAddAnnotation={() => {}}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode="selection"
|
|
inputMethod="pinpoint"
|
|
onUnanchoredChange={(ids) => received.push(ids)}
|
|
fullViewport
|
|
/>,
|
|
);
|
|
});
|
|
};
|
|
await render('/', [row('home-1', { pageUrl: '/' })]);
|
|
const iframe = host.querySelector<HTMLIFrameElement>('iframe');
|
|
if (!iframe?.contentWindow) throw new Error('live iframe missing');
|
|
const posted: Array<Record<string, unknown>> = [];
|
|
const realPost = iframe.contentWindow.postMessage.bind(iframe.contentWindow);
|
|
(iframe.contentWindow as unknown as { postMessage: (data: unknown, origin?: unknown) => void }).postMessage =
|
|
((data: unknown, targetOrigin?: unknown, ...rest: unknown[]) => {
|
|
if (data && typeof data === 'object') posted.push(data as Record<string, unknown>);
|
|
return (realPost as (...args: unknown[]) => unknown)(data, targetOrigin, ...rest);
|
|
}) as typeof iframe.contentWindow.postMessage;
|
|
const post = async (data: Record<string, unknown>) => {
|
|
await act(async () => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
origin: LIVE_ORIGIN,
|
|
data: { ...data, token: LIVE_TOKEN },
|
|
}));
|
|
});
|
|
};
|
|
await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION, pageUrl: '/' });
|
|
await post({ type: 'plannotator-bridge-unanchored', ids: ['home-1'] });
|
|
expect(received).toEqual([['home-1']]);
|
|
|
|
// Navigate: the parent clears marks, re-applies, and asks for a report.
|
|
const before = posted.length;
|
|
await render('/about', [row('home-1', { pageUrl: '/' })]);
|
|
expect(posted.slice(before).map((m) => m.type)).toContain('plannotator-bridge-report-unanchored');
|
|
// A prop-side change before the answer delivers nothing.
|
|
await render('/about', [row('home-1', { pageUrl: '/' }), row('textless-1', { originalText: '', pageUrl: '/about' })]);
|
|
expect(received).toEqual([['home-1']]);
|
|
// The page's answer is delivered, completed with the textless row.
|
|
await post({ type: 'plannotator-bridge-unanchored', ids: [] });
|
|
expect(received).toEqual([['home-1'], ['textless-1']]);
|
|
});
|
|
|
|
test('the Interact/Annotate mode is pushed on EVERY bridge ready, so it survives page-change reloads and bridge re-injection', async () => {
|
|
const { post, postedToIframe } = await mountLiveViewer({ annotateModeActive: false });
|
|
await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION, pageUrl: '/', token: LIVE_TOKEN });
|
|
const modePosts = () =>
|
|
postedToIframe.filter((p) => p.data.type === 'plannotator-bridge-set-annotate-mode');
|
|
expect(modePosts().length).toBe(1);
|
|
expect(modePosts()[0]!.data.active).toBe(false);
|
|
// A live navigation / HMR reload re-injects the bridge and posts ready
|
|
// again from a FRESH document: the mode must be re-established, not lost
|
|
// to the fresh bridge's default.
|
|
postedToIframe.length = 0;
|
|
await post({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION, pageUrl: '/about', token: LIVE_TOKEN });
|
|
expect(modePosts().length).toBe(1);
|
|
expect(modePosts()[0]!.data.active).toBe(false);
|
|
});
|
|
|
|
test('annotate-exit and annotate-toggle reach the parent callbacks only when authenticated', async () => {
|
|
let exits = 0;
|
|
let toggles = 0;
|
|
const { post } = await mountLiveViewer({
|
|
annotateModeActive: true,
|
|
onAnnotateModeExit: () => { exits += 1; },
|
|
onAnnotateModeToggle: () => { toggles += 1; },
|
|
});
|
|
await post({ type: 'plannotator-bridge-annotate-exit' });
|
|
await post({ type: 'plannotator-bridge-annotate-toggle', token: 'forged' });
|
|
await post({ type: 'plannotator-bridge-annotate-exit', token: LIVE_TOKEN }, 'http://evil.example');
|
|
expect(exits).toBe(0);
|
|
expect(toggles).toBe(0);
|
|
await post({ type: 'plannotator-bridge-annotate-exit', token: LIVE_TOKEN });
|
|
await post({ type: 'plannotator-bridge-annotate-toggle', token: LIVE_TOKEN });
|
|
expect(exits).toBe(1);
|
|
expect(toggles).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe.if(hasDom)('live bridge gate (composed body in the eval harness)', () => {
|
|
type ParentPost = { data: Record<string, unknown>; targetOrigin: unknown };
|
|
const parentPosts: ParentPost[] = [];
|
|
const fakeParent = {
|
|
postMessage(data: unknown, targetOrigin?: unknown) {
|
|
if (data && typeof data === 'object') {
|
|
parentPosts.push({ data: data as Record<string, unknown>, targetOrigin });
|
|
}
|
|
},
|
|
};
|
|
const editorOrigin = 'http://localhost:4100';
|
|
const bridgeToken = 'bridge-live-token';
|
|
|
|
// The live bridge is evaluated against a DEDICATED iframe window so its DOM
|
|
// side effects (overlay hosts, hover boxes, pinpoint listeners, the page
|
|
// MutationObserver) stay contained: the srcdoc suites run the same script
|
|
// against the shared global document later in this process and must not see
|
|
// a second live instance there. Same-realm Function parameters rebind the
|
|
// globals the bridge touches, including the parent/top pair the frame gate
|
|
// reads.
|
|
let bridgeFrame: HTMLIFrameElement;
|
|
let bridgeWindow: Window & { __plannotatorLiveConfig?: unknown };
|
|
let bridgeDocument: Document;
|
|
|
|
// Failed dead-target searches carry a wall-clock backoff (300ms doubling
|
|
// to a 5s cap) on top of the generation gate. Tests asserting legitimate
|
|
// recovery jump the bridge's monotonic clock (performance.now resolves in
|
|
// the test realm's scope chain) instead of sleeping.
|
|
let monotonicOffsetMs = 0;
|
|
const realPerformanceNow = performance.now.bind(performance);
|
|
beforeAll(() => {
|
|
performance.now = () => realPerformanceNow() + monotonicOffsetMs;
|
|
});
|
|
afterAll(() => {
|
|
performance.now = realPerformanceNow;
|
|
});
|
|
|
|
function postToBridge(data: Record<string, unknown>, origin: string = editorOrigin) {
|
|
bridgeWindow.dispatchEvent(new MessageEvent('message', {
|
|
data,
|
|
origin,
|
|
// The bridge accepts messages whose source is its parent window.
|
|
source: fakeParent as unknown as Window,
|
|
}));
|
|
}
|
|
|
|
beforeAll(() => {
|
|
bridgeFrame = document.createElement('iframe');
|
|
// documentElement, not body: the file-level afterEach clears body
|
|
// children between tests and must not tear the harness frame down.
|
|
document.documentElement.appendChild(bridgeFrame);
|
|
if (!bridgeFrame.contentWindow || !bridgeFrame.contentDocument) {
|
|
throw new Error('bridge harness iframe missing contentWindow');
|
|
}
|
|
bridgeWindow = bridgeFrame.contentWindow as typeof bridgeWindow;
|
|
bridgeDocument = bridgeFrame.contentDocument;
|
|
const config = {
|
|
live: true,
|
|
token: bridgeToken,
|
|
editorOrigins: [editorOrigin, 'http://127.0.0.1:4100'],
|
|
css: '.pn-live-probe { color: red; }',
|
|
};
|
|
bridgeWindow.__plannotatorLiveConfig = config;
|
|
const body = bridgeModule!.LIVE_BRIDGE_BOOTSTRAP + '\n' + bridgeModule!.BRIDGE_SCRIPT;
|
|
// Rebind the globals the bridge reads: its window/document/location/
|
|
// history are the iframe's, and parent/top are the fake editor pair so
|
|
// the frame gate passes (window !== parent, parent === top).
|
|
const run = new Function(
|
|
'window',
|
|
'document',
|
|
'location',
|
|
'history',
|
|
'parent',
|
|
'top',
|
|
body,
|
|
);
|
|
run(
|
|
bridgeWindow,
|
|
bridgeDocument,
|
|
bridgeWindow.location,
|
|
bridgeWindow.history,
|
|
fakeParent,
|
|
fakeParent,
|
|
);
|
|
if (parentPosts.length === 0) {
|
|
// Environments that report a loading readyState defer onReady.
|
|
bridgeDocument.dispatchEvent(new Event('DOMContentLoaded'));
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
bridgeFrame.remove();
|
|
});
|
|
|
|
test('the bootstrap installs the annotation CSS from the config', () => {
|
|
const style = bridgeDocument.querySelector('style[data-plannotator-live-css]');
|
|
expect(style).not.toBeNull();
|
|
expect(style!.textContent).toContain('.pn-live-probe');
|
|
});
|
|
|
|
test('ready carries the current pageUrl and token, posted once per listed editor origin', () => {
|
|
// The server cannot know which origin form (localhost vs 127.0.0.1) the
|
|
// editor tab was opened on: the bridge posts to every listed origin and
|
|
// the browser delivers only the matching one. An editor opened at
|
|
// 127.0.0.1 must not silently miss ready.
|
|
const readies = parentPosts.filter((p) => p.data.type === 'plannotator-bridge-ready');
|
|
expect(readies.map((p) => p.targetOrigin)).toEqual([editorOrigin, 'http://127.0.0.1:4100']);
|
|
for (const ready of readies) {
|
|
expect(ready.data.token).toBe(bridgeToken);
|
|
expect(ready.data.pageUrl).toBe(
|
|
(bridgeWindow.location.pathname + bridgeWindow.location.search).slice(0, 2048),
|
|
);
|
|
}
|
|
});
|
|
|
|
/** One logical post goes out once per listed editor origin; count only the
|
|
* primary origin's copy so assertions read in logical messages. */
|
|
function primaryPosts(type: string): ParentPost[] {
|
|
return parentPosts.filter(
|
|
(p) => p.data.type === `plannotator-bridge-${type}` && p.targetOrigin === editorOrigin,
|
|
);
|
|
}
|
|
|
|
function selectionPosts(): ParentPost[] {
|
|
return primaryPosts('selection');
|
|
}
|
|
|
|
/** Dispatch a real cancelable click and report whether the bridge captured
|
|
* it (preventDefault + a posted selection) or let it through to the page. */
|
|
function clickProbe(el: HTMLElement): { prevented: boolean; selections: number } {
|
|
const before = selectionPosts().length;
|
|
const click = new MouseEvent('click', { bubbles: true, cancelable: true });
|
|
el.dispatchEvent(click);
|
|
return { prevented: click.defaultPrevented, selections: selectionPosts().length - before };
|
|
}
|
|
|
|
function pressEscape() {
|
|
bridgeDocument.body.dispatchEvent(
|
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
|
);
|
|
}
|
|
|
|
function probeButton(): HTMLElement {
|
|
let btn = bridgeDocument.getElementById('live-probe-button');
|
|
if (!btn) {
|
|
btn = bridgeDocument.createElement('button');
|
|
btn.id = 'live-probe-button';
|
|
btn.textContent = 'Register a click';
|
|
bridgeDocument.body.appendChild(btn);
|
|
}
|
|
return btn as HTMLElement;
|
|
}
|
|
|
|
/** Simulate a real text drag over `el`: select its contents, then run the
|
|
* mousedown → >4px mousemove (button held) → mouseup sequence the drag
|
|
* arming keys off. Returns after the 10ms selection pass has run. */
|
|
async function dragSelectContents(el: HTMLElement) {
|
|
const range = bridgeDocument.createRange();
|
|
range.selectNodeContents(el);
|
|
const sel = bridgeWindow.getSelection()!;
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0, clientX: 5, clientY: 5 }));
|
|
el.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true, buttons: 1, clientX: 40, clientY: 12 }));
|
|
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0, clientX: 40, clientY: 12 }));
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
|
|
function dragProbeParagraph(): HTMLElement {
|
|
let p = bridgeDocument.getElementById('live-drag-probe');
|
|
if (!p) {
|
|
p = bridgeDocument.createElement('p');
|
|
p.id = 'live-drag-probe';
|
|
p.textContent = 'Draggable live copy';
|
|
bridgeDocument.body.appendChild(p);
|
|
}
|
|
return p as HTMLElement;
|
|
}
|
|
|
|
test('live sessions start ARMED: pinpoint capture and the cursor affordance are live before any parent message', () => {
|
|
// No set-annotate-mode or set-input-method has arrived yet — the default
|
|
// must already be pinpoint-armed on the live surface.
|
|
expect(bridgeDocument.body.hasAttribute('data-plannotator-pinpoint-cursor')).toBe(true);
|
|
const probe = clickProbe(probeButton());
|
|
expect(probe.prevented).toBe(true);
|
|
expect(probe.selections).toBe(1);
|
|
expect(selectionPosts().at(-1)!.data.pinpoint).toBe(true);
|
|
postToBridge({ type: 'plannotator-bridge-cancel-selection', token: bridgeToken });
|
|
});
|
|
|
|
test('forged disarm attempts (no token / wrong origin) are ignored', () => {
|
|
postToBridge({ type: 'plannotator-bridge-set-annotate-mode', active: false });
|
|
postToBridge({ type: 'plannotator-bridge-set-input-method', method: 'drag' });
|
|
postToBridge(
|
|
{ type: 'plannotator-bridge-set-annotate-mode', active: false, token: bridgeToken },
|
|
'http://evil.example',
|
|
);
|
|
expect(bridgeDocument.body.hasAttribute('data-plannotator-pinpoint-cursor')).toBe(true);
|
|
const probe = clickProbe(probeButton());
|
|
expect(probe.prevented).toBe(true);
|
|
expect(probe.selections).toBe(1);
|
|
postToBridge({ type: 'plannotator-bridge-cancel-selection', token: bridgeToken });
|
|
});
|
|
|
|
test('an authenticated drag input-method request stays clamped to pinpoint (clicks still pin)', () => {
|
|
postToBridge({ type: 'plannotator-bridge-set-input-method', method: 'drag', token: bridgeToken });
|
|
expect(bridgeDocument.body.hasAttribute('data-plannotator-pinpoint-cursor')).toBe(true);
|
|
const probe = clickProbe(probeButton());
|
|
expect(probe.prevented).toBe(true);
|
|
expect(probe.selections).toBe(1);
|
|
expect(selectionPosts().at(-1)!.data.pinpoint).toBe(true);
|
|
postToBridge({ type: 'plannotator-bridge-cancel-selection', token: bridgeToken });
|
|
});
|
|
|
|
test('ARMED: a real text drag posts a drag selection, and its trailing click never re-pins', async () => {
|
|
const p = dragProbeParagraph();
|
|
const before = selectionPosts().length;
|
|
await dragSelectContents(p);
|
|
const posts = selectionPosts();
|
|
expect(posts.length).toBe(before + 1);
|
|
expect(posts.at(-1)!.data.text).toBe('Draggable live copy');
|
|
expect(posts.at(-1)!.data.pinpoint).toBeFalsy();
|
|
// The click event that follows a completed drag must not clobber the
|
|
// drag selection with a pinpoint pin.
|
|
const trailing = clickProbe(p);
|
|
expect(trailing.prevented).toBe(false);
|
|
expect(trailing.selections).toBe(0);
|
|
postToBridge({ type: 'plannotator-bridge-cancel-selection', token: bridgeToken });
|
|
bridgeWindow.getSelection()!.removeAllRanges();
|
|
});
|
|
|
|
test('ARMED: a drifted click (>4px travel, no selection) still pinpoint-pins and never reaches the page', async () => {
|
|
// Trackpad reality: mousedown → a few pixels of travel → mouseup with
|
|
// nothing selected. The >4px drift alone must not arm the trailing-click
|
|
// suppression — that regression both swallowed the pin AND let the
|
|
// unprevented click through to the page.
|
|
const btn = probeButton();
|
|
bridgeWindow.getSelection()!.removeAllRanges();
|
|
let pageClicks = 0;
|
|
const pageListener = () => { pageClicks += 1; };
|
|
btn.addEventListener('click', pageListener);
|
|
try {
|
|
const before = selectionPosts().length;
|
|
btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0, clientX: 5, clientY: 5 }));
|
|
btn.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true, buttons: 1, clientX: 15, clientY: 12 }));
|
|
btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0, clientX: 15, clientY: 12 }));
|
|
const click = new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 15, clientY: 12 });
|
|
btn.dispatchEvent(click);
|
|
// The click pinned the element under the pointer...
|
|
expect(click.defaultPrevented).toBe(true);
|
|
expect(selectionPosts().length).toBe(before + 1);
|
|
expect(selectionPosts().at(-1)!.data.pinpoint).toBe(true);
|
|
// ...and the page's own handler never saw it (capture-phase stop).
|
|
expect(pageClicks).toBe(0);
|
|
// No trailing drag-selection pass runs to clear the draft just opened.
|
|
const clearsBefore = primaryPosts('selection-clear').length;
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
expect(primaryPosts('selection-clear').length).toBe(clearsBefore);
|
|
} finally {
|
|
btn.removeEventListener('click', pageListener);
|
|
}
|
|
postToBridge({ type: 'plannotator-bridge-cancel-selection', token: bridgeToken });
|
|
bridgeWindow.getSelection()!.removeAllRanges();
|
|
});
|
|
|
|
test('Esc with only a hover outline: hover clears AND annotate-exit posts on the SAME press', () => {
|
|
// Armed from the previous test, no pending draft. Hover an element so the
|
|
// pinpoint outline is showing:
|
|
const btn = probeButton();
|
|
btn.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX: 8, clientY: 8 }));
|
|
const box = bridgeDocument.querySelector<HTMLElement>('[data-plannotator-pinpoint-box]');
|
|
if (!box) throw new Error('pinpoint hover box missing');
|
|
expect(box.style.display).toBe('block');
|
|
const exitsBefore = primaryPosts('annotate-exit').length;
|
|
const clearsBefore = primaryPosts('selection-clear').length;
|
|
pressEscape();
|
|
// One press: the outline is gone AND the exit request went out. Clearing
|
|
// the outline is not a rung the user perceives — only a draft earns one.
|
|
expect(box.style.display).toBe('none');
|
|
expect(primaryPosts('annotate-exit').length).toBe(exitsBefore + 1);
|
|
expect(primaryPosts('selection-clear').length).toBe(clearsBefore);
|
|
// The bridge stays armed until the parent answers set-annotate-mode.
|
|
expect(bridgeDocument.body.hasAttribute('data-plannotator-pinpoint-cursor')).toBe(true);
|
|
});
|
|
|
|
test('Esc ladder: a pending draft clears first, then Esc asks to exit Annotate; the parent flips the mode', () => {
|
|
// Armed from the previous test. A fresh pending pinpoint draft:
|
|
const probe = clickProbe(probeButton());
|
|
expect(probe.prevented).toBe(true);
|
|
const countOf = (type: string) => primaryPosts(type).length;
|
|
const clearsBefore = countOf('selection-clear');
|
|
const exitsBefore = countOf('annotate-exit');
|
|
pressEscape();
|
|
// Rung 1: the draft clears; the mode is untouched (no exit post).
|
|
expect(countOf('selection-clear')).toBe(clearsBefore + 1);
|
|
expect(countOf('annotate-exit')).toBe(exitsBefore);
|
|
pressEscape();
|
|
// Final rung: nothing left to close — ask the parent to exit Annotate.
|
|
// The bridge does NOT flip itself (the parent owns the mode): capture
|
|
// stays armed until set-annotate-mode comes back down.
|
|
expect(countOf('annotate-exit')).toBe(exitsBefore + 1);
|
|
expect(bridgeDocument.body.hasAttribute('data-plannotator-pinpoint-cursor')).toBe(true);
|
|
// The parent answers: Interact. Cursor affordance drops, clicks are native.
|
|
postToBridge({ type: 'plannotator-bridge-set-annotate-mode', active: false, token: bridgeToken });
|
|
expect(bridgeDocument.body.hasAttribute('data-plannotator-pinpoint-cursor')).toBe(false);
|
|
const native = clickProbe(probeButton());
|
|
expect(native.prevented).toBe(false);
|
|
expect(native.selections).toBe(0);
|
|
});
|
|
|
|
test('INTERACT: text drag-selection commenting still works while plain clicks stay native', async () => {
|
|
// The ladder test above left the session in Interact.
|
|
expect(bridgeDocument.body.hasAttribute('data-plannotator-pinpoint-cursor')).toBe(false);
|
|
const p = dragProbeParagraph();
|
|
const before = selectionPosts().length;
|
|
await dragSelectContents(p);
|
|
const posts = selectionPosts();
|
|
expect(posts.length).toBe(before + 1);
|
|
expect(posts.at(-1)!.data.text).toBe('Draggable live copy');
|
|
// Esc with the drag draft open closes the draft — and ONLY the draft:
|
|
// there is no armed mode to exit, so no annotate-exit ever posts.
|
|
const clearsBefore = primaryPosts('selection-clear').length;
|
|
const exitsBefore = primaryPosts('annotate-exit').length;
|
|
pressEscape();
|
|
expect(primaryPosts('selection-clear').length).toBe(clearsBefore + 1);
|
|
expect(primaryPosts('annotate-exit').length).toBe(exitsBefore);
|
|
pressEscape();
|
|
expect(primaryPosts('annotate-exit').length).toBe(exitsBefore);
|
|
// Plain clicks reach the page natively in Interact.
|
|
const native = clickProbe(probeButton());
|
|
expect(native.prevented).toBe(false);
|
|
expect(native.selections).toBe(0);
|
|
});
|
|
|
|
test('INTERACT: a drifted click reaches the page untouched', async () => {
|
|
// Same trackpad drift as the armed test above, but in Interact the click
|
|
// belongs to the page: nothing is prevented, nothing posts.
|
|
const btn = probeButton();
|
|
bridgeWindow.getSelection()!.removeAllRanges();
|
|
let pageClicks = 0;
|
|
const pageListener = () => { pageClicks += 1; };
|
|
btn.addEventListener('click', pageListener);
|
|
try {
|
|
const before = selectionPosts().length;
|
|
btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0, clientX: 5, clientY: 5 }));
|
|
btn.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true, buttons: 1, clientX: 15, clientY: 12 }));
|
|
btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0, clientX: 15, clientY: 12 }));
|
|
const click = new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 15, clientY: 12 });
|
|
btn.dispatchEvent(click);
|
|
expect(click.defaultPrevented).toBe(false);
|
|
expect(pageClicks).toBe(1);
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
expect(selectionPosts().length).toBe(before);
|
|
} finally {
|
|
btn.removeEventListener('click', pageListener);
|
|
}
|
|
});
|
|
|
|
test('Mod+Shift+A inside the iframe forwards a toggle request to the parent in both modes', () => {
|
|
const countToggles = () => primaryPosts('annotate-toggle').length;
|
|
const before = countToggles();
|
|
const chord = new KeyboardEvent('keydown', { key: 'A', shiftKey: true, metaKey: true, bubbles: true, cancelable: true });
|
|
bridgeDocument.body.dispatchEvent(chord);
|
|
expect(countToggles()).toBe(before + 1);
|
|
expect(chord.defaultPrevented).toBe(true);
|
|
});
|
|
|
|
test('a placed marker still opens its comment in Interact mode', async () => {
|
|
// Committed overlay artifacts are mode-independent: restore an anchored
|
|
// annotation while the session is in Interact, then click its marker.
|
|
const host = bridgeDocument.createElement('div');
|
|
host.id = 'interact-marker-host';
|
|
host.textContent = 'Marker host content';
|
|
bridgeDocument.body.appendChild(host);
|
|
postToBridge({
|
|
type: 'plannotator-bridge-find-and-mark',
|
|
id: 'interact-pin',
|
|
originalText: '',
|
|
annotationType: 'comment',
|
|
anchor: { selector: '#interact-marker-host', tagName: 'div' },
|
|
token: bridgeToken,
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const overlay = bridgeDocument.querySelector('[data-plannotator-overlay-host]');
|
|
const root = (overlay as HTMLElement | null)?.shadowRoot ?? overlay;
|
|
const marker = root?.querySelector<HTMLElement>(
|
|
'button[data-plannotator-marker][data-annotation-id="interact-pin"]',
|
|
);
|
|
if (!marker) throw new Error('placed marker missing');
|
|
expect(marker.style.display).not.toBe('none');
|
|
const clicksBefore = primaryPosts('mark-click').length;
|
|
marker.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
const clicks = primaryPosts('mark-click');
|
|
expect(clicks.length).toBe(clicksBefore + 1);
|
|
expect(clicks.at(-1)!.data.id).toBe('interact-pin');
|
|
// The page element UNDER the annotation is still natively clickable.
|
|
const probe = clickProbe(host);
|
|
expect(probe.prevented).toBe(false);
|
|
expect(probe.selections).toBe(0);
|
|
});
|
|
|
|
test('set-vim-mode is ignored in live mode', () => {
|
|
postToBridge({
|
|
type: 'plannotator-bridge-set-vim-mode',
|
|
enabled: true,
|
|
hudEnabled: true,
|
|
mode: 'selection',
|
|
token: bridgeToken,
|
|
});
|
|
expect(bridgeDocument.body.hasAttribute('data-plannotator-vim-focus-owner')).toBe(false);
|
|
// Vim-owned surfaces only: the shared [data-plannotator-vim-ui] tag also
|
|
// rides the pinpoint hover box, which earlier annotate-mode tests create.
|
|
expect(bridgeDocument.querySelector('[data-plannotator-vim-cursor]')).toBeNull();
|
|
expect(bridgeDocument.querySelector('[data-plannotator-vim-badge]')).toBeNull();
|
|
expect(bridgeDocument.querySelector('[data-plannotator-vim-reticle]')).toBeNull();
|
|
});
|
|
|
|
test('a pushState burst posts exactly one coalesced page-change per editor origin', async () => {
|
|
const changesFor = (origin: string) =>
|
|
parentPosts.filter(
|
|
(p) => p.data.type === 'plannotator-bridge-page-change' && p.targetOrigin === origin,
|
|
);
|
|
const beforePrimary = changesFor(editorOrigin).length;
|
|
const beforeAlternate = changesFor('http://127.0.0.1:4100').length;
|
|
bridgeWindow.history.pushState({}, '', '/first');
|
|
bridgeWindow.history.pushState({}, '', '/second?tab=2');
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const primary = changesFor(editorOrigin);
|
|
expect(primary.length).toBe(beforePrimary + 1);
|
|
expect(changesFor('http://127.0.0.1:4100').length).toBe(beforeAlternate + 1);
|
|
const last = primary.at(-1)!;
|
|
// The reported page is whatever location the environment resolved the
|
|
// pushState to (happy-dom keeps about:blank-relative paths); the CONTRACT
|
|
// is that it always mirrors the live location, capped at 2048.
|
|
expect(last.data.pageUrl).toBe(
|
|
(bridgeWindow.location.pathname + bridgeWindow.location.search).slice(0, 2048),
|
|
);
|
|
expect(last.data.token).toBe(bridgeToken);
|
|
});
|
|
|
|
test('a zero-target restore is kept in live mode and re-acquired when its element appears', async () => {
|
|
// SPA navigation race (phase 1 exit bar): the parent re-applies a page's
|
|
// annotations right after a route change, but a lazy route has not
|
|
// rendered its elements yet. The restore resolves nothing; the record
|
|
// must survive so the reconcile machinery re-acquires it once the
|
|
// element exists, instead of the pin staying invisible for the visit.
|
|
postToBridge({
|
|
type: 'plannotator-bridge-find-and-mark',
|
|
id: 'late-pin',
|
|
originalText: '',
|
|
annotationType: 'comment',
|
|
anchor: { selector: '#late-target', tagName: 'div' },
|
|
token: bridgeToken,
|
|
});
|
|
const applied = parentPosts.filter(
|
|
(p) => p.data.type === 'plannotator-bridge-mark-applied' && p.data.id === 'late-pin',
|
|
);
|
|
expect(applied.length).toBeGreaterThan(0);
|
|
expect(applied[0]!.data.success).toBe(false);
|
|
// Let the queued overlay pass run (and fail its first dead search).
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
// The route finishes rendering: the anchored element appears.
|
|
const late = bridgeDocument.createElement('div');
|
|
late.id = 'late-target';
|
|
late.textContent = 'late content';
|
|
bridgeDocument.body.appendChild(late);
|
|
// Jump the wall-clock backoff a failed dead search installs, and bump
|
|
// the re-search generation deterministically via a settle event (the
|
|
// harness cannot rely on MutationObserver delivery timing).
|
|
monotonicOffsetMs += 6000;
|
|
late.dispatchEvent(new Event('transitionend', { bubbles: true }));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
// scroll-to proves the record still exists and its placeholder target
|
|
// re-resolved to the late element: a dropped record would scroll nothing.
|
|
const scrolled: Element[] = [];
|
|
const originalScrollIntoView = Element.prototype.scrollIntoView;
|
|
Element.prototype.scrollIntoView = function scrollProbe(this: Element) {
|
|
scrolled.push(this);
|
|
};
|
|
try {
|
|
postToBridge({ type: 'plannotator-bridge-scroll-to', id: 'late-pin', token: bridgeToken });
|
|
} finally {
|
|
Element.prototype.scrollIntoView = originalScrollIntoView;
|
|
}
|
|
expect(scrolled).toContain(late);
|
|
});
|
|
|
|
test('the unanchored report is token-stamped and posted per editor origin in live mode', async () => {
|
|
// Guards the rebase seam between the live message contract and the
|
|
// unanchored-transparency report: a raw parent.postMessage(..., '*')
|
|
// emission carries no token, so the live parent would silently drop it
|
|
// (rejectsLiveMessage) exactly where restores fail most. The report must
|
|
// travel like every other live outbound: token stamped, one post per
|
|
// listed editor origin.
|
|
parentPosts.length = 0;
|
|
// No anchor and no text: nothing can seed placeholder targets, so this
|
|
// restore is a total failure and must surface in the unanchored set.
|
|
postToBridge({
|
|
type: 'plannotator-bridge-find-and-mark',
|
|
id: 'ghost-pin',
|
|
originalText: '',
|
|
annotationType: 'comment',
|
|
token: bridgeToken,
|
|
});
|
|
let reports: ParentPost[] = [];
|
|
for (let i = 0; i < 50 && reports.length === 0; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
reports = parentPosts.filter((p) => p.data.type === 'plannotator-bridge-unanchored');
|
|
}
|
|
expect(reports.length).toBeGreaterThan(0);
|
|
const latest = reports[reports.length - 1]!;
|
|
expect(latest.data.ids as string[]).toContain('ghost-pin');
|
|
expect(latest.data.token).toBe(bridgeToken);
|
|
const reportOrigins = reports.map((p) => p.targetOrigin);
|
|
expect(reportOrigins).toContain(editorOrigin);
|
|
expect(reportOrigins).toContain('http://127.0.0.1:4100');
|
|
expect(reportOrigins).not.toContain('*');
|
|
});
|
|
});
|