Files
hyperframes/packages/core/src/studio-api/helpers/subComposition.test.ts
T
Miguel Ángel 3336fddd41 fix(studio): handle full HTML doc sub-compositions in preview (#885)
## Summary

- **Root cause**: `buildSubCompositionHtml` assumed all sub-compositions used `<template>` wrappers. Full HTML document blocks (like `north-korea-locked-down` and `nyc-paris-flight`) were nested as-is inside `<body>`, producing invalid HTML with nested `<html>` and `<head>` elements
- **Effect**: the composition's `<style>` tags ended up misplaced inside `<body>`, and `<img src="assets/...">` paths failed to resolve when combined with the injected `<base>` tag — resulting in missing map images in the Studio sub-composition preview
- **Fix**: detect full HTML documents and properly extract head styles/scripts and body content into separate sections, producing valid HTML where CSS lands in `<head>` and relative asset paths resolve correctly

## Test plan

- [x] New unit test: full HTML document composition produces clean output without nested `<html>` in `<body>`
- [x] Existing test: `<template>`-wrapped compositions still rewrite `../` asset paths correctly
- [x] Visual verification: captured sub-composition preview frames before/after fix — maps now render correctly for both blocks
- [x] Manual: open a project with `north-korea-locked-down` or `nyc-paris-flight` as a sub-composition in Studio, click on the sub-comp in the timeline → map should be visible
2026-05-16 06:23:55 +02:00

138 lines
5.0 KiB
TypeScript

// @vitest-environment node
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { buildSubCompositionHtml } from "./subComposition";
function makeTempProject(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), "hf-subcomp-preview-"));
for (const [rel, content] of Object.entries(files)) {
const full = join(dir, rel);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, content, "utf-8");
}
return dir;
}
describe("buildSubCompositionHtml", () => {
it("handles full HTML document compositions without nesting <html> in <body>", () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head><title>Host</title></head><body></body></html>`,
"compositions/map-block.html": `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<link rel="stylesheet" href="../styles/theme.css" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
.map { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
#root { position: relative; width: 1920px; height: 1080px; overflow: hidden; }
</style>
</head>
<body>
<div id="root" data-composition-id="map-block" data-width="1920" data-height="1080">
<img class="map" src="assets/map.png" alt="" />
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["map-block"] = gsap.timeline({ paused: true });
</script>
</body>
</html>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/map-block.html",
"/api/runtime.js",
"/api/projects/demo/preview/",
);
expect(html).not.toBeNull();
// Must not nest a full HTML document inside <body>
const bodyStart = html!.indexOf("<body>");
const afterBody = html!.slice(bodyStart);
expect(afterBody).not.toContain("<html");
expect(afterBody).not.toContain("<head>");
// Composition styles must be in <head>, not lost
expect(html).toContain(".map {");
expect(html).toContain("#root {");
// Image src preserved (no ../ rewrite needed for bare relative paths)
expect(html).toContain('src="assets/map.png"');
// Base tag for asset resolution
expect(html).toContain('<base href="/api/projects/demo/preview/">');
// GSAP from the composition's own <head> must be preserved
expect(html).toContain("gsap@3.14.2");
// Body script content preserved
expect(html).toContain('__timelines["map-block"]');
// <link> and <meta> from composition head must not be dropped
expect(html).toContain('rel="stylesheet"');
expect(html).toContain('href="styles/theme.css"');
expect(html).toContain('name="viewport"');
// <html lang="en"> attribute forwarded to the output
expect(html).toContain('lang="en"');
});
it("handles raw fragment compositions (no template, no full document)", () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head><title>Host</title></head><body></body></html>`,
"compositions/card.html": `<div data-composition-id="card" data-width="400" data-height="300">
<img src="../icon.svg" alt="" />
<p>Hello</p>
</div>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/card.html",
"/api/runtime.js",
"/api/projects/demo/preview/",
);
expect(html).not.toBeNull();
expect(html).toContain('<base href="/api/projects/demo/preview/">');
// ../icon.svg from compositions/ rewrites to icon.svg at project root
expect(html).toContain('src="icon.svg"');
expect(html).not.toContain('src="../icon.svg"');
expect(html).toContain("<p>Hello</p>");
});
it("rewrites sub-composition asset paths against the project root preview base", () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head><title>Test</title></head><body></body></html>`,
"compositions/hero.html": `<template id="hero-template">
<div data-composition-id="hero" data-width="1920" data-height="1080">
<img src="../logo.png" alt="Logo" />
<div style="background-image: url('../poster.png')"></div>
<style>
@font-face {
font-family: "Brand Sans";
src: url("../fonts/brand.woff2") format("woff2");
}
</style>
</div>
</template>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/hero.html",
"/api/runtime.js",
"/api/projects/demo/preview/",
);
expect(html).toContain('<base href="/api/projects/demo/preview/">');
expect(html).toContain('src="logo.png"');
expect(html).toContain("background-image: url('poster.png')");
expect(html).toContain('url("fonts/brand.woff2")');
expect(html).not.toContain('src="../logo.png"');
expect(html).not.toContain("url('../poster.png')");
expect(html).not.toContain('url("../fonts/brand.woff2")');
});
});