Files
hyperframes/packages/producer/src/services/render/renderEventPublisher.test.ts
T
miga-heygenandClaude Opus 4.6 e69be30e98 fix(engine): fail render on sub-composition script failures (#3352) (#3528)
When a composition script throws during execution, the GSAP timeline
registration never arrives and pollSubCompositionTimelines times out.
Previously the render continued with a degenerate 2-frame output and
reported success — now it fails loudly.

Two changes:
1. Detect composition script runtime errors in the browser console
   handler and feed them into scriptLoadFailures, triggering the
   existing fail-fast path (same as script load 404s).
2. Make sub_timeline_script_failure a fatal warning in
   applyRenderWarningPolicy, alongside audio_processing_failed.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-08-28 00:21:34 +00:00

306 lines
10 KiB
TypeScript

// fallow-ignore-file code-duplication
import { describe, expect, it, vi } from "vitest";
import {
RenderQualityError,
applyRenderWarningPolicy,
createRenderJob,
} from "../renderOrchestrator.js";
import { updateJobStatus } from "./shared.js";
import { OrderedRenderEventPublisher, publishRenderFailure } from "./renderEventPublisher.js";
describe("OrderedRenderEventPublisher", () => {
it("delivers immutable snapshots in order and waits for async sinks", async () => {
const delivered: Array<{ progress: number; message: string }> = [];
const publisher = new OrderedRenderEventPublisher(
async (job, message) => {
await Promise.resolve();
delivered.push({ progress: job.progress, message });
},
{ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
);
const job = createRenderJob({ fps: 30, quality: "high" });
const publish = (nextJob: typeof job, message: string) => publisher.publish(nextJob, message);
updateJobStatus(job, "preprocessing", "first", 5, publish);
updateJobStatus(job, "rendering", "second", 25, publish);
updateJobStatus(job, "complete", "done", 100, publish);
await publisher.flush();
expect(delivered).toEqual([
{ progress: 5, message: "first" },
{ progress: 25, message: "second" },
{ progress: 100, message: "done" },
]);
});
it("deep-clones typed warning arrays across the progress sink boundary", async () => {
const deliveredReasons: string[][] = [];
const publisher = new OrderedRenderEventPublisher(
async (snapshot) => {
const reasons = snapshot.warnings[0]?.details?.failureReasons;
if (reasons) {
deliveredReasons.push([...reasons]);
reasons.push("sink_mutation");
}
},
{ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
);
const job = createRenderJob({ fps: 30, quality: "high" });
job.warnings.push({
code: "audio_processing_failed",
message: "audio failed",
stage: "capture-readiness",
details: {
mediaType: "audio",
failureReasons: ["ffmpeg_timeout"],
failureStages: ["prepare"],
},
});
publisher.publish(job, "warning");
job.warnings[0]!.details!.failureReasons!.push("source_mutation");
await publisher.flush();
expect(deliveredReasons).toEqual([["ffmpeg_timeout"]]);
expect(job.warnings[0]?.details?.failureReasons).toEqual(["ffmpeg_timeout", "source_mutation"]);
});
it("contains sink rejection and still delivers the terminal event", async () => {
const delivered: number[] = [];
const warn = vi.fn();
const publisher = new OrderedRenderEventPublisher(
async (job) => {
if (job.progress === 5) throw new Error("sink down");
delivered.push(job.progress);
},
{ error: vi.fn(), warn, info: vi.fn(), debug: vi.fn() },
);
const job = createRenderJob({ fps: 30, quality: "high" });
const publish = (nextJob: typeof job, message: string) => publisher.publish(nextJob, message);
updateJobStatus(job, "preprocessing", "first", 5, publish);
updateJobStatus(job, "complete", "done", 100, publish);
await publisher.flush();
expect(delivered).toEqual([100]);
expect(warn).toHaveBeenCalledTimes(1);
});
it("publishes the complete structured failure in the terminal snapshot", async () => {
const delivered: Array<ReturnType<typeof createRenderJob>> = [];
const publisher = new OrderedRenderEventPublisher(
async (job) => {
delivered.push(job);
},
{ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
);
const job = createRenderJob({ fps: 30, quality: "high" });
job.currentStage = "Capturing frames";
const publish = (nextJob: typeof job, message: string) => publisher.publish(nextJob, message);
const errorDetails = {
message: "capture failed",
elapsedMs: 125,
freeMemoryMB: 512,
};
publishRenderFailure(
job,
{
error: "capture failed",
failedStage: job.currentStage,
errorDetails,
},
publish,
);
await publisher.flush();
expect(delivered).toHaveLength(1);
expect(delivered[0]).toMatchObject({
status: "failed",
currentStage: "Failed: capture failed",
outcome: "failed",
error: "capture failed",
failedStage: "Capturing frames",
errorDetails,
});
});
});
describe("updateJobStatus", () => {
it("keeps one bounded monotonic integer-percent representation", () => {
const job = createRenderJob({ fps: 30, quality: "high" });
updateJobStatus(job, "rendering", "advance", 25.6);
updateJobStatus(job, "rendering", "stale", 20);
updateJobStatus(job, "rendering", "overflow", 120);
expect(job.progress).toBe(100);
});
it("timestamps every terminal outcome, including cancellation", () => {
const job = createRenderJob({ fps: 30, quality: "high" });
updateJobStatus(job, "cancelled", "cancelled", 42);
expect(job.completedAt).toBeInstanceOf(Date);
expect(job.outcome).toBe("cancelled");
});
it("qualifies best-effort completion when correctness warnings exist", () => {
const job = createRenderJob({ fps: 30, quality: "high", strictness: "best-effort" });
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
applyRenderWarningPolicy(
job,
[
{
code: "media_load_failed",
message: "video failed",
details: { mediaType: "video", sources: ["missing.mp4"] },
},
],
log,
);
updateJobStatus(job, "complete", "done", 100);
expect(job.outcome).toBe("completed_with_warnings");
expect(job.warnings).toHaveLength(1);
});
it("blocks audio processing failures when strictness is omitted", () => {
const job = createRenderJob({ fps: 30, quality: "high" });
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
expect(() =>
applyRenderWarningPolicy(
job,
[
{
code: "audio_processing_failed",
message: "audio mix failed",
details: { mediaType: "audio", sources: ["broken.mp3"] },
},
],
log,
),
).toThrow(RenderQualityError);
expect(job.config.strictness).toBe("best-effort");
expect(job.warnings).toHaveLength(1);
expect(log.warn).toHaveBeenCalledWith(
"Render completed capture with correctness warnings",
expect.objectContaining({ warningRetryable: undefined }),
);
});
it("logs bounded audio failure taxonomy without requiring raw stderr", () => {
const job = createRenderJob({ fps: 30, quality: "high" });
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
expect(() =>
applyRenderWarningPolicy(
job,
[
{
code: "audio_processing_failed",
message: "raw diagnostic stays on the warning object",
details: {
mediaType: "audio",
failureReasons: ["ffmpeg_timeout"],
failureStages: ["prepare"],
failureOwner: "system",
retryable: true,
},
},
],
log,
),
).toThrow(RenderQualityError);
expect(log.warn).toHaveBeenCalledWith(
"Render completed capture with correctness warnings",
expect.objectContaining({
warningCodes: ["audio_processing_failed"],
warningReasons: ["ffmpeg_timeout"],
warningStages: ["prepare"],
warningOwners: ["system"],
warningRetryable: true,
}),
);
});
it("only logs an aggregate warning as retryable when every typed warning is retryable", () => {
const job = createRenderJob({ fps: 30, quality: "high" });
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
expect(() =>
applyRenderWarningPolicy(
job,
[
{
code: "audio_processing_failed",
message: "temporary audio failure",
details: { mediaType: "audio", retryable: true },
},
{
code: "media_load_failed",
message: "terminal media failure",
details: { mediaType: "video", retryable: false },
},
],
log,
),
).toThrow(RenderQualityError);
expect(log.warn).toHaveBeenCalledWith(
"Render completed capture with correctness warnings",
expect.objectContaining({ warningRetryable: false }),
);
});
it("blocks sub-timeline script failures in best-effort mode (#3352)", () => {
const job = createRenderJob({ fps: 30, quality: "high" });
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
expect(() =>
applyRenderWarningPolicy(
job,
[
{
code: "sub_timeline_script_failure",
message: "A sub-composition script threw during execution",
details: {
timeoutMs: 45_000,
sources: ["runtime-error:decision-tree-123"],
},
},
],
log,
),
).toThrow(RenderQualityError);
expect(job.warnings).toHaveLength(1);
});
it("allows sub-timeline readiness timeout in best-effort mode", () => {
const job = createRenderJob({ fps: 30, quality: "high" });
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
applyRenderWarningPolicy(
job,
[
{
code: "sub_timeline_readiness_timeout",
message: "Sub-composition timelines did not become ready within 45000ms",
details: { timeoutMs: 45_000 },
},
],
log,
);
expect(job.warnings).toHaveLength(1);
});
it("fails explicitly strict renders on correctness warnings", () => {
const job = createRenderJob({ fps: 30, quality: "high", strictness: "strict" });
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
expect(() =>
applyRenderWarningPolicy(
job,
[
{
code: "audio_processing_failed",
message: "audio mix failed",
details: { mediaType: "audio", sources: ["broken.mp3"] },
},
],
log,
),
).toThrow(RenderQualityError);
});
});