refactor(lint): split monolithic test file into per-rule-module test files (#181)

## Summary

- Splits the 872-line `hyperframeLinter.test.ts` into focused per-domain test files matching the rule module structure from #170
- Tests still use the public API (`lintHyperframeHtml`) — no internals exposed
- Fixes misplaced tests: `gsap_css_transform_conflict` tests were inside `describe("lintScriptUrls")`, caption/adapter tests were inside `describe("template_literal_selector rule")`

## New structure

```
src/lint/
  hyperframeLinter.test.ts   — orchestrator integration + lintScriptUrls (8 tests)
  rules/
    core.test.ts             — root attrs, timeline registry, host id (6 tests)
    media.test.ts            — duplicate ids, missing id/src, preload (6 tests)
    gsap.test.ts             — clip element, transform conflict, missing script (12 tests)
    captions.test.ts         — caption exit, overflow, relative position (5 tests)
    composition.test.ts      — external script, template literal selector (5 tests)
    adapters.test.ts         — missing Lottie/Three.js scripts (8 tests)
```

## Test plan

- [x] 404 tests pass (402 before — 2 extra from previously misplaced adapter tests now correctly counted)
- [x] Lint + format clean
This commit is contained in:
Miguel Ángel
2026-04-01 18:30:13 +02:00
committed by GitHub
parent 1681350ac4
commit 2213e9f7a4
7 changed files with 808 additions and 743 deletions
+1 -743
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { lintHyperframeHtml, lintScriptUrls } from "./hyperframeLinter.js";
describe("lintHyperframeHtml", () => {
describe("lintHyperframeHtml — orchestrator", () => {
const validComposition = `
<html>
<body>
@@ -24,72 +24,6 @@ describe("lintHyperframeHtml", () => {
expect(result.errorCount).toBe(0);
});
it("reports error when root is missing data-composition-id", () => {
const html = `
<html><body>
<div id="root" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = 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", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "root_missing_dimensions");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports error when timeline registry is missing", () => {
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 = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_timeline_registry");
expect(finding).toBeDefined();
});
it("reports error for duplicate media ids", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="a.mp4" data-start="0" data-duration="5"></video>
<video id="v1" src="b.mp4" data-start="0" data-duration="3"></video>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "duplicate_media_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
});
it("reports error for composition host missing data-composition-id", () => {
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 = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "host_missing_composition_id");
expect(finding).toBeDefined();
});
it("attaches filePath to findings when option is set", () => {
const html = "<html><body><div></div></body></html>";
const result = lintHyperframeHtml(html, { filePath: "test.html" });
@@ -99,7 +33,6 @@ describe("lintHyperframeHtml", () => {
});
it("deduplicates identical findings", () => {
// Calling with the same HTML should not produce duplicate entries
const html = `
<html><body>
<div id="root"></div>
@@ -111,44 +44,6 @@ describe("lintHyperframeHtml", () => {
expect(codes.length).toBe(uniqueCodes.length);
});
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("strips <template> wrapper before linting composition files", () => {
const html = `<template id="my-comp-template">
<div data-composition-id="my-comp" data-width="1920" data-height="1080"
@@ -168,209 +63,6 @@ describe("lintHyperframeHtml", () => {
);
expect(missing).toHaveLength(0);
});
it("reports error when timeline registry is assigned without initializing", () => {
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 = 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("does not flag timeline assignment when init guard is present", () => {
const result = lintHyperframeHtml(validComposition);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeUndefined();
});
it("reports error when GSAP targets a clip element by id", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
<h1>Hello</h1>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#overlay", { opacity: 0, duration: 0.5 }, 4.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#overlay");
expect(finding?.message).toContain("inner wrapper");
});
it("reports error when GSAP targets a clip element by class", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="card" class="clip my-card" data-start="0" data-duration="5" data-track-index="0">
<p>Content</p>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from(".my-card", { y: 100, duration: 0.3 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".my-card");
});
it("does NOT flag GSAP targeting a child of a clip element", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
<h1 class="title">Hello</h1>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to(".title", { opacity: 1, duration: 0.5 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("does NOT flag GSAP targeting a nested selector like '#overlay .title'", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
<h1 class="title">Hello</h1>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#overlay .title", { opacity: 1, duration: 0.5 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("reports error when GSAP targets a clip element with no id (class-only)", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div class="clip scene-card" data-start="0" data-duration="5" data-track-index="0">
<p>Content</p>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to(".scene-card", { y: -50, duration: 0.4 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".scene-card");
expect(finding?.elementId).toBeUndefined();
});
it("reports error for audio with data-start but no id", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio data-start="0" data-duration="10" src="narration.wav"></audio>
</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 === "media_missing_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("SILENT");
});
it("reports error for video with data-start but no id", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video data-start="0" data-duration="10" src="clip.mp4" muted playsinline></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 === "media_missing_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("FROZEN");
});
it("does not flag media elements that have id", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio id="a1" data-start="0" data-duration="10" src="narration.wav"></audio>
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline></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 === "media_missing_id");
expect(finding).toBeUndefined();
});
it("reports warning for media with preload=none", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline preload="none"></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 === "media_preload_none");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("reports error for media with id but no src", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio id="a1" data-start="0" data-duration="10"></audio>
</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 === "media_missing_src");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
});
describe("lintScriptUrls", () => {
@@ -434,438 +126,4 @@ describe("lintScriptUrls", () => {
vi.unstubAllGlobals();
});
// ── gsap_css_transform_conflict ──────────────────────────────────────────
it("warns when tl.to animates x on an element with CSS translateX", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="title" style=""></div>
</div>
<style>
#title { position: absolute; top: 240px; left: 50%; transform: translateX(-50%); }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#title", { x: 0, opacity: 1, duration: 0.4 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.selector).toBe("#title");
expect(finding?.fixHint).toMatch(/fromTo/);
expect(finding?.fixHint).toMatch(/xPercent/);
});
it("warns when tl.to animates scale on an element with CSS scale transform", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="hero"></div>
</div>
<style>
#hero { transform: scale(0.8); opacity: 0; }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { opacity: 1, scale: 1, duration: 0.5 }, 1.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.selector).toBe("#hero");
});
it("does NOT warn when tl.to targets element without CSS transform", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="card"></div>
</div>
<style>
#card { position: absolute; top: 100px; left: 100px; opacity: 0; }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#card", { x: 0, opacity: 1, duration: 0.3 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(conflict).toBeUndefined();
});
it("does NOT warn when tl.fromTo targets element WITH CSS transform (author owns both ends)", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="title"></div>
</div>
<style>
#title { position: absolute; left: 50%; transform: translateX(-50%); }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo("#title", { xPercent: -50, x: -1000, opacity: 0 }, { xPercent: -50, x: 0, opacity: 1, duration: 0.4 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(conflict).toBeUndefined();
});
it("emits one warning when a combined CSS transform conflicts with multiple GSAP properties", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="hero"></div>
</div>
<style>
#hero { transform: translateX(-50%) scale(0.8); }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { x: 0, scale: 1, opacity: 1, duration: 0.5 }, 1.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const conflicts = result.findings.filter((f) => f.code === "gsap_css_transform_conflict");
expect(conflicts).toHaveLength(1);
expect(conflicts[0]?.message).toMatch(/x\/scale|scale\/x/);
});
});
describe("template_literal_selector rule", () => {
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();
});
// ── Caption lint rules ────────────────────────────────────────────────
it("warns when caption exit has no hard kill tl.set", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<div id="caption-container"></div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function(group, gi) {
var groupEl = document.createElement("div");
groupEl.id = "cg-" + gi;
tl.set(groupEl, { opacity: 1 }, group.start);
tl.to(groupEl, { opacity: 0, duration: 0.12 }, group.end - 0.12);
});
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not warn when caption exit has hard kill tl.set", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<div id="caption-container"></div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function(group, gi) {
var groupEl = document.createElement("div");
groupEl.id = "cg-" + gi;
tl.set(groupEl, { opacity: 1 }, group.start);
tl.to(groupEl, { opacity: 0, duration: 0.12 }, group.end - 0.12);
tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end);
});
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("warns when caption group has nowrap without max-width", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: absolute;
white-space: nowrap;
text-align: center;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_text_overflow_risk");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not warn when caption group has nowrap with max-width", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: absolute;
white-space: nowrap;
max-width: 1600px;
overflow: hidden;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "caption_text_overflow_risk" && f.severity === "warning",
);
expect(finding).toBeUndefined();
});
it("warns when caption container uses position: relative", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: relative;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_container_relative_position");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
// ── Missing adapter library script checks ──────────────────────────────
it("reports error when GSAP is used without a GSAP script tag", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 100, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("GSAP");
});
it("does not report missing_gsap_script when GSAP CDN script is present", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></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("#box", { x: 100, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeUndefined();
});
it("reports error when Lottie container exists without a Lottie script tag", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="lottie-player" data-lottie-src="animation.json"></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 === "missing_lottie_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Lottie");
});
it("reports error when lottie.loadAnimation is used without a Lottie script tag", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
lottie.loadAnimation({ container: document.getElementById('lottie'), path: 'anim.json' });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_lottie_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not report missing_lottie_script when Lottie CDN script is present", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="lottie-player" data-lottie-src="animation.json"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lottie-web@5/build/player/lottie.min.js"></script>
<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 === "missing_lottie_script");
expect(finding).toBeUndefined();
});
it("reports error when Three.js is used without a Three.js script tag", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer();
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Three.js");
});
it("does not report missing_three_script when Three.js CDN script is present", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer();
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeUndefined();
});
it("does not report any adapter errors for composition with no adapter usage", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="content">Hello World</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = { totalDuration: function() { return 3; } };
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const adapterFindings = result.findings.filter((f) =>
["missing_gsap_script", "missing_lottie_script", "missing_three_script"].includes(f.code),
);
expect(adapterFindings).toHaveLength(0);
});
});
@@ -0,0 +1,143 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("adapter rules", () => {
it("reports error when GSAP is used without a GSAP script tag", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 100, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("GSAP");
});
it("does not report missing_gsap_script when GSAP CDN script is present", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></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("#box", { x: 100, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeUndefined();
});
it("reports error when Lottie container exists without a Lottie script tag", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="lottie-player" data-lottie-src="animation.json"></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 === "missing_lottie_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Lottie");
});
it("reports error when lottie.loadAnimation is used without a Lottie script tag", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
lottie.loadAnimation({ container: document.getElementById('lottie'), path: 'anim.json' });
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_lottie_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not report missing_lottie_script when Lottie CDN script is present", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="lottie-player" data-lottie-src="animation.json"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lottie-web@5/build/player/lottie.min.js"></script>
<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 === "missing_lottie_script");
expect(finding).toBeUndefined();
});
it("reports error when Three.js is used without a Three.js script tag", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer();
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Three.js");
});
it("does not report missing_three_script when Three.js CDN script is present", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer();
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeUndefined();
});
it("does not report any adapter errors for composition with no adapter usage", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="content">Hello World</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = { totalDuration: function() { return 3; } };
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const adapterFindings = result.findings.filter((f) =>
["missing_gsap_script", "missing_lottie_script", "missing_three_script"].includes(f.code),
);
expect(adapterFindings).toHaveLength(0);
});
});
@@ -0,0 +1,124 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("caption rules", () => {
it("warns when caption exit has no hard kill tl.set", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<div id="caption-container"></div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function(group, gi) {
var groupEl = document.createElement("div");
groupEl.id = "cg-" + gi;
tl.set(groupEl, { opacity: 1 }, group.start);
tl.to(groupEl, { opacity: 0, duration: 0.12 }, group.end - 0.12);
});
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not warn when caption exit has hard kill tl.set", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<div id="caption-container"></div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function(group, gi) {
var groupEl = document.createElement("div");
groupEl.id = "cg-" + gi;
tl.set(groupEl, { opacity: 1 }, group.start);
tl.to(groupEl, { opacity: 0, duration: 0.12 }, group.end - 0.12);
tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end);
});
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("warns when caption group has nowrap without max-width", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: absolute;
white-space: nowrap;
text-align: center;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_text_overflow_risk");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not warn when caption group has nowrap with max-width", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: absolute;
white-space: nowrap;
max-width: 1600px;
overflow: hidden;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "caption_text_overflow_risk" && f.severity === "warning",
);
expect(finding).toBeUndefined();
});
it("warns when caption container uses position: relative", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: relative;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_container_relative_position");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
});
@@ -0,0 +1,97 @@
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();
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("core rules", () => {
it("reports error when root is missing data-composition-id", () => {
const html = `
<html><body>
<div id="root" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = 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", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "root_missing_dimensions");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports error when timeline registry is missing", () => {
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 = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_timeline_registry");
expect(finding).toBeDefined();
});
it("reports error for composition host missing data-composition-id", () => {
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 = 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", () => {
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 = 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("does not flag timeline assignment when init guard is present", () => {
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 = lintHyperframeHtml(validComposition);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeUndefined();
});
});
+257
View File
@@ -0,0 +1,257 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("GSAP rules", () => {
it("reports error when GSAP targets a clip element by id", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
<h1>Hello</h1>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#overlay", { opacity: 0, duration: 0.5 }, 4.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#overlay");
expect(finding?.message).toContain("inner wrapper");
});
it("reports error when GSAP targets a clip element by class", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="card" class="clip my-card" data-start="0" data-duration="5" data-track-index="0">
<p>Content</p>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from(".my-card", { y: 100, duration: 0.3 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".my-card");
});
it("does NOT flag GSAP targeting a child of a clip element", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
<h1 class="title">Hello</h1>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to(".title", { opacity: 1, duration: 0.5 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("does NOT flag GSAP targeting a nested selector like '#overlay .title'", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay" class="clip" data-start="0" data-duration="5" data-track-index="0">
<h1 class="title">Hello</h1>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#overlay .title", { opacity: 1, duration: 0.5 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeUndefined();
});
it("reports error when GSAP targets a clip element with no id (class-only)", () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div class="clip scene-card" data-start="0" data-duration="5" data-track-index="0">
<p>Content</p>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to(".scene-card", { y: -50, duration: 0.4 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_animates_clip_element");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".scene-card");
expect(finding?.elementId).toBeUndefined();
});
it("warns when tl.to animates x on an element with CSS translateX", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="title" style=""></div>
</div>
<style>
#title { position: absolute; top: 240px; left: 50%; transform: translateX(-50%); }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#title", { x: 0, opacity: 1, duration: 0.4 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.selector).toBe("#title");
expect(finding?.fixHint).toMatch(/fromTo/);
expect(finding?.fixHint).toMatch(/xPercent/);
});
it("warns when tl.to animates scale on an element with CSS scale transform", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="hero"></div>
</div>
<style>
#hero { transform: scale(0.8); opacity: 0; }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { opacity: 1, scale: 1, duration: 0.5 }, 1.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.selector).toBe("#hero");
});
it("does NOT warn when tl.to targets element without CSS transform", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="card"></div>
</div>
<style>
#card { position: absolute; top: 100px; left: 100px; opacity: 0; }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#card", { x: 0, opacity: 1, duration: 0.3 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(conflict).toBeUndefined();
});
it("does NOT warn when tl.fromTo targets element WITH CSS transform (author owns both ends)", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="title"></div>
</div>
<style>
#title { position: absolute; left: 50%; transform: translateX(-50%); }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo("#title", { xPercent: -50, x: -1000, opacity: 0 }, { xPercent: -50, x: 0, opacity: 1, duration: 0.4 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const conflict = result.findings.find((f) => f.code === "gsap_css_transform_conflict");
expect(conflict).toBeUndefined();
});
it("emits one warning when a combined CSS transform conflicts with multiple GSAP properties", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="hero"></div>
</div>
<style>
#hero { transform: translateX(-50%) scale(0.8); }
</style>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { x: 0, scale: 1, opacity: 1, duration: 0.5 }, 1.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const conflicts = result.findings.filter((f) => f.code === "gsap_css_transform_conflict");
expect(conflicts).toHaveLength(1);
expect(conflicts[0]?.message).toMatch(/x\/scale|scale\/x/);
});
it("reports error when GSAP is used without a GSAP script tag", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 100, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("GSAP");
});
it("does not report missing_gsap_script when GSAP CDN script is present", () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></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("#box", { x: 100, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeUndefined();
});
});
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("media rules", () => {
it("reports error for duplicate media ids", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="a.mp4" data-start="0" data-duration="5"></video>
<video id="v1" src="b.mp4" data-start="0" data-duration="3"></video>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "duplicate_media_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
});
it("reports error for audio with data-start but no id", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio data-start="0" data-duration="10" src="narration.wav"></audio>
</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 === "media_missing_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("SILENT");
});
it("reports error for video with data-start but no id", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video data-start="0" data-duration="10" src="clip.mp4" muted playsinline></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 === "media_missing_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("FROZEN");
});
it("does not flag media elements that have id", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio id="a1" data-start="0" data-duration="10" src="narration.wav"></audio>
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline></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 === "media_missing_id");
expect(finding).toBeUndefined();
});
it("reports warning for media with preload=none", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline preload="none"></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 === "media_preload_none");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("reports error for media with id but no src", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio id="a1" data-start="0" data-duration="10"></audio>
</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 === "media_missing_src");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
});