137 lines
6.0 KiB
TypeScript
137 lines
6.0 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('falls back to prepending when there is no <head>', () => {
|
|
const out = patchHtmlForIframe('<div>no head</div>');
|
|
// The error-capture shim is injected first, so it leads the prepended block.
|
|
expect(out.startsWith('\n<script data-iframe-error-shim>')).toBe(true);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|