diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d32c433edc..f912bbc1f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,6 +217,103 @@ jobs: echo "PASS: global install smoke test succeeded" + cli-smoke-required: + name: "CLI smoke (required)" + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + with: + lfs: true + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install FFmpeg + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Build monorepo + run: bun run build + + - name: Create smoke input video + run: | + set -euo pipefail + ffmpeg -hide_banner -loglevel error \ + -f lavfi -i testsrc2=size=640x360:rate=30 \ + -f lavfi -i sine=frequency=880:sample_rate=48000 \ + -t 3 \ + -c:v libx264 \ + -pix_fmt yuv420p \ + -c:a aac \ + -shortest \ + -y /tmp/hf-cli-input.mp4 + test -s /tmp/hf-cli-input.mp4 + + - name: Smoke-test CLI from monorepo source + run: | + set -euo pipefail + rm -rf /tmp/hf-cli-inside + + bun run --filter @hyperframes/cli dev -- init /tmp/hf-cli-inside \ + --example warm-grain \ + --video /tmp/hf-cli-input.mp4 \ + --skip-transcribe \ + --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 3000 + bun run --filter @hyperframes/cli dev -- render /tmp/hf-cli-inside \ + --quality standard \ + --workers auto \ + --strict \ + --output /tmp/hf-cli-inside/renders/inside.mp4 + + test -s /tmp/hf-cli-inside/renders/inside.mp4 + + - name: Pack CLI tarball + run: | + set -euo pipefail + mkdir -p /tmp/hf-cli-pack + cd packages/cli + PACKED_TARBALL="$(npm pack --pack-destination /tmp/hf-cli-pack | tail -n 1)" + test -n "$PACKED_TARBALL" + test -f "/tmp/hf-cli-pack/$PACKED_TARBALL" + echo "HF_CLI_TARBALL=/tmp/hf-cli-pack/$PACKED_TARBALL" >> "$GITHUB_ENV" + + - name: Install packed CLI outside monorepo + run: | + set -euo pipefail + npm install -g --prefix /tmp/hf-cli-global "$HF_CLI_TARBALL" + + - name: Smoke-test packed CLI outside monorepo + run: | + set -euo pipefail + export PATH="/tmp/hf-cli-global/bin:$PATH" + rm -rf /tmp/hf-cli-outside + + hyperframes init /tmp/hf-cli-outside \ + --example warm-grain \ + --video /tmp/hf-cli-input.mp4 \ + --skip-transcribe \ + --non-interactive \ + --skip-skills + + hyperframes lint /tmp/hf-cli-outside + hyperframes validate /tmp/hf-cli-outside --timeout 3000 + hyperframes render /tmp/hf-cli-outside \ + --quality standard \ + --workers auto \ + --strict \ + --output /tmp/hf-cli-outside/renders/outside.mp4 + + test -s /tmp/hf-cli-outside/renders/outside.mp4 + semantic-pr-title: name: Semantic PR title if: github.event_name == 'pull_request' diff --git a/packages/cli/scripts/build-copy.mjs b/packages/cli/scripts/build-copy.mjs index 9132c7095f..926493a9db 100644 --- a/packages/cli/scripts/build-copy.mjs +++ b/packages/cli/scripts/build-copy.mjs @@ -82,6 +82,11 @@ async function main() { 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")); console.log("[build-copy] done"); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index a018835ee4..fb323eb85e 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -2,15 +2,11 @@ import { spawn } from "node:child_process"; import { defineCommand } from "citty"; import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { resolve, join, dirname, relative, isAbsolute } from "node:path"; -import { fileURLToPath } from "node:url"; +import { resolve, join, relative, isAbsolute } from "node:path"; import { resolveProject } from "../utils/project.js"; import { c } from "../ui/colors.js"; import type { Example } from "./_examples.js"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - /** Maximum time a single-frame FFmpeg extract is allowed to run. Mirrors the * default applied by `@hyperframes/engine`'s `runFfmpeg` so a pathological * clip (corrupt media, stalled network mount, codec edge case) cannot wedge @@ -103,12 +99,11 @@ async function captureSnapshots( let html = await bundleToSingleHtml(projectDir); // Inject local runtime if available. - // The runtime IIFE is copied into the CLI dist during build (build:runtime), - // so it lives alongside cli.js. The old ../../../core/dist/ path only worked - // in the monorepo dev layout and broke for published/npx installs. - const runtimePath = resolve(__dirname, "hyperframe.runtime.iife.js"); - if (existsSync(runtimePath)) { - const runtimeSource = readFileSync(runtimePath, "utf-8"); + // Uses the same multi-strategy resolver as the studio preview server + // (runtimeSource.ts) so snapshot works in dev (tsx), built CLI, and npx. + const { loadRuntimeSource } = await import("../server/runtimeSource.js"); + const runtimeSource = await loadRuntimeSource(); + if (runtimeSource) { html = html.replace( /]*data-hyperframes-preview-runtime[^>]*src="[^"]*"[^>]*><\/script>/, () => ``, diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 4cfb607805..0e1a9bd782 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -27,10 +27,6 @@ interface ContrastEntry { 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 SEEK_SETTLE_MS = 150; @@ -64,7 +60,7 @@ async function runContrastAudit(page: import("puppeteer-core").Page): Promise { + describe("subcomposition guidance", () => { + it("warns when any HTML composition file is over 300 lines", () => { + const html = Array.from({ length: 301 }, (_, i) => + i === 0 ? "" : ``, + ).join("\n"); + + const result = lintHyperframeHtml(html, { filePath: "/project/compositions/scene.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 an HTML composition file is exactly 300 lines", () => { + const html = Array.from({ length: 300 }, (_, i) => + i === 0 ? "" : ``, + ).join("\n"); + + const result = lintHyperframeHtml(html, { filePath: "/project/index.html" }); + const finding = result.findings.find((f) => f.code === "composition_file_too_large"); + expect(finding).toBeUndefined(); + }); + + it("does not count a final trailing newline as an extra physical line", () => { + const html = + Array.from({ length: 300 }, (_, i) => + i === 0 ? "" : ``, + ).join("\n") + "\n"; + + const result = lintHyperframeHtml(html, { filePath: "/project/index.html" }); + const finding = result.findings.find((f) => f.code === "composition_file_too_large"); + expect(finding).toBeUndefined(); + }); + + it("warns on large HTML files regardless of path", () => { + const html = Array.from({ length: 301 }, (_, i) => + i === 0 ? "" : ``, + ).join("\n"); + + const result = lintHyperframeHtml(html, { + filePath: "/project/registry/blocks/data-chart.html", + }); + const finding = result.findings.find((f) => f.code === "composition_file_too_large"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("warning"); + }); + + it("uses nested split copy for large sub-composition files", () => { + const html = Array.from({ length: 301 }, (_, i) => + i === 0 ? "" : ``, + ).join("\n"); + + const result = lintHyperframeHtml(html, { + filePath: "/project/compositions/scene.html", + isSubComposition: true, + }); + const finding = result.findings.find((f) => f.code === "composition_file_too_large"); + expect(finding?.fixHint).toContain("Split this sub-composition further"); + }); + + it("warns when more than 3 timed elements share the same track", () => { + const html = ` + +
+
A
+
B
+
C
+
D
+
+`; + + const result = lintHyperframeHtml(html, { filePath: "/project/compositions/scene.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 = ` + +
+
A
+
B
+
C
+
+`; + + const result = lintHyperframeHtml(html, { filePath: "/project/index.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 = ` + +
+
A
+
B
+
C
+
D
+
+`; + + const result = lintHyperframeHtml(html, { filePath: "/project/index.html" }); + const finding = result.findings.find((f) => f.code === "timeline_track_too_dense"); + expect(finding).toBeUndefined(); + }); + + it("does not count timed media or script/style tags as dense track elements", () => { + const html = ` + +
+ + + + + +
+`; + + const result = lintHyperframeHtml(html, { filePath: "/project/index.html" }); + const finding = result.findings.find((f) => f.code === "timeline_track_too_dense"); + expect(finding).toBeUndefined(); + }); + + it("does not count root composition or mounted sub-compositions as dense elements", () => { + const html = ` + +
+
+
+
+
+
+`; + + const result = lintHyperframeHtml(html, { filePath: "/project/index.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", () => { const html = `