Merge pull request #402 from shenlvkang-collab/pr/webview-route-masking

fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads
This commit is contained in:
Codeman maintainer
2026-09-14 23:38:54 +02:00
9 changed files with 424 additions and 10 deletions
+71
View File
@@ -40,6 +40,7 @@ interface AppLike {
openWebview(id: string, options?: { path?: string }): Promise<void>;
_apiJson(path: string, opts?: { method?: string; body?: unknown }): Promise<unknown>;
_updateActiveWebviewTab(): void;
_installWebviewLostListener(): void;
showToast?: (msg: string, kind: string) => void;
}
@@ -315,3 +316,73 @@ describe('callers consult the hook first', () => {
expect(section.indexOf('openLinkThroughWebTabIfLoopback?.(urlLink.href)')).toBeGreaterThan(urlHandler);
});
});
/**
* Lost-frame recovery: the server's recovery page posts `{type, path}` to the
* parent; the tab that owns the frame remounts it inside the prefix at that path.
*/
describe('lost-frame recovery', () => {
const lost = (win: Window, source: unknown, path: unknown) =>
win.dispatchEvent(
new (win as unknown as { MessageEvent: typeof MessageEvent }).MessageEvent('message', {
data: { type: 'codeman:webview-lost', path },
source: source as Window,
})
);
const frameOf = (win: Window, id: string) =>
win.document.querySelector(`.webview-frame[data-webview-id="${id}"] iframe`) as HTMLIFrameElement;
it('remounts the frame that sent the message at the path it lost, inside the prefix', async () => {
const { win, app } = boot();
app._installWebviewLostListener();
await app.openWebview('dev');
const frame = frameOf(win, 'dev');
lost(win, frame.contentWindow, '/about?tab=2#top');
await vi.waitFor(() => expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/about?tab=2#top'));
expect(frameOf(win, 'dev')).toBe(frame);
});
it('recovers to the landing page for a bare reload', async () => {
const { win, app } = boot();
app._installWebviewLostListener();
await app.openWebview('dev');
await app.openUrlInWebTab('http://localhost:5173/deep');
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/deep');
lost(win, frameOf(win, 'dev').contentWindow, '/');
await vi.waitFor(() => expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/'));
});
it('ignores a message that did not come from one of its frames, or is malformed', async () => {
const { win, app, calls } = boot();
app._installWebviewLostListener();
await app.openWebview('dev');
const before = calls.length;
lost(win, win, '/elsewhere');
lost(win, frameOf(win, 'dev').contentWindow, 42);
win.dispatchEvent(
new (win as unknown as { MessageEvent: typeof MessageEvent }).MessageEvent('message', {
data: 'codeman:webview-lost',
source: frameOf(win, 'dev').contentWindow as Window,
})
);
await new Promise((r) => setTimeout(r, 20));
expect(calls.length).toBe(before);
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/');
});
it('never lets the path jump the frame off the proxy, and bounds a reload loop', async () => {
const { win, app, calls } = boot();
app._installWebviewLostListener();
await app.openWebview('dev');
lost(win, frameOf(win, 'dev').contentWindow, '//evil.example/x');
await vi.waitFor(() => expect(calls.filter((c) => c.path.endsWith('/open')).length).toBe(2));
expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/');
const opensBefore = calls.filter((c) => c.path.endsWith('/open')).length;
for (let i = 0; i < 10; i += 1) lost(win, frameOf(win, 'dev').contentWindow, `/spin-${i}`);
await new Promise((r) => setTimeout(r, 50));
const opens = calls.filter((c) => c.path.endsWith('/open')).length - opensBefore;
expect(opens).toBeLessThanOrEqual(5);
expect(opens).toBeGreaterThan(0);
});
});