mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
1422 lines
55 KiB
TypeScript
1422 lines
55 KiB
TypeScript
import { describe, expect, it, mock, beforeAll } from "bun:test";
|
|
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { parseHTML } from "linkedom";
|
|
import {
|
|
collectExternalAssets,
|
|
compileForRender,
|
|
detectRenderModeHints,
|
|
detectShaderTransitionUsage,
|
|
discoverAudioVolumeAutomationFromTimeline,
|
|
inlineExternalScripts,
|
|
localizeRemoteMediaSources,
|
|
localizeRemoteImageSources,
|
|
localizeRemoteFontFaces,
|
|
recompileWithResolutions,
|
|
} from "./htmlCompiler.js";
|
|
|
|
// ── collectExternalAssets ──────────────────────────────────────────────────
|
|
|
|
describe("collectExternalAssets", () => {
|
|
let projectDir: string;
|
|
let externalDir: string;
|
|
|
|
beforeAll(() => {
|
|
// Create a project dir and an external dir with assets
|
|
const base = mkdtempSync(join(tmpdir(), "hf-compiler-test-"));
|
|
projectDir = join(base, "project");
|
|
externalDir = join(base, "external");
|
|
mkdirSync(projectDir, { recursive: true });
|
|
mkdirSync(externalDir, { recursive: true });
|
|
|
|
// Internal asset (should NOT be collected)
|
|
writeFileSync(join(projectDir, "logo.png"), "fake-png");
|
|
|
|
// External asset (should be collected)
|
|
writeFileSync(join(externalDir, "hero.png"), "fake-hero");
|
|
writeFileSync(join(externalDir, "font.woff2"), "fake-font");
|
|
});
|
|
|
|
it("does not collect assets inside projectDir", () => {
|
|
const html = `<html><body><img src="logo.png"></body></html>`;
|
|
const result = collectExternalAssets(html, projectDir);
|
|
expect(result.externalAssets.size).toBe(0);
|
|
expect(result.html).toBe(html); // unchanged
|
|
});
|
|
|
|
it("collects and rewrites assets outside projectDir via src attribute", () => {
|
|
const html = `<html><body><img src="../external/hero.png"></body></html>`;
|
|
const result = collectExternalAssets(html, projectDir);
|
|
expect(result.externalAssets.size).toBe(1);
|
|
|
|
const [safeKey, absPath] = [...result.externalAssets.entries()][0]!;
|
|
expect(safeKey).toContain("hf-ext/");
|
|
expect(safeKey).toContain("external/hero.png");
|
|
expect(absPath).toBe(join(externalDir, "hero.png"));
|
|
expect(result.html).toContain(safeKey);
|
|
expect(result.html).not.toContain("../external/hero.png");
|
|
});
|
|
|
|
it("collects and rewrites CSS url() references outside projectDir", () => {
|
|
const html = `<html><head><style>.bg { background: url(../external/hero.png); }</style></head><body></body></html>`;
|
|
const result = collectExternalAssets(html, projectDir);
|
|
expect(result.externalAssets.size).toBe(1);
|
|
expect(result.html).toContain("hf-ext/");
|
|
expect(result.html).not.toContain("../external/hero.png");
|
|
});
|
|
|
|
it("collects and rewrites inline style url() references", () => {
|
|
const html = `<html><body><div style="background-image: url('../external/hero.png')"></div></body></html>`;
|
|
const result = collectExternalAssets(html, projectDir);
|
|
expect(result.externalAssets.size).toBe(1);
|
|
expect(result.html).toContain("hf-ext/");
|
|
});
|
|
|
|
it("skips http/https URLs", () => {
|
|
const html = `<html><body><img src="https://cdn.example.com/img.png"></body></html>`;
|
|
const result = collectExternalAssets(html, projectDir);
|
|
expect(result.externalAssets.size).toBe(0);
|
|
});
|
|
|
|
it("skips data: URIs", () => {
|
|
const html = `<html><body><img src="data:image/png;base64,abc123"></body></html>`;
|
|
const result = collectExternalAssets(html, projectDir);
|
|
expect(result.externalAssets.size).toBe(0);
|
|
});
|
|
|
|
it("skips absolute paths", () => {
|
|
const html = `<html><body><img src="/usr/share/fonts/foo.woff"></body></html>`;
|
|
const result = collectExternalAssets(html, projectDir);
|
|
expect(result.externalAssets.size).toBe(0);
|
|
});
|
|
|
|
it("skips fragment references", () => {
|
|
const html = `<html><body><a href="#section">link</a></body></html>`;
|
|
const result = collectExternalAssets(html, projectDir);
|
|
expect(result.externalAssets.size).toBe(0);
|
|
});
|
|
|
|
it("skips external paths that don't exist on disk", () => {
|
|
const html = `<html><body><img src="../nonexistent/nope.png"></body></html>`;
|
|
const result = collectExternalAssets(html, projectDir);
|
|
expect(result.externalAssets.size).toBe(0);
|
|
});
|
|
|
|
it("deduplicates multiple references to the same external file", () => {
|
|
const html = `<html><head>
|
|
<style>.a { background: url(../external/hero.png); } .b { background: url(../external/hero.png); }</style>
|
|
</head><body><img src="../external/hero.png"></body></html>`;
|
|
const result = collectExternalAssets(html, projectDir);
|
|
// Same file referenced 3 times, but Map deduplicates
|
|
expect(result.externalAssets.size).toBe(1);
|
|
});
|
|
|
|
it("handles paths with .. that resolve back into projectDir", () => {
|
|
// projectDir/subdir/../logo.png = projectDir/logo.png (inside project)
|
|
mkdirSync(join(projectDir, "subdir"), { recursive: true });
|
|
const html = `<html><body><img src="subdir/../logo.png"></body></html>`;
|
|
const result = collectExternalAssets(html, projectDir);
|
|
expect(result.externalAssets.size).toBe(0); // stays inside projectDir
|
|
});
|
|
|
|
it("collects multiple different external assets", () => {
|
|
const html = `<html><body>
|
|
<img src="../external/hero.png">
|
|
<link href="../external/font.woff2">
|
|
</body></html>`;
|
|
const result = collectExternalAssets(html, projectDir);
|
|
expect(result.externalAssets.size).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ── inlineExternalScripts ──────────────────────────────────────────────────
|
|
|
|
describe("inlineExternalScripts", () => {
|
|
it("returns HTML unchanged when no external scripts exist", async () => {
|
|
const html = `<html><body><script>var x = 1;</script></body></html>`;
|
|
const result = await inlineExternalScripts(html);
|
|
expect(result).toBe(html);
|
|
});
|
|
|
|
it("skips local script src (not http)", async () => {
|
|
const html = `<html><body><script src="./lib/app.js"></script></body></html>`;
|
|
const result = await inlineExternalScripts(html);
|
|
expect(result).toBe(html);
|
|
});
|
|
|
|
it("inlines a CDN script on successful fetch", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mock(async () => new Response("var gsap = {};", { status: 200 })) as any;
|
|
|
|
try {
|
|
const html = `<html><body><script src="https://cdn.example.com/gsap.min.js"></script></body></html>`;
|
|
const result = await inlineExternalScripts(html);
|
|
expect(result).toContain("/* inlined: https://cdn.example.com/gsap.min.js */");
|
|
expect(result).toContain("var gsap = {};");
|
|
expect(result).not.toContain('src="https://cdn.example.com/gsap.min.js"');
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("preserves non-src script attributes when inlining", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mock(
|
|
async () => new Response('console.log("module");', { status: 200 }),
|
|
) as any;
|
|
|
|
try {
|
|
const html =
|
|
'<html><body><script type="module" data-role="boot" src="https://cdn.example.com/module.js"></script></body></html>';
|
|
const result = await inlineExternalScripts(html);
|
|
|
|
expect(result).toMatch(/<script\b[^>]*\btype="module"/);
|
|
expect(result).toMatch(/<script\b[^>]*\bdata-role="boot"/);
|
|
expect(result).toContain('console.log("module");');
|
|
expect(result).not.toContain('src="https://cdn.example.com/module.js"');
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("escapes </script in downloaded content", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mock(
|
|
async () => new Response('var x = "</script><script>alert(1)</script>";', { status: 200 }),
|
|
) as any;
|
|
|
|
try {
|
|
const html = `<html><body><script src="https://cdn.example.com/evil.js"></script></body></html>`;
|
|
const result = await inlineExternalScripts(html);
|
|
// Should escape </script to <\/script
|
|
expect(result).not.toContain("</script><script>alert(1)</script>");
|
|
expect(result).toContain("<\\/script");
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("preserves literal replacement tokens in downloaded script content", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mock(
|
|
async () =>
|
|
new Response('const before = "$`"; const after = "$\'"; const both = "$&";', {
|
|
status: 200,
|
|
}),
|
|
) as any;
|
|
|
|
try {
|
|
const html = `<html><body><script src="https://cdn.example.com/d3.min.js"></script><div>tail</div></body></html>`;
|
|
const result = await inlineExternalScripts(html);
|
|
|
|
expect(result).toContain('const before = "$`";');
|
|
expect(result).toContain('const after = "$\'";');
|
|
expect(result).toContain('const both = "$&";');
|
|
expect(result.match(/<script>/g)?.length).toBe(1);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("returns a fragment when the input has no html/body wrapper", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mock(async () => new Response("var d3 = {};", { status: 200 })) as any;
|
|
|
|
try {
|
|
const html = '<script src="https://cdn.example.com/d3.min.js"></script><div>tail</div>';
|
|
const result = await inlineExternalScripts(html);
|
|
|
|
expect(result).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
|
|
expect(result).toContain("var d3 = {};");
|
|
expect(result).toContain("<div>tail</div>");
|
|
expect(result).not.toContain('src="https://cdn.example.com/d3.min.js"');
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("warns but keeps original tag when fetch fails", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mock(async () => {
|
|
throw new Error("Network error");
|
|
}) as any;
|
|
|
|
try {
|
|
const html = `<html><body><script src="https://cdn.example.com/gsap.min.js"></script></body></html>`;
|
|
const result = await inlineExternalScripts(html);
|
|
// Original script tag should remain since download failed
|
|
expect(result).toContain('src="https://cdn.example.com/gsap.min.js"');
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("handles multiple CDN scripts with mixed success/failure", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mock(async (url: string) => {
|
|
if (url.includes("gsap")) {
|
|
return new Response("var gsap = {};", { status: 200 });
|
|
}
|
|
throw new Error("404");
|
|
}) as any;
|
|
|
|
try {
|
|
const html = `<html><body>
|
|
<script src="https://cdn.example.com/gsap.min.js"></script>
|
|
<script src="https://cdn.example.com/lottie.min.js"></script>
|
|
</body></html>`;
|
|
const result = await inlineExternalScripts(html);
|
|
// GSAP should be inlined
|
|
expect(result).toContain("var gsap = {};");
|
|
// Lottie should remain as original tag
|
|
expect(result).toContain('src="https://cdn.example.com/lottie.min.js"');
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("handles duplicate CDN URLs (same script referenced twice)", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
let fetchCount = 0;
|
|
globalThis.fetch = mock(async () => {
|
|
fetchCount++;
|
|
return new Response("var gsap = {};", { status: 200 });
|
|
}) as any;
|
|
|
|
try {
|
|
const html = `<html><body>
|
|
<script src="https://cdn.example.com/gsap.min.js"></script>
|
|
<script src="https://cdn.example.com/gsap.min.js"></script>
|
|
</body></html>`;
|
|
const result = await inlineExternalScripts(html);
|
|
// Both identical script tags should be fetched and replaced independently.
|
|
expect(fetchCount).toBe(2);
|
|
expect(
|
|
result.match(/\/\* inlined: https:\/\/cdn\.example\.com\/gsap\.min\.js \*\//g)?.length,
|
|
).toBe(2);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("detectRenderModeHints", () => {
|
|
it("recommends screenshot mode for iframe compositions", () => {
|
|
const html = `<!DOCTYPE html>
|
|
<html><body>
|
|
<div data-composition-id="root" data-width="1920" data-height="1080">
|
|
<iframe src="./target.html"></iframe>
|
|
</div>
|
|
</body></html>`;
|
|
|
|
const result = detectRenderModeHints(html);
|
|
|
|
expect(result.recommendScreenshot).toBe(true);
|
|
expect(result.reasons.map((reason) => reason.code)).toEqual(["iframe"]);
|
|
});
|
|
|
|
it("recommends screenshot mode for inline requestAnimationFrame loops", () => {
|
|
const html = `<!DOCTYPE html>
|
|
<html><body>
|
|
<div data-composition-id="root" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
function tick() {
|
|
requestAnimationFrame(tick);
|
|
}
|
|
tick();
|
|
</script>
|
|
</body></html>`;
|
|
|
|
const result = detectRenderModeHints(html);
|
|
|
|
expect(result.recommendScreenshot).toBe(true);
|
|
expect(result.reasons.map((reason) => reason.code)).toEqual(["requestAnimationFrame"]);
|
|
});
|
|
|
|
it("ignores requestAnimationFrame inside comments and external scripts", () => {
|
|
const html = `<!DOCTYPE html>
|
|
<html><body>
|
|
<div data-composition-id="root" data-width="1920" data-height="1080"></div>
|
|
<script src="./runtime.js"></script>
|
|
<script>
|
|
// requestAnimationFrame(loop);
|
|
/* requestAnimationFrame(otherLoop); */
|
|
const label = "safe";
|
|
</script>
|
|
</body></html>`;
|
|
|
|
const result = detectRenderModeHints(html);
|
|
|
|
expect(result.recommendScreenshot).toBe(false);
|
|
expect(result.reasons).toEqual([]);
|
|
});
|
|
|
|
it("ignores compiler-generated nested mount wrappers when detecting requestAnimationFrame", () => {
|
|
const html = `<!DOCTYPE html>
|
|
<html><body>
|
|
<div data-composition-id="root" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
(function(){
|
|
var __compId = "intro";
|
|
var __run = function() {
|
|
const label = "safe";
|
|
};
|
|
if (!__compId) { __run(); return; }
|
|
/* __HF_COMPILER_MOUNT_START__ */
|
|
var __selector = '[data-composition-id="intro"]';
|
|
var __attempt = 0;
|
|
var __tryRun = function() {
|
|
if (document.querySelector(__selector)) { __run(); return; }
|
|
if (++__attempt >= 8) { __run(); return; }
|
|
requestAnimationFrame(__tryRun);
|
|
};
|
|
__tryRun();
|
|
/* __HF_COMPILER_MOUNT_END__ */
|
|
})();
|
|
</script>
|
|
</body></html>`;
|
|
|
|
const result = detectRenderModeHints(html);
|
|
|
|
expect(result.recommendScreenshot).toBe(false);
|
|
expect(result.reasons).toEqual([]);
|
|
});
|
|
|
|
it("still flags user-authored requestAnimationFrame inside nested composition scripts", () => {
|
|
const html = `<!DOCTYPE html>
|
|
<html><body>
|
|
<div data-composition-id="root" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
(function(){
|
|
var __compId = "intro";
|
|
var __run = function() {
|
|
function tick() {
|
|
requestAnimationFrame(tick);
|
|
}
|
|
tick();
|
|
};
|
|
if (!__compId) { __run(); return; }
|
|
/* __HF_COMPILER_MOUNT_START__ */
|
|
var __selector = '[data-composition-id="intro"]';
|
|
var __attempt = 0;
|
|
var __tryRun = function() {
|
|
if (document.querySelector(__selector)) { __run(); return; }
|
|
if (++__attempt >= 8) { __run(); return; }
|
|
requestAnimationFrame(__tryRun);
|
|
};
|
|
__tryRun();
|
|
/* __HF_COMPILER_MOUNT_END__ */
|
|
})();
|
|
</script>
|
|
</body></html>`;
|
|
|
|
const result = detectRenderModeHints(html);
|
|
|
|
expect(result.recommendScreenshot).toBe(true);
|
|
expect(result.reasons.map((reason) => reason.code)).toEqual(["requestAnimationFrame"]);
|
|
});
|
|
|
|
it("detects html-in-canvas API via layoutsubtree canvas attribute", () => {
|
|
const html = `<!DOCTYPE html>
|
|
<html><body>
|
|
<div data-composition-id="root" data-width="1920" data-height="1080">
|
|
<canvas id="glass-canvas" layoutsubtree width="1920" height="1080">
|
|
<div class="panel">Glass content</div>
|
|
</canvas>
|
|
</div>
|
|
</body></html>`;
|
|
|
|
const result = detectRenderModeHints(html);
|
|
|
|
expect(result.reasons.map((reason) => reason.code)).toContain("htmlInCanvas");
|
|
expect(result.recommendScreenshot).toBe(true);
|
|
});
|
|
|
|
it("does not flag htmlInCanvas for plain canvas elements without layoutsubtree", () => {
|
|
const html = `<!DOCTYPE html>
|
|
<html><body>
|
|
<div data-composition-id="root" data-width="1920" data-height="1080">
|
|
<canvas id="my-canvas" width="1920" height="1080"></canvas>
|
|
</div>
|
|
</body></html>`;
|
|
|
|
const result = detectRenderModeHints(html);
|
|
|
|
expect(result.reasons.map((reason) => reason.code)).not.toContain("htmlInCanvas");
|
|
});
|
|
|
|
it("does not recommend screenshot mode for nested compositions that hoist GSAP from a CDN script", async () => {
|
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-mode-"));
|
|
const compositionsDir = join(projectDir, "compositions");
|
|
mkdirSync(compositionsDir, { recursive: true });
|
|
|
|
writeFileSync(
|
|
join(projectDir, "index.html"),
|
|
`<!DOCTYPE html>
|
|
<html><body>
|
|
<div data-composition-id="root" data-width="1920" data-height="1080">
|
|
<div data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="0"></div>
|
|
</div>
|
|
</body></html>`,
|
|
);
|
|
writeFileSync(
|
|
join(compositionsDir, "intro.html"),
|
|
`<template id="intro-template">
|
|
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
|
<div data-composition-id="intro" data-width="1920" data-height="1080">
|
|
<div class="title">Hello</div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
window.__timelines["intro"] = gsap.timeline({ paused: true });
|
|
</script>
|
|
</div>
|
|
</template>`,
|
|
);
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = mock(async () => {
|
|
return new Response(
|
|
"window.gsap = { timeline: function() { return { paused: true }; } }; function __ticker(){ requestAnimationFrame(__ticker); }",
|
|
{ status: 200 },
|
|
);
|
|
}) as any;
|
|
|
|
try {
|
|
const result = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
|
|
|
expect(result.renderModeHints.recommendScreenshot).toBe(false);
|
|
expect(result.renderModeHints.reasons).toEqual([]);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
it("compileForRender skips empty sub-composition files instead of aborting", async () => {
|
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-empty-subcomp-"));
|
|
const compositionsDir = join(projectDir, "compositions");
|
|
mkdirSync(compositionsDir, { recursive: true });
|
|
writeFileSync(
|
|
join(projectDir, "index.html"),
|
|
`<!DOCTYPE html>
|
|
<html>
|
|
<head></head>
|
|
<body>
|
|
<div data-composition-id="main" data-width="100" data-height="100" data-start="0" data-duration="1">
|
|
<div data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="0" data-duration="1"></div>
|
|
</div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
window.__timelines.main = { duration: function() { return 1; } };
|
|
</script>
|
|
</body>
|
|
</html>`,
|
|
);
|
|
writeFileSync(join(compositionsDir, "intro.html"), "");
|
|
|
|
const result = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
|
expect(result.html).toContain("data-composition-id");
|
|
});
|
|
});
|
|
|
|
describe("detectShaderTransitionUsage", () => {
|
|
it("detects authored HyperShader initialization", () => {
|
|
const html = `<!doctype html>
|
|
<html><body>
|
|
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/shader-transitions/dist/index.global.js"></script>
|
|
<script>
|
|
window.HyperShader.init({
|
|
scenes: ["s1", "s2"],
|
|
transitions: [{ time: 1, shader: "cinematic-zoom", duration: 0.5 }],
|
|
});
|
|
</script>
|
|
</body></html>`;
|
|
|
|
expect(detectShaderTransitionUsage(html)).toBe(true);
|
|
});
|
|
|
|
it("ignores comments and external scripts by themselves", () => {
|
|
const html = `<!doctype html>
|
|
<html><body>
|
|
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/shader-transitions/dist/index.global.js"></script>
|
|
<script>
|
|
// window.HyperShader.init({ scenes: ["s1", "s2"], transitions: [] });
|
|
const label = "safe";
|
|
</script>
|
|
</body></html>`;
|
|
|
|
expect(detectShaderTransitionUsage(html)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("template-wrapped sub-composition media offsets", () => {
|
|
function writeTemplateWrappedProject(
|
|
hostAttrs: string,
|
|
mediaAttrs: string = 'data-start="0" data-duration="4"',
|
|
extraMediaMarkup: string = "",
|
|
): {
|
|
projectDir: string;
|
|
indexPath: string;
|
|
} {
|
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-template-offset-"));
|
|
const compositionsDir = join(projectDir, "compositions");
|
|
mkdirSync(compositionsDir, { recursive: true });
|
|
writeFileSync(
|
|
join(projectDir, "index.html"),
|
|
`<!DOCTYPE html>
|
|
<html>
|
|
<head></head>
|
|
<body>
|
|
<div
|
|
id="root"
|
|
data-composition-id="root"
|
|
data-start="0"
|
|
data-width="640"
|
|
data-height="360"
|
|
data-duration="4"
|
|
>
|
|
<div
|
|
id="scene-host"
|
|
data-composition-id="scene"
|
|
data-composition-src="compositions/scene.html"
|
|
${hostAttrs}
|
|
></div>
|
|
</div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
window.__timelines["root"] = { duration: () => 4 };
|
|
</script>
|
|
</body>
|
|
</html>`,
|
|
);
|
|
writeFileSync(
|
|
join(compositionsDir, "scene.html"),
|
|
`<template id="scene-template">
|
|
<div
|
|
data-composition-id="scene"
|
|
data-start="0"
|
|
data-width="640"
|
|
data-height="360"
|
|
data-duration="4"
|
|
>
|
|
<style>.title { opacity: 0; }</style>
|
|
<h1 class="title">Scene</h1>
|
|
<video
|
|
id="scene-video"
|
|
src="../assets/clip.mp4"
|
|
${mediaAttrs}
|
|
data-track-index="0"
|
|
></video>
|
|
${extraMediaMarkup}
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
window.__timelines["scene"] = { duration: () => 4 };
|
|
</script>
|
|
</div>
|
|
</template>`,
|
|
);
|
|
|
|
return { projectDir, indexPath: join(projectDir, "index.html") };
|
|
}
|
|
|
|
it("offsets template-wrapped media to the host start during compile", async () => {
|
|
const { projectDir, indexPath } = writeTemplateWrappedProject(
|
|
'data-start="2" data-duration="2" data-width="640" data-height="360"',
|
|
);
|
|
|
|
const compiled = await compileForRender(projectDir, indexPath, projectDir);
|
|
|
|
expect(compiled.videos).toHaveLength(1);
|
|
expect(compiled.videos[0]).toMatchObject({
|
|
id: "scene-video",
|
|
start: 2,
|
|
end: 6,
|
|
});
|
|
expect(compiled.audios).toHaveLength(1);
|
|
expect(compiled.audios[0]).toMatchObject({
|
|
id: "scene-video-audio",
|
|
start: 2,
|
|
end: 6,
|
|
});
|
|
});
|
|
|
|
it("preserves first-pass media offsets when durations are resolved after inlining", async () => {
|
|
const { projectDir, indexPath } = writeTemplateWrappedProject(
|
|
'data-start="2" data-width="640" data-height="360"',
|
|
);
|
|
|
|
const compiled = await compileForRender(projectDir, indexPath, projectDir);
|
|
expect(compiled.videos[0]?.start).toBe(2);
|
|
|
|
const recompiled = await recompileWithResolutions(
|
|
compiled,
|
|
[{ id: "scene-host", duration: 2 }],
|
|
projectDir,
|
|
projectDir,
|
|
);
|
|
|
|
expect(recompiled.videos).toHaveLength(1);
|
|
expect(recompiled.videos[0]).toMatchObject({
|
|
id: "scene-video",
|
|
start: 2,
|
|
end: 6,
|
|
});
|
|
expect(recompiled.audios).toHaveLength(1);
|
|
expect(recompiled.audios[0]).toMatchObject({
|
|
id: "scene-video-audio",
|
|
start: 2,
|
|
end: 6,
|
|
});
|
|
});
|
|
|
|
it("offsets scene-local media in compositions that start much later on the timeline", async () => {
|
|
const { projectDir, indexPath } = writeTemplateWrappedProject(
|
|
'data-start="20" data-duration="6" data-width="640" data-height="360"',
|
|
'data-start="1.5" data-duration="4"',
|
|
);
|
|
|
|
const compiled = await compileForRender(projectDir, indexPath, projectDir);
|
|
|
|
expect(compiled.videos).toHaveLength(1);
|
|
expect(compiled.videos[0]).toMatchObject({
|
|
id: "scene-video",
|
|
start: 21.5,
|
|
end: 25.5,
|
|
});
|
|
expect(compiled.audios).toHaveLength(1);
|
|
expect(compiled.audios[0]).toMatchObject({
|
|
id: "scene-video-audio",
|
|
start: 21.5,
|
|
end: 25.5,
|
|
});
|
|
});
|
|
|
|
it("includes explicit audio from template-wrapped sub-compositions", async () => {
|
|
const { projectDir, indexPath } = writeTemplateWrappedProject(
|
|
'data-start="5" data-duration="6" data-width="640" data-height="360"',
|
|
'data-start="1" data-duration="4"',
|
|
`<audio
|
|
id="scene-audio"
|
|
src="../assets/narration.wav"
|
|
data-start="2"
|
|
data-duration="3"
|
|
data-track-index="1"
|
|
></audio>`,
|
|
);
|
|
|
|
const compiled = await compileForRender(projectDir, indexPath, projectDir);
|
|
|
|
expect(compiled.audios).toContainEqual(
|
|
expect.objectContaining({
|
|
id: "scene-audio",
|
|
start: 7,
|
|
end: 10,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("flattens the sub-composition root onto the host in compiled render HTML", async () => {
|
|
const { projectDir, indexPath } = writeTemplateWrappedProject(
|
|
'data-start="20" data-duration="6" data-width="640" data-height="360"',
|
|
'data-start="1.5" data-duration="4"',
|
|
);
|
|
|
|
const compiled = await compileForRender(projectDir, indexPath, projectDir);
|
|
|
|
const { document } = parseHTML(compiled.html);
|
|
const host = document.querySelector("#scene-host");
|
|
|
|
expect(host?.getAttribute("data-composition-id")).toBe("scene");
|
|
expect(host?.getAttribute("data-start")).toBe("20");
|
|
expect(host?.getAttribute("data-width")).toBe("640");
|
|
expect(host?.querySelector(".title")?.textContent).toBe("Scene");
|
|
expect(
|
|
Array.from(host?.children ?? []).some(
|
|
(child) => child.getAttribute("data-composition-id") === "scene",
|
|
),
|
|
).toBe(false);
|
|
expect(compiled.html).toContain('[data-composition-id="scene"] .title');
|
|
expect(compiled.html).toContain("new Proxy(window.document");
|
|
expect(compiled.html).toContain("__hfNormalizeSelector");
|
|
});
|
|
|
|
it("preserves the inferred composition boundary when the host has no composition id", async () => {
|
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-anonymous-host-"));
|
|
const compositionsDir = join(projectDir, "compositions");
|
|
mkdirSync(compositionsDir, { recursive: true });
|
|
writeFileSync(
|
|
join(projectDir, "index.html"),
|
|
`<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<div id="root" data-composition-id="root" data-width="640" data-height="360">
|
|
<div id="scene-host" data-composition-src="compositions/scene.html" data-start="0"></div>
|
|
</div>
|
|
</body>
|
|
</html>`,
|
|
);
|
|
writeFileSync(
|
|
join(compositionsDir, "scene.html"),
|
|
`<template id="scene-template">
|
|
<div data-composition-id="scene" data-width="640" data-height="360" data-duration="4">
|
|
<style>.title { opacity: 0; }</style>
|
|
<h1 class="title">Scene</h1>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
window.__timelines.scene = { duration: () => 4 };
|
|
</script>
|
|
</div>
|
|
</template>`,
|
|
);
|
|
|
|
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
|
const { document } = parseHTML(compiled.html);
|
|
const host = document.querySelector("#scene-host");
|
|
|
|
expect(host?.getAttribute("data-composition-id")).toBeNull();
|
|
expect(host?.querySelector('[data-composition-id="scene"] .title')?.textContent).toBe("Scene");
|
|
expect(compiled.html).toContain('var __hfCompId = "scene";');
|
|
});
|
|
});
|
|
|
|
// ── injectTextRenderingRule (via compileForRender) ─────────────────────────
|
|
//
|
|
// Forces `text-rendering: geometricPrecision` so chrome-headless-shell
|
|
// (BeginFrame) and full Chrome lay text out identically. See
|
|
// `injectTextRenderingRule` in htmlCompiler.ts for full context.
|
|
|
|
describe("text-rendering rule injection", () => {
|
|
it("injects a single geometricPrecision rule into <head> for a full-document composition", async () => {
|
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-text-rendering-"));
|
|
writeFileSync(
|
|
join(projectDir, "index.html"),
|
|
`<!DOCTYPE html>
|
|
<html>
|
|
<head><title>t</title></head>
|
|
<body>
|
|
<div data-composition-id="root" data-width="640" data-height="360" data-duration="1">
|
|
<h1>Hello</h1>
|
|
</div>
|
|
</body>
|
|
</html>`,
|
|
);
|
|
|
|
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
|
|
|
const { document } = parseHTML(compiled.html);
|
|
const styleEls = document.querySelectorAll("style[data-hyperframes-text-rendering]");
|
|
expect(styleEls.length).toBe(1);
|
|
expect((styleEls[0]?.textContent || "").replace(/\s+/g, "")).toContain(
|
|
"html,body,*{text-rendering:geometricPrecision}",
|
|
);
|
|
expect(styleEls[0]?.parentElement?.tagName.toLowerCase()).toBe("head");
|
|
});
|
|
|
|
it("includes geometricPrecision in the fragment-wrap fallback stylesheet", async () => {
|
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-text-rendering-frag-"));
|
|
// Fragment (no <html>/<head>/<body>) — exercises ensureFullDocument.
|
|
writeFileSync(
|
|
join(projectDir, "index.html"),
|
|
`<div data-composition-id="root" data-width="640" data-height="360" data-duration="1"><h1>Hi</h1></div>`,
|
|
);
|
|
|
|
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
|
|
|
expect(compiled.html.replace(/\s+/g, "")).toContain("text-rendering:geometricPrecision");
|
|
expect(compiled.html.replace(/\s+/g, "")).toContain('font-family:"Inter",sans-serif');
|
|
});
|
|
});
|
|
|
|
// ── crossorigin stripping ───────────────────────────────────────────────────
|
|
//
|
|
// External images/videos with crossorigin="anonymous" force CORS-mode requests
|
|
// against the renderer's localhost file server. S3 and similar origins reject
|
|
// those requests, so the element renders blank. The strip removes the attribute
|
|
// so the browser falls back to no-cors (visual-only) mode.
|
|
|
|
describe("crossorigin attribute stripping", () => {
|
|
it("strips crossorigin from <img> elements", async () => {
|
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-crossorigin-img-"));
|
|
writeFileSync(
|
|
join(projectDir, "index.html"),
|
|
`<!DOCTYPE html><html><body>
|
|
<div data-composition-id="root" data-width="640" data-height="360" data-duration="1">
|
|
<img id="hero" src="https://example.com/photo.jpg" crossorigin="anonymous" alt="" />
|
|
<img id="plain" src="local.jpg" alt="" />
|
|
</div>
|
|
</body></html>`,
|
|
);
|
|
|
|
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
|
|
|
expect(compiled.html).not.toContain('crossorigin="anonymous"');
|
|
expect(compiled.html).toContain('id="hero"');
|
|
expect(compiled.html).toContain('id="plain"');
|
|
});
|
|
|
|
it("strips crossorigin from <video> elements", async () => {
|
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-crossorigin-video-"));
|
|
writeFileSync(
|
|
join(projectDir, "index.html"),
|
|
`<!DOCTYPE html><html><body>
|
|
<div data-composition-id="root" data-width="640" data-height="360" data-duration="1">
|
|
<video id="clip" src="https://example.com/clip.mp4" crossorigin="anonymous" data-start="0" data-duration="1"></video>
|
|
</div>
|
|
</body></html>`,
|
|
);
|
|
|
|
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
|
|
|
expect(compiled.html).not.toContain("crossorigin");
|
|
expect(compiled.html).toContain('id="clip"');
|
|
});
|
|
|
|
it("strips crossorigin from <audio> elements", async () => {
|
|
const projectDir = mkdtempSync(join(tmpdir(), "hf-crossorigin-audio-"));
|
|
writeFileSync(
|
|
join(projectDir, "index.html"),
|
|
`<!DOCTYPE html><html><body>
|
|
<div data-composition-id="root" data-width="640" data-height="360" data-duration="5">
|
|
<audio id="bgm" src="https://example.com/bgm.mp3" crossorigin="anonymous" data-start="0" data-duration="5" data-volume="0.8"></audio>
|
|
</div>
|
|
</body></html>`,
|
|
);
|
|
|
|
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
|
|
|
|
expect(compiled.html).not.toContain("crossorigin");
|
|
expect(compiled.html).toContain('id="bgm"');
|
|
});
|
|
});
|
|
|
|
// ── remote media localization ────────────────────────────────────────────────
|
|
//
|
|
// Tests run on localizeRemoteMediaSources directly (exported for testing) to
|
|
// avoid invoking ffprobe / the full compileForRender pipeline. fetch is patched
|
|
// in-process for success cases; real 404s from example.com cover fallback.
|
|
|
|
describe("localizeRemoteMediaSources", () => {
|
|
it("rewrites remote <video> src to _remote_media path when download succeeds", async () => {
|
|
const orig = globalThis.fetch;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-dl-ok-"));
|
|
const html = `<video id="v1" src="https://media-ok.example.com/a/clip.mp4" data-start="0" data-end="10" muted></video>`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteMediaSources(html, dl);
|
|
expect(result).not.toContain("https://media-ok.example.com/");
|
|
expect(result).toContain("_remote_media/");
|
|
expect(remoteMediaAssets.size).toBe(1);
|
|
} finally {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = orig;
|
|
}
|
|
});
|
|
|
|
it("preserves original URL on download failure without throwing", async () => {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-dl-fail-"));
|
|
const url = "https://example.com/will-404-localize-test.mp4";
|
|
const html = `<video id="v1" src="${url}" data-start="0" data-end="10" muted></video>`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteMediaSources(html, dl);
|
|
expect(result).toContain(url);
|
|
expect(remoteMediaAssets.size).toBe(0);
|
|
});
|
|
|
|
it("deduplicates: two tags with the same src URL → one download", async () => {
|
|
const orig = globalThis.fetch;
|
|
let fetchCount = 0;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async () => {
|
|
fetchCount++;
|
|
return new Response(new Uint8Array(100), { status: 200 });
|
|
};
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-dl-dedup-"));
|
|
const html = `<video id="v1" src="https://dedup.example.com/b/shared.mp4" data-start="0" data-end="10" muted></video>
|
|
<video id="v2" src="https://dedup.example.com/b/shared.mp4" data-start="10" data-end="20" muted></video>`;
|
|
await localizeRemoteMediaSources(html, dl);
|
|
expect(fetchCount).toBe(1);
|
|
} finally {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = orig;
|
|
}
|
|
});
|
|
|
|
it("does not rewrite local (non-HTTP) src paths", async () => {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-dl-local-"));
|
|
const html = `<video id="v1" src="assets/local.mp4" data-start="0" data-end="10" muted></video>`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteMediaSources(html, dl);
|
|
expect(result).toContain("assets/local.mp4");
|
|
expect(result).not.toContain("_remote_media/");
|
|
expect(remoteMediaAssets.size).toBe(0);
|
|
});
|
|
|
|
it("rewrites src in both double-quoted and single-quoted attributes", async () => {
|
|
const orig = globalThis.fetch;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-dl-quotes-"));
|
|
const html = `<video id="v1" src="https://q.example.com/c/dq.mp4" data-start="0" data-end="10" muted></video>
|
|
<audio id="a1" src='https://q.example.com/c/sq.mp3' data-start="0" data-end="10"></audio>`;
|
|
const { html: result } = await localizeRemoteMediaSources(html, dl);
|
|
expect(result).not.toContain("https://q.example.com/");
|
|
expect(result.match(/_remote_media\//g)?.length).toBe(2);
|
|
} finally {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = orig;
|
|
}
|
|
});
|
|
|
|
it("uses path.basename for OS-portable filename extraction from downloaded path", () => {
|
|
// Guards against the prior absPath.split('/').at(-1) pattern. On Windows
|
|
// path.join uses `\` separators; splitting on `/` would return the entire
|
|
// path as a single element, producing a garbage relPath. path.basename is
|
|
// OS-aware and extracts the filename correctly on both platforms.
|
|
const { basename: b } = require("node:path");
|
|
expect(b("/tmp/_remote_media/download_abc123.mp4")).toBe("download_abc123.mp4");
|
|
});
|
|
});
|
|
|
|
// ── localizeRemoteImageSources ───────────────────────────────────────────────
|
|
//
|
|
// Regression coverage for the agent-pipeline `<img>` flicker bug: producer's
|
|
// frame-capture has no `pollImagesReady` analog of `pollVideosReady`, so a
|
|
// composition with raw S3 `<img src="https://...">` URLs (astral / daphne /
|
|
// hyperion multi-v2 outputs) reaches Chrome with a network dependency that
|
|
// races the readiness gate AND can be evicted mid-render. Localising before
|
|
// render is the architectural fix; `pollImagesReady` in frameCapture is the
|
|
// defense-in-depth layer.
|
|
//
|
|
// Mirrors the localizeRemoteMediaSources test shape; fetch is patched in
|
|
// for success cases and a real 404 covers the fallback path.
|
|
|
|
describe("localizeRemoteImageSources", () => {
|
|
it("rewrites remote <img> src to _remote_media path when download succeeds", async () => {
|
|
const orig = globalThis.fetch;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-img-ok-"));
|
|
const html = `<img class="hero" src="https://img-ok.example.com/photo.png" />`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteImageSources(html, dl);
|
|
expect(result).not.toContain("https://img-ok.example.com/");
|
|
expect(result).toContain("_remote_media/");
|
|
expect(remoteMediaAssets.size).toBe(1);
|
|
} finally {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = orig;
|
|
}
|
|
});
|
|
|
|
it("preserves original URL on download failure without throwing", async () => {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-img-fail-"));
|
|
const url = "https://example.com/will-404-image-localize-test.png";
|
|
const html = `<img src="${url}" />`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteImageSources(html, dl);
|
|
expect(result).toContain(url);
|
|
expect(remoteMediaAssets.size).toBe(0);
|
|
});
|
|
|
|
it("deduplicates: two <img> tags with the same src URL → one download", async () => {
|
|
const orig = globalThis.fetch;
|
|
let fetchCount = 0;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async () => {
|
|
fetchCount++;
|
|
return new Response(new Uint8Array(100), { status: 200 });
|
|
};
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-img-dedup-"));
|
|
const html = `<img src="https://dedup-img.example.com/hero.jpg" />
|
|
<img src="https://dedup-img.example.com/hero.jpg" />`;
|
|
await localizeRemoteImageSources(html, dl);
|
|
expect(fetchCount).toBe(1);
|
|
} finally {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = orig;
|
|
}
|
|
});
|
|
|
|
it("does not rewrite local (non-HTTP) src paths", async () => {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-img-local-"));
|
|
const html = `<img src="assets/hero.png" />`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteImageSources(html, dl);
|
|
expect(result).toContain("assets/hero.png");
|
|
expect(result).not.toContain("_remote_media/");
|
|
expect(remoteMediaAssets.size).toBe(0);
|
|
});
|
|
|
|
it("does not rewrite data: URI src", async () => {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-img-data-"));
|
|
const html = `<img src="data:image/svg+xml,%3Csvg/%3E" />`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteImageSources(html, dl);
|
|
expect(result).toContain("data:image/svg+xml");
|
|
expect(remoteMediaAssets.size).toBe(0);
|
|
});
|
|
|
|
it("rewrites both double-quoted and single-quoted src attributes", async () => {
|
|
const orig = globalThis.fetch;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-img-quotes-"));
|
|
const html = `<img src="https://q-img.example.com/dq.png" />
|
|
<img src='https://q-img.example.com/sq.jpg' />`;
|
|
const { html: result } = await localizeRemoteImageSources(html, dl);
|
|
expect(result).not.toContain("https://q-img.example.com/");
|
|
expect(result.match(/_remote_media\//g)?.length).toBe(2);
|
|
} finally {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = orig;
|
|
}
|
|
});
|
|
|
|
it("does not match data-src (lazy-loader placeholder), only the real src attribute", async () => {
|
|
// A lazy-loader emits the real asset in `data-src` and a placeholder in
|
|
// `src`. We must localise what Chrome actually paints (the real `src`),
|
|
// not the `data-src` URL — matching `data-src` would download an asset the
|
|
// render never shows and could break the loader's runtime swap.
|
|
const orig = globalThis.fetch;
|
|
let fetchCount = 0;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async () => {
|
|
fetchCount++;
|
|
return new Response(new Uint8Array(100), { status: 200 });
|
|
};
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-img-datasrc-"));
|
|
const html = `<img data-src="https://lazy.example.com/real.png" src="https://cdn.example.com/placeholder.png" />`;
|
|
const { html: result } = await localizeRemoteImageSources(html, dl);
|
|
// The real src is localised; the data-src URL is left untouched.
|
|
expect(result).toContain("https://lazy.example.com/real.png");
|
|
expect(result).not.toContain("https://cdn.example.com/placeholder.png");
|
|
expect(fetchCount).toBe(1);
|
|
} finally {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = orig;
|
|
}
|
|
});
|
|
|
|
it("handles src attribute not as the first attribute (agent-pipeline shape)", async () => {
|
|
// The 02_kobe astral-pipeline composition that surfaced this bug emits
|
|
// <img> tags with `class` before `src`. Regex must not assume src position.
|
|
const orig = globalThis.fetch;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-img-attr-order-"));
|
|
const html = `<img class="kobe-cutout" alt="kobe" src="https://astral.example.com/d828bca.png" />`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteImageSources(html, dl);
|
|
expect(result).not.toContain("https://astral.example.com/");
|
|
expect(result).toContain("_remote_media/");
|
|
expect(remoteMediaAssets.size).toBe(1);
|
|
} finally {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = orig;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── localizeRemoteFontFaces ──────────────────────────────────────────────────
|
|
|
|
describe("localizeRemoteFontFaces", () => {
|
|
const FONT_URL = "https://gen-os-static.s3.us-east-2.amazonaws.com/fonts/komika-axis.ttf";
|
|
|
|
it("rewrites @font-face url() inside <style> to _remote_media/ path", async () => {
|
|
const orig = globalThis.fetch;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async () => new Response(new Uint8Array(16), { status: 200 });
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-ff-"));
|
|
const html = `<style>
|
|
@font-face {
|
|
font-family: "Komika Axis";
|
|
src: url("${FONT_URL}") format("truetype");
|
|
}
|
|
</style>`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
|
expect(result).not.toContain(FONT_URL);
|
|
expect(result).toContain("_remote_media/");
|
|
expect(remoteMediaAssets.size).toBe(1);
|
|
} finally {
|
|
globalThis.fetch = orig;
|
|
}
|
|
});
|
|
|
|
it("ignores url() references outside @font-face (e.g. background-image)", async () => {
|
|
const orig = globalThis.fetch;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async () => new Response(new Uint8Array(16), { status: 200 });
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-ff-bg-"));
|
|
const BG_URL = "https://cdn.example.com/bg.png";
|
|
const html = `<style>
|
|
body { background-image: url("${BG_URL}"); }
|
|
@font-face { font-family: "F"; src: url("${FONT_URL}") format("truetype"); }
|
|
</style>`;
|
|
const { html: result } = await localizeRemoteFontFaces(html, dl);
|
|
// Font URL rewritten, background URL untouched
|
|
expect(result).not.toContain(FONT_URL);
|
|
expect(result).toContain(BG_URL);
|
|
} finally {
|
|
globalThis.fetch = orig;
|
|
}
|
|
});
|
|
|
|
it("preserves original URL when download fails", async () => {
|
|
const orig = globalThis.fetch;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async () => new Response(null, { status: 403 });
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-ff-fail-"));
|
|
const FAIL_URL = "https://fail-font.example.com/f.ttf";
|
|
const html = `<style>@font-face { font-family: "F"; src: url("${FAIL_URL}") format("truetype"); }</style>`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
|
expect(result).toContain(FAIL_URL);
|
|
expect(remoteMediaAssets.size).toBe(0);
|
|
} finally {
|
|
globalThis.fetch = orig;
|
|
}
|
|
});
|
|
|
|
it("deduplicates: same font URL in two @font-face blocks → 1 download", async () => {
|
|
const orig = globalThis.fetch;
|
|
let fetchCount = 0;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async () => {
|
|
fetchCount++;
|
|
return new Response(new Uint8Array(16), { status: 200 });
|
|
};
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-ff-dedup-"));
|
|
const DEDUP_URL = "https://dedup-font.example.com/d.ttf";
|
|
const html = `<style>
|
|
@font-face { font-family: "F1"; src: url("${DEDUP_URL}") format("truetype"); font-weight: 400; }
|
|
@font-face { font-family: "F2"; src: url("${DEDUP_URL}") format("truetype"); font-weight: 700; }
|
|
</style>`;
|
|
const { remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
|
expect(fetchCount).toBe(1);
|
|
expect(remoteMediaAssets.size).toBe(1);
|
|
} finally {
|
|
globalThis.fetch = orig;
|
|
}
|
|
});
|
|
|
|
it("no-ops when no @font-face blocks are present", async () => {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-ff-noop-"));
|
|
const html = `<style>body { color: red; }</style>`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
|
expect(result).toBe(html);
|
|
expect(remoteMediaAssets.size).toBe(0);
|
|
});
|
|
|
|
it("ignores local (non-HTTP) @font-face src URLs", async () => {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-ff-local-"));
|
|
const html = `<style>@font-face { font-family: "F"; src: url("assets/fonts/f.ttf") format("truetype"); }</style>`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
|
expect(result).toBe(html);
|
|
expect(remoteMediaAssets.size).toBe(0);
|
|
});
|
|
|
|
// ── External <link rel="stylesheet"> inlining ──
|
|
|
|
it("leaves Google Fonts <link> tags untouched", async () => {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-ff-gf-"));
|
|
const html = `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700">
|
|
<link rel="stylesheet" href="https://fonts.gstatic.com/s/inter/inter.css">
|
|
<style>body { color: red; }</style>`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
|
// Google Fonts links should remain as-is — the deterministic font injector handles them
|
|
expect(result).toContain("fonts.googleapis.com");
|
|
expect(result).toContain("fonts.gstatic.com");
|
|
expect(result).toBe(html);
|
|
expect(remoteMediaAssets.size).toBe(0);
|
|
});
|
|
|
|
it("does not process non-stylesheet <link> tags (rel=icon, rel=preconnect)", async () => {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-ff-nonss-"));
|
|
const html = `<link rel="icon" href="https://cdn.example.com/favicon.ico">
|
|
<link rel="preconnect" href="https://cdn.example.com">
|
|
<link rel="dns-prefetch" href="https://cdn.example.com">`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
|
expect(result).toBe(html);
|
|
expect(remoteMediaAssets.size).toBe(0);
|
|
});
|
|
|
|
it("inlines @font-face rules from external non-Google stylesheet and downloads font files", async () => {
|
|
const EXTERNAL_FONT_URL = "https://cdn.example.com/fonts/custom.woff2";
|
|
const STYLESHEET_URL = "https://cdn.example.com/fonts/style.css";
|
|
const fakeCss = `
|
|
/* Reset styles */
|
|
body { margin: 0; }
|
|
@font-face {
|
|
font-family: "CustomFont";
|
|
src: url("${EXTERNAL_FONT_URL}") format("woff2");
|
|
font-weight: 400;
|
|
}
|
|
h1 { font-size: 2rem; }`;
|
|
|
|
const orig = globalThis.fetch;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async (url: string) => {
|
|
if (url === STYLESHEET_URL) {
|
|
return new Response(fakeCss, { status: 200 });
|
|
}
|
|
// Font file download
|
|
return new Response(new Uint8Array(16), { status: 200 });
|
|
};
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-ff-ext-"));
|
|
const html = `<link rel="stylesheet" href="${STYLESHEET_URL}">
|
|
<style>body { color: red; }</style>`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
|
// The <link> tag should be replaced with an inline <style> containing the @font-face
|
|
expect(result).not.toContain(`href="${STYLESHEET_URL}"`);
|
|
expect(result).not.toContain("<link");
|
|
expect(result).toContain("@font-face");
|
|
expect(result).toContain("CustomFont");
|
|
// The remote font URL should be rewritten to a local path
|
|
expect(result).not.toContain(EXTERNAL_FONT_URL);
|
|
expect(result).toContain("_remote_media/");
|
|
expect(remoteMediaAssets.size).toBe(1);
|
|
} finally {
|
|
globalThis.fetch = orig;
|
|
}
|
|
});
|
|
|
|
it("keeps <link> tag when external stylesheet fetch fails (graceful degradation)", async () => {
|
|
const STYLESHEET_URL = "https://down.example.com/fonts.css";
|
|
const orig = globalThis.fetch;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async () => new Response(null, { status: 503 });
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-ff-extfail-"));
|
|
const html = `<link rel="stylesheet" href="${STYLESHEET_URL}">`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
|
// Original link tag preserved on failure
|
|
expect(result).toContain(STYLESHEET_URL);
|
|
expect(result).toContain("<link");
|
|
expect(remoteMediaAssets.size).toBe(0);
|
|
} finally {
|
|
globalThis.fetch = orig;
|
|
}
|
|
});
|
|
|
|
it("keeps <link> tag when external stylesheet has no @font-face rules", async () => {
|
|
const STYLESHEET_URL = "https://cdn.example.com/reset.css";
|
|
const orig = globalThis.fetch;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async () =>
|
|
new Response("body { margin: 0; } h1 { color: blue; }", { status: 200 });
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-ff-noff-"));
|
|
const html = `<link rel="stylesheet" href="${STYLESHEET_URL}">`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
|
// No @font-face → keep the original link tag (non-font CSS may be needed)
|
|
expect(result).toContain(STYLESHEET_URL);
|
|
expect(result).toContain("<link");
|
|
expect(remoteMediaAssets.size).toBe(0);
|
|
} finally {
|
|
globalThis.fetch = orig;
|
|
}
|
|
});
|
|
|
|
it("handles multiple external stylesheets, mixing Google and non-Google", async () => {
|
|
const FONT_CDN_URL = "https://use.typekit.net/abc123.css";
|
|
const FONT_FILE_URL = "https://use.typekit.net/af/font.woff2";
|
|
const fontCss = `@font-face { font-family: "AdobeFont"; src: url("${FONT_FILE_URL}") format("woff2"); }`;
|
|
|
|
const orig = globalThis.fetch;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).fetch = async (url: string) => {
|
|
if (url === FONT_CDN_URL) return new Response(fontCss, { status: 200 });
|
|
return new Response(new Uint8Array(16), { status: 200 });
|
|
};
|
|
try {
|
|
const dl = mkdtempSync(join(tmpdir(), "hf-ff-multi-"));
|
|
const html = `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto">
|
|
<link rel="stylesheet" href="${FONT_CDN_URL}">`;
|
|
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
|
|
// Google link untouched
|
|
expect(result).toContain("fonts.googleapis.com");
|
|
// Typekit link inlined — the <link> tag is replaced; the URL only appears in a CSS comment
|
|
expect(result).not.toContain(`href="${FONT_CDN_URL}"`);
|
|
expect(result).toContain("AdobeFont");
|
|
expect(result).toContain("_remote_media/");
|
|
expect(remoteMediaAssets.size).toBe(1);
|
|
} finally {
|
|
globalThis.fetch = orig;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("discoverAudioVolumeAutomationFromTimeline", () => {
|
|
it("samples video-derived audio volume without firing GSAP callbacks", async () => {
|
|
class TestAudioElement {}
|
|
class TestVideoElement {
|
|
id = "bg-video";
|
|
dataset = { start: "0", duration: "1", volume: "0" };
|
|
volume = 0;
|
|
}
|
|
|
|
const video = new TestVideoElement();
|
|
const seekCalls: { time: number; suppressEvents: boolean | undefined }[] = [];
|
|
const previousWindow = globalThis.window;
|
|
const previousDocument = globalThis.document;
|
|
const previousAudioElement = globalThis.HTMLAudioElement;
|
|
const previousVideoElement = globalThis.HTMLVideoElement;
|
|
|
|
globalThis.window = {
|
|
__timelines: {
|
|
root: {
|
|
totalTime: (time: number, suppressEvents?: boolean) => {
|
|
seekCalls.push({ time, suppressEvents });
|
|
video.volume = Math.min(1, Math.max(0, time));
|
|
},
|
|
},
|
|
},
|
|
} as any;
|
|
globalThis.document = {
|
|
querySelector: (selector: string) =>
|
|
selector === "[data-composition-id]"
|
|
? { getAttribute: (name: string) => (name === "data-composition-id" ? "root" : null) }
|
|
: null,
|
|
getElementById: (id: string) => (id === "bg-video" ? video : null),
|
|
} as any;
|
|
globalThis.HTMLAudioElement = TestAudioElement as any;
|
|
globalThis.HTMLVideoElement = TestVideoElement as any;
|
|
|
|
try {
|
|
const page = {
|
|
evaluate: async (fn: (arg: unknown) => unknown, arg: unknown) => fn(arg),
|
|
} as any;
|
|
|
|
const result = await discoverAudioVolumeAutomationFromTimeline(
|
|
page,
|
|
["bg-video-audio"],
|
|
1,
|
|
2,
|
|
);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
id: "bg-video-audio",
|
|
keyframes: [
|
|
{ time: 0, volume: 0 },
|
|
{ time: 0.5, volume: 0.5 },
|
|
{ time: 1, volume: 1 },
|
|
],
|
|
},
|
|
]);
|
|
expect(seekCalls.length).toBeGreaterThan(0);
|
|
expect(seekCalls.every((call) => call.suppressEvents === true)).toBe(true);
|
|
} finally {
|
|
globalThis.window = previousWindow;
|
|
globalThis.document = previousDocument;
|
|
globalThis.HTMLAudioElement = previousAudioElement;
|
|
globalThis.HTMLVideoElement = previousVideoElement;
|
|
}
|
|
});
|
|
});
|