Files
hyperframes/packages/cli/src/utils/renderArgs.test.ts
T
Miguel Ángel de27b46680 fix(cli): default render fps to the composition's data-fps
* fix(cli): default render fps to the composition's data-fps

hyperframes render hard-coded fps to 30 when --fps was omitted, ignoring a
data-fps declared on the composition root — so a composition authored at
data-fps="24" silently rendered at 30fps unless the user knew to pass --fps 24.
The runtime already honors data-fps; the CLI now matches it.

Precedence: explicit --fps > composition root data-fps > 30. New pure
readCompositionFps() extracts the root data-fps via linkedom (mirrors the
runtime's root resolution: [data-composition-id][data-root=true], else the
outermost [data-composition-id]); render validates it through parseFps and
falls back to 30 on an absent/invalid value. Unit-tested.

* fix(cli): honor composition data-fps on cloud renders and --composition targets

The local render command read data-fps from project.dir/index.html even when
--composition rendered a different file, and the lambda/cloudrun render paths
ignored data-fps entirely (hardcoded ?? 30). Both are the same silently-wrong-
fps bug on other render entry points:
- render.ts resolves the entry file first, then reads data-fps from the file
  actually being rendered (falling back to index.html).
- lambda render/render-batch and cloudrun render/render-batch default fps from
  the composition's data-fps, accepted only when it is one of the cloud-allowed
  values {24,30,60}, else the existing 30 default. Explicit --fps still wins.

* fix(cli): drop citty fps default so data-fps resolution actually runs

The fps arg had default: "30", so citty set args.fps="30" on omission and
resolveDefaultFpsArg short-circuited (explicitFps never null) — reverting the
command to always-30 and making the whole data-fps feature a no-op (caught in
review). Remove the arg default; the "30" fallback already lives at
parseFps(fpsArg ?? "30"). Adds a regression guard asserting the arg has no
default.

* test(cli): read citty args through a plain record in the fps-default guard

The regression guard accessed cmd.args.fps directly, but citty types args as
Resolvable<ArgsDef> so .fps failed typecheck in CI. Read it through a plain
record cast.
2026-07-07 17:10:10 -04:00

