import { describe, it, expect, afterEach } from "vitest"; import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import type { HyperframeLintFinding } from "@hyperframes/core/lint"; import { lintProject, shouldBlockRender } from "./lintProject.js"; function tmpProject(name: string): string { return mkdtempSync(join(tmpdir(), `hf-test-${name}-`)); } function validHtml(compId = "main"): string { return `
`; } function htmlWithMissingMediaId(): string { return `
`; } function htmlWithPreloadNone(): string { return `
`; } let dirs: string[] = []; function makeProject(indexHtml: string, subComps?: Record): string { const dir = tmpProject("lint"); dirs.push(dir); writeFileSync(join(dir, "index.html"), indexHtml); if (subComps) { const compsDir = join(dir, "compositions"); mkdirSync(compsDir, { recursive: true }); for (const [name, html] of Object.entries(subComps)) { writeFileSync(join(compsDir, name), html); } } return dir; } afterEach(() => { for (const d of dirs) { rmSync(d, { recursive: true, force: true }); } dirs = []; }); describe("lintProject", () => { it("returns zero errors/warnings for a clean project", async () => { const project = makeProject(validHtml()); const { totalErrors, totalWarnings, results } = await lintProject(project); expect(totalErrors).toBe(0); expect(totalWarnings).toBe(0); expect(results).toHaveLength(1); const first = results[0]; expect(first).toBeDefined(); expect(first?.file).toBe("index.html"); }); it("detects errors in index.html", async () => { const project = makeProject(htmlWithMissingMediaId()); const { totalErrors, results } = await lintProject(project); expect(totalErrors).toBeGreaterThan(0); const first = results[0]; expect(first).toBeDefined(); const mediaFinding = first?.result.findings.find((f) => f.code === "media_missing_id"); expect(mediaFinding).toBeDefined(); }); it("recurses into compositions/frames/ and flags a CSS↔GSAP transform conflict there", async () => { // End-to-end guard: a per-frame composition under compositions/frames/ that // seats centering via a standalone gsap.set on a grouped #root-scoped selector // against a CSS class transform — the exact shape that shipped off-centre. // Both the recursive discovery and the strengthened rule must fire. const dir = tmpProject("lint-frames"); dirs.push(dir); writeFileSync(join(dir, "index.html"), validHtml()); const framesDir = join(dir, "compositions", "frames"); mkdirSync(framesDir, { recursive: true }); const frameHtml = ``; writeFileSync(join(framesDir, "04-mechanism.html"), frameHtml); const { results } = await lintProject(dir); const frameResult = results.find((r) => r.file === "compositions/frames/04-mechanism.html"); expect(frameResult).toBeDefined(); const conflict = frameResult?.result.findings.find( (f) => f.code === "gsap_css_transform_conflict", ); expect(conflict).toBeDefined(); }); it("lints sub-compositions in compositions/ directory", async () => { const project = makeProject(validHtml(), { "captions.html": htmlWithMissingMediaId(), }); const { totalErrors, results } = await lintProject(project); expect(results).toHaveLength(2); const second = results[1]; expect(second).toBeDefined(); expect(second?.file).toBe("compositions/captions.html"); expect(totalErrors).toBeGreaterThan(0); const subFindings = second?.result.findings ?? []; expect(subFindings.some((f) => f.code === "media_missing_id")).toBe(true); }); it("lints linked CSS next to sub-compositions", async () => { const project = makeProject(validHtml(), { "scene.html": `
`, }); writeFileSync( join(project, "compositions", "scene.css"), '[data-composition-id="scene"] .title { opacity: 0; }', ); const { results } = await lintProject(project); const subResult = results.find((result) => result.file === "compositions/scene.html"); const finding = subResult?.result.findings.find( (item) => item.code === "composition_self_attribute_selector", ); expect(finding).toBeDefined(); expect(finding?.selector).toBe('[data-composition-id="scene"] .title'); }); it("lints percent-encoded linked CSS filenames that exist decoded on disk", async () => { const encodedFilename = "%E6%97%A5%E6%9C%AC%E8%AA%9E.css"; const project = makeProject(validHtml(), { "scene.html": `
`, }); writeFileSync( join(project, "compositions", decodeURIComponent(encodedFilename)), '[data-composition-id="scene"] .title { opacity: 0; }', ); const { results } = await lintProject(project); const subResult = results.find((result) => result.file === "compositions/scene.html"); const finding = subResult?.result.findings.find( (item) => item.code === "composition_self_attribute_selector", ); expect(finding).toBeDefined(); expect(finding?.selector).toBe('[data-composition-id="scene"] .title'); }); it("aggregates errors across index.html and sub-compositions", async () => { const project = makeProject(htmlWithMissingMediaId(), { "overlay.html": htmlWithMissingMediaId(), }); const { totalErrors, results } = await lintProject(project); expect(results).toHaveLength(2); const first = results[0]; const second = results[1]; expect(first).toBeDefined(); expect(second).toBeDefined(); // Both files have media_missing_id errors const rootErrors = first?.result.errorCount ?? 0; const subErrors = second?.result.errorCount ?? 0; expect(totalErrors).toBe(rootErrors + subErrors); }); it("aggregates warnings from sub-compositions", async () => { const project = makeProject(validHtml(), { "captions.html": htmlWithPreloadNone(), }); const { totalWarnings, results } = await lintProject(project); expect(results).toHaveLength(2); expect(totalWarnings).toBeGreaterThan(0); const second = results[1]; expect(second).toBeDefined(); const preloadWarning = second?.result.findings.find((f) => f.code === "media_preload_none"); expect(preloadWarning).toBeDefined(); }); it("handles project with no compositions/ directory", async () => { const project = makeProject(validHtml()); // No compositions/ dir created const { results } = await lintProject(project); expect(results).toHaveLength(1); }); it("ignores registry component templates under compositions/components", async () => { const project = makeProject(validHtml()); const componentsDir = join(project, "compositions", "components", "unused-widget"); mkdirSync(componentsDir, { recursive: true }); writeFileSync( join(componentsDir, "template.html"), ``, ); const { results, totalErrors } = await lintProject(project); expect(results.map((result) => result.file)).toEqual(["index.html"]); expect(totalErrors).toBe(0); }); it("ignores non-HTML files in compositions/", async () => { const project = makeProject(validHtml(), { "captions.html": validHtml("captions"), }); // Add a non-HTML file writeFileSync(join(project, "compositions", "readme.txt"), "not html"); const { results } = await lintProject(project); expect(results).toHaveLength(2); // index.html + captions.html, not readme.txt }); }); function validHtmlWithAudio(compId = "main"): string { return `
`; } function validHtmlWithAudioSrc(src: string): string { return `
`; } function validHtmlWithMaskImageUrl(url: string): string { return `
TEXT
`; } describe("audio_file_without_element", () => { it("warns when audio file exists but no