mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(core): lint rule for timeline ID mismatches (#93)
## What Added three new lint rules to the Hyperframe HTML linter to catch common runtime errors and invalid script references. ## Why These lint rules prevent silent failures and runtime errors that can break Hyperframe compositions: 1. Timeline assignments without initialization guards cause silent failures when `window.__timelines` is undefined 2. Mismatched timeline IDs between `data-composition-id` attributes and `window.__timelines` keys prevent proper auto-nesting 3. Hallucinated script sources referencing non-existent `@hyperframe/` packages result in 404 errors ## How Implemented three new lint rules with corresponding error codes: - `timeline_registry_missing_init`: Detects timeline assignments without proper initialization guard using regex pattern matching - `timeline_id_mismatch`: Cross-references composition IDs from HTML attributes against timeline registry keys to identify mismatches - `hallucinated_script_src`: Checks script `src` attributes against known bad patterns for non-existent CDN packages Each rule provides specific error messages and fix hints to guide developers toward correct implementations. ## Test plan - [x] Unit tests added/updated - [x] Manual testing performed - [ ] Documentation updated (if applicable) Added comprehensive test coverage for all three new lint rules, including both positive and negative test cases to ensure proper detection and avoid false positives.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "./hyperframeLinter.js";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { lintHyperframeHtml, lintScriptUrls } from "./hyperframeLinter.js";
|
||||
|
||||
describe("lintHyperframeHtml", () => {
|
||||
const validComposition = `
|
||||
@@ -110,4 +110,111 @@ describe("lintHyperframeHtml", () => {
|
||||
const uniqueCodes = [...new Set(codes)];
|
||||
expect(codes.length).toBe(uniqueCodes.length);
|
||||
});
|
||||
|
||||
it("detects timeline ID mismatch", () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div data-composition-id="intro" data-start="0" data-duration="3"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
window.__timelines["intro-anim"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const mismatch = result.findings.find((f) => f.code === "timeline_id_mismatch");
|
||||
expect(mismatch).toBeDefined();
|
||||
expect(mismatch?.message).toContain("intro-anim");
|
||||
});
|
||||
|
||||
it("does not flag matching timeline IDs", () => {
|
||||
const result = lintHyperframeHtml(validComposition);
|
||||
const mismatch = result.findings.find((f) => f.code === "timeline_id_mismatch");
|
||||
expect(mismatch).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when timeline assignment has no init guard", () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
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 === "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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lintScriptUrls", () => {
|
||||
it("reports error for script URL returning non-2xx", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://unpkg.com/@hyperframe/player@latest/dist/player.js"></script>
|
||||
</body></html>`;
|
||||
const findings = await lintScriptUrls(html);
|
||||
const finding = findings.find((f) => f.code === "inaccessible_script_url");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("404");
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("reports error for unreachable script URL", async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error("AbortError"));
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://example.invalid/nonexistent.js"></script>
|
||||
</body></html>`;
|
||||
const findings = await lintScriptUrls(html);
|
||||
const finding = findings.find((f) => f.code === "inaccessible_script_url");
|
||||
expect(finding).toBeDefined();
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not flag accessible script URLs", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
</body></html>`;
|
||||
const findings = await lintScriptUrls(html);
|
||||
expect(findings.length).toBe(0);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("skips inline scripts without src", async () => {
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script>console.log("inline")</script>
|
||||
</body></html>`;
|
||||
const findings = await lintScriptUrls(html);
|
||||
expect(findings.length).toBe(0);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,47 @@ export function lintHyperframeHtml(
|
||||
});
|
||||
}
|
||||
|
||||
// Timeline assignment without initialization guard — causes silent failure
|
||||
// when the runtime script hasn't loaded yet (window.__timelines is undefined).
|
||||
if (
|
||||
TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) &&
|
||||
!TIMELINE_REGISTRY_INIT_PATTERN.test(source)
|
||||
) {
|
||||
pushFinding({
|
||||
code: "timeline_registry_missing_init",
|
||||
severity: "error",
|
||||
message:
|
||||
"`window.__timelines[…] = …` is used without initializing `window.__timelines` first.",
|
||||
fixHint:
|
||||
"Add `window.__timelines = window.__timelines || {};` before any timeline assignment.",
|
||||
});
|
||||
}
|
||||
|
||||
// Check for timeline ID mismatches: data-composition-id vs window.__timelines["X"] keys.
|
||||
{
|
||||
const htmlCompIds = new Set<string>();
|
||||
const timelineRegKeys = new Set<string>();
|
||||
const compIdRe = /data-composition-id\s*=\s*["']([^"']+)["']/gi;
|
||||
const tlKeyRe = /window\.__timelines\[\s*["']([^"']+)["']\s*\]/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = compIdRe.exec(source)) !== null) {
|
||||
if (m[1]) htmlCompIds.add(m[1]);
|
||||
}
|
||||
while ((m = tlKeyRe.exec(source)) !== null) {
|
||||
if (m[1]) timelineRegKeys.add(m[1]);
|
||||
}
|
||||
for (const key of timelineRegKeys) {
|
||||
if (!htmlCompIds.has(key)) {
|
||||
pushFinding({
|
||||
code: "timeline_id_mismatch",
|
||||
severity: "error",
|
||||
message: `Timeline registered as "${key}" but no element has data-composition-id="${key}". The runtime cannot auto-nest this timeline.`,
|
||||
fixHint: `Change window.__timelines["${key}"] to match the data-composition-id attribute, or vice versa.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (INVALID_SCRIPT_CLOSE_PATTERN.test(source)) {
|
||||
pushFinding({
|
||||
code: "invalid_inline_script_syntax",
|
||||
@@ -878,3 +919,86 @@ export async function lintMediaUrls(
|
||||
await Promise.all(checks);
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all external script URLs from the HTML.
|
||||
*/
|
||||
function extractScriptUrls(html: string): Array<{ url: string; snippet: string }> {
|
||||
const results: Array<{ url: string; snippet: string }> = [];
|
||||
const scriptRe = /<script\b[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = scriptRe.exec(html)) !== null) {
|
||||
const raw = match[0];
|
||||
const src = readAttr(raw, "src");
|
||||
if (!src) continue;
|
||||
if (/^https?:\/\//i.test(src)) {
|
||||
results.push({
|
||||
url: src,
|
||||
snippet: raw.length > 120 ? raw.slice(0, 117) + "..." : raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async lint pass: HEAD-checks every external script URL in the HTML.
|
||||
* Returns findings for URLs that are unreachable (non-2xx status or network error).
|
||||
*
|
||||
* Call this after `lintHyperframeHtml()` and merge the findings.
|
||||
*
|
||||
* @param timeoutMs - per-request timeout (default 8000ms)
|
||||
*/
|
||||
export async function lintScriptUrls(
|
||||
html: string,
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<HyperframeLintFinding[]> {
|
||||
const urls = extractScriptUrls(html);
|
||||
if (urls.length === 0) return [];
|
||||
|
||||
const timeout = options.timeoutMs ?? 8000;
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const unique = urls.filter((u) => {
|
||||
if (seen.has(u.url)) return false;
|
||||
seen.add(u.url);
|
||||
return true;
|
||||
});
|
||||
|
||||
const checks = unique.map(async ({ url, snippet }) => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
const resp = await fetch(url, {
|
||||
method: "HEAD",
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!resp.ok) {
|
||||
findings.push({
|
||||
code: "inaccessible_script_url",
|
||||
severity: "error",
|
||||
message: `<script> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 120)}`,
|
||||
fixHint:
|
||||
"This script URL is not accessible. Remove it or replace with a valid URL. The HyperFrames runtime is injected automatically — do not load it manually.",
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.name : "unknown";
|
||||
findings.push({
|
||||
code: "inaccessible_script_url",
|
||||
severity: "error",
|
||||
message: `<script> references an unreachable URL (${reason}): ${url.slice(0, 120)}`,
|
||||
fixHint: "This script URL is not accessible. Remove it or replace with a valid URL.",
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(checks);
|
||||
return findings;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user