// fallow-ignore-file code-duplication import { describe, it, expect } from "vitest"; import { lintHyperframeHtml } from "../hyperframeLinter.js"; describe("composition rules", () => { describe("canonical timing contract", () => { it("rejects deprecated attributes even when canonical attributes are also present", async () => { const result = await lintHyperframeHtml(`
`); expect(result.findings.map(({ code }) => code)).toEqual( expect.arrayContaining(["deprecated_data_end", "deprecated_data_layer"]), ); }); it("uses canonical timing when deprecated attributes conflict", async () => { const result = await lintHyperframeHtml(`
`); expect( result.findings.find(({ code }) => code === "overlapping_clips_same_track"), ).toBeUndefined(); }); }); describe("subcomposition guidance", () => { it("warns when any HTML composition file is over 300 lines", async () => { const html = Array.from({ length: 301 }, (_, i) => i === 0 ? "" : ``, ).join("\n"); const result = await lintHyperframeHtml(html, { filePath: "/project/compositions/scene.html", }); const finding = result.findings.find((f) => f.code === "composition_file_too_large"); expect(finding).toBeDefined(); expect(finding?.severity).toBe("warning"); }); it("does not warn when an HTML composition file is exactly 300 lines", async () => { const html = Array.from({ length: 300 }, (_, i) => i === 0 ? "" : ``, ).join("\n"); const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" }); const finding = result.findings.find((f) => f.code === "composition_file_too_large"); expect(finding).toBeUndefined(); }); it("does not count a final trailing newline as an extra physical line", async () => { const html = Array.from({ length: 300 }, (_, i) => i === 0 ? "" : ``, ).join("\n") + "\n"; const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" }); const finding = result.findings.find((f) => f.code === "composition_file_too_large"); expect(finding).toBeUndefined(); }); it("does not count inline style block internals as structural lines", async () => { const style = ``; const html = ` ${style}
TEXT
`; const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" }); const finding = result.findings.find((f) => f.code === "composition_file_too_large"); expect(finding).toBeUndefined(); }); it("does not warn for large registry source block files", async () => { const html = Array.from({ length: 301 }, (_, i) => i === 0 ? "" : ``, ).join("\n"); const result = await lintHyperframeHtml(html, { filePath: "/project/registry/blocks/data-chart/data-chart.html", }); const finding = result.findings.find((f) => f.code === "composition_file_too_large"); expect(finding).toBeUndefined(); }); it("warns for large installed block composition files", async () => { const html = Array.from({ length: 301 }, (_, i) => i === 0 ? "" : ``, ).join("\n"); const result = await lintHyperframeHtml(html, { filePath: "/project/compositions/data-chart.html", }); const finding = result.findings.find((f) => f.code === "composition_file_too_large"); expect(finding).toBeDefined(); expect(finding?.severity).toBe("warning"); }); it("does not warn for large registry-installed block composition files", async () => { const html = "\n" + Array.from({ length: 300 }, (_, i) => i === 0 ? "" : ``, ).join("\n"); const result = await lintHyperframeHtml(html, { filePath: "/project/compositions/data-chart.html", }); const finding = result.findings.find((f) => f.code === "composition_file_too_large"); expect(finding).toBeUndefined(); }); it("uses nested split copy for large sub-composition files", async () => { const html = Array.from({ length: 301 }, (_, i) => i === 0 ? "" : ``, ).join("\n"); const result = await lintHyperframeHtml(html, { filePath: "/project/compositions/scene.html", isSubComposition: true, }); const finding = result.findings.find((f) => f.code === "composition_file_too_large"); expect(finding?.fixHint).toContain("Split this sub-composition further"); }); it("warns when more than 3 timed elements share the same track", async () => { const html = `
A
B
C
D
`; const result = await lintHyperframeHtml(html, { filePath: "/project/compositions/scene.html", }); const finding = result.findings.find((f) => f.code === "timeline_track_too_dense"); expect(finding).toBeDefined(); expect(finding?.severity).toBe("warning"); expect(finding?.message).toContain("Track 0 has 4 timed elements"); }); it("does not warn when 3 timed elements share the same track", async () => { const html = `
A
B
C
`; const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" }); const finding = result.findings.find((f) => f.code === "timeline_track_too_dense"); expect(finding).toBeUndefined(); }); it("does not warn when timed elements are split across tracks", async () => { const html = `
A
B
C
D
`; const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" }); const finding = result.findings.find((f) => f.code === "timeline_track_too_dense"); expect(finding).toBeUndefined(); }); it("does not count timed media or script/style tags as dense track elements", async () => { const html = `
`; const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" }); const finding = result.findings.find((f) => f.code === "timeline_track_too_dense"); expect(finding).toBeUndefined(); }); it("does not count transcript caption cues as dense track elements", async () => { const html = `
`; const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" }); const finding = result.findings.find((f) => f.code === "timeline_track_too_dense"); expect(finding).toBeUndefined(); }); it("does not count root composition or mounted sub-compositions as dense elements", async () => { const html = `
`; const result = await lintHyperframeHtml(html, { filePath: "/project/index.html" }); const finding = result.findings.find((f) => f.code === "timeline_track_too_dense"); expect(finding).toBeUndefined(); }); }); describe("duplicate_composition_id", () => { it("flags a meta tag and root div sharing the same data-composition-id", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); expect(finding).toBeDefined(); expect(finding?.severity).toBe("error"); }); it("does not flag a single valid composition id", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); expect(finding).toBeUndefined(); }); it("does not flag distinct composition ids in one file", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); expect(finding).toBeUndefined(); }); it("ignores composition ids inside inert template content", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); expect(finding).toBeUndefined(); }); it("flags entity-equivalent composition ids", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); expect(finding).toBeDefined(); }); it("uses the browser's first value for duplicate attributes", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "duplicate_composition_id"); expect(finding).toBeDefined(); }); }); it("reports error when querySelector uses template literal variable", async () => { const html = `
`; const result = await 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", async () => { const html = `
`; const result = await 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", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "template_literal_selector"); expect(finding).toBeUndefined(); }); it("reports error when a selector combines data attributes in one bracket", async () => { const html = ` `; const result = await lintHyperframeHtml(html, { filePath: "compositions/scene.html" }); const findings = result.findings.filter((f) => f.code === "split_data_attribute_selector"); expect(findings.length).toBe(1); expect(findings[0]?.severity).toBe("error"); expect(findings[0]?.fixHint).toContain('[data-composition-id="scene"][data-start="0"]'); }); describe("timed_element_missing_clip_class", () => { it("flags element with data-start but no class='clip'", async () => { const html = `
Hello
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class"); expect(finding).toBeDefined(); expect(finding?.severity).toBe("error"); }); it("does not flag element that has class='clip'", async () => { const html = `
Hello
`; const result = await 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", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class"); expect(finding).toBeUndefined(); }); it("does not flag element with only data-track-index (layer container, no timing)", async () => { const html = `
Hello
`; const result = await 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", async () => { const html = `
A
B
`; const result = await 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", async () => { const html = `
A
B
`; const result = await 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", async () => { const html = `
A
B
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track"); expect(finding).toBeUndefined(); }); it("does not flag adjacencies where parseFloat + add drifts by a few ulps", async () => { // parseFloat("0.1") + parseFloat("0.2") = 0.30000000000000004 const html = `
A
B
`; const result = await 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", async () => { // Exact scenario from the screenshot — bare div with composition attributes, no HTML wrapper const html = `
`; const result = await 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", async () => { const html = `
Hello
`; const result = await 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 (no doctype)", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find( (f) => f.code === "root_composition_missing_html_wrapper", ); expect(finding).toBeUndefined(); }); it("does not flag sub-compositions", async () => { const html = `
`; const result = await 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", async () => { const html = `

