mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
* 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.
256 lines
9.0 KiB
TypeScript
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);
|
|
});
|
|
});
|