Files
backnotprop__plannotator/packages/ui/components/html-viewer/HtmlViewer.vimHud.test.tsx
Michael Ramos 7d6dd29c08 perf(ui): load the HTML viewer bridge by URL for hosts, with a protocol version and ready timeout (#1398)
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.
2026-08-27 08:45:22 -07:00

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());
});
});