Files
hyperframes/packages/cli/src/utils/renderArgs.test.ts
T
Miguel Ángel fdc5932897 fix(cli): honor check navigation timeout (#2860)
* fix(cli): honor check navigation timeout

* test(cli): clarify diagnostic timeout precedence
2026-07-29 20:50:20 +02:00

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);
});
});