import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseBatchFile, runWithConcurrencyLimit } from "./render-batch.js"; let tmpDir: string; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "hf-render-batch-")); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); function writeBatch(content: string): string { const p = join(tmpDir, "batch.jsonl"); writeFileSync(p, content, "utf8"); return p; } describe("runWithConcurrencyLimit", () => { it("preserves input order in the output array regardless of completion order", async () => { // First input takes longest to resolve; output array still positional. const delays = [40, 10, 20]; const out = await runWithConcurrencyLimit(delays, 3, async (ms, i) => { await new Promise((r) => setTimeout(r, ms)); return `done-${i}`; }); expect(out).toEqual(["done-0", "done-1", "done-2"]); }); it("caps simultaneous in-flight work to the limit", async () => { let inFlight = 0; let peak = 0; const inputs = Array.from({ length: 12 }, (_, i) => i); const worker = async (i: number): Promise => { inFlight++; peak = Math.max(peak, inFlight); await new Promise((r) => setTimeout(r, 5)); inFlight--; return i; }; await runWithConcurrencyLimit(inputs, 3, worker); expect(peak).toBe(3); }); it("does not exceed the input length even when limit > inputs.length", async () => { let inFlight = 0; let peak = 0; const inputs = [1, 2]; await runWithConcurrencyLimit(inputs, 50, async (n) => { inFlight++; peak = Math.max(peak, inFlight); await new Promise((r) => setTimeout(r, 2)); inFlight--; return n; }); // Only 2 inputs → only 2 concurrent workers, even with limit=50. expect(peak).toBe(2); }); it("rejects a limit < 1", async () => { await expect(runWithConcurrencyLimit([1, 2], 0, async (n) => n)).rejects.toThrow( /limit must be/, ); }); it("returns immediately for an empty input array", async () => { const out = await runWithConcurrencyLimit([], 10, async (n: number) => n * 2); expect(out).toEqual([]); }); it("propagates the first worker rejection", async () => { await expect( runWithConcurrencyLimit([1, 2, 3], 2, async (n) => { if (n === 2) throw new Error("boom"); return n; }), ).rejects.toThrow(/boom/); }); }); describe("parseBatchFile", () => { it("parses a JSONL file into ordered entries (line numbers preserve source order)", () => { const path = writeBatch( [ '{"outputKey":"renders/alice.mp4","variables":{"name":"Alice"}}', '{"outputKey":"renders/bob.mp4","variables":{"name":"Bob"},"executionName":"hf-bob-001"}', ].join("\n") + "\n", ); const out = parseBatchFile(path); expect(out).toHaveLength(2); expect(out[0]?.entry.outputKey).toBe("renders/alice.mp4"); expect(out[0]?.entry.variables).toEqual({ name: "Alice" }); expect(out[0]?.lineNumber).toBe(1); expect(out[1]?.entry.executionName).toBe("hf-bob-001"); expect(out[1]?.lineNumber).toBe(2); }); it("skips blank lines and preserves line numbers", () => { const path = writeBatch( ["", '{"outputKey":"renders/a.mp4"}', "", "", '{"outputKey":"renders/b.mp4"}'].join("\n") + "\n", ); const out = parseBatchFile(path); expect(out).toHaveLength(2); expect(out[0]?.lineNumber).toBe(2); expect(out[1]?.lineNumber).toBe(5); }); // Helper: stub `process.exit` to throw a sentinel, run the parser, and // verify it called exit(1). Dedupes the 3 error-path tests so each one // is a single readable assertion. function expectExitOne(content: string): void { const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("EXIT_CALLED"); }); try { expect(() => parseBatchFile(writeBatch(content))).toThrow(/EXIT_CALLED/); expect(exitSpy).toHaveBeenCalledWith(1); } finally { exitSpy.mockRestore(); } } it("exits with a clear message on malformed JSON, naming the offending line", () => { expectExitOne(['{"outputKey":"renders/a.mp4"}', "{not json"].join("\n")); }); it("rejects entries missing outputKey", () => { expectExitOne('{"variables":{"name":"Alice"}}\n'); }); it("rejects variables that's not a plain object", () => { expectExitOne('{"outputKey":"renders/a.mp4","variables":[1,2,3]}\n'); }); });