mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
* fix(cli): honor check navigation timeout * test(cli): clarify diagnostic timeout precedence
301 lines
10 KiB
TypeScript
301 lines
10 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,
|
|
hasExplicitCompositionArg,
|
|
parseBrowserTimeoutMsArg,
|
|
resolveDiagnosticNavigationTimeoutMs,
|
|
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("resolveDiagnosticNavigationTimeoutMs", () => {
|
|
it("uses the render navigation timeout env override", () => {
|
|
expect(
|
|
resolveDiagnosticNavigationTimeoutMs({ PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS: "90000" }),
|
|
).toBe(90_000);
|
|
});
|
|
|
|
it("falls back to ten seconds for missing or invalid values", () => {
|
|
expect(resolveDiagnosticNavigationTimeoutMs({})).toBe(10_000);
|
|
expect(
|
|
resolveDiagnosticNavigationTimeoutMs({ PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS: "invalid" }),
|
|
).toBe(10_000);
|
|
});
|
|
|
|
it("raises the diagnostic navigation budget to a larger caller-provided minimum", () => {
|
|
expect(resolveDiagnosticNavigationTimeoutMs({}, 30_000)).toBe(30_000);
|
|
});
|
|
|
|
it("does not let a caller-provided minimum shorten the default budget", () => {
|
|
expect(resolveDiagnosticNavigationTimeoutMs({}, 3_000)).toBe(10_000);
|
|
});
|
|
|
|
it("does not let a caller-provided minimum shorten the environment override", () => {
|
|
expect(
|
|
resolveDiagnosticNavigationTimeoutMs(
|
|
{ PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS: "90000" },
|
|
30_000,
|
|
),
|
|
).toBe(90_000);
|
|
});
|
|
});
|
|
|
|
describe("parseCompositionEntryArg", () => {
|
|
it("uses one sentinel classifier for default and explicit composition values", () => {
|
|
expect([undefined, "", " ", ".", "./"].map(hasExplicitCompositionArg)).toEqual([
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
]);
|
|
expect(hasExplicitCompositionArg("./compositions/intro.html")).toBe(true);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|