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): (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, `
x
`, ); } 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); }); });