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
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
import { failUsage } from "./commandResult.js";
|
|
/**
|
|
* Pure parsers for `hyperframes render` argv that aren't already shared
|
|
* (fps, quality, format, variables live elsewhere). Lives separately so
|
|
* the validation branches are unit-testable without `process.exit` — the
|
|
* side-effecting wrappers (`resolve*`) own the `errorBox + exit(1)` UI.
|
|
*
|
|
* Issue #1199 motivated the extraction: the original inline validators
|
|
* in `render.ts` were untestable and re-introduced the EISDIR / silent
|
|
* `timeout: 0` footguns at the rate of "one per missing branch".
|
|
*/
|
|
|
|
import { readFileSync, type Stats } from "node:fs";
|
|
import { resolve, sep } from "node:path";
|
|
import { parseFps } from "@hyperframes/core";
|
|
import { errorBox } from "../ui/format.js";
|
|
import { readCompositionFps } from "./compositionFps.js";
|
|
|
|
// ── --browser-timeout ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Lower bound on `pageNavigationTimeout` after the seconds→ms multiply.
|
|
* Puppeteer treats `page.goto({ timeout: 0 })` as "no timeout / wait
|
|
* forever", so a positive-looking input like `--browser-timeout 0.0004`
|
|
* (rounds to 0 ms) must NOT silently flip the semantics. 1 ms is the
|
|
* smallest value that survives `Math.round` without becoming the
|
|
* disabled sentinel.
|
|
*/
|
|
const MIN_PAGE_NAVIGATION_TIMEOUT_MS = 1;
|
|
|
|
/**
|
|
* Upper bound on `--browser-timeout` in seconds. Above ~24 days Node's
|
|
* `setTimeout` overflows TIMEOUT_MAX (`2^31 - 1` ms ≈ 24.8 days) and
|
|
* fires immediately, which is the opposite of "long timeout." Cap at
|
|
* 24h so a typo (`1e10` for `1e1`) errors out instead of silently
|
|
* disabling the budget.
|
|
*/
|
|
export const MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS = 86_400;
|
|
|
|
export type BrowserTimeoutParseError =
|
|
| { kind: "not-a-number"; raw: string }
|
|
| { kind: "not-positive"; raw: string }
|
|
| { kind: "too-small"; raw: string }
|
|
| { kind: "too-large"; raw: string };
|
|
|
|
export type BrowserTimeoutParseResult =
|
|
| { ok: true; value: number | undefined }
|
|
| { ok: false; error: BrowserTimeoutParseError };
|
|
|
|
/**
|
|
* Parse and validate `--browser-timeout <seconds>` into milliseconds.
|
|
* Returns `{ ok: true, value: undefined }` when the flag is absent so
|
|
* callers can spread the result without clobbering the engine default.
|
|
*/
|
|
export function parseBrowserTimeoutMsArg(raw: string | undefined): BrowserTimeoutParseResult {
|
|
if (raw == null) return { ok: true, value: undefined };
|
|
const parsed = Number(raw);
|
|
if (!Number.isFinite(parsed)) {
|
|
return { ok: false, error: { kind: "not-a-number", raw } };
|
|
}
|
|
if (parsed <= 0) {
|
|
return { ok: false, error: { kind: "not-positive", raw } };
|
|
}
|
|
if (parsed > MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS) {
|
|
return { ok: false, error: { kind: "too-large", raw } };
|
|
}
|
|
const ms = Math.round(parsed * 1000);
|
|
if (ms < MIN_PAGE_NAVIGATION_TIMEOUT_MS) {
|
|
// Sub-millisecond inputs (e.g. 0.0004 s) round to 0 ms, which
|
|
// Puppeteer treats as "no timeout" — the opposite of the user's
|
|
// intent. Reject explicitly.
|
|
return { ok: false, error: { kind: "too-small", raw } };
|
|
}
|
|
return { ok: true, value: ms };
|
|
}
|
|
|
|
function browserTimeoutErrorMessage(error: BrowserTimeoutParseError): {
|
|
title: string;
|
|
message: string;
|
|
hint?: string;
|
|
} {
|
|
const title = "Invalid browser-timeout";
|
|
switch (error.kind) {
|
|
case "not-a-number":
|
|
return {
|
|
title,
|
|
message: `Got "${error.raw}", which is not a number. Pass a positive number of seconds (e.g. 180).`,
|
|
};
|
|
case "not-positive":
|
|
return {
|
|
title,
|
|
message: `Got "${error.raw}" seconds, which is not positive. Pass a positive number of seconds (e.g. 180).`,
|
|
};
|
|
case "too-small":
|
|
return {
|
|
title,
|
|
message: `Got "${error.raw}" seconds, which rounds to 0 ms. Puppeteer treats 0 as 'no timeout' — pass a value that rounds to at least 1 ms.`,
|
|
};
|
|
case "too-large":
|
|
return {
|
|
title,
|
|
message: `Got "${error.raw}" seconds, which exceeds the ${MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS}s (24h) cap. Node's setTimeout overflows for larger values.`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Side-effecting wrapper around `parseBrowserTimeoutMsArg`. Exits the
|
|
* process with a friendly error box on validation failure.
|
|
*/
|
|
export function resolveBrowserTimeoutMsArg(raw: string | undefined): number | undefined {
|
|
const result = parseBrowserTimeoutMsArg(raw);
|
|
if (!result.ok) {
|
|
const { title, message, hint } = browserTimeoutErrorMessage(result.error);
|
|
errorBox(title, message, hint);
|
|
failUsage();
|
|
}
|
|
return result.value;
|
|
}
|
|
|
|
/**
|
|
* Navigation budget shared by snapshot/check/inspect browser diagnostics.
|
|
*
|
|
* The environment variable remains the historical global override. Callers
|
|
* with their own timeout knob can supply a minimum without shortening that
|
|
* override or the existing 10-second default.
|
|
*/
|
|
export function resolveDiagnosticNavigationTimeoutMs(
|
|
env: Record<string, string | undefined> = process.env,
|
|
minimumTimeoutMs = 0,
|
|
): number {
|
|
const parsed = Number(env.PRODUCER_PAGE_NAVIGATION_TIMEOUT_MS);
|
|
const configured = Number.isFinite(parsed) && parsed > 0 ? parsed : 10_000;
|
|
return Math.max(configured, minimumTimeoutMs);
|
|
}
|
|
|
|
// ── --composition ──────────────────────────────────────────────────────
|
|
|
|
export type CompositionEntryParseError =
|
|
| { kind: "outside-project"; entryFile: string }
|
|
| { kind: "not-found"; entryFile: string }
|
|
| { kind: "not-a-file"; entryFile: string };
|
|
|
|
export type CompositionEntryParseResult =
|
|
| { ok: true; value: string | undefined }
|
|
| { ok: false; error: CompositionEntryParseError };
|
|
|
|
function normalizeCompositionEntryArg(raw: string | undefined): string | undefined {
|
|
const trimmed = raw?.trim().replace(/^\.\//, "") || undefined;
|
|
return !trimmed || trimmed === "." ? undefined : trimmed;
|
|
}
|
|
|
|
export function hasExplicitCompositionArg(raw: string | undefined): boolean {
|
|
return normalizeCompositionEntryArg(raw) !== undefined;
|
|
}
|
|
|
|
/**
|
|
* Parse and validate `--composition <path>` into a project-relative
|
|
* entry file (or `undefined` for the index.html default).
|
|
*
|
|
* - `undefined` / `""` / `"."` / `"./"` → undefined (defaults to
|
|
* index.html). Issue #1199: the prior code threaded `.` straight
|
|
* through and the producer's `readFileSync` blew up with
|
|
* `EISDIR: illegal operation on a directory, read`.
|
|
* - Other strings are resolved against `projectDir`, checked for
|
|
* containment, existence, and isFile() via the injected `stat`
|
|
* adapter. The adapter shape lets unit tests inject fixtures
|
|
* without touching the filesystem.
|
|
*/
|
|
export function parseCompositionEntryArg(
|
|
raw: string | undefined,
|
|
projectDir: string,
|
|
stat: (path: string) => Stats,
|
|
): CompositionEntryParseResult {
|
|
const trimmed = normalizeCompositionEntryArg(raw);
|
|
// Normalize the project-root shorthands to "no entry override" so the
|
|
// producer falls back to index.html instead of statSync-ing the dir
|
|
// and later blowing up with EISDIR inside readFileSync().
|
|
if (!trimmed) return { ok: true, value: undefined };
|
|
|
|
const absProjectDir = resolve(projectDir);
|
|
const entryPath = resolve(absProjectDir, trimmed);
|
|
// Trailing-separator guard: `startsWith` alone treats `/proj` as a
|
|
// prefix of `/proj-evil`, letting a sibling-directory escape through.
|
|
// Allow the resolved path to BE the project dir (already covered by
|
|
// the trimmed === "." branch above) or to live beneath it with a
|
|
// path separator.
|
|
if (entryPath !== absProjectDir && !entryPath.startsWith(absProjectDir + sep)) {
|
|
return { ok: false, error: { kind: "outside-project", entryFile: trimmed } };
|
|
}
|
|
|
|
let entryStat: Stats;
|
|
try {
|
|
entryStat = stat(entryPath);
|
|
} catch {
|
|
return { ok: false, error: { kind: "not-found", entryFile: trimmed } };
|
|
}
|
|
if (!entryStat.isFile()) {
|
|
// Directory paths slip past existsSync downstream and explode with
|
|
// `EISDIR: illegal operation on a directory, read` inside the
|
|
// producer's readFileSync. Reject here with an actionable message.
|
|
return { ok: false, error: { kind: "not-a-file", entryFile: trimmed } };
|
|
}
|
|
return { ok: true, value: trimmed };
|
|
}
|
|
|
|
function compositionEntryErrorMessage(error: CompositionEntryParseError): {
|
|
title: string;
|
|
message: string;
|
|
hint?: string;
|
|
} {
|
|
switch (error.kind) {
|
|
case "outside-project":
|
|
return {
|
|
title: "Invalid composition path",
|
|
message: `Entry file must stay inside the project directory: ${error.entryFile}`,
|
|
};
|
|
case "not-found":
|
|
return {
|
|
title: "Composition not found",
|
|
message: `"${error.entryFile}" does not exist in the project directory.`,
|
|
hint: "Pass a path to a .html file relative to the project root (e.g. compositions/intro.html).",
|
|
};
|
|
case "not-a-file":
|
|
return {
|
|
title: "Invalid composition path",
|
|
message: `"${error.entryFile}" is a directory, not an .html file.`,
|
|
hint: "Pass a path to a .html file (e.g. compositions/intro.html), or omit --composition to render index.html.",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Side-effecting wrapper around `parseCompositionEntryArg`. Exits the
|
|
* process with a friendly error box on validation failure.
|
|
*/
|
|
export function resolveCompositionEntryArg(
|
|
raw: string | undefined,
|
|
projectDir: string,
|
|
stat: (path: string) => Stats,
|
|
): string | undefined {
|
|
const result = parseCompositionEntryArg(raw, projectDir, stat);
|
|
if (!result.ok) {
|
|
const { title, message, hint } = compositionEntryErrorMessage(result.error);
|
|
errorBox(title, message, hint);
|
|
failUsage();
|
|
}
|
|
return result.value;
|
|
}
|
|
|
|
// ── default fps ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve the fps argument that local `render` should parse: explicit --fps,
|
|
* else the actual composition entry file's root data-fps when valid, else
|
|
* undefined so the caller can apply its final "30" default.
|
|
*/
|
|
export function resolveDefaultFpsArg(
|
|
explicitFps: string | undefined,
|
|
projectDir: string,
|
|
indexPath: string,
|
|
entryFile: string | undefined,
|
|
): string | undefined {
|
|
if (explicitFps != null) return explicitFps;
|
|
try {
|
|
const fpsSourcePath = entryFile ? resolve(projectDir, entryFile) : indexPath;
|
|
const declared = readCompositionFps(readFileSync(fpsSourcePath, "utf8"));
|
|
if (declared != null && parseFps(declared).ok) {
|
|
return declared;
|
|
}
|
|
} catch {
|
|
// Unreadable composition file — fall back to the default fps in render.ts.
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export type GifLoopParseResult =
|
|
| { ok: true; value: number | undefined }
|
|
| { ok: false; message: string };
|
|
|
|
/**
|
|
* Parse and validate `--gif-loop <count>` (GIF Netscape loop count).
|
|
* Returns `{ ok: true, value: undefined }` when the flag is absent so the
|
|
* caller can apply the format-dependent default (0 = infinite for gif).
|
|
*/
|
|
export function parseGifLoopArg(raw: string | undefined): GifLoopParseResult {
|
|
if (raw === undefined) return { ok: true, value: undefined };
|
|
const trimmed = raw.trim();
|
|
if (trimmed.length === 0) {
|
|
return { ok: false, message: "GIF loop count must not be empty." };
|
|
}
|
|
const parsed = Number(trimmed);
|
|
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) {
|
|
return {
|
|
ok: false,
|
|
message: `Got "${raw}". GIF loop count must be an integer between 0 and 65535.`,
|
|
};
|
|
}
|
|
return { ok: true, value: parsed };
|
|
}
|