mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(lint): add rules for non-deterministic code, missing clip class, overlapping tracks, and rAF detection (#193)
## Summary Adds 4 new lint rules found missing during comprehensive E2E testing: - **`non_deterministic_code`** (error) — detects `Math.random()`, `Date.now()`, `new Date()`, `performance.now()`, `crypto.getRandomValues()` in scripts. Confirmed: two renders with Math.random() produced different checksums. - **`timed_element_missing_clip_class`** (warning) — flags elements with `data-start`/`data-duration` but no `class="clip"`. Without it, elements are visible forever instead of only during their time window. - **`overlapping_clips_same_track`** (error) — detects clips on the same `data-track-index` with overlapping time ranges. - **`requestanimationframe_in_composition`** (warning) — warns that rAF-based animations don't sync with frame capture. Discovered when Vivus.js (rAF-based) produced incorrect output. 12 new tests, all passing. **Part 1 of 5** in a stacked PR series fixing E2E test findings. ## Test plan - [x] 12 new tests (3 per rule: positive, negative, edge case) - [x] Full suite: 56 pass in rule tests - [x] `npx tsx scripts/lint-skills.ts` — no issues
This commit is contained in:
@@ -94,4 +94,147 @@ describe("composition rules", () => {
|
||||
const finding = result.findings.find((f) => f.code === "template_literal_selector");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("timed_element_missing_clip_class", () => {
|
||||
it("flags element with data-start but no class='clip'", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="box" data-start="0" data-duration="2">Hello</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "timed_element_missing_clip_class");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("does not flag element that has class='clip'", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="box" class="clip" data-start="0" data-duration="2">Hello</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = 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", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<audio data-start="0" data-duration="5" src="music.mp3"></audio>
|
||||
<video data-start="0" data-duration="5" src="clip.mp4"></video>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = 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", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div class="clip" data-start="0" data-duration="3" data-track-index="0">A</div>
|
||||
<div class="clip" data-start="2" data-duration="3" data-track-index="0">B</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = 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", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div class="clip" data-start="0" data-duration="3" data-track-index="0">A</div>
|
||||
<div class="clip" data-start="1" data-duration="3" data-track-index="1">B</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = 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", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div class="clip" data-start="0" data-duration="2" data-track-index="0">A</div>
|
||||
<div class="clip" data-start="2" data-duration="2" data-track-index="0">B</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "overlapping_clips_same_track");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestanimationframe_in_composition", () => {
|
||||
it("flags requestAnimationFrame usage in script content", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
requestAnimationFrame(() => { console.log("tick"); });
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "requestanimationframe_in_composition",
|
||||
);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("does not flag requestAnimationFrame in comments", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
// requestAnimationFrame(() => { });
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "requestanimationframe_in_composition",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,4 +106,109 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// timed_element_missing_clip_class
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const skipTags = new Set(["audio", "video", "script", "style", "template"]);
|
||||
for (const tag of tags) {
|
||||
if (skipTags.has(tag.name)) continue;
|
||||
// Skip composition hosts
|
||||
if (readAttr(tag.raw, "data-composition-id")) continue;
|
||||
if (readAttr(tag.raw, "data-composition-src")) continue;
|
||||
|
||||
const hasStart = readAttr(tag.raw, "data-start") !== null;
|
||||
const hasDuration = readAttr(tag.raw, "data-duration") !== null;
|
||||
const hasTrackIndex = readAttr(tag.raw, "data-track-index") !== null;
|
||||
if (!hasStart && !hasDuration && !hasTrackIndex) continue;
|
||||
|
||||
const classAttr = readAttr(tag.raw, "class") || "";
|
||||
const hasClip = classAttr.split(/\s+/).includes("clip");
|
||||
if (hasClip) continue;
|
||||
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "timed_element_missing_clip_class",
|
||||
severity: "warning",
|
||||
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has timing attributes but no class="clip". The element will be visible for the entire composition instead of only during its scheduled time range.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
'Add class="clip" to the element. The HyperFrames runtime uses .clip to control visibility based on data-start/data-duration.',
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// overlapping_clips_same_track
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
type ClipInfo = { start: number; end: number; elementId?: string; snippet: string };
|
||||
const trackMap = new Map<string, ClipInfo[]>();
|
||||
|
||||
for (const tag of tags) {
|
||||
const startStr = readAttr(tag.raw, "data-start");
|
||||
const durationStr = readAttr(tag.raw, "data-duration");
|
||||
const trackStr = readAttr(tag.raw, "data-track-index");
|
||||
if (!startStr || !durationStr || !trackStr) continue;
|
||||
|
||||
const start = Number(startStr);
|
||||
const duration = Number(durationStr);
|
||||
const track = trackStr;
|
||||
|
||||
// Skip non-numeric (relative timing references like "intro-comp")
|
||||
if (Number.isNaN(start) || Number.isNaN(duration)) continue;
|
||||
|
||||
const clips = trackMap.get(track) || [];
|
||||
clips.push({
|
||||
start,
|
||||
end: start + duration,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
snippet: truncateSnippet(tag.raw) || "",
|
||||
});
|
||||
trackMap.set(track, clips);
|
||||
}
|
||||
|
||||
for (const [track, clips] of trackMap) {
|
||||
clips.sort((a, b) => a.start - b.start);
|
||||
for (let i = 0; i < clips.length - 1; i++) {
|
||||
const current = clips[i];
|
||||
const next = clips[i + 1];
|
||||
if (!current || !next) continue;
|
||||
if (current.end > next.start) {
|
||||
findings.push({
|
||||
code: "overlapping_clips_same_track",
|
||||
severity: "error",
|
||||
message: `Track ${track}: clip ending at ${current.end}s overlaps with clip starting at ${next.start}s. Overlapping clips on the same track cause rendering conflicts.`,
|
||||
fixHint:
|
||||
"Adjust data-start or data-duration so clips on the same track do not overlap, or move one clip to a different data-track-index.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
},
|
||||
|
||||
// requestanimationframe_in_composition
|
||||
({ scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const script of scripts) {
|
||||
// Strip comments to avoid false positives
|
||||
const stripped = script.content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
if (/requestAnimationFrame\s*\(/.test(stripped)) {
|
||||
findings.push({
|
||||
code: "requestanimationframe_in_composition",
|
||||
severity: "warning",
|
||||
message:
|
||||
"`requestAnimationFrame` runs on wall-clock time, not the GSAP timeline. It will not sync with frame capture and may cause flickering or missed frames during rendering.",
|
||||
fixHint:
|
||||
"Use GSAP tweens or onUpdate callbacks instead of requestAnimationFrame for animation logic.",
|
||||
snippet: truncateSnippet(script.content),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
|
||||
@@ -91,4 +91,56 @@ describe("core rules", () => {
|
||||
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("non_deterministic_code", () => {
|
||||
it("detects Math.random() in script content", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const x = Math.random();
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("Math.random");
|
||||
});
|
||||
|
||||
it("detects Date.now() in script content", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const ts = Date.now();
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("Date.now");
|
||||
});
|
||||
|
||||
it("does not flag non-deterministic calls inside single-line comments", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
// const x = Math.random();
|
||||
// Date.now() is not used here
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,4 +166,53 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// non_deterministic_code
|
||||
({ scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const patterns: Array<{ pattern: RegExp; label: string; hint: string }> = [
|
||||
{
|
||||
pattern: /Math\.random\s*\(/,
|
||||
label: "Math.random()",
|
||||
hint: "Use a seeded PRNG (e.g. a simple mulberry32) so renders are deterministic across frames.",
|
||||
},
|
||||
{
|
||||
pattern: /Date\.now\s*\(/,
|
||||
label: "Date.now()",
|
||||
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
|
||||
},
|
||||
{
|
||||
pattern: /new\s+Date\s*\(/,
|
||||
label: "new Date()",
|
||||
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
|
||||
},
|
||||
{
|
||||
pattern: /performance\.now\s*\(/,
|
||||
label: "performance.now()",
|
||||
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
|
||||
},
|
||||
{
|
||||
pattern: /crypto\.getRandomValues\s*\(/,
|
||||
label: "crypto.getRandomValues()",
|
||||
hint: "Remove time-dependent code. Use a seeded PRNG for deterministic renders.",
|
||||
},
|
||||
];
|
||||
|
||||
for (const script of scripts) {
|
||||
// Strip comments to avoid false positives
|
||||
const stripped = script.content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
for (const { pattern, label, hint } of patterns) {
|
||||
if (pattern.test(stripped)) {
|
||||
findings.push({
|
||||
code: "non_deterministic_code",
|
||||
severity: "error",
|
||||
message: `Script contains \`${label}\` which produces non-deterministic output. Renders may differ between frames or runs.`,
|
||||
fixHint: hint,
|
||||
snippet: truncateSnippet(script.content),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user