mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
Windows users with the OS temp dir on a small system drive have hit C: exhaustion mid-render (Slack ts=1784219488 · CLI v0.7.58 · win32 15 GB / 8-core, ~5500 frames). The engine already honors HYPERFRAMES_EXTRACT_CACHE_DIR for relocation, but the knob was undocumented and invisible in diagnostics — the reporter had to piece together a 4-flag compound workaround including EXTRACT_CACHE_DIR=off. Changes: - Extract the env-var resolver into a public engine API (resolveExtractCacheDir, defaultExtractCacheDir, EXTRACT_CACHE_DIR_DISABLED_ALIASES) with a typed resolution shape distinguishing "disabled by user" vs "default" vs "env override". - Add a Frames-cache check to `hyperframes doctor` that reports the effective directory, its free space, source (env or default), and fails with a relocation hint when <2 GB free at that mount. - Add `hyperframes render --frames-cache-dir <path>` as discoverable CLI sugar for the env var, including the opt-out aliases (off/none/false/0) and CWD-safe absolute-path resolution. - Document the flag in docs/packages/cli.mdx with the field-signal citation, and add a render example row for the Windows workflow. - Cover both surfaces with unit tests (6 doctor cases + 4 engine cases including all disabled-alias variants). Refs Slack #hyperframes-cli-feedback ts=1784219488 (win32 v0.7.58). Co-authored-by: Via <via-heygen[bot]@users.noreply.github.com>
275 lines
9.3 KiB
TypeScript
275 lines
9.3 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
buildDoctorReport,
|
|
checkFramesCache,
|
|
redactHome,
|
|
parseToolVersion,
|
|
type CheckOutcome,
|
|
} from "./doctor.js";
|
|
|
|
// ── Fixtures ────────────────────────────────────────────────────────────────
|
|
|
|
const OUTCOMES_ALL_OK: CheckOutcome[] = [
|
|
{ name: "Version", ok: true, detail: "0.4.4 (latest)" },
|
|
{ name: "Node.js", ok: true, detail: "v22.0.0 (darwin arm64)" },
|
|
{ name: "FFmpeg", ok: true, detail: "ffmpeg version 8.1" },
|
|
];
|
|
|
|
const OUTCOMES_WITH_FAILURE: CheckOutcome[] = [
|
|
{ name: "Version", ok: true, detail: "0.4.4 (latest)" },
|
|
{
|
|
name: "Docker",
|
|
ok: false,
|
|
detail: "Not found",
|
|
hint: "https://docs.docker.com/get-docker/",
|
|
},
|
|
];
|
|
|
|
describe("redactHome", () => {
|
|
const originalHome = process.env["HOME"];
|
|
const originalUserProfile = process.env["USERPROFILE"];
|
|
|
|
afterEach(() => {
|
|
if (originalHome !== undefined) process.env["HOME"] = originalHome;
|
|
else delete process.env["HOME"];
|
|
if (originalUserProfile !== undefined) process.env["USERPROFILE"] = originalUserProfile;
|
|
else delete process.env["USERPROFILE"];
|
|
});
|
|
|
|
it("replaces HOME paths with the literal $HOME", () => {
|
|
process.env["HOME"] = "/Users/alice";
|
|
delete process.env["USERPROFILE"];
|
|
expect(redactHome("system: /Users/alice/Library/Caches/chrome")).toBe(
|
|
"system: $HOME/Library/Caches/chrome",
|
|
);
|
|
});
|
|
|
|
it("replaces all occurrences, not just the first", () => {
|
|
process.env["HOME"] = "/home/bob";
|
|
delete process.env["USERPROFILE"];
|
|
expect(redactHome("/home/bob/a and /home/bob/b")).toBe("$HOME/a and $HOME/b");
|
|
});
|
|
|
|
it("falls back to USERPROFILE when HOME is unset (Windows)", () => {
|
|
delete process.env["HOME"];
|
|
process.env["USERPROFILE"] = "C:\\Users\\carol";
|
|
expect(redactHome("C:\\Users\\carol\\AppData")).toBe("$HOME\\AppData");
|
|
});
|
|
|
|
it("is a no-op when neither HOME nor USERPROFILE is set", () => {
|
|
delete process.env["HOME"];
|
|
delete process.env["USERPROFILE"];
|
|
expect(redactHome("/Users/someone/path")).toBe("/Users/someone/path");
|
|
});
|
|
|
|
it("leaves strings without HOME unchanged", () => {
|
|
process.env["HOME"] = "/Users/alice";
|
|
expect(redactHome("brew install ffmpeg")).toBe("brew install ffmpeg");
|
|
});
|
|
});
|
|
|
|
describe("parseToolVersion", () => {
|
|
it("extracts ffmpeg version from full copyright line", () => {
|
|
expect(
|
|
parseToolVersion("ffmpeg version 8.1.1 Copyright (c) 2000-2026 the FFmpeg developers"),
|
|
).toBe("ffmpeg 8.1.1");
|
|
});
|
|
|
|
it("extracts ffprobe version from full copyright line", () => {
|
|
expect(
|
|
parseToolVersion("ffprobe version 8.1.1 Copyright (c) 2007-2026 the FFmpeg developers"),
|
|
).toBe("ffprobe 8.1.1");
|
|
});
|
|
|
|
it("handles Windows gyan.dev builds with suffix", () => {
|
|
expect(
|
|
parseToolVersion(
|
|
"ffmpeg version 7.1.1-essentials_build-www.gyan.dev Copyright (c) 2000-2024",
|
|
),
|
|
).toBe("ffmpeg 7.1.1-essentials_build-www.gyan.dev");
|
|
});
|
|
|
|
it("returns trimmed input when pattern does not match", () => {
|
|
expect(parseToolVersion(" some unrecognized output ")).toBe("some unrecognized output");
|
|
});
|
|
});
|
|
|
|
describe("checkArchiveExtractor", () => {
|
|
it("reports a missing unzip dependency on non-Windows hosts", async () => {
|
|
const doctor = await import("./doctor.js");
|
|
expect(doctor).toHaveProperty("checkArchiveExtractor");
|
|
const result = doctor.checkArchiveExtractor("linux", () => false);
|
|
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
detail: "Not found",
|
|
hint: "Install unzip so HyperFrames can extract its managed Chrome download.",
|
|
});
|
|
});
|
|
|
|
it("accepts unzip on non-Windows hosts", async () => {
|
|
const { checkArchiveExtractor } = await import("./doctor.js");
|
|
expect(checkArchiveExtractor("darwin", (command) => command === "unzip")).toEqual({
|
|
ok: true,
|
|
detail: "unzip",
|
|
});
|
|
});
|
|
|
|
it("uses the built-in Windows extraction path", async () => {
|
|
const { checkArchiveExtractor } = await import("./doctor.js");
|
|
expect(checkArchiveExtractor("win32", () => false)).toEqual({
|
|
ok: true,
|
|
detail: "Built into Windows",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("buildDoctorReport", () => {
|
|
it("emits the locked schema shape", () => {
|
|
const report = buildDoctorReport(OUTCOMES_ALL_OK);
|
|
|
|
expect(report).toMatchObject({
|
|
ok: expect.any(Boolean),
|
|
platform: expect.any(String),
|
|
arch: expect.any(String),
|
|
checks: expect.any(Array),
|
|
_meta: expect.objectContaining({
|
|
version: expect.any(String),
|
|
updateAvailable: expect.any(Boolean),
|
|
}),
|
|
});
|
|
|
|
// Top-level keys are exactly these — any accidental addition or rename
|
|
// should force an explicit update to this test + PR review.
|
|
expect(Object.keys(report).sort()).toEqual(["_meta", "arch", "checks", "ok", "platform"]);
|
|
});
|
|
|
|
it("reports ok=true when all checks pass", () => {
|
|
expect(buildDoctorReport(OUTCOMES_ALL_OK).ok).toBe(true);
|
|
});
|
|
|
|
it("reports ok=false when any check fails", () => {
|
|
expect(buildDoctorReport(OUTCOMES_WITH_FAILURE).ok).toBe(false);
|
|
});
|
|
|
|
it("preserves check order exactly as provided", () => {
|
|
const report = buildDoctorReport(OUTCOMES_ALL_OK);
|
|
expect(report.checks.map((c) => c.name)).toEqual(["Version", "Node.js", "FFmpeg"]);
|
|
});
|
|
|
|
it("omits hint when not provided (doesn't emit hint:undefined)", () => {
|
|
const report = buildDoctorReport([{ name: "X", ok: true, detail: "fine" }]);
|
|
expect(report.checks[0]).toEqual({ name: "X", ok: true, detail: "fine" });
|
|
expect("hint" in report.checks[0]!).toBe(false);
|
|
});
|
|
|
|
it("preserves hint when provided", () => {
|
|
const report = buildDoctorReport(OUTCOMES_WITH_FAILURE);
|
|
const docker = report.checks.find((c) => c.name === "Docker");
|
|
expect(docker?.hint).toBe("https://docs.docker.com/get-docker/");
|
|
});
|
|
|
|
describe("redact option", () => {
|
|
const originalHome = process.env["HOME"];
|
|
beforeEach(() => {
|
|
process.env["HOME"] = "/Users/alice";
|
|
});
|
|
afterEach(() => {
|
|
if (originalHome !== undefined) process.env["HOME"] = originalHome;
|
|
else delete process.env["HOME"];
|
|
});
|
|
|
|
it("redacts HOME in detail and hint when redact=true", () => {
|
|
const outcomes: CheckOutcome[] = [
|
|
{
|
|
name: "Chrome",
|
|
ok: false,
|
|
detail: "system: /Users/alice/Applications/Chrome",
|
|
hint: "Try /Users/alice/bin/chrome",
|
|
},
|
|
];
|
|
const report = buildDoctorReport(outcomes, { redact: true });
|
|
expect(report.checks[0]?.detail).toBe("system: $HOME/Applications/Chrome");
|
|
expect(report.checks[0]?.hint).toBe("Try $HOME/bin/chrome");
|
|
});
|
|
|
|
it("leaves HOME alone when redact is off (default)", () => {
|
|
const outcomes: CheckOutcome[] = [
|
|
{ name: "Chrome", ok: true, detail: "/Users/alice/Chrome" },
|
|
];
|
|
expect(buildDoctorReport(outcomes).checks[0]?.detail).toBe("/Users/alice/Chrome");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("checkFramesCache", () => {
|
|
it("reports disabled state with the raw env value when the user opts out", () => {
|
|
const result = checkFramesCache(
|
|
{ HYPERFRAMES_EXTRACT_CACHE_DIR: "off" },
|
|
() => 100_000,
|
|
() => true,
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
expect(result.detail).toContain("Disabled (off)");
|
|
expect(result.detail).toContain("per-render workDir");
|
|
});
|
|
|
|
it("reports the effective directory + free space + default source when env is unset", () => {
|
|
const result = checkFramesCache(
|
|
{},
|
|
() => 50_000, // ~48.8 GB
|
|
() => true,
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
expect(result.detail).toContain("48.8 GB free");
|
|
expect(result.detail).toContain("default");
|
|
});
|
|
|
|
it("reports a positive env override with source: HYPERFRAMES_EXTRACT_CACHE_DIR", () => {
|
|
const result = checkFramesCache(
|
|
{ HYPERFRAMES_EXTRACT_CACHE_DIR: "/mnt/scratch/hf" },
|
|
() => 10_000,
|
|
() => true,
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
expect(result.detail).toContain("/mnt/scratch/hf");
|
|
expect(result.detail).toContain("HYPERFRAMES_EXTRACT_CACHE_DIR");
|
|
});
|
|
|
|
it("fails with a relocation hint when free space at the cache location is <2 GB", () => {
|
|
const result = checkFramesCache(
|
|
{ HYPERFRAMES_EXTRACT_CACHE_DIR: "/tmp/hf" },
|
|
() => 512, // 0.5 GB
|
|
() => true,
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
expect(result.hint).toContain("--frames-cache-dir");
|
|
expect(result.hint).toContain("HYPERFRAMES_EXTRACT_CACHE_DIR");
|
|
});
|
|
|
|
it("falls back to the first existing ancestor when the cache dir does not exist yet", () => {
|
|
const seen: string[] = [];
|
|
const result = checkFramesCache(
|
|
{ HYPERFRAMES_EXTRACT_CACHE_DIR: "/mnt/scratch/newly/created/subdir" },
|
|
(path) => {
|
|
seen.push(path);
|
|
return 20_000;
|
|
},
|
|
(path) => path === "/mnt/scratch",
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
// statfs was called against the walked-up ancestor, not the raw dir.
|
|
expect(seen).toContain("/mnt/scratch");
|
|
});
|
|
|
|
it("handles a null free-space reading without failing the check", () => {
|
|
const result = checkFramesCache(
|
|
{ HYPERFRAMES_EXTRACT_CACHE_DIR: "/mnt/scratch" },
|
|
() => null,
|
|
() => true,
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
expect(result.detail).toContain("free space unknown");
|
|
});
|
|
});
|