Files
hyperframes/packages/lint/src/rules/media.test.ts
T

320 lines
15 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("media rules", () => {
it("reports error for duplicate media ids", async () => {
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 = await 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", async () => {
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 = await 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", async () => {
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 = await 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("flags media that has data-hf-id but no real id", async () => {
// Regression: readAttr(tag, "id") used a \b boundary that matched the
// trailing `id="…"` inside `data-hf-id="…"`, so media carrying only a
// Studio-stamped data-hf-id passed the check and then rendered as a blank
// wash (video) / silent (audio). data-hf-id is NOT a render id.
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video data-hf-id="hf-v1a2b3" data-start="0" data-duration="10" src="clip.mp4" muted playsinline></video>
<audio data-hf-id="hf-a4c5d6" 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 = await lintHyperframeHtml(html);
const findings = result.findings.filter((f) => f.code === "media_missing_id");
expect(findings).toHaveLength(2);
expect(findings.every((f) => f.severity === "error")).toBe(true);
});
it("does not flag media elements that have id", async () => {
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 = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_id");
expect(finding).toBeUndefined();
});
it("reports warning for media with preload=none", async () => {
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 = await 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", async () => {
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 = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_src");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports error for media with src but no data-start", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" src="clip.mp4" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_data_start");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("allows audible video clips to omit muted when data-has-audio is true", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" data-has-audio="true" src="clip.mp4" playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "video_missing_muted")).toBeUndefined();
expect(
result.findings.find((f) => f.code === "video_muted_with_declared_audio"),
).toBeUndefined();
});
it("reports error for videos that declare audio while muted", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" data-has-audio="true" src="clip.mp4" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "video_muted_with_declared_audio");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("does NOT flag <video> as nested in a void element with data-start (regression)", async () => {
// Regression: void elements like <img> have no closing tag, so the previous
// implementation kept them on the parent stack indefinitely and flagged any
// later <video> with data-start as "nested" inside them.
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<img id="hdr-img" src="hdr.png" data-start="0" data-duration="5" data-track-index="0" />
<video id="hdr-vid" src="clip.mp4" data-start="5" data-duration="5" data-track-index="1" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "video_nested_in_timed_element");
expect(finding).toBeUndefined();
});
it("reports imperative play() control on managed media ids", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" src="clip.mp4" muted playsinline></video>
</div>
<script>
const video = document.getElementById("demo-video");
video.play();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("reports imperative currentTime writes on query-selected managed media", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" src="clip.mp4" muted playsinline></video>
</div>
<script>
const demo = document.querySelector("#demo-video");
demo.currentTime = 1.5;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports imperative muted/play control on class-selected media without ids", async () => {
const html = `
<template id="scene-template">
<div data-composition-id="scene" data-width="1920" data-height="1080">
<video class="demo-video" src="clip.mp4" muted playsinline></video>
<script>
const vid = document.querySelector('[data-composition-id="scene"] .demo-video');
if (vid) { vid.muted = true; vid.play(); }
</script>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { filePath: "compositions/scene.html" });
const imperativeFindings = result.findings.filter((f) => f.code === "imperative_media_control");
expect(imperativeFindings.length).toBe(2);
expect(imperativeFindings.some((f) => f.snippet === "vid.muted =")).toBe(true);
expect(imperativeFindings.some((f) => f.snippet === "vid.play(")).toBe(true);
});
it("does not flag play() on non-media elements", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="panel"></div>
</div>
<script>
const panel = document.getElementById("panel");
panel.play?.();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeUndefined();
});
it("flags <video> inside a sub-composition (media must be a host-root child)", async () => {
const html = `<template id="scene-template">
<div id="root" data-composition-id="scene" data-width="1920" data-height="1080">
<video id="v1" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "media_in_subcomposition");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
expect(finding?.message).toContain("sub-composition");
});
it("does not flag media in a host-root (non-sub) composition", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_in_subcomposition");
expect(finding).toBeUndefined();
});
it("reports error for media with crossorigin (breaks preview when host omits CORS)", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" crossorigin="anonymous" src="https://cdn.example.com/clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_crossorigin_breaks_preview");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
});
it("does not flag media without crossorigin", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="https://cdn.example.com/clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_crossorigin_breaks_preview");
expect(finding).toBeUndefined();
});
});
describe("media_variable_src_no_fallback", () => {
it("downgrades missing src to a warning when data-var-src is present", async () => {
const html = `<html><body>
<video id="clip" data-start="0" data-duration="2" data-var-src="media"></video>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.some((f) => f.code === "media_missing_src")).toBe(false);
const finding = result.findings.find((f) => f.code === "media_variable_src_no_fallback");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("keeps the hard error when neither src nor data-var-src exists", async () => {
const html = `<html><body>
<video id="clip" data-start="0" data-duration="2"></video>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.some((f) => f.code === "media_missing_src")).toBe(true);
});
});