mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
1160 lines
40 KiB
TypeScript
1160 lines
40 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
|
|
|
function compositionWithHead(headContent: string): string {
|
|
return `
|
|
<html>
|
|
<head>
|
|
${headContent}
|
|
</head>
|
|
<body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>window.__timelines = {};</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function compositionWithHeadBoundary(boundaryContent: string): string {
|
|
return `
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body { margin: 0; }
|
|
</style>
|
|
</head>
|
|
${boundaryContent}
|
|
<body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>window.__timelines = {};</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function compositionWithBodyPrefix(prefixContent: string, rootContent = ""): string {
|
|
return `
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body { margin: 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
${prefixContent}
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
|
${rootContent}
|
|
</div>
|
|
<script>window.__timelines = {};</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function compositionWithImplicitBodyPrefix(prefixContent: string): string {
|
|
return `
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body { margin: 0; }
|
|
</style>
|
|
</head>
|
|
${prefixContent}
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>window.__timelines = {};</script>
|
|
</html>`;
|
|
}
|
|
|
|
function templateCompositionWithHead(headContent: string): string {
|
|
return `
|
|
<template>
|
|
<html>
|
|
<head>
|
|
${headContent}
|
|
</head>
|
|
<body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
</body>
|
|
</html>
|
|
</template>`;
|
|
}
|
|
|
|
describe("core rules", () => {
|
|
it("does not lint scripts embedded inside an iframe srcdoc attribute", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="root" data-width="1280" data-height="720"></div>
|
|
<iframe srcdoc="<script>const child = gsap.timeline({ paused: true }); child.to("#x", { opacity: 1 });</script>"></iframe>
|
|
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
const rootTl = gsap.timeline({ paused: true });
|
|
window.__timelines["root"] = rootTl;
|
|
</script>
|
|
</body></html>`;
|
|
|
|
const result = await lintHyperframeHtml(html);
|
|
|
|
expect(
|
|
result.findings.find((finding) => finding.code === "invalid_inline_script_syntax"),
|
|
).toBeUndefined();
|
|
expect(
|
|
result.findings.find((finding) => finding.code === "gsap_timeline_not_registered"),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("does not lint elements embedded inside an iframe srcdoc attribute", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="root" data-width="1280" data-height="720"></div>
|
|
<iframe srcdoc='<video src="child.mp4" data-start="0"></video>'></iframe>
|
|
<script>window.__timelines = {};</script>
|
|
</body></html>`;
|
|
|
|
const result = await lintHyperframeHtml(html);
|
|
|
|
expect(
|
|
result.findings.find(
|
|
(finding) => finding.elementId === undefined && finding.message.includes("<video"),
|
|
),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("warns when an id starts with a digit and is unsafe in a hash selector", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
|
<div id="123-frame"></div>
|
|
</div>
|
|
<script>window.__timelines = {};</script>
|
|
</body></html>`;
|
|
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((item) => item.code === "id_requires_css_escape");
|
|
|
|
expect(finding?.severity).toBe("warning");
|
|
expect(finding?.elementId).toBe("123-frame");
|
|
expect(finding?.fixHint).toContain("CSS.escape");
|
|
});
|
|
|
|
it("accepts ids that start with a letter", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
|
<div id="frame-123"></div>
|
|
</div>
|
|
<script>window.__timelines = {};</script>
|
|
</body></html>`;
|
|
|
|
const result = await lintHyperframeHtml(html);
|
|
|
|
expect(result.findings.find((item) => item.code === "id_requires_css_escape")).toBeUndefined();
|
|
});
|
|
|
|
it("reports error when root is missing data-composition-id", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div id="root" data-width="1920" data-height="1080"></div>
|
|
<script>window.__timelines = {};</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "root_missing_composition_id");
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.severity).toBe("error");
|
|
});
|
|
|
|
it("reports error when root is missing data-width or data-height", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div id="root" data-composition-id="c1"></div>
|
|
<script>window.__timelines = {};</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "root_missing_dimensions");
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.severity).toBe("error");
|
|
});
|
|
|
|
it("accepts body as the composition root", async () => {
|
|
const html = `
|
|
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
|
|
<div id="overlay-flash"></div>
|
|
<script>window.__timelines = window.__timelines || {};</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
|
|
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
|
|
});
|
|
|
|
it("skips a leading <svg> defs block when detecting the composition root", async () => {
|
|
// Regression: two independent reports of a leading <svg><defs><filter>...
|
|
// block (icon/gradient/filter plumbing referenced via url(#id) elsewhere)
|
|
// getting mistaken for the composition root, since findRootTag returned
|
|
// the first non-script/style/meta/link/title body child unconditionally.
|
|
// The <svg> here carries no composition markers, so it must be skipped in
|
|
// favor of the real root that follows it.
|
|
const html = `
|
|
<html><body>
|
|
<svg width="0" height="0" style="position:absolute">
|
|
<defs><filter id="glow"><feGaussianBlur stdDeviation="4" /></filter></defs>
|
|
</svg>
|
|
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>window.__timelines = window.__timelines || {};</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
|
|
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
|
|
});
|
|
|
|
it("still treats an <svg> as the root when it carries composition markers itself", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<svg id="root" data-composition-id="c1" data-width="1920" data-height="1080"></svg>
|
|
<script>window.__timelines = window.__timelines || {};</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
|
|
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
|
|
});
|
|
|
|
it("does not mistake a <tag>-shaped CSS comment inside <style> for the composition root", async () => {
|
|
// Regression: a CSS comment referencing an SVG tag name (e.g. `/* <g> wrapper */`)
|
|
// inside a <style> block reads as a real open tag to the flat TAG_PATTERN scan,
|
|
// manufacturing a phantom root before the real composition root and firing
|
|
// root_missing_composition_id/root_missing_dimensions/head_leaked_text on an
|
|
// otherwise valid sub-composition.
|
|
const html = `
|
|
<html><body>
|
|
<style>
|
|
/* <g> wrapper for icon groups */
|
|
.icon { fill: currentColor; }
|
|
</style>
|
|
<svg id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
|
<g class="icon"></g>
|
|
</svg>
|
|
<script>window.__timelines = window.__timelines || {};</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
|
|
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
|
|
expect(result.findings.find((f) => f.code === "head_leaked_text")).toBeUndefined();
|
|
});
|
|
|
|
it("reports error when timeline registry is missing", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
const tl = gsap.timeline({ paused: true });
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "missing_timeline_registry");
|
|
expect(finding).toBeDefined();
|
|
});
|
|
|
|
it("allows a timeline-free root that explicitly declares data-no-timeline", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div id="root" data-composition-id="c1" data-no-timeline data-width="1920" data-height="1080" data-duration="5"></div>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
expect(result.findings.find((f) => f.code === "missing_timeline_registry")).toBeUndefined();
|
|
});
|
|
|
|
it("does not flag missing_timeline_registry on a sub-composition (inherits from host)", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
const tl = gsap.timeline({ paused: true });
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html, { isSubComposition: true });
|
|
const finding = result.findings.find((f) => f.code === "missing_timeline_registry");
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
it("reports error for composition host missing data-composition-id", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
|
<div id="host1" data-composition-src="child.html"></div>
|
|
</div>
|
|
<script>window.__timelines = {};</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "host_missing_composition_id");
|
|
expect(finding).toBeDefined();
|
|
});
|
|
|
|
it("reports error when timeline registry is assigned without initializing", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
|
<div id="stage"></div>
|
|
</div>
|
|
<script>
|
|
const tl = gsap.timeline({ paused: true });
|
|
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
|
|
window.__timelines["c1"] = tl;
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.severity).toBe("error");
|
|
expect(finding?.message).toContain("without initializing");
|
|
});
|
|
|
|
it("reports error when dot timeline registry is assigned without initializing", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
|
<div id="stage"></div>
|
|
</div>
|
|
<script>
|
|
const tl = gsap.timeline({ paused: true });
|
|
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
|
|
window.__timelines.c1 = tl;
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.severity).toBe("error");
|
|
});
|
|
|
|
it("does not flag timeline assignment when init guard is present", async () => {
|
|
const validComposition = `
|
|
<html>
|
|
<body>
|
|
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080">
|
|
<div id="stage"></div>
|
|
</div>
|
|
<script src="https://cdn.gsap.com/gsap.min.js"></script>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
const tl = gsap.timeline({ paused: true });
|
|
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
|
|
window.__timelines["comp-1"] = tl;
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
const result = await lintHyperframeHtml(validComposition);
|
|
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
it("reports error when CSS text is left outside a style block in the document head", async () => {
|
|
const html = compositionWithHead(`
|
|
<style>
|
|
body { margin: 0; }
|
|
</style>
|
|
</style>
|
|
/* Decorative Elements */
|
|
.particle {
|
|
position: absolute;
|
|
width: 4px;
|
|
height: 4px;
|
|
background: #fff;
|
|
}
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.severity).toBe("error");
|
|
expect(finding?.message).toContain("<head>");
|
|
expect(finding?.snippet).toContain(".particle");
|
|
});
|
|
|
|
it("reports error when CSS variables leak between head and body", async () => {
|
|
const html = compositionWithHeadBoundary(`
|
|
--bg-color: #F5F1E8;
|
|
--text-color: #212121;
|
|
}
|
|
|
|
body {
|
|
background-color: var(--bg-color);
|
|
color: var(--text-color);
|
|
}
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.message).toContain("<head>");
|
|
expect(finding?.snippet).toContain("body");
|
|
});
|
|
|
|
it("reports error when stray close tags leak between head and body", async () => {
|
|
const html = compositionWithHeadBoundary(`
|
|
</style>
|
|
</script>
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.snippet).toContain("</style>");
|
|
});
|
|
|
|
it("reports error when markdown code fences leak between head and body", async () => {
|
|
const html = compositionWithHeadBoundary(`
|
|
\`\`\`css
|
|
.particle {
|
|
color: white;
|
|
}
|
|
\`\`\`
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.snippet).toContain("```css");
|
|
});
|
|
|
|
it("reports error when CSS at-rules leak between head and body", async () => {
|
|
const html = compositionWithHeadBoundary(`
|
|
@media (min-width: 800px) {
|
|
.particle {
|
|
transform: scale(1.2);
|
|
}
|
|
}
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.snippet).toContain("@media");
|
|
});
|
|
|
|
it("does not report leaked text for valid script and style blocks around the head boundary", async () => {
|
|
const html = compositionWithHeadBoundary(`
|
|
<script>
|
|
window.__headReady = true;
|
|
</script>
|
|
<template>
|
|
<style>
|
|
.template-only { color: red; }
|
|
</style>
|
|
</template>
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
it("reports error when CSS text leaks before the composition root", async () => {
|
|
const html = compositionWithBodyPrefix(`
|
|
.orphan {
|
|
position: absolute;
|
|
inset: 0;
|
|
}
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.snippet).toContain(".orphan");
|
|
});
|
|
|
|
it("reports error when CSS text leaks before the composition root without an explicit body", async () => {
|
|
const html = compositionWithImplicitBodyPrefix(`
|
|
.implicit-body-orphan {
|
|
position: absolute;
|
|
inset: 0;
|
|
}
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.snippet).toContain(".implicit-body-orphan");
|
|
});
|
|
|
|
it("does not report leaked text for valid script and style blocks before the composition root", async () => {
|
|
const html = compositionWithBodyPrefix(`
|
|
<style>
|
|
.pre-root-helper { color: red; }
|
|
</style>
|
|
<script>
|
|
window.__preRootReady = true;
|
|
</script>
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
it("does not report CSS-looking educational text inside the composition root", async () => {
|
|
const html = compositionWithBodyPrefix(
|
|
"",
|
|
`
|
|
<pre>
|
|
body {
|
|
margin: 0;
|
|
}
|
|
</pre>
|
|
`,
|
|
);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
it("reports error when CSS block comment syntax leaks into visible markup", async () => {
|
|
const html = compositionWithBodyPrefix(
|
|
"",
|
|
`
|
|
/* Main Content Block */
|
|
<div class="editorial-block">Hello</div>
|
|
`,
|
|
);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "visible_markup_comment");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.severity).toBe("error");
|
|
expect(finding?.message).toContain("visible HTML markup");
|
|
expect(finding?.snippet).toContain("Main Content Block");
|
|
});
|
|
|
|
it("reports error when a misbalanced style block leaves block comment syntax visible", async () => {
|
|
const html = compositionWithBodyPrefix(
|
|
"",
|
|
`
|
|
<style>
|
|
.editorial-block { color: #fff; }
|
|
</style>
|
|
</style>
|
|
/* Main Content Block */
|
|
<div class="editorial-block">Hello</div>
|
|
`,
|
|
);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "visible_markup_comment");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.snippet).toContain("Main Content Block");
|
|
});
|
|
|
|
it("does not report block comments inside style or script blocks", async () => {
|
|
const html = `
|
|
<html>
|
|
<head>
|
|
<title>/* tab name */ Particle Field</title>
|
|
<style>
|
|
/* Layout reset */
|
|
body { margin: 0; }
|
|
</style>
|
|
<noscript>/* fallback note */</noscript>
|
|
</head>
|
|
<body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
/* Timeline registry */
|
|
window.__timelines = {};
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "visible_markup_comment");
|
|
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
it("does not report block comments in attributes, html comments, or protected text contexts", async () => {
|
|
const html = compositionWithBodyPrefix(
|
|
"",
|
|
`
|
|
<!-- /* hidden implementation note */ -->
|
|
<div data-note="/* attribute note */"></div>
|
|
<div data-note="a > b /* quoted attribute note */"></div>
|
|
<pre>/* visible code sample */</pre>
|
|
<code>/* visible inline code sample */</code>
|
|
<textarea>/* editable code sample */</textarea>
|
|
<template>/* template-only note */</template>
|
|
<svg viewBox="0 0 100 20"><text x="0" y="15">/* svg label */</text></svg>
|
|
`,
|
|
);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "visible_markup_comment");
|
|
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
it("reports error when a stray style close tag is left in the document head", async () => {
|
|
const html = compositionWithHead(`
|
|
<style>
|
|
body { margin: 0; }
|
|
</style>
|
|
</style>
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.snippet).toContain("</style>");
|
|
});
|
|
|
|
it("reports error when a stray script close tag is left in the document head", async () => {
|
|
const html = compositionWithHead(`
|
|
<script>
|
|
window.__headReady = true;
|
|
</script>
|
|
</script>
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.snippet).toContain("</script>");
|
|
});
|
|
|
|
it("does not report leaked head text for valid closing tags with trailing whitespace", async () => {
|
|
const html = compositionWithHead(`
|
|
<style>
|
|
body { margin: 0; }
|
|
</style data-parser-error-close>
|
|
<script>
|
|
window.__headReady = true;
|
|
</script
|
|
data-parser-error-close>
|
|
<title>Particle Field</title >
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
it("reports error when markdown code fences leak into the document head", async () => {
|
|
const withLanguage = compositionWithHead(`
|
|
\`\`\`css
|
|
.particle {
|
|
position: absolute;
|
|
}
|
|
\`\`\`
|
|
`);
|
|
const withoutLanguage = compositionWithHead(`
|
|
\`\`\`
|
|
.particle {
|
|
position: absolute;
|
|
}
|
|
\`\`\`
|
|
`);
|
|
const withTsxLanguage = compositionWithHead(`
|
|
\`\`\`tsx
|
|
export function Particle() {
|
|
return <div className="particle" />;
|
|
}
|
|
\`\`\`
|
|
`);
|
|
const withLanguageResult = await lintHyperframeHtml(withLanguage);
|
|
const withoutLanguageResult = await lintHyperframeHtml(withoutLanguage);
|
|
const withTsxLanguageResult = await lintHyperframeHtml(withTsxLanguage);
|
|
const languageFinding = withLanguageResult.findings.find((f) => f.code === "head_leaked_text");
|
|
const unlabeledFinding = withoutLanguageResult.findings.find(
|
|
(f) => f.code === "head_leaked_text",
|
|
);
|
|
const tsxLanguageFinding = withTsxLanguageResult.findings.find(
|
|
(f) => f.code === "head_leaked_text",
|
|
);
|
|
|
|
expect(languageFinding).toBeDefined();
|
|
expect(languageFinding?.snippet).toContain("```css");
|
|
expect(unlabeledFinding).toBeDefined();
|
|
expect(unlabeledFinding?.snippet).toContain("```");
|
|
expect(tsxLanguageFinding).toBeDefined();
|
|
expect(tsxLanguageFinding?.snippet).toContain("```tsx");
|
|
});
|
|
|
|
it("reports error when CSS at-rules leak into the document head", async () => {
|
|
const html = compositionWithHead(`
|
|
@media (min-width: 800px) {
|
|
.particle {
|
|
transform: scale(1.2);
|
|
}
|
|
}
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.snippet).toContain("@media");
|
|
});
|
|
|
|
it("reports leaked CSS when a style block is unclosed in the document head", async () => {
|
|
const html = compositionWithHead(`
|
|
<style>
|
|
.particle {
|
|
color: white;
|
|
}
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.snippet).toContain(".particle");
|
|
});
|
|
|
|
it("does not report leaked head text for commented CSS", async () => {
|
|
const html = compositionWithHead(`
|
|
<!-- .particle { color: red; } -->
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
it("does not report leaked head text for valid noscript content", async () => {
|
|
const html = compositionWithHead(`
|
|
<noscript>
|
|
.no-js { display: block; }
|
|
</noscript>
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
it("does not report orphan CSS for valid head metadata and style blocks", async () => {
|
|
const html = compositionWithHead(`
|
|
<title>Particle Field</title>
|
|
<meta name="description" content="Particle field">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com">
|
|
<base href="https://example.com/">
|
|
<style>
|
|
.particle {
|
|
position: absolute;
|
|
width: 4px;
|
|
height: 4px;
|
|
}
|
|
</style>
|
|
`);
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
it("reports leaked head text inside template-wrapped sub-compositions", async () => {
|
|
const html = templateCompositionWithHead(`
|
|
</style>
|
|
.particle { color: white; }
|
|
`);
|
|
const result = await lintHyperframeHtml(html, { isSubComposition: true });
|
|
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.snippet).toContain(".particle");
|
|
});
|
|
|
|
describe("timeline_id_mismatch", () => {
|
|
it("accepts dot timeline registration", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="launch" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
const tl = gsap.timeline({ paused: true });
|
|
window.__timelines.launch = tl;
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
it("reports mismatched dot timeline registration", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="launch" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
const tl = gsap.timeline({ paused: true });
|
|
window.__timelines.intro = tl;
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.message).toContain('Timeline registered as "intro"');
|
|
});
|
|
|
|
it("accepts bracket timeline registration for hyphenated ids", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="product-launch" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
const tl = gsap.timeline({ paused: true });
|
|
window.__timelines["product-launch"] = tl;
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
it("matches timeline keys against browser-decoded composition ids", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
const tl = gsap.timeline({ paused: true });
|
|
window.__timelines["c1"] = tl;
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeUndefined();
|
|
});
|
|
|
|
it("accepts object-literal timeline registration and extracts its keys", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="comp-1" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
const tl = gsap.timeline({ paused: true });
|
|
window.__timelines = { "comp-1": tl };
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
expect(result.findings.find((f) => f.code === "missing_timeline_registry")).toBeUndefined();
|
|
expect(
|
|
result.findings.find((f) => f.code === "timeline_registry_missing_init"),
|
|
).toBeUndefined();
|
|
expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeUndefined();
|
|
});
|
|
|
|
it("reports mismatched object-literal timeline registration keys", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="comp-1" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
const tl = gsap.timeline({ paused: true });
|
|
window.__timelines = { main: tl };
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.message).toContain('Timeline registered as "main"');
|
|
});
|
|
});
|
|
|
|
describe("repeated_id_descendant_selector", () => {
|
|
it("reports a selector that nests the same id inside itself", async () => {
|
|
const html = `<div id="scene_01" data-composition-id="root" data-width="1920" data-height="1080">
|
|
<style>#scene_01 #scene_01 .headline { color: red; }</style>
|
|
</div>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "repeated_id_descendant_selector");
|
|
expect(finding?.severity).toBe("error");
|
|
expect(finding?.selector).toBe("#scene_01 #scene_01 .headline");
|
|
});
|
|
|
|
it("does not report distinct descendant ids", async () => {
|
|
const html = `<div id="scene_01" data-composition-id="root" data-width="1920" data-height="1080">
|
|
<style>#scene_01 #headline { color: red; }</style>
|
|
</div>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
expect(
|
|
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it.each(["#scene_01 > #scene_01", "#scene_01 .wrapper #scene_01"])(
|
|
"reports repeated ids across descendant combinators: %s",
|
|
async (selector) => {
|
|
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
|
|
<style>${selector} { color: red; }</style>
|
|
</div>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
expect(
|
|
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
|
|
).toBeDefined();
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
":is(#scene_01) #scene_01",
|
|
":where(#scene_01) #scene_01",
|
|
"#scene_01 :is(#scene_01)",
|
|
])("reports repeated ids required by selector pseudos: %s", async (selector) => {
|
|
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
|
|
<style>${selector} { color: red; }</style>
|
|
</div>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
expect(
|
|
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
|
|
).toBeDefined();
|
|
});
|
|
|
|
it.each([
|
|
":is(#scene_01, .scene) #scene_01",
|
|
":not(#scene_01) #scene_01",
|
|
":has(#scene_01) #scene_01",
|
|
])("does not report ids that are not required by a selector pseudo: %s", async (selector) => {
|
|
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
|
|
<style>${selector} { color: red; }</style>
|
|
</div>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
expect(
|
|
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it.each(["& #scene_01 .headline", "#scene_01 .headline"])(
|
|
"reports repeated ids created by nested CSS: %s",
|
|
async (nestedSelector) => {
|
|
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
|
|
<style>#scene_01 { ${nestedSelector} { color: red; } }</style>
|
|
</div>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find(
|
|
(candidate) => candidate.code === "repeated_id_descendant_selector",
|
|
);
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.selector).toContain("#scene_01 #scene_01");
|
|
},
|
|
);
|
|
|
|
it("preserves dollar sequences while resolving nested selectors", async () => {
|
|
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
|
|
<style>#scene_01[data-query="$1"] { & #scene_01 { color: red; } }</style>
|
|
</div>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find(
|
|
(candidate) => candidate.code === "repeated_id_descendant_selector",
|
|
);
|
|
expect(finding?.selector).toBe('#scene_01[data-query="$1"] #scene_01');
|
|
});
|
|
|
|
it.each(['[data-query="#scene_01 #scene_01"]', String.raw`#scene_01 #scene_01\:child`])(
|
|
"does not report non-repeated parsed ids: %s",
|
|
async (selector) => {
|
|
const html = `<div data-composition-id="root" data-width="1920" data-height="1080">
|
|
<style>${selector} { color: red; }</style>
|
|
</div>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
expect(
|
|
result.findings.find((f) => f.code === "repeated_id_descendant_selector"),
|
|
).toBeUndefined();
|
|
},
|
|
);
|
|
});
|
|
|
|
it("warns when a timeline-visible element has no stable id for Studio editing", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
|
<section class="clip hero-card" data-start="0" data-duration="3"></section>
|
|
</div>
|
|
<script>window.__timelines = {};</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "studio_missing_editable_id");
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.severity).toBe("warning");
|
|
expect(finding?.message).toContain('<section class="hero-card" data-start="0">');
|
|
expect(finding?.fixHint).toContain("stable, human-readable id");
|
|
});
|
|
|
|
it("does not warn about the composition root or timeline elements with ids", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0">
|
|
<section id="hero-card" class="clip hero-card" data-start="0" data-duration="3"></section>
|
|
</div>
|
|
<script>window.__timelines = {};</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "studio_missing_editable_id");
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
describe("non_deterministic_code", () => {
|
|
it("detects Math.random() in script content", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
const x = Math.random();
|
|
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.severity).toBe("error");
|
|
expect(finding?.message).toContain("Math.random");
|
|
});
|
|
|
|
it("detects Date.now() in script content", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
const ts = Date.now();
|
|
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.severity).toBe("error");
|
|
expect(finding?.message).toContain("Date.now");
|
|
});
|
|
|
|
it("does not flag non-deterministic calls inside single-line comments", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
// const x = Math.random();
|
|
// Date.now() is not used here
|
|
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
|
|
it("detects gsap.utils.random() in script content", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
const tl = gsap.timeline({ paused: true });
|
|
tl.to(".chip", { x: gsap.utils.random(-100, 100), duration: 1 }, 0);
|
|
window.__timelines["c1"] = tl;
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.severity).toBe("error");
|
|
expect(finding?.message).toContain("gsap.utils.random");
|
|
});
|
|
|
|
it("detects GSAP 'random(...)' string tween values", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
const tl = gsap.timeline({ paused: true });
|
|
tl.to(".chip", { x: "random(-100, 100)", duration: 1 }, 0);
|
|
window.__timelines["c1"] = tl;
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.message).toContain('"random(...)"');
|
|
});
|
|
|
|
it("detects prefixed '+=random(...)' string tween values", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
const tl = gsap.timeline({ paused: true });
|
|
tl.to(".chip", { x: "+=random(-10, 10)", duration: 1 }, 0);
|
|
window.__timelines["c1"] = tl;
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
|
|
expect(finding).toBeDefined();
|
|
});
|
|
|
|
it("does NOT flag prose strings that merely mention random(", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
const note = "avoid random(seed) helpers in render code";
|
|
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
|
</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("composition_self_attribute_selector", () => {
|
|
it("warns when inline CSS targets the root composition id", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080">
|
|
<style>
|
|
[data-composition-id="scene"] .title { opacity: 0; }
|
|
[data-composition-id="other"] .title { color: red; }
|
|
</style>
|
|
<h1 class="title">Hello</h1>
|
|
</div>
|
|
<script>window.__timelines = {};</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const findings = result.findings.filter(
|
|
(f) => f.code === "composition_self_attribute_selector",
|
|
);
|
|
|
|
expect(findings).toHaveLength(1);
|
|
expect(findings[0]?.severity).toBe("warning");
|
|
expect(findings[0]?.selector).toBe('[data-composition-id="scene"] .title');
|
|
expect(findings[0]?.fixHint).toContain("#scene");
|
|
expect(findings[0]?.fixHint).not.toContain("#556");
|
|
});
|
|
|
|
it("warns when external CSS targets the root composition id", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080"></div>
|
|
<script>window.__timelines = {};</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html, {
|
|
externalStyles: [
|
|
{
|
|
href: "scene.css",
|
|
content: '[data-composition-id="scene"] .title { opacity: 0; }',
|
|
},
|
|
],
|
|
});
|
|
const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector");
|
|
|
|
expect(finding).toBeDefined();
|
|
expect(finding?.selector).toBe('[data-composition-id="scene"] .title');
|
|
});
|
|
|
|
it("does not warn when CSS targets a different composition id", async () => {
|
|
const html = `
|
|
<html><body>
|
|
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080">
|
|
<style>[data-composition-id="other"] .title { opacity: 0; }</style>
|
|
</div>
|
|
<script>window.__timelines = {};</script>
|
|
</body></html>`;
|
|
const result = await lintHyperframeHtml(html);
|
|
const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector");
|
|
|
|
expect(finding).toBeUndefined();
|
|
});
|
|
});
|
|
});
|