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"),
`unused registry template
`,
);
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 `
`;
}
describe("audio_file_without_element", () => {
it("warns when audio file exists but no