Not a composition

`; const result = await 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", async () => { const html = `
`; const result = await 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", async () => { const html = ``; const result = await lintHyperframeHtml(html); const finding = result.findings.find( (f) => f.code === "standalone_composition_wrapped_in_template", ); expect(finding).toBeDefined(); expect(finding?.severity).toBe("error"); }); it("does not flag sub-compositions in template", async () => { const html = ``; const result = await 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", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find( (f) => f.code === "requestanimationframe_in_composition", ); expect(finding).toBeDefined(); expect(finding?.severity).toBe("error"); }); it("does not flag requestAnimationFrame in comments", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find( (f) => f.code === "requestanimationframe_in_composition", ); expect(finding).toBeUndefined(); }); it("does not flag installed registry blocks that use rAF (e.g. particle effects)", async () => { const html = `\n` + `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find( (f) => f.code === "requestanimationframe_in_composition", ); expect(finding).toBeUndefined(); }); }); describe("missing_data_no_timeline", () => { it("warns when root has no timeline registration and no data-no-timeline", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "missing_data_no_timeline"); expect(finding).toBeDefined(); expect(finding?.severity).toBe("warning"); }); it("does not warn when data-no-timeline is present (boolean form)", async () => { const html = `
`; const result = await lintHyperframeHtml(html); expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined(); }); it("does not warn when a script registers window.__timelines[id]", async () => { const html = `
`; const result = await lintHyperframeHtml(html); expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined(); }); it("does not warn when there is no root composition-id", async () => { const html = `

hello

`; const result = await lintHyperframeHtml(html); expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined(); }); it("does not false-positive when data-no-timeline appears only inside an attribute value", async () => { // Regression: /\bdata-no-timeline\b/ matched substrings inside values const html = `
`; const result = await lintHyperframeHtml(html); expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeDefined(); }); it("does not suppress when a hyphenated variant like data-no-timeline-start is present", async () => { // Regression: /\bdata-no-timeline\b/ matched data-no-timeline-start because // hyphen is a non-word char and \b fires between 'e' and '-' const html = `
`; const result = await lintHyperframeHtml(html); expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeDefined(); }); it("does not warn for sub-compositions", async () => { const html = ``; const result = await lintHyperframeHtml(html, { isSubComposition: true }); expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined(); }); it("does not warn when composition has external scripts (cannot scan for timeline registration)", async () => { const html = `
`; const result = await lintHyperframeHtml(html); expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).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", async () => { // The documented authoring model: root composition without // data-duration, runtime derives it from the GSAP timeline. const html = `
`; const result = await 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)", async () => { // 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 = `
hello
`; const result = await 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(); }); }); describe("root_composition_missing_data_start", () => { it("does not warn for template-wrapped sub-composition files", async () => { const html = ` `; const result = await lintHyperframeHtml(html, { isSubComposition: true }); const finding = result.findings.find((f) => f.code === "root_composition_missing_data_start"); expect(finding).toBeUndefined(); }); }); describe("unknown_variable_binding", () => { it("warns when data-var-src references an undeclared variable", async () => { const html = ` `; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "unknown_variable_binding"); expect(finding).toBeDefined(); expect(finding?.severity).toBe("warning"); expect(finding?.message).toMatch(/heroImge/); }); it("stays quiet for declared binding ids and for fragment files", async () => { const declared = `

x

`; expect( (await lintHyperframeHtml(declared)).findings.some( (f) => f.code === "unknown_variable_binding", ), ).toBe(false); const fragment = `
x
`; expect( (await lintHyperframeHtml(fragment)).findings.some( (f) => f.code === "unknown_variable_binding", ), ).toBe(false); }); }); describe("invalid_variable_values_json", () => { it("warns when data-variable-values is unparseable JSON", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "invalid_variable_values_json"); expect(finding).toBeDefined(); expect(finding?.severity).toBe("error"); }); it("warns when data-variable-values is a JSON array (must be an object)", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "invalid_variable_values_json"); expect(finding).toBeDefined(); expect(finding?.message).toMatch(/must be a JSON object/); }); it("warns when data-variable-values is a JSON string (must be an object)", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "invalid_variable_values_json"); expect(finding).toBeDefined(); }); it("does not warn for a valid JSON object", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "invalid_variable_values_json"); expect(finding).toBeUndefined(); }); it("does not warn when data-variable-values is absent", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find((f) => f.code === "invalid_variable_values_json"); expect(finding).toBeUndefined(); }); }); describe("invalid_composition_variables_declaration", () => { it("warns when data-composition-variables is unparseable JSON", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find( (f) => f.code === "invalid_composition_variables_declaration", ); expect(finding).toBeDefined(); }); it("warns when data-composition-variables is not an array", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find( (f) => f.code === "invalid_composition_variables_declaration", ); expect(finding).toBeDefined(); expect(finding?.message).toMatch(/array of variable declarations/); }); it("warns per-entry when an entry is missing required fields", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const findings = result.findings.filter( (f) => f.code === "invalid_composition_variables_declaration", ); expect(findings.length).toBe(1); expect(findings[0]?.message).toMatch(/\[1\]/); expect(findings[0]?.message).toMatch(/type|label|default/); }); it("warns when a declaration uses an unknown type", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find( (f) => f.code === "invalid_composition_variables_declaration", ); expect(finding).toBeDefined(); expect(finding?.message).toMatch(/type/); }); it("does not warn for a fully valid declarations array", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find( (f) => f.code === "invalid_composition_variables_declaration", ); expect(finding).toBeUndefined(); }); it("does not warn when data-composition-variables is absent", async () => { const html = `
`; const result = await lintHyperframeHtml(html); const finding = result.findings.find( (f) => f.code === "invalid_composition_variables_declaration", ); expect(finding).toBeUndefined(); }); }); describe("invalid_parent_traversal_in_asset_path", () => { const RULE_CODE = "invalid_parent_traversal_in_asset_path"; it("errors when an src uses ../capture/", async () => { const html = `
logo
`; const result = await lintHyperframeHtml(html, { filePath: "/project/compositions/scene.html", }); const finding = result.findings.find((f) => f.code === RULE_CODE); expect(finding).toBeDefined(); expect(finding?.severity).toBe("error"); expect(finding?.message).toContain("../capture/"); }); it("errors when a