mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
281 lines
8.3 KiB
TypeScript
281 lines
8.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import {
|
|
BatchRenderInputError,
|
|
parseBatchRows,
|
|
prepareBatchRender,
|
|
resolveOutputTemplate,
|
|
runBatchRender,
|
|
} from "./batchRender.js";
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), "hf-local-batch-render-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function writeJson(name: string, content: string): string {
|
|
const path = join(tmpDir, name);
|
|
writeFileSync(path, content, "utf8");
|
|
return path;
|
|
}
|
|
|
|
function writeIndex(schema = "[]"): string {
|
|
return writeJson(
|
|
"index.html",
|
|
`<html data-composition-variables='${schema}'><body><div data-composition-id="root"></div></body></html>`,
|
|
);
|
|
}
|
|
|
|
function expectBatchError(fn: () => unknown, title: string): BatchRenderInputError {
|
|
try {
|
|
fn();
|
|
} catch (error: unknown) {
|
|
expect(error).toBeInstanceOf(BatchRenderInputError);
|
|
if (error instanceof BatchRenderInputError) {
|
|
expect(error.title).toBe(title);
|
|
return error;
|
|
}
|
|
}
|
|
throw new Error("Expected BatchRenderInputError");
|
|
}
|
|
|
|
function eventType(value: unknown): string | undefined {
|
|
if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
return "type" in value && typeof value.type === "string" ? value.type : undefined;
|
|
}
|
|
|
|
describe("parseBatchRows", () => {
|
|
it("parses a JSON array of variable rows", () => {
|
|
expect(parseBatchRows('[{"name":"Alice"},{"name":"Bob"}]', "rows.json")).toEqual([
|
|
{ name: "Alice" },
|
|
{ name: "Bob" },
|
|
]);
|
|
});
|
|
|
|
it("parses an object with a rows array", () => {
|
|
expect(parseBatchRows('{"rows":[{"name":"Alice"}]}', "rows.json")).toEqual([{ name: "Alice" }]);
|
|
});
|
|
|
|
it("rejects non-object rows", () => {
|
|
const error = expectBatchError(
|
|
() => parseBatchRows('[{"name":"Alice"},null]', "rows.json"),
|
|
"Invalid batch row",
|
|
);
|
|
expect(error.message).toMatch(/Row 1/);
|
|
});
|
|
});
|
|
|
|
describe("resolveOutputTemplate", () => {
|
|
it("replaces row placeholders and index", () => {
|
|
expect(resolveOutputTemplate("renders/{name}-{index}.mp4", { name: "Alice" }, 3)).toBe(
|
|
"renders/Alice-3.mp4",
|
|
);
|
|
});
|
|
|
|
it("rejects missing placeholder keys", () => {
|
|
const error = expectBatchError(
|
|
() => resolveOutputTemplate("renders/{slug}.mp4", { name: "Alice" }, 0),
|
|
"Invalid output template",
|
|
);
|
|
expect(error.message).toMatch(/Missing value/);
|
|
});
|
|
});
|
|
|
|
describe("prepareBatchRender", () => {
|
|
it("resolves output paths and the manifest path", () => {
|
|
const batchPath = writeJson("rows.json", '[{"name":"Alice"},{"name":"Bob"}]');
|
|
const outDir = join(tmpDir, "renders");
|
|
const prepared = prepareBatchRender({
|
|
batchPath,
|
|
outputTemplate: join(outDir, "{name}.mp4"),
|
|
indexPath: writeIndex(),
|
|
strictVariables: false,
|
|
quiet: true,
|
|
json: false,
|
|
});
|
|
|
|
expect(prepared.rows.map((row) => row.outputPath)).toEqual([
|
|
resolve(outDir, "Alice.mp4"),
|
|
resolve(outDir, "Bob.mp4"),
|
|
]);
|
|
expect(prepared.manifestPath).toBe(resolve(outDir, "manifest.json"));
|
|
});
|
|
|
|
it("rejects output collisions before rendering", () => {
|
|
const batchPath = writeJson("rows.json", '[{"name":"Alice"},{"name":"Bob"}]');
|
|
const error = expectBatchError(
|
|
() =>
|
|
prepareBatchRender({
|
|
batchPath,
|
|
outputTemplate: join(tmpDir, "same.mp4"),
|
|
indexPath: writeIndex(),
|
|
strictVariables: false,
|
|
quiet: true,
|
|
json: false,
|
|
}),
|
|
"Batch output collision",
|
|
);
|
|
expect(error.message).toMatch(/Rows 0 and 1/);
|
|
});
|
|
|
|
it("fails strict variable validation per row", () => {
|
|
const batchPath = writeJson("rows.json", '[{"title":"Hello"},{"title":3}]');
|
|
const schema = '[{"id":"title","type":"string","label":"Title","default":"Untitled"}]';
|
|
const error = expectBatchError(
|
|
() =>
|
|
prepareBatchRender({
|
|
batchPath,
|
|
outputTemplate: join(tmpDir, "{index}.mp4"),
|
|
indexPath: writeIndex(schema),
|
|
strictVariables: true,
|
|
quiet: true,
|
|
json: true,
|
|
}),
|
|
"Variable validation failed",
|
|
);
|
|
expect(error.message).toMatch(/row 1/);
|
|
});
|
|
|
|
it("counts non-strict variable validation issues without failing", () => {
|
|
const batchPath = writeJson("rows.json", '[{"title":3}]');
|
|
const schema = '[{"id":"title","type":"string","label":"Title","default":"Untitled"}]';
|
|
const prepared = prepareBatchRender({
|
|
batchPath,
|
|
outputTemplate: join(tmpDir, "{index}.mp4"),
|
|
indexPath: writeIndex(schema),
|
|
strictVariables: false,
|
|
quiet: true,
|
|
json: false,
|
|
});
|
|
|
|
expect(prepared.variableIssueCount).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("runBatchRender", () => {
|
|
it("writes a manifest with completed rows", async () => {
|
|
const prepared = prepareBatchRender({
|
|
batchPath: writeJson("rows.json", '[{"name":"Alice"}]'),
|
|
outputTemplate: join(tmpDir, "renders/{name}.mp4"),
|
|
indexPath: writeIndex(),
|
|
strictVariables: false,
|
|
quiet: true,
|
|
json: false,
|
|
});
|
|
|
|
const manifest = await runBatchRender({
|
|
prepared,
|
|
concurrency: 1,
|
|
failFast: false,
|
|
quiet: true,
|
|
json: false,
|
|
renderOne: async () => ({ durationMs: 3000, renderTimeMs: 42 }),
|
|
});
|
|
|
|
expect(manifest.completed).toBe(1);
|
|
expect(manifest.failed).toBe(0);
|
|
expect(manifest.rows[0]).toMatchObject({
|
|
index: 0,
|
|
status: "completed",
|
|
durationMs: 3000,
|
|
renderTimeMs: 42,
|
|
error: null,
|
|
});
|
|
expect(readFileSync(prepared.manifestPath, "utf8")).toContain('"status": "completed"');
|
|
});
|
|
|
|
it("emits JSON progress events when json mode is enabled", async () => {
|
|
const prepared = prepareBatchRender({
|
|
batchPath: writeJson("rows.json", '[{"name":"Alice"}]'),
|
|
outputTemplate: join(tmpDir, "renders/{name}.mp4"),
|
|
indexPath: writeIndex(),
|
|
strictVariables: false,
|
|
quiet: true,
|
|
json: true,
|
|
});
|
|
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
|
|
await runBatchRender({
|
|
prepared,
|
|
concurrency: 1,
|
|
failFast: false,
|
|
quiet: true,
|
|
json: true,
|
|
renderOne: async () => ({ renderTimeMs: 10 }),
|
|
});
|
|
|
|
const events = log.mock.calls.map((call): unknown => JSON.parse(String(call[0])));
|
|
expect(events.map(eventType)).toEqual([
|
|
"batch-row-start",
|
|
"batch-row-complete",
|
|
"batch-complete",
|
|
]);
|
|
});
|
|
|
|
it("continues after row failure by default", async () => {
|
|
const prepared = prepareBatchRender({
|
|
batchPath: writeJson("rows.json", '[{"name":"Alice"},{"name":"Bob"}]'),
|
|
outputTemplate: join(tmpDir, "renders/{name}.mp4"),
|
|
indexPath: writeIndex(),
|
|
strictVariables: false,
|
|
quiet: true,
|
|
json: false,
|
|
});
|
|
|
|
const seen: number[] = [];
|
|
const manifest = await runBatchRender({
|
|
prepared,
|
|
concurrency: 1,
|
|
failFast: false,
|
|
quiet: true,
|
|
json: false,
|
|
renderOne: async (row) => {
|
|
seen.push(row.index);
|
|
if (row.index === 0) throw new Error("boom");
|
|
return { renderTimeMs: 10 };
|
|
},
|
|
});
|
|
|
|
expect(seen).toEqual([0, 1]);
|
|
expect(manifest.failed).toBe(1);
|
|
expect(manifest.completed).toBe(1);
|
|
});
|
|
|
|
it("marks unstarted rows skipped when fail-fast is enabled", async () => {
|
|
const prepared = prepareBatchRender({
|
|
batchPath: writeJson("rows.json", '[{"name":"Alice"},{"name":"Bob"},{"name":"Cleo"}]'),
|
|
outputTemplate: join(tmpDir, "renders/{name}.mp4"),
|
|
indexPath: writeIndex(),
|
|
strictVariables: false,
|
|
quiet: true,
|
|
json: false,
|
|
});
|
|
|
|
const seen: number[] = [];
|
|
const manifest = await runBatchRender({
|
|
prepared,
|
|
concurrency: 1,
|
|
failFast: true,
|
|
quiet: true,
|
|
json: false,
|
|
renderOne: async (row) => {
|
|
seen.push(row.index);
|
|
if (row.index === 1) throw new Error("boom");
|
|
return { renderTimeMs: 10 };
|
|
},
|
|
});
|
|
|
|
expect(seen).toEqual([0, 1]);
|
|
expect(manifest.rows.map((row) => row.status)).toEqual(["completed", "failed", "skipped"]);
|
|
expect(manifest.skipped).toBe(1);
|
|
});
|
|
});
|