256 lines
9.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, type Stats } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import {
MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS,
parseBrowserTimeoutMsArg,
parseCompositionEntryArg,
parseGifLoopArg,
resolveDefaultFpsArg,
type BrowserTimeoutParseResult,
type CompositionEntryParseResult,
} from "./renderArgs.js";
function expectBrowserTimeoutErr(result: BrowserTimeoutParseResult): { kind: string; raw: string } {
if (result.ok) throw new Error(`expected error, got value=${result.value}`);
return result.error;
}
function expectCompositionErr(result: CompositionEntryParseResult): {
kind: string;
entryFile: string;
} {
if (result.ok) throw new Error(`expected error, got value=${result.value}`);
return result.error;
}
/** Build a fake `Stats` for the in-memory stat adapter. */
function fakeStats(kind: "file" | "directory"): Stats {
return {
isFile: () => kind === "file",
isDirectory: () => kind === "directory",
} as Stats;
}
/**
* Build a stat adapter from a path → kind map. Throws ENOENT for any
* path not in the map, matching `statSync` behaviour.
*/
function makeStat(entries: Record<string, "file" | "directory">): (path: string) => Stats {
return (path) => {
const kind = entries[path];
if (!kind) {
const err = new Error(`ENOENT: no such file or directory, stat '${path}'`);
(err as NodeJS.ErrnoException).code = "ENOENT";
throw err;
}
return fakeStats(kind);
};
}
describe("parseBrowserTimeoutMsArg", () => {
it("returns undefined when the flag is absent", () => {
expect(parseBrowserTimeoutMsArg(undefined)).toEqual({ ok: true, value: undefined });
});
it("converts whole seconds to milliseconds", () => {
expect(parseBrowserTimeoutMsArg("180")).toEqual({ ok: true, value: 180_000 });
});
it("accepts fractional seconds and rounds to integer ms", () => {
expect(parseBrowserTimeoutMsArg("90.5")).toEqual({ ok: true, value: 90_500 });
expect(parseBrowserTimeoutMsArg("0.001")).toEqual({ ok: true, value: 1 });
});
it("rejects non-numeric input", () => {
const err = expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("abc"));
expect(err.kind).toBe("not-a-number");
});
it("rejects Infinity and NaN", () => {
expect(expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("Infinity")).kind).toBe("not-a-number");
expect(expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("NaN")).kind).toBe("not-a-number");
});
it("rejects zero and negative values", () => {
expect(expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("0")).kind).toBe("not-positive");
expect(expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("-5")).kind).toBe("not-positive");
expect(expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("")).kind).toBe("not-positive");
});
it("rejects sub-millisecond inputs that would round to timeout: 0 (Puppeteer 'no timeout' sentinel)", () => {
// Regression for issue #1199 follow-up: --browser-timeout 0.0004 passes
// > 0 in seconds but Math.round(0.4) = 0 ms, which Puppeteer interprets
// as "wait forever". The validator must reject before the multiply.
expect(expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("0.0004")).kind).toBe("too-small");
});
it("rejects values above the 24h cap to prevent setTimeout overflow", () => {
expect(expectBrowserTimeoutErr(parseBrowserTimeoutMsArg("1e10")).kind).toBe("too-large");
expect(
expectBrowserTimeoutErr(
parseBrowserTimeoutMsArg(String(MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS + 1)),
).kind,
).toBe("too-large");
});
it("accepts values right at the 24h cap", () => {
expect(parseBrowserTimeoutMsArg(String(MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS))).toEqual({
ok: true,
value: MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS * 1000,
});
});
});
describe("parseCompositionEntryArg", () => {
const PROJECT = "/proj";
const stat = makeStat({
[resolve(PROJECT, "index.html")]: "file",
[resolve(PROJECT, "compositions/intro.html")]: "file",
[resolve(PROJECT, "compositions")]: "directory",
[PROJECT]: "directory",
});
it("returns undefined when the flag is absent", () => {
expect(parseCompositionEntryArg(undefined, PROJECT, stat)).toEqual({
ok: true,
value: undefined,
});
});
it("normalizes '.' to undefined so the producer falls back to index.html (issue #1199)", () => {
expect(parseCompositionEntryArg(".", PROJECT, stat)).toEqual({ ok: true, value: undefined });
});
it("normalizes './' to undefined", () => {
expect(parseCompositionEntryArg("./", PROJECT, stat)).toEqual({ ok: true, value: undefined });
});
it("normalizes empty / whitespace-only to undefined", () => {
expect(parseCompositionEntryArg("", PROJECT, stat)).toEqual({ ok: true, value: undefined });
expect(parseCompositionEntryArg(" ", PROJECT, stat)).toEqual({ ok: true, value: undefined });
});
it("passes through a valid .html file path", () => {
expect(parseCompositionEntryArg("compositions/intro.html", PROJECT, stat)).toEqual({
ok: true,
value: "compositions/intro.html",
});
});
it("strips a leading ./ before resolution", () => {
expect(parseCompositionEntryArg("./compositions/intro.html", PROJECT, stat)).toEqual({
ok: true,
value: "compositions/intro.html",
});
});
it("rejects a directory path with 'not-a-file' (the EISDIR cause)", () => {
const err = expectCompositionErr(parseCompositionEntryArg("compositions", PROJECT, stat));
expect(err).toEqual({ kind: "not-a-file", entryFile: "compositions" });
});
it("rejects a non-existent file with 'not-found'", () => {
const err = expectCompositionErr(parseCompositionEntryArg("missing.html", PROJECT, stat));
expect(err).toEqual({ kind: "not-found", entryFile: "missing.html" });
});
it("rejects a path that escapes the project directory", () => {
const err = expectCompositionErr(parseCompositionEntryArg("../escape.html", PROJECT, stat));
expect(err.kind).toBe("outside-project");
});
it("rejects a sibling-prefix path (trailing-separator guard)", () => {
// Without the trailing-separator guard, `/proj-evil/x.html`.startsWith('/proj')
// returns true and the sibling-directory escape passes validation.
const siblingStat = makeStat({
"/proj-evil/x.html": "file",
[PROJECT]: "directory",
});
const err = expectCompositionErr(
parseCompositionEntryArg("../proj-evil/x.html", PROJECT, siblingStat),
);
expect(err.kind).toBe("outside-project");
});
});
describe("resolveDefaultFpsArg", () => {
function writeComposition(path: string, fps: string): void {
writeFileSync(
path,
`<!DOCTYPE html><html><body><div data-composition-id="root" data-root="true" data-fps="${fps}">x</div></body></html>`,
);
}
function makeProject(): {
dir: string;
indexPath: string;
entryFile: string;
cleanup: () => void;
} {
const dir = mkdtempSync(join(tmpdir(), "hyperframes-render-fps-"));
const indexPath = join(dir, "index.html");
const entryFile = "compositions/intro.html";
const entryPath = join(dir, entryFile);
writeComposition(indexPath, "24");
mkdirSync(dirname(entryPath), { recursive: true });
return {
dir,
indexPath,
entryFile,
cleanup: () => rmSync(dir, { recursive: true, force: true }),
};
}
it("reads data-fps from the --composition target instead of index.html", () => {
const project = makeProject();
try {
writeComposition(join(project.dir, project.entryFile), "60");
expect(
resolveDefaultFpsArg(undefined, project.dir, project.indexPath, project.entryFile),
).toBe("60");
} finally {
project.cleanup();
}
});
it("falls back to index.html data-fps when --composition is not set", () => {
const project = makeProject();
try {
expect(resolveDefaultFpsArg(undefined, project.dir, project.indexPath, undefined)).toBe("24");
} finally {
project.cleanup();
}
});
it("keeps an explicit --fps value ahead of any composition default", () => {
const project = makeProject();
try {
writeComposition(join(project.dir, project.entryFile), "60");
expect(resolveDefaultFpsArg("120", project.dir, project.indexPath, project.entryFile)).toBe(
"120",
);
} finally {
project.cleanup();
}
});
});
describe("parseGifLoopArg", () => {
it("accepts absent flag, bounds, and integers", () => {
expect(parseGifLoopArg(undefined)).toEqual({ ok: true, value: undefined });
expect(parseGifLoopArg("0")).toEqual({ ok: true, value: 0 });
expect(parseGifLoopArg("65535")).toEqual({ ok: true, value: 65535 });
});
it("rejects out-of-range, non-integer, and empty inputs", () => {
expect(parseGifLoopArg("-1").ok).toBe(false);
expect(parseGifLoopArg("65536").ok).toBe(false);
expect(parseGifLoopArg("1.5").ok).toBe(false);
expect(parseGifLoopArg(" ").ok).toBe(false);
});
});