Files
hyperframes/packages/core/src/lint/rules/composition.test.ts
T
Mu-Tsun Tsai 9b72a87c17 fix(lint): remove root_composition_missing_data_duration (#490)
* fix(lint): remove root_composition_missing_data_duration

Lint cannot statically observe the runtime's true Infinity-emission
condition: it requires a finite GSAP timeline duration AND a finite
media/sub-comp window AND timeline > floor + 1, none of which are
visible to the static linter. The looping shapes that drive the
condition are already covered by `gsap_infinite_repeat` and
`gsap_repeat_ceil_overshoot` (both from #243), which point at the
real authoring mistake — flagging the missing duration separately
was a noisy proxy for the same signal.

Per #490 review discussion, deprecate the static rule and let those
two GSAP rules carry the pre-render coverage. If perfect precision
on `durationInFrames = Infinity` is needed, that belongs on the
runtime/render path where `shouldEmitNonDeterministicInf` is
actually known.

Add regression tests pinning the removal: a docs-compliant root
without `data-duration` no longer warns, and the canonical
loop-inflated shape now surfaces only via `gsap_infinite_repeat`
instead of two duplicate findings.

* docs(skills): update step-6 build checklist after rule removal

Drops the `root_composition_missing_data_duration` reference now that
the rule is gone. Keeps the authoring recommendation (and explains the
runtime Infinity case) but points authors at the GSAP rules that
actually carry the lint signal: `gsap_infinite_repeat` and
`gsap_repeat_ceil_overshoot`.
2026-04-25 17:52:12 +02:00

443 lines
17 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("composition rules", () => {
it("reports info for composition with external CDN script dependency", () => {
const html = `<template id="rockets-template">
<div data-composition-id="rockets" data-width="1920" data-height="1080">
<div id="rocket-container"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["rockets"] = gsap.timeline({ paused: true });
</script>
</div>
</template>`;
const result = lintHyperframeHtml(html, { filePath: "compositions/rockets.html" });
const finding = result.findings.find(
(f) => f.code === "external_script_dependency" && f.message.includes("cdnjs.cloudflare.com"),
);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("info");
// info findings do not count as errors — ok should still be true
expect(result.ok).toBe(true);
expect(result.errorCount).toBe(0);
});
it("does not report external_script_dependency for inline scripts", () => {
const html = `
<html><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<script>
window.__timelines = {};
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "external_script_dependency")).toBeUndefined();
});
it("reports error when querySelector uses template literal variable", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div class="chart"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const compId = "main";
const el = document.querySelector(\`[data-composition-id="\${compId}"] .chart\`);
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "template_literal_selector");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports error for querySelectorAll with template literal variable", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const id = "main";
document.querySelectorAll(\`[data-composition-id="\${id}"] .item\`);
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "template_literal_selector");
expect(finding).toBeDefined();
});
it("does not report error for hardcoded querySelector strings", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div class="chart"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const el = document.querySelector('[data-composition-id="main"] .chart');
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "template_literal_selector");
expect(finding).toBeUndefined();
});
describe("timed_element_missing_clip_class", () => {
it("flags element with data-start but no class='clip'", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="box" data-start="0" data-duration="2">Hello</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not flag element that has class='clip'", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="box" class="clip" data-start="0" data-duration="2">Hello</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
expect(finding).toBeUndefined();
});
it("does not flag audio or video elements", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<audio data-start="0" data-duration="5" src="music.mp3"></audio>
<video data-start="0" data-duration="5" src="clip.mp4"></video>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
expect(finding).toBeUndefined();
});
});
describe("overlapping_clips_same_track", () => {
it("flags overlapping clips on the same track", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div class="clip" data-start="0" data-duration="3" data-track-index="0">A</div>
<div class="clip" data-start="2" data-duration="3" data-track-index="0">B</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not flag clips on different tracks", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div class="clip" data-start="0" data-duration="3" data-track-index="0">A</div>
<div class="clip" data-start="1" data-duration="3" data-track-index="1">B</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
expect(finding).toBeUndefined();
});
it("does not flag sequential clips on the same track", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div class="clip" data-start="0" data-duration="2" data-track-index="0">A</div>
<div class="clip" data-start="2" data-duration="2" data-track-index="0">B</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
expect(finding).toBeUndefined();
});
});
describe("root_composition_missing_html_wrapper", () => {
it("flags bare composition div as error", () => {
// Exact scenario from the screenshot — bare div with composition attributes, no HTML wrapper
const html = `<div
id="comp-main"
data-composition-id="no-limits"
data-start="0"
data-duration="15"
data-width="1920"
data-height="1080"
>
<!-- Sub-composition: the visual spectacle -->
<div
id="el-visuals"
data-composition-id="visuals"
data-composition-src="compositions/visuals.html"
data-duration="15"
data-track-index="0"
></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["no-limits"] = tl;
</script>
</div>`;
const result = lintHyperframeHtml(html, { filePath: "index.html" });
const finding = result.findings.find(
(f) => f.code === "root_composition_missing_html_wrapper",
);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(result.ok).toBe(false);
});
it("does not flag properly wrapped HTML composition", () => {
const html = `<!DOCTYPE html>
<html><head><meta charset="UTF-8"></head><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
<div class="clip" data-start="0" data-duration="5">Hello</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "root_composition_missing_html_wrapper",
);
expect(finding).toBeUndefined();
});
it("does not flag composition starting with <html> (no doctype)", () => {
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"></div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "root_composition_missing_html_wrapper",
);
expect(finding).toBeUndefined();
});
it("does not flag sub-compositions", () => {
const html = `<div data-composition-id="sub" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
window.__timelines["sub"] = gsap.timeline({ paused: true });
</script>
</div>`;
const result = lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find(
(f) => f.code === "root_composition_missing_html_wrapper",
);
expect(finding).toBeUndefined();
});
it("does not flag HTML without composition attributes", () => {
const html = `<div id="hello"><p>Not a composition</p></div>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "root_composition_missing_html_wrapper",
);
expect(finding).toBeUndefined();
});
it("includes root tag snippet in finding", () => {
const html = `<div data-composition-id="bare" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
window.__timelines["bare"] = gsap.timeline({ paused: true });
</script>
</div>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "root_composition_missing_html_wrapper",
);
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("data-composition-id");
});
});
describe("standalone_composition_wrapped_in_template", () => {
it("flags root index.html wrapped in template", () => {
const html = `<template id="main-template">
<div data-composition-id="main" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</div>
</template>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "standalone_composition_wrapped_in_template",
);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not flag sub-compositions in template", () => {
const html = `<template id="sub-template">
<div data-composition-id="sub" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
window.__timelines["sub"] = gsap.timeline({ paused: true });
</script>
</div>
</template>`;
const result = lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find(
(f) => f.code === "standalone_composition_wrapped_in_template",
);
expect(finding).toBeUndefined();
});
});
describe("requestanimationframe_in_composition", () => {
it("flags requestAnimationFrame usage in script content", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
requestAnimationFrame(() => { console.log("tick"); });
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "requestanimationframe_in_composition",
);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not flag requestAnimationFrame in comments", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
// requestAnimationFrame(() => { });
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "requestanimationframe_in_composition",
);
expect(finding).toBeUndefined();
});
});
describe("root_composition_missing_data_duration (removed)", () => {
// The rule was a static proxy for the runtime's loop-inflation Infinity
// emission, but lint cannot observe GSAP timeline duration statically and
// the looping shapes that drive it are already covered by
// `gsap_infinite_repeat` and `gsap_repeat_ceil_overshoot`. The rule has
// been removed (#243's Infinity-emission concern is now carried by those
// GSAP rules); these tests pin the removal so the rule does not silently
// come back.
it("does not warn on a docs-compliant root with no data-duration", () => {
// The documented authoring model: root composition without
// data-duration, runtime derives it from the GSAP timeline.
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="docs" data-width="1920" data-height="1080" data-start="0">
<video src="clip.mp4" data-start="0" data-track-index="0" muted playsinline></video>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["docs"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "root_composition_missing_data_duration",
);
expect(finding).toBeUndefined();
});
it("does not warn even on the original Infinity-risk shape (no media, looping timeline)", () => {
// This was the canonical "warn" case under the old rule — root with no
// data-duration, no media, GSAP timeline driven by repeat: -1. The
// looping shape itself is now flagged by `gsap_infinite_repeat`; the
// duplicate `root_composition_missing_data_duration` warning is gone.
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="loopy" data-width="1920" data-height="1080" data-start="0">
<div class="caption" data-start="1" data-duration="2">hello</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to(".caption", { x: 100, duration: 1, repeat: -1 });
window.__timelines["loopy"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
// The deprecated rule must not fire.
const removedFinding = result.findings.find(
(f) => f.code === "root_composition_missing_data_duration",
);
expect(removedFinding).toBeUndefined();
// The looping shape is still surfaced — by `gsap_infinite_repeat`,
// which is the more actionable signal pointing at the real authoring
// mistake.
const gsapFinding = result.findings.find((f) => f.code === "gsap_infinite_repeat");
expect(gsapFinding).toBeDefined();
});
});
});