Files
openmaic/OpenMAIC/tests/lib/utils/iframe.test.ts
2026-08-16 14:58:47 +08:00

146 lines
6.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { patchHtmlForIframe } from '@/lib/utils/iframe';
describe('patchHtmlForIframe', () => {
it('injects the storage shim and sizing CSS after <head>', () => {
const out = patchHtmlForIframe(
'<!DOCTYPE html><html><head><title>t</title></head><body></body></html>',
);
expect(out).toContain('data-iframe-storage-shim');
expect(out).toContain('data-iframe-patch');
});
it('runs the storage shim before the page scripts', () => {
const html =
'<!DOCTYPE html><html><head><script>window.__x = localStorage.getItem("k");</script></head><body></body></html>';
const out = patchHtmlForIframe(html);
// The shim must appear before the page's own <script> so storage is safe by then.
expect(out.indexOf('data-iframe-storage-shim')).toBeLessThan(out.indexOf('window.__x'));
});
it('the shim provides a working in-memory storage when the real one throws', () => {
// Execute the injected shim against a fake window whose localStorage getter
// throws (mirroring a null-origin sandboxed iframe), then assert the shim
// installed a usable in-memory store.
const out = patchHtmlForIframe('<html><head></head><body></body></html>');
const shim = out.match(/<script data-iframe-storage-shim>([\s\S]*?)<\/script>/)?.[1];
expect(shim).toBeTruthy();
const win: Record<string, unknown> = {};
Object.defineProperty(win, 'localStorage', {
configurable: true,
get() {
throw new Error('sandboxed');
},
});
Object.defineProperty(win, 'sessionStorage', {
configurable: true,
get() {
throw new Error('sandboxed');
},
});
new Function('window', shim as string)(win);
const ls = win.localStorage as Storage;
expect(ls.getItem('missing')).toBeNull();
ls.setItem('a', '1');
expect(ls.getItem('a')).toBe('1');
expect(ls.length).toBe(1);
ls.removeItem('a');
expect(ls.getItem('a')).toBeNull();
});
it('creates a head when the authored document has none', () => {
const out = patchHtmlForIframe('<div>no head</div>');
expect(out.startsWith('<head>\n<script data-iframe-error-shim>')).toBe(true);
expect(out.indexOf('</head>')).toBeLessThan(out.indexOf('<div>'));
});
it('does not treat head-like text in comments or scripts as the document head', () => {
const authoredScript = 'const template = "<head>"; window.__template = template;';
const html = `<!doctype html><!-- <head> --><html><body><script>${authoredScript}</script></body></html>`;
const out = patchHtmlForIframe(html);
expect(out).toContain(`<script>${authoredScript}</script>`);
expect(out.indexOf('data-iframe-error-shim')).toBeLessThan(out.indexOf('<body>'));
});
it('injects the error-capture shim before the storage shim and page scripts', () => {
const html = '<!DOCTYPE html><html><head><script>boom()</script></head><body></body></html>';
const out = patchHtmlForIframe(html);
expect(out).toContain('data-iframe-error-shim');
// error shim runs first → before storage shim → before page scripts, so it
// catches errors from everything that follows.
expect(out.indexOf('data-iframe-error-shim')).toBeLessThan(
out.indexOf('data-iframe-storage-shim'),
);
expect(out.indexOf('data-iframe-storage-shim')).toBeLessThan(out.indexOf('boom()'));
});
it('the error shim posts runtime errors (onerror / resource / rejection / console.error) to the parent', () => {
const out = patchHtmlForIframe('<html><head></head><body></body></html>');
const shim = out.match(/<script data-iframe-error-shim>([\s\S]*?)<\/script>/)?.[1];
expect(shim).toBeTruthy();
const posts: Array<[Record<string, unknown>, string]> = [];
const handlers: Record<string, (e: unknown) => void> = {};
const win = {
parent: { postMessage: (m: Record<string, unknown>, o: string) => posts.push([m, o]) },
addEventListener: (t: string, cb: (e: unknown) => void) => {
handlers[t] = cb;
},
console: { error: (..._args: unknown[]) => {} },
};
new Function('window', shim as string)(win);
handlers.error({ message: 'JSON.parse boom', filename: 'p.html', lineno: 12 });
expect(posts[0][0]).toMatchObject({ kind: 'runtime-error', errorKind: 'error' });
expect(posts[0][0].message).toContain('JSON.parse boom');
expect(posts[0][1]).toBe('*');
handlers.error({ target: { src: 'https://cdn/katex.js' } });
expect(String(posts[1][0].message)).toContain('Failed to load resource');
handlers.unhandledrejection({ reason: { message: 'rej' } });
expect(posts[2][0]).toMatchObject({ errorKind: 'unhandledrejection' });
win.console.error('console boom');
expect(posts[3][0]).toMatchObject({ errorKind: 'console.error' });
expect(String(posts[3][0].message)).toContain('console boom');
});
it('the error shim buffers errors and re-emits them on a parent replay request', () => {
// Guards the subscribe-after-insert race: a page that throws synchronously
// while srcDoc parses may post before the parent subscribes. The shim must
// re-emit the whole buffer when the parent asks, so nothing is lost.
const out = patchHtmlForIframe('<html><head></head><body></body></html>');
const shim = out.match(/<script data-iframe-error-shim>([\s\S]*?)<\/script>/)?.[1];
const posts: Array<[Record<string, unknown>, string]> = [];
const handlers: Record<string, (e: unknown) => void> = {};
const win = {
parent: { postMessage: (m: Record<string, unknown>, o: string) => posts.push([m, o]) },
addEventListener: (t: string, cb: (e: unknown) => void) => {
handlers[t] = cb;
},
console: { error: (..._args: unknown[]) => {} },
};
new Function('window', shim as string)(win);
// Two errors fire "before the parent subscribed".
handlers.error({ message: 'first boom' });
handlers.unhandledrejection({ reason: { message: 'second boom' } });
expect(posts).toHaveLength(2);
// Parent now subscribes and requests a replay.
handlers.message({ data: { __maicErrorReplayRequest: true } });
expect(posts).toHaveLength(4);
expect(String(posts[2][0].message)).toContain('first boom');
expect(String(posts[3][0].message)).toContain('second boom');
expect(posts[2][0]).toMatchObject({ kind: 'runtime-error', errorKind: 'error' });
// An unrelated message must NOT trigger a replay.
handlers.message({ data: { foo: 1 } });
expect(posts).toHaveLength(4);
});
});