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.
357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import React from 'react';
|
|
import { act } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { AnnotationType } from '../../types';
|
|
import { BRIDGE_PROTOCOL_VERSION } from './bridge-script';
|
|
|
|
const hasDom = typeof document !== 'undefined';
|
|
const htmlViewerModule = hasDom ? await import('./HtmlViewer') : null;
|
|
|
|
afterEach(() => {
|
|
if (hasDom) document.body.replaceChildren();
|
|
});
|
|
|
|
describe.if(hasDom)('HtmlViewer Vim HUD bridge', () => {
|
|
// Guards the parent-side plumbing the refresh flow depends on: a bridge
|
|
// "unanchored" report from the viewer's own iframe reaches
|
|
// onUnanchoredChange. No reload happens here; the bridge's restore pass is
|
|
// simulated by dispatching the message it would post.
|
|
test('forwards the bridge unanchored report to onUnanchoredChange', async () => {
|
|
if (!htmlViewerModule) throw new Error('DOM test environment is not registered');
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
const reports: string[][] = [];
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<htmlViewerModule.HtmlViewer
|
|
rawHtml="<html><body><p>Updated document</p></body></html>"
|
|
annotations={[
|
|
{
|
|
id: 'still-here',
|
|
blockId: '',
|
|
startOffset: 0,
|
|
endOffset: 10,
|
|
type: AnnotationType.COMMENT,
|
|
originalText: 'Updated',
|
|
createdA: 1,
|
|
},
|
|
{
|
|
id: 'removed-text',
|
|
blockId: '',
|
|
startOffset: 0,
|
|
endOffset: 7,
|
|
type: AnnotationType.COMMENT,
|
|
originalText: 'Removed',
|
|
createdA: 2,
|
|
},
|
|
]}
|
|
onAddAnnotation={() => {}}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode="selection"
|
|
inputMethod="drag"
|
|
onUnanchoredChange={(missingIds) => reports.push(missingIds)}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
const iframe = host.querySelector<HTMLIFrameElement>('iframe');
|
|
if (!iframe?.contentWindow) throw new Error('HTML iframe missing');
|
|
const dispatchBridgeMessage = (data: Record<string, unknown>) => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data,
|
|
}));
|
|
};
|
|
|
|
act(() => dispatchBridgeMessage({ type: 'plannotator-bridge-ready', protocolVersion: BRIDGE_PROTOCOL_VERSION }));
|
|
act(() => dispatchBridgeMessage({
|
|
type: 'plannotator-bridge-unanchored',
|
|
ids: ['removed-text'],
|
|
}));
|
|
|
|
expect(reports).toEqual([['removed-text']]);
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
test('copies only validated Vim text from the focused sandbox', async () => {
|
|
if (!htmlViewerModule) {
|
|
throw new Error('DOM test environment is not registered');
|
|
}
|
|
const clipboardDescriptor = Object.getOwnPropertyDescriptor(
|
|
navigator,
|
|
'clipboard',
|
|
);
|
|
const writes: string[] = [];
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: {
|
|
writeText: async (text: string) => {
|
|
writes.push(text);
|
|
},
|
|
},
|
|
});
|
|
|
|
const host = document.createElement('div');
|
|
const outsideButton = document.createElement('button');
|
|
document.body.append(host, outsideButton);
|
|
const root = createRoot(host);
|
|
|
|
try {
|
|
await act(async () => {
|
|
root.render(
|
|
<htmlViewerModule.HtmlViewer
|
|
rawHtml="<html><body><p>Raw copy target</p></body></html>"
|
|
annotations={[]}
|
|
onAddAnnotation={() => {}}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode="selection"
|
|
inputMethod="drag"
|
|
vimModeEnabled
|
|
vimHudEnabled={false}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
const iframe = host.querySelector<HTMLIFrameElement>('iframe');
|
|
if (!iframe?.contentWindow) throw new Error('HTML iframe missing');
|
|
const postCopy = (text: unknown) => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data: {
|
|
type: 'plannotator-bridge-vim-copy',
|
|
text,
|
|
},
|
|
}));
|
|
};
|
|
|
|
act(() => outsideButton.focus());
|
|
act(() => postCopy('not focused'));
|
|
expect(writes).toEqual([]);
|
|
|
|
act(() => iframe.focus());
|
|
act(() => postCopy('Raw copy target'));
|
|
expect(writes).toEqual(['Raw copy target']);
|
|
expect(document.activeElement).toBe(iframe);
|
|
|
|
act(() => postCopy(''));
|
|
act(() => postCopy({ unsafe: true }));
|
|
act(() => postCopy('x'.repeat(2 * 1024 * 1024 + 1)));
|
|
act(() => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: window,
|
|
data: {
|
|
type: 'plannotator-bridge-vim-copy',
|
|
text: 'wrong source',
|
|
},
|
|
}));
|
|
});
|
|
expect(writes).toEqual(['Raw copy target']);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<htmlViewerModule.HtmlViewer
|
|
rawHtml="<html><body><p>Raw copy target</p></body></html>"
|
|
annotations={[]}
|
|
onAddAnnotation={() => {}}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode="selection"
|
|
inputMethod="drag"
|
|
vimModeEnabled={false}
|
|
vimHudEnabled={false}
|
|
/>,
|
|
);
|
|
});
|
|
act(() => iframe.focus());
|
|
act(() => postCopy('disabled'));
|
|
expect(writes).toEqual(['Raw copy target']);
|
|
} finally {
|
|
act(() => root.unmount());
|
|
if (clipboardDescriptor) {
|
|
Object.defineProperty(
|
|
navigator,
|
|
'clipboard',
|
|
clipboardDescriptor,
|
|
);
|
|
} else {
|
|
delete (navigator as Navigator & { clipboard?: Clipboard }).clipboard;
|
|
}
|
|
}
|
|
});
|
|
|
|
test('renders validated iframe command messages through the shared parent HUD', async () => {
|
|
if (!htmlViewerModule) {
|
|
throw new Error('DOM test environment is not registered');
|
|
}
|
|
const host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
const root = createRoot(host);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<htmlViewerModule.HtmlViewer
|
|
rawHtml="<html><body><p>First</p><p>Second</p></body></html>"
|
|
annotations={[]}
|
|
onAddAnnotation={() => {}}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode="selection"
|
|
inputMethod="pinpoint"
|
|
vimModeEnabled
|
|
vimHudEnabled
|
|
/>,
|
|
);
|
|
});
|
|
|
|
const iframe = host.querySelector<HTMLIFrameElement>('iframe');
|
|
if (!iframe?.contentWindow) throw new Error('HTML iframe missing');
|
|
act(() => iframe.focus());
|
|
act(() => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data: {
|
|
type: 'plannotator-bridge-vim-state',
|
|
phase: 'block',
|
|
},
|
|
}));
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data: {
|
|
type: 'plannotator-bridge-vim-command',
|
|
actionId: 'moveDown',
|
|
key: 'j',
|
|
context: 'block',
|
|
},
|
|
}));
|
|
});
|
|
|
|
expect(document.querySelector('[data-vim-key-hud]')).not.toBeNull();
|
|
expect(document.querySelector('[data-vim-hud-active-key="j"]')).not.toBeNull();
|
|
expect(document.querySelector('[data-vim-hud-phase]')?.textContent)
|
|
.toBe('BLOCK / PINPOINT');
|
|
expect(document.querySelector('[data-vim-hud-command]')?.textContent)
|
|
.toBe('Next block');
|
|
|
|
const mapToggle = document.querySelector<HTMLButtonElement>(
|
|
'[data-vim-key-map-toggle]',
|
|
);
|
|
act(() => mapToggle?.focus());
|
|
expect(document.activeElement).toBe(mapToggle);
|
|
expect(document.querySelector('[data-vim-key-hud]')).not.toBeNull();
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data: {
|
|
type: 'plannotator-bridge-vim-help',
|
|
open: true,
|
|
},
|
|
}));
|
|
});
|
|
expect(document.querySelector('[data-vim-key-map]')).not.toBeNull();
|
|
expect(document.querySelector('[data-vim-key-hud]')?.getAttribute('data-expanded'))
|
|
.toBe('true');
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data: {
|
|
type: 'plannotator-bridge-vim-help',
|
|
open: false,
|
|
},
|
|
}));
|
|
});
|
|
expect(document.querySelector('[data-vim-key-map]')).toBeNull();
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data: {
|
|
type: 'plannotator-bridge-vim-command',
|
|
actionId: 'not-a-real-action',
|
|
key: 'x',
|
|
context: 'block',
|
|
},
|
|
}));
|
|
});
|
|
expect(document.querySelector('[data-vim-hud-active-key="j"]')).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<htmlViewerModule.HtmlViewer
|
|
rawHtml="<html><body><p>First</p><p>Second</p></body></html>"
|
|
annotations={[]}
|
|
onAddAnnotation={() => {}}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode="selection"
|
|
inputMethod="pinpoint"
|
|
vimModeEnabled
|
|
vimHudEnabled
|
|
vimHudKeyPanelEnabled={false}
|
|
/>,
|
|
);
|
|
});
|
|
act(() => iframe.focus());
|
|
expect(document.querySelector('[data-vim-key-hud]')).toBeNull();
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data: {
|
|
type: 'plannotator-bridge-vim-help',
|
|
open: true,
|
|
},
|
|
}));
|
|
});
|
|
expect(document.querySelector('[data-vim-key-hud]')).not.toBeNull();
|
|
expect(document.querySelector('[data-vim-key-map]')).not.toBeNull();
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data: {
|
|
type: 'plannotator-bridge-vim-help',
|
|
open: false,
|
|
},
|
|
}));
|
|
});
|
|
expect(document.querySelector('[data-vim-key-hud]')).toBeNull();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<htmlViewerModule.HtmlViewer
|
|
rawHtml="<html><body><p>First</p><p>Second</p></body></html>"
|
|
annotations={[]}
|
|
onAddAnnotation={() => {}}
|
|
onSelectAnnotation={() => {}}
|
|
selectedAnnotationId={null}
|
|
mode="selection"
|
|
inputMethod="pinpoint"
|
|
vimModeEnabled
|
|
vimHudEnabled={false}
|
|
/>,
|
|
);
|
|
});
|
|
act(() => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
source: iframe.contentWindow,
|
|
data: {
|
|
type: 'plannotator-bridge-vim-command',
|
|
actionId: 'moveUp',
|
|
key: 'k',
|
|
context: 'block',
|
|
},
|
|
}));
|
|
});
|
|
expect(document.querySelector('[data-vim-key-hud]')).toBeNull();
|
|
|
|
act(() => root.unmount());
|
|
});
|
|
});
|