Files
openmaic/OpenMAIC/tests/video-export/prepare-interactive-html.test.ts
2026-08-16 14:58:47 +08:00

234 lines
8.9 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { prepareInteractiveHtmlScenes } from '@/lib/video-export-app/prepare-interactive-html';
import type { Scene } from '@/lib/types/stage';
function scene(html?: string): Scene {
return {
id: 'widget',
stageId: 'stage',
title: 'Widget',
order: 0,
type: 'interactive',
content: { type: 'interactive', url: '', html },
actions: [],
} as Scene;
}
describe('prepareInteractiveHtmlScenes', () => {
it('produces a bounded packaged page with CSP, iframe shims, and freeze runtime', async () => {
const prepared = await prepareInteractiveHtmlScenes([
scene('<!doctype html><html><head></head><body><h1>Ready</h1></body></html>'),
]);
const meta = prepared.html(scene());
const html = prepared.content('interactive:widget');
expect(meta).toMatchObject({
id: 'interactive:widget',
present: true,
contentHash: expect.stringMatching(/^[a-f0-9]{64}$/),
});
expect(html).toContain('data-openmaic-static-csp');
expect(html).toContain('data-iframe-storage-shim');
expect(html).toContain('data-iframe-error-shim');
expect(html).toContain('data-openmaic-static-capture');
expect(html).toContain(
"document.documentElement.setAttribute('data-openmaic-static-state', 'frozen')",
);
expect(html).toContain("connect-src 'none'");
expect(html).toContain("worker-src 'none'");
expect(html).toContain('interactive-worker-disabled');
});
it('keeps the doctype first when authored HTML omits an explicit head', async () => {
const prepared = await prepareInteractiveHtmlScenes([
scene('<!doctype html><html><body><h1>Standards mode</h1></body></html>'),
]);
const html = prepared.content('interactive:widget')!;
expect(html.toLowerCase().startsWith('<!doctype html><html><head>')).toBe(true);
expect(html.indexOf('data-openmaic-static-csp')).toBeGreaterThan(html.indexOf('<head>'));
expect(html.indexOf('</head>')).toBeLessThan(html.indexOf('<body>'));
});
it('does not inject into head-like text in comments or authored scripts', async () => {
const authoredScript = 'const template = "<head>"; window.__template = template;';
const prepared = await prepareInteractiveHtmlScenes([
scene(
`<!doctype html><!-- <head> --><html><body><script>${authoredScript}</script></body></html>`,
),
]);
const html = prepared.content('interactive:widget')!;
expect(prepared.html(scene())?.present).toBe(true);
expect(html).toContain(`<script>${authoredScript}</script>`);
expect(html.indexOf('data-openmaic-static-csp')).toBeLessThan(html.indexOf('<body>'));
});
it('ignores embedded-document markup inside authored script text', async () => {
const authoredScript =
'const template = `<iframe src="data:text/html,example"></iframe>`; window.__template = template;';
const prepared = await prepareInteractiveHtmlScenes([
scene(`<script>${authoredScript}</script><main>Ready</main>`),
]);
expect(prepared.html(scene())).toMatchObject({ present: true });
expect(prepared.content('interactive:widget')).toContain(`<script>${authoredScript}</script>`);
});
it('inlines supported remote assets and removes the network URL', async () => {
const prepared = await prepareInteractiveHtmlScenes(
[scene('<html><head></head><body><img src="https://cdn.test/pixel.png"></body></html>')],
{
fetcher: async (url) =>
url === 'https://cdn.test/pixel.png'
? { bytes: new Uint8Array([1, 2, 3]), contentType: 'image/png' }
: null,
},
);
expect(prepared.html(scene())?.present).toBe(true);
expect(prepared.content('interactive:widget')).toContain('src="data:image/png;base64,');
expect(prepared.content('interactive:widget')).not.toContain('https://cdn.test/pixel.png');
});
it('accepts bare module imports resolved by the final offline import map', async () => {
const prepared = await prepareInteractiveHtmlScenes(
[
scene(
'<script type="importmap">{"imports":{"dep":"https://cdn.test/dep.js"}}</script>' +
'<script type="module">import { value } from "dep"; window.__value = value;</script>',
),
],
{
fetcher: async (url) =>
url === 'https://cdn.test/dep.js'
? {
bytes: new TextEncoder().encode('export const value = 42;'),
contentType: 'text/javascript',
}
: null,
},
);
expect(prepared.html(scene())).toMatchObject({ present: true });
expect(prepared.content('interactive:widget')).toContain('"dep":"data:text/javascript;base64,');
});
it('explicitly rejects scoped import maps instead of changing their semantics', async () => {
const authored =
'<script type="importmap">' +
'{"imports":{},"scopes":{"https://app.test/":{"dep":"https://cdn.test/dep.js"}}}' +
'</script><script type="module" src="https://app.test/main.js"></script>';
const calls: string[] = [];
const prepared = await prepareInteractiveHtmlScenes([scene(authored)], {
fetcher: async (url) => {
calls.push(url);
return url === 'https://app.test/main.js'
? {
bytes: new TextEncoder().encode('import { value } from "dep";'),
contentType: 'text/javascript',
}
: null;
},
});
expect(prepared.html(scene())).toMatchObject({
present: false,
failure: 'unresolved-resource',
message: expect.stringContaining('unsupported-importmap-scopes'),
});
expect(prepared.content('interactive:widget')).toBeUndefined();
expect(calls).toEqual([]);
});
it('rejects unresolved remote or relative resources with an explicit failure', async () => {
const remote = await prepareInteractiveHtmlScenes(
[scene('<img src="https://cdn.test/missing.png">')],
{ fetcher: async () => null },
);
expect(remote.html(scene())).toMatchObject({
present: false,
failure: 'unresolved-resource',
message: expect.stringContaining('https://cdn.test/missing.png'),
});
expect(remote.content('interactive:widget')).toBeUndefined();
const relative = await prepareInteractiveHtmlScenes([
scene('<script src="./app.js"></script>'),
]);
expect(relative.html(scene())).toMatchObject({
present: false,
failure: 'unresolved-resource',
message: expect.stringContaining('./app.js'),
});
const responsive = await prepareInteractiveHtmlScenes(
[scene('<img srcset="https://cdn.test/missing.png 2x">')],
{ fetcher: async () => null },
);
expect(responsive.html(scene())).toMatchObject({
present: false,
failure: 'unresolved-resource',
message: expect.stringContaining('https://cdn.test/missing.png'),
});
const poster = await prepareInteractiveHtmlScenes(
[scene('<video poster="https://cdn.test/missing-poster.jpg"></video>')],
{ fetcher: async () => null },
);
expect(poster.html(scene())).toMatchObject({
present: false,
failure: 'unresolved-resource',
message: expect.stringContaining('https://cdn.test/missing-poster.jpg'),
});
for (const embedded of [
'<iframe src="https://embed.test/frame"></iframe>',
'<object data="https://embed.test/object"></object>',
'<embed src="https://embed.test/plugin">',
'<iframe src="data:text/html,%3Cp%3Eframe%3C/p%3E"></iframe>',
'<object data="data:image/svg+xml,%3Csvg%3E%3C/svg%3E"></object>',
'<embed src="blob:https://embed.test/plugin-id">',
'<iframe srcdoc="<script>setInterval(function(){}, 10)<\/script>"></iframe>',
]) {
const prepared = await prepareInteractiveHtmlScenes([scene(embedded)], {
fetcher: async () => null,
});
expect(prepared.html(scene())).toMatchObject({
present: false,
failure: 'unresolved-resource',
message: expect.stringMatching(/(?:https?|data|blob):|iframe\[srcdoc\]/),
});
}
});
it('rejects a page whose packaged bytes exceed the configured cap', async () => {
const prepared = await prepareInteractiveHtmlScenes([scene('<p>large</p>')], {
maxHtmlBytes: 32,
});
expect(prepared.html(scene())).toMatchObject({
present: false,
failure: 'too-large',
message: expect.stringContaining('/32'),
});
});
it('records missing embedded HTML without throwing', async () => {
const prepared = await prepareInteractiveHtmlScenes([scene('')]);
expect(prepared.html(scene())).toMatchObject({
present: false,
failure: 'missing-html',
});
});
it('removes KaTeX about:invalid font fallbacks from the packaged page', async () => {
const prepared = await prepareInteractiveHtmlScenes([
scene('<style>@font-face{src:url(about:invalid) format("woff")}</style>'),
]);
expect(prepared.html(scene())).toMatchObject({ present: true });
expect(prepared.content('interactive:widget')).not.toContain('about:invalid');
});
});