fix: validate CLI smoke paths and warn on oversized compositions

This commit is contained in:
Miguel Ángel
2026-04-28 10:57:50 -04:00
parent 8e5593b6ba
commit 9c7983211a
5 changed files with 248 additions and 5 deletions
+79
View File
@@ -217,6 +217,85 @@ jobs:
echo "PASS: global install smoke test succeeded" echo "PASS: global install smoke test succeeded"
cli-smoke-required:
name: "CLI smoke (required)"
needs: changes
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Skip when no code changed
if: needs.changes.outputs.code != 'true'
run: echo "No code changes; CLI smoke required check passes."
- uses: actions/checkout@v4
if: needs.changes.outputs.code == 'true'
with:
lfs: true
- uses: oven-sh/setup-bun@v2
if: needs.changes.outputs.code == 'true'
- uses: actions/setup-node@v4
if: needs.changes.outputs.code == 'true'
with:
node-version: 22
- name: Install dependencies
if: needs.changes.outputs.code == 'true'
run: bun install --frozen-lockfile
- name: Build monorepo
if: needs.changes.outputs.code == 'true'
run: bun run build
- name: Smoke-test CLI from monorepo source
if: needs.changes.outputs.code == 'true'
run: |
rm -rf /tmp/hf-cli-inside
bun run --filter @hyperframes/cli dev -- init /tmp/hf-cli-inside \
--example blank \
--non-interactive \
--skip-skills
bun run --filter @hyperframes/cli dev -- lint /tmp/hf-cli-inside
bun run --filter @hyperframes/cli dev -- validate /tmp/hf-cli-inside --timeout 1000
bun run --filter @hyperframes/cli dev -- render /tmp/hf-cli-inside \
--quality draft \
--workers 1 \
--strict-all \
--output /tmp/hf-cli-inside/renders/inside.mp4
test -s /tmp/hf-cli-inside/renders/inside.mp4
- name: Pack CLI tarball
if: needs.changes.outputs.code == 'true'
run: |
mkdir -p /tmp/hf-cli-pack
cd packages/cli
npm pack --pack-destination /tmp/hf-cli-pack
- name: Install packed CLI outside monorepo
if: needs.changes.outputs.code == 'true'
run: npm install -g --prefix /tmp/hf-cli-global /tmp/hf-cli-pack/hyperframes-cli-*.tgz
- name: Smoke-test packed CLI outside monorepo
if: needs.changes.outputs.code == 'true'
run: |
export PATH="/tmp/hf-cli-global/bin:$PATH"
rm -rf /tmp/hf-cli-outside
hyperframes init /tmp/hf-cli-outside \
--example blank \
--non-interactive \
--skip-skills
hyperframes lint /tmp/hf-cli-outside
hyperframes validate /tmp/hf-cli-outside --timeout 1000
hyperframes render /tmp/hf-cli-outside \
--quality draft \
--workers 1 \
--strict-all \
--output /tmp/hf-cli-outside/renders/outside.mp4
test -s /tmp/hf-cli-outside/renders/outside.mp4
semantic-pr-title: semantic-pr-title:
name: Semantic PR title name: Semantic PR title
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
+5
View File
@@ -82,6 +82,11 @@ async function main() {
cpSync(layoutAuditScript, join(DIST, "commands", "layout-audit.browser.js")); cpSync(layoutAuditScript, join(DIST, "commands", "layout-audit.browser.js"));
} }
const contrastAuditScript = join(CLI_ROOT, "src", "commands", "contrast-audit.browser.js");
if (existsSync(contrastAuditScript)) {
cpSync(contrastAuditScript, join(DIST, "commands", "contrast-audit.browser.js"));
}
copyMdFiles(join(CLI_ROOT, "src", "docs"), join(DIST, "docs")); copyMdFiles(join(CLI_ROOT, "src", "docs"), join(DIST, "docs"));
console.log("[build-copy] done"); console.log("[build-copy] done");
+14 -5
View File
@@ -27,10 +27,6 @@ interface ContrastEntry {
bg: string; bg: string;
} }
// esbuild's text loader inlines this at build time — no runtime file read.
// @ts-expect-error — .browser.js files use esbuild text loader, not TS module resolution
import CONTRAST_AUDIT_SCRIPT from "./contrast-audit.browser.js";
const CONTRAST_SAMPLES = 5; const CONTRAST_SAMPLES = 5;
const SEEK_SETTLE_MS = 150; const SEEK_SETTLE_MS = 150;
@@ -64,7 +60,7 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise<Co
const duration = await getCompositionDuration(page); const duration = await getCompositionDuration(page);
if (duration <= 0) return []; if (duration <= 0) return [];
await page.addScriptTag({ content: CONTRAST_AUDIT_SCRIPT }); await page.addScriptTag({ content: loadContrastAuditScript() });
const results: ContrastEntry[] = []; const results: ContrastEntry[] = [];
for (let i = 0; i < CONTRAST_SAMPLES; i++) { for (let i = 0; i < CONTRAST_SAMPLES; i++) {
@@ -86,6 +82,19 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise<Co
return results; return results;
} }
function loadContrastAuditScript(): string {
const candidates = [
join(__dirname, "contrast-audit.browser.js"),
join(__dirname, "commands", "contrast-audit.browser.js"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return readFileSync(candidate, "utf-8");
}
throw new Error("Missing contrast audit browser script");
}
async function validateInBrowser( async function validateInBrowser(
projectDir: string, projectDir: string,
opts: { timeout?: number; contrast?: boolean }, opts: { timeout?: number; contrast?: boolean },
@@ -2,6 +2,109 @@ import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js"; import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("composition rules", () => { describe("composition rules", () => {
describe("subcomposition guidance", () => {
it("warns when a composition file is over 300 lines", () => {
const html = Array.from({ length: 301 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");
const result = lintHyperframeHtml(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 a composition file is exactly 300 lines", () => {
const html = Array.from({ length: 300 }, (_, i) =>
i === 0 ? "<html><body>" : `<!-- filler ${i} -->`,
).join("\n");
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "composition_file_too_large");
expect(finding).toBeUndefined();
});
it("warns when more than 3 timed elements share the same track", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0">
<div class="clip" data-start="0" data-duration="1" data-track-index="0">A</div>
<div class="clip" data-start="1" data-duration="1" data-track-index="0">B</div>
<div class="clip" data-start="2" data-duration="1" data-track-index="0">C</div>
<div class="clip" data-start="3" data-duration="1" data-track-index="0">D</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 === "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", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0">
<div class="clip" data-start="0" data-duration="1" data-track-index="0">A</div>
<div class="clip" data-start="1" data-duration="1" data-track-index="0">B</div>
<div class="clip" data-start="2" data-duration="1" data-track-index="0">C</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 === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});
it("does not warn when timed elements are split across tracks", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0">
<div class="clip" data-start="0" data-duration="1" data-track-index="0">A</div>
<div class="clip" data-start="1" data-duration="1" data-track-index="0">B</div>
<div class="clip" data-start="2" data-duration="1" data-track-index="1">C</div>
<div class="clip" data-start="3" data-duration="1" data-track-index="1">D</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 === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});
it("does not count the root composition element as a timed track element", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0" data-track-index="0">
<div class="clip" data-start="0" data-duration="1" data-track-index="0">A</div>
<div class="clip" data-start="1" data-duration="1" data-track-index="0">B</div>
<div class="clip" data-start="2" data-duration="1" data-track-index="0">C</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 === "timeline_track_too_dense");
expect(finding).toBeUndefined();
});
});
it("reports info for composition with external CDN script dependency", () => { it("reports info for composition with external CDN script dependency", () => {
const html = `<template id="rockets-template"> const html = `<template id="rockets-template">
<div data-composition-id="rockets" data-width="1920" data-height="1080"> <div data-composition-id="rockets" data-width="1920" data-height="1080">
@@ -1,7 +1,54 @@
import type { LintContext, HyperframeLintFinding } from "../context"; import type { LintContext, HyperframeLintFinding } from "../context";
import { readAttr, truncateSnippet } from "../utils"; import { readAttr, truncateSnippet } from "../utils";
const MAX_COMPOSITION_LINES = 300;
const MAX_TIMED_ELEMENTS_PER_TRACK = 3;
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// composition_file_too_large
({ rawSource }) => {
const lineCount = rawSource.split(/\r\n|\r|\n/).length;
if (lineCount <= MAX_COMPOSITION_LINES) return [];
return [
{
code: "composition_file_too_large",
severity: "warning",
message: `This composition file has ${lineCount} lines. Large single-file compositions are hard for agents to inspect and revise reliably.`,
fixHint:
"Split coherent scenes or layers into smaller .html files under compositions/, then mount them from the parent with data-composition-src so each piece can be validated independently.",
},
];
},
// timeline_track_too_dense
({ tags }) => {
const trackCounts = new Map<string, number>();
for (const tag of tags) {
if (readAttr(tag.raw, "data-composition-id")) continue;
const startStr = readAttr(tag.raw, "data-start");
const trackStr = readAttr(tag.raw, "data-track-index");
if (!startStr || !trackStr) continue;
trackCounts.set(trackStr, (trackCounts.get(trackStr) ?? 0) + 1);
}
const findings: HyperframeLintFinding[] = [];
for (const [track, count] of trackCounts) {
if (count <= MAX_TIMED_ELEMENTS_PER_TRACK) continue;
findings.push({
code: "timeline_track_too_dense",
severity: "warning",
message: `Track ${track} has ${count} timed elements in this file. Dense tracks usually mean too much scene structure is packed into one composition.`,
fixHint:
"Move coherent scene groups into separate .html files under compositions/ and mount them from the parent with data-composition-src so the timeline stays easier to inspect, revise, and validate.",
});
}
return findings;
},
// timed_element_missing_visibility_hidden // timed_element_missing_visibility_hidden
({ tags }) => { ({ tags }) => {
const findings: HyperframeLintFinding[] = []; const findings: HyperframeLintFinding[] = [];