Files
hyperframes/packages/core/src/parsers/htmlParser.test.ts
T
Vance IngallsandClaude Sonnet 4.6 806b226b34 test(core): add T9 CompositionVariable font/image parse spec (before R1) (#1246)
* test(core): add T9 CompositionVariable font/image parse spec (before R1)

* fix(core): mark font+image spec tests as it.fails() — not yet implemented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 11:41:21 -07:00

747 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @vitest-environment jsdom
*/
import { describe, it, expect } from "vitest";
import {
parseHtml,
updateElementInHtml,
addElementToHtml,
removeElementFromHtml,
validateCompositionHtml,
extractCompositionMetadata,
} from "./htmlParser.js";
describe("parseHtml", () => {
it("extracts elements with data-start and data-end", () => {
const html = `
<html>
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5" data-name="Title"><div>Hello World</div></div>
<div id="text2" data-start="2" data-end="7" data-name="Subtitle"><div>Sub</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.elements).toHaveLength(2);
expect(result.elements[0].id).toBe("text1");
expect(result.elements[0].startTime).toBe(0);
expect(result.elements[0].duration).toBe(5);
expect(result.elements[0].name).toBe("Title");
expect(result.elements[0].type).toBe("text");
expect(result.elements[1].id).toBe("text2");
expect(result.elements[1].startTime).toBe(2);
expect(result.elements[1].duration).toBe(5);
});
it("handles nested compositions", () => {
const html = `
<html>
<body>
<div id="stage">
<div id="comp1" data-start="0" data-end="10" data-type="composition" data-composition-id="abc123">
<iframe src="/compositions/abc123"></iframe>
</div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.elements).toHaveLength(1);
expect(result.elements[0].type).toBe("composition");
expect(result.elements[0].id).toBe("comp1");
if (result.elements[0].type === "composition") {
expect(result.elements[0].compositionId).toBe("abc123");
expect(result.elements[0].src).toBe("/compositions/abc123");
}
});
it("extracts media elements (video, audio, img)", () => {
const html = `
<html>
<body>
<div id="stage">
<video id="vid1" data-start="0" data-end="10" src="video.mp4" data-name="My Video"></video>
<audio id="aud1" data-start="0" data-end="5" src="music.mp3" data-name="Music"></audio>
<img id="img1" data-start="2" data-end="8" src="photo.jpg" data-name="Photo" />
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.elements).toHaveLength(3);
const video = result.elements.find((e) => e.id === "vid1");
expect(video).toBeDefined();
expect(video?.type).toBe("video");
if (video?.type === "video") {
expect(video.src).toBe("video.mp4");
}
const audio = result.elements.find((e) => e.id === "aud1");
expect(audio).toBeDefined();
expect(audio?.type).toBe("audio");
const img = result.elements.find((e) => e.id === "img1");
expect(img).toBeDefined();
expect(img?.type).toBe("image");
});
it("handles missing attributes gracefully", () => {
const html = `
<html>
<body>
<div id="stage">
<div id="el1" data-start="3"><div>Some text</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.elements).toHaveLength(1);
expect(result.elements[0].startTime).toBe(3);
// Default duration is 5 when data-end is missing
expect(result.elements[0].duration).toBe(5);
});
it("assigns generated ids when elements have no id", () => {
const html = `
<html>
<body>
<div id="stage">
<div data-start="0" data-end="5"><div>No ID</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.elements).toHaveLength(1);
expect(result.elements[0].id).toMatch(/^element-\d+$/);
});
it("extracts GSAP script from script tags", () => {
const html = `
<html>
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#text1", { opacity: 1, duration: 1 }, 0);
</script>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.gsapScript).not.toBeNull();
expect(result.gsapScript).toContain("gsap.timeline");
expect(result.gsapScript).toContain('tl.to("#text1"');
});
it("extracts styles from style tags", () => {
const html = `
<html>
<body>
<style data-hf-custom="true">
.my-class { color: red; }
</style>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.styles).not.toBeNull();
expect(result.styles).toContain(".my-class");
});
it("detects landscape resolution from data attribute", () => {
const html = `
<html data-resolution="landscape">
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("landscape");
});
it("detects portrait resolution from data attribute", () => {
const html = `
<html data-resolution="portrait">
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("portrait");
});
it("keeps explicit portrait resolution even when dimensions are square", () => {
const html = `
<html data-resolution="portrait" data-composition-width="1080" data-composition-height="1080">
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("portrait");
});
it("defaults to portrait when no resolution info is available", () => {
const html = `
<html>
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("portrait");
});
it("detects landscape-4k resolution from data attribute", () => {
const html = `
<html data-resolution="landscape-4k">
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("landscape-4k");
});
it("infers landscape-4k from composition dimensions", () => {
const html = `
<html data-composition-width="3840" data-composition-height="2160">
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("landscape-4k");
});
it("infers portrait-4k from inline stage style", () => {
const html = `
<html>
<body>
<div id="stage" style="width: 2160px; height: 3840px;">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("portrait-4k");
});
it("classifies 1440p (QHD) as landscape, not landscape-4k", () => {
// Regression: an earlier `>= 2560` cutoff misclassified QHD compositions
// as 4K. The current rule uses the canonical 4K long-side (3840) so
// 2560×1440 stays in the landscape preset.
const html = `
<html data-composition-width="2560" data-composition-height="1440">
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("landscape");
});
it("infers square resolution from equal width/height", () => {
const html = `
<html data-composition-width="1080" data-composition-height="1080">
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("square");
});
it("infers square-4k from equal width/height >= 2160", () => {
const html = `
<html data-composition-width="2160" data-composition-height="2160">
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5"><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.resolution).toBe("square-4k");
});
it("extracts x, y, scale, opacity from data attributes", () => {
const html = `
<html>
<body>
<div id="stage">
<video id="vid1" data-start="0" data-end="5" src="v.mp4" data-x="100" data-y="200" data-scale="1.5" data-opacity="0.8"></video>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.elements[0].x).toBe(100);
expect(result.elements[0].y).toBe(200);
expect(result.elements[0].scale).toBe(1.5);
expect(result.elements[0].opacity).toBe(0.8);
});
it("parses text element properties (color, fontSize, fontWeight, fontFamily)", () => {
const html = `
<html>
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5" data-color="red" data-font-size="72" data-font-weight="900" data-font-family="Montserrat"><div>Styled</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
const textEl = result.elements[0];
expect(textEl.type).toBe("text");
if (textEl.type === "text") {
expect(textEl.color).toBe("red");
expect(textEl.fontSize).toBe(72);
expect(textEl.fontWeight).toBe(900);
expect(textEl.fontFamily).toBe("Montserrat");
}
});
it("parses media element properties (mediaStartTime, sourceDuration, volume)", () => {
const html = `
<html>
<body>
<div id="stage">
<video id="vid1" data-start="0" data-end="10" src="v.mp4" data-media-start="5" data-source-duration="30" data-volume="0.5" data-has-audio="true"></video>
</div>
</body>
</html>
`;
const result = parseHtml(html);
const vid = result.elements[0];
expect(vid.type).toBe("video");
if (vid.type === "video") {
expect(vid.mediaStartTime).toBe(5);
expect(vid.sourceDuration).toBe(30);
expect(vid.volume).toBe(0.5);
expect(vid.hasAudio).toBe(true);
}
});
it("extracts data-keyframes attribute", () => {
const keyframes = JSON.stringify([
{ id: "kf1", time: 0, properties: { opacity: 0 } },
{ id: "kf2", time: 1, properties: { opacity: 1 } },
]);
const html = `
<html>
<body>
<div id="stage">
<div id="text1" data-start="0" data-end="5" data-keyframes='${keyframes}'><div>Hello</div></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.keyframes["text1"]).toBeDefined();
expect(result.keyframes["text1"]).toHaveLength(2);
expect(result.keyframes["text1"][0].id).toBe("kf1");
});
it("parses stage zoom keyframes", () => {
const zoomKeyframes = JSON.stringify([
{ id: "z1", time: 0, zoom: { scale: 1, focusX: 960, focusY: 540 } },
{ id: "z2", time: 2, zoom: { scale: 2, focusX: 500, focusY: 300 } },
]);
const html = `
<html>
<body>
<div id="stage">
<div id="stage-zoom-container" data-zoom-keyframes='${zoomKeyframes}'></div>
</div>
</body>
</html>
`;
const result = parseHtml(html);
expect(result.stageZoomKeyframes).toHaveLength(2);
expect(result.stageZoomKeyframes[0].zoom.scale).toBe(1);
expect(result.stageZoomKeyframes[1].zoom.scale).toBe(2);
});
it("returns empty zoom keyframes when no zoom container exists", () => {
const html = `
<html>
<body><div id="stage"></div></body>
</html>
`;
const result = parseHtml(html);
expect(result.stageZoomKeyframes).toHaveLength(0);
});
});
describe("updateElementInHtml", () => {
it("updates startTime and duration", () => {
const html = `<!DOCTYPE html>
<html><body>
<div id="el1" data-start="0" data-end="5"><div>Hello</div></div>
</body></html>`;
const updated = updateElementInHtml(html, "el1", { startTime: 2, duration: 3 });
expect(updated).toContain('data-start="2"');
expect(updated).toContain('data-end="5"'); // data-end gets set to start + duration
});
it("updates element name", () => {
const html = `<!DOCTYPE html>
<html><body>
<div id="el1" data-start="0" data-end="5" data-name="Old"><div>Hello</div></div>
</body></html>`;
const updated = updateElementInHtml(html, "el1", { name: "New Name" });
expect(updated).toContain('data-name="New Name"');
});
it("returns original html when element not found", () => {
const html = `<!DOCTYPE html>
<html><body>
<div id="el1" data-start="0" data-end="5"><div>Hello</div></div>
</body></html>`;
const updated = updateElementInHtml(html, "nonexistent", { name: "Test" });
expect(updated).toBe(html);
});
});
describe("addElementToHtml", () => {
it("adds a new text element to the HTML", () => {
const html = `<!DOCTYPE html>
<html><body>
<div id="stage">
<div id="stage-zoom-container"></div>
</div>
</body></html>`;
const { html: updated, id } = addElementToHtml(html, {
type: "text",
name: "New Text",
content: "Hello!",
startTime: 1,
duration: 3,
zIndex: 1,
});
expect(id).toBeDefined();
expect(updated).toContain(`id="${id}"`);
expect(updated).toContain('data-start="1"');
expect(updated).toContain('data-end="4"');
expect(updated).toContain("Hello!");
});
it("adds a video element", () => {
const html = `<!DOCTYPE html>
<html><body>
<div id="stage">
<div id="stage-zoom-container"></div>
</div>
</body></html>`;
const { html: updated, id } = addElementToHtml(html, {
type: "video",
name: "My Video",
src: "video.mp4",
startTime: 0,
duration: 10,
zIndex: 0,
});
expect(updated).toContain(`id="${id}"`);
expect(updated).toContain("video.mp4");
});
});
describe("removeElementFromHtml", () => {
it("removes an element by id", () => {
const html = `<!DOCTYPE html>
<html><body>
<div id="stage">
<div id="el1" data-start="0" data-end="5"><div>Hello</div></div>
<div id="el2" data-start="1" data-end="6"><div>World</div></div>
</div>
</body></html>`;
const updated = removeElementFromHtml(html, "el1");
expect(updated).not.toContain('id="el1"');
expect(updated).toContain('id="el2"');
});
});
describe("validateCompositionHtml", () => {
it("returns valid for a well-formed composition", () => {
const html = `<!DOCTYPE html>
<html data-composition-id="comp-1" data-composition-duration="10">
<body>
<div id="stage"></div>
</body>
</html>`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it("reports error for missing composition-id", () => {
const html = `<!DOCTYPE html>
<html data-composition-duration="10">
<body>
<div id="stage"></div>
</body>
</html>`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Missing data-composition-id attribute on <html> element");
});
it("reports error for missing composition-duration", () => {
const html = `<!DOCTYPE html>
<html data-composition-id="comp-1">
<body>
<div id="stage"></div>
</body>
</html>`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(false);
expect(result.errors).toContain(
"Missing data-composition-duration attribute on <html> element",
);
});
it("reports error for missing #stage", () => {
const html = `<!DOCTYPE html>
<html data-composition-id="comp-1" data-composition-duration="10">
<body></body>
</html>`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Missing #stage element");
});
it("reports error for inline event handlers", () => {
const html = `<!DOCTYPE html>
<html data-composition-id="comp-1" data-composition-duration="10">
<body>
<div id="stage" onclick="alert('hi')"></div>
</body>
</html>`;
const result = validateCompositionHtml(html);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Inline event handlers (onclick, onload, etc.) not allowed");
});
});
describe("extractCompositionMetadata", () => {
it("extracts composition id and duration", () => {
const html = `<!DOCTYPE html>
<html data-composition-id="comp-abc" data-composition-duration="15.5">
<body></body>
</html>`;
const meta = extractCompositionMetadata(html);
expect(meta.compositionId).toBe("comp-abc");
expect(meta.compositionDuration).toBe(15.5);
});
it("returns null for missing metadata", () => {
const html = `<!DOCTYPE html><html><body></body></html>`;
const meta = extractCompositionMetadata(html);
expect(meta.compositionId).toBeNull();
expect(meta.compositionDuration).toBeNull();
});
it("extracts composition variables", () => {
const variables = JSON.stringify([
{ id: "title", type: "string", label: "Title", default: "Hello" },
{ id: "count", type: "number", label: "Count", default: 5 },
]);
const html = `<!DOCTYPE html>
<html data-composition-id="comp-1" data-composition-duration="10" data-composition-variables='${variables}'>
<body></body>
</html>`;
const meta = extractCompositionMetadata(html);
expect(meta.variables).toHaveLength(2);
expect(meta.variables[0].id).toBe("title");
expect(meta.variables[0].type).toBe("string");
expect(meta.variables[1].id).toBe("count");
expect(meta.variables[1].type).toBe("number");
});
// T9 — CompositionVariable font/image parse (spec for R1).
// These tests are intentionally red until R1 adds "font" and "image" to
// CompositionVariableType and updates parseCompositionVariables accordingly.
// Currently failing (spec): tests 1, 2, 3 — filter rejects unknown types.
// Currently passing (baseline): test 4 — unknown type graceful rejection already works.
it.fails("[spec] parses a font variable (type: font) with name and source", () => {
const variables = JSON.stringify([
{
id: "brand-font-primary",
type: "font",
label: "Heading font",
default: "Inter",
source: "https://fonts.googleapis.com/css2?family=Inter",
default_name: "sans-serif",
default_source: "",
},
]);
const html = `<!DOCTYPE html>
<html data-composition-id="c" data-composition-duration="5" data-composition-variables='${variables}'>
<body></body></html>`;
const meta = extractCompositionMetadata(html);
const v = meta.variables.find((x) => x.id === "brand-font-primary");
expect(v).toBeDefined();
expect(v?.type).toBe("font");
// source, default_name, default_source are load-bearing for brand-kit font apply.
expect((v as Record<string, unknown>)?.source).toBe(
"https://fonts.googleapis.com/css2?family=Inter",
);
expect((v as Record<string, unknown>)?.default_name).toBe("sans-serif");
expect((v as Record<string, unknown>)?.default_source).toBe("");
});
it.fails("[spec] parses an image variable with brandRole logo:primary", () => {
const variables = JSON.stringify([
{ id: "brand-logo", type: "image", label: "Logo", default: "", brandRole: "logo:primary" },
]);
const html = `<!DOCTYPE html>
<html data-composition-id="c" data-composition-duration="5" data-composition-variables='${variables}'>
<body></body></html>`;
const meta = extractCompositionMetadata(html);
const v = meta.variables.find((x) => x.id === "brand-logo");
expect(v).toBeDefined();
expect(v?.type).toBe("image");
// TODO(R1): remove cast once ImageVariable.brandRole is typed
expect((v as Record<string, unknown>)?.brandRole).toBe("logo:primary");
});
it("[spec] parses a color variable with brandRole and preserves the brandRole field", () => {
const variables = JSON.stringify([
{
id: "brand-color-primary",
type: "color",
label: "Primary",
default: "#0066cc",
brandRole: "color:primary",
},
]);
const html = `<!DOCTYPE html>
<html data-composition-id="c" data-composition-duration="5" data-composition-variables='${variables}'>
<body></body></html>`;
const meta = extractCompositionMetadata(html);
const v = meta.variables.find((x) => x.id === "brand-color-primary");
expect(v).toBeDefined();
// TODO(R1): remove cast once ColorVariable.brandRole is typed
expect((v as Record<string, unknown>)?.brandRole).toBe("color:primary");
});
it("color variable without brandRole parses normally (brandRole is optional)", () => {
const variables = JSON.stringify([
{ id: "accent", type: "color", label: "Accent", default: "#ff0000" },
]);
const html = `<!DOCTYPE html>
<html data-composition-id="c" data-composition-duration="5" data-composition-variables='${variables}'>
<body></body></html>`;
const meta = extractCompositionMetadata(html);
const v = meta.variables.find((x) => x.id === "accent");
expect(v).toBeDefined();
expect(v?.type).toBe("color");
expect((v as Record<string, unknown>)?.brandRole).toBeUndefined();
});
it.todo(
"[spec] font variable without source field is rejected (source is required for brand-kit font apply)",
);
it.todo(
"[spec] image variable without default is rejected gracefully (default is required for image variables)",
);
it("unknown variable type is rejected gracefully (does not throw, variable is skipped)", () => {
const variables = JSON.stringify([
{ id: "ok", type: "string", label: "Ok", default: "yes" },
{ id: "x", type: "widget", label: "Unknown", default: null },
]);
const html = `<!DOCTYPE html>
<html data-composition-id="c" data-composition-duration="5" data-composition-variables='${variables}'>
<body></body></html>`;
expect(() => extractCompositionMetadata(html)).not.toThrow();
const meta = extractCompositionMetadata(html);
expect(meta.variables.find((v) => v.id === "x")).toBeUndefined();
expect(meta.variables.find((v) => v.id === "ok")).toBeDefined();
});
});