You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

575 lines
18 KiB

import { describe, it, expect, vi, beforeEach } from "vitest";
import { executeGenerateVideo } from "../generateVideoExecutor";
import type { NodeExecutionContext } from "../types";
import type { WorkflowNode } from "@/types";
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
const defaultProviderSettings = {
providers: {
gemini: { apiKey: "" },
replicate: { apiKey: "" },
fal: { apiKey: "fal-key" },
kie: { apiKey: "" },
wavespeed: { apiKey: "" },
openai: { apiKey: "" },
newapiwg: { apiKey: "" },
},
} as any;
function makeNode(data: Record<string, unknown> = {}): WorkflowNode {
return {
id: "vid-1",
type: "generateVideo",
position: { x: 0, y: 0 },
data: {
outputVideo: null,
inputImages: [],
inputPrompt: null,
status: null,
error: null,
selectedModel: { provider: "fal", modelId: "fal-video-model", displayName: "Fal Video" },
parameters: {},
videoHistory: [],
selectedVideoHistoryIndex: 0,
...data,
},
} as WorkflowNode;
}
function makeCtx(
node: WorkflowNode,
overrides: Partial<NodeExecutionContext> = {}
): NodeExecutionContext {
return {
node,
getConnectedInputs: vi.fn().mockReturnValue({
images: [],
videos: [],
audio: [],
text: "video prompt",
dynamicInputs: {},
easeCurve: null,
}),
updateNodeData: vi.fn(),
getFreshNode: vi.fn().mockReturnValue(node),
getEdges: vi.fn().mockReturnValue([]),
getNodes: vi.fn().mockReturnValue([node]),
providerSettings: defaultProviderSettings,
addIncurredCost: vi.fn(),
addToGlobalHistory: vi.fn(),
generationsPath: null,
saveDirectoryPath: null,
trackSaveGeneration: vi.fn(),
appendOutputGalleryImage: vi.fn(),
get: vi.fn(),
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("executeGenerateVideo", () => {
it("should throw when missing required inputs", async () => {
const node = makeNode();
const ctx = makeCtx(node, {
getConnectedInputs: vi.fn().mockReturnValue({
images: [],
videos: [],
audio: [],
text: null,
dynamicInputs: {},
easeCurve: null,
}),
});
await expect(executeGenerateVideo(ctx)).rejects.toThrow("Missing required inputs");
});
it("should throw when no model selected", async () => {
const node = makeNode({ selectedModel: null });
const ctx = makeCtx(node);
await expect(executeGenerateVideo(ctx)).rejects.toThrow("No model selected");
});
it("should set loading status before API call", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
});
const ctx = makeCtx(node);
await executeGenerateVideo(ctx);
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
const loadingCall = calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "loading"
);
expect(loadingCall).toBeDefined();
});
it("should call /api/generate with mediaType=video", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
});
const ctx = makeCtx(node);
await executeGenerateVideo(ctx);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.mediaType).toBe("video");
expect(body.prompt).toBe("video prompt");
});
it("should allow connected video input without prompt", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
});
const ctx = makeCtx(node, {
getConnectedInputs: vi.fn().mockReturnValue({
images: [],
videos: ["data:video/mp4;base64,input"],
audio: [],
text: null,
dynamicInputs: {},
easeCurve: null,
}),
});
await executeGenerateVideo(ctx);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.dynamicInputs).toEqual({
video: "data:video/mp4;base64,input",
videos: ["data:video/mp4;base64,input"],
});
});
it("should preserve schema-mapped video dynamic input", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
});
const ctx = makeCtx(node, {
getConnectedInputs: vi.fn().mockReturnValue({
images: [],
videos: ["data:video/mp4;base64,input"],
audio: [],
text: null,
dynamicInputs: { video_urls: "data:video/mp4;base64,input" },
easeCurve: null,
}),
});
await executeGenerateVideo(ctx);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.dynamicInputs).toEqual({ video_urls: "data:video/mp4;base64,input" });
});
it("maps first-class video fields into provider parameters", async () => {
const node = makeNode({
aspectRatio: "16:9",
resolution: "1080p",
durationSeconds: 8,
fps: 24,
soundEnabled: true,
parameters: { motion: "cinematic" },
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
});
const ctx = makeCtx(node);
await executeGenerateVideo(ctx);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.parameters).toEqual({
motion: "cinematic",
aspectRatio: "16:9",
resolution: "1080p",
durationSeconds: 8,
fps: 24,
sound: true,
});
});
it("promotes NewApiWG fallback when stored Gemini video model has no key", async () => {
const node = makeNode({
selectedModel: {
provider: "gemini",
modelId: "veo-3.1-fast/image-to-video",
displayName: "VEO 3.1 Fast I2V",
},
fallbackModel: {
provider: "newapiwg",
modelId: "doubao-seedance-2-0-260128",
displayName: "doubao-seedance-2-0-260128",
},
fallbackParameters: { duration: 5 },
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, videoUrl: "https://cdn.example.com/video.mp4" }),
});
const ctx = makeCtx(node, {
providerSettings: {
providers: {
...defaultProviderSettings.providers,
gemini: { apiKey: "" },
newapiwg: { apiKey: "newapi-key" },
},
} as any,
});
await executeGenerateVideo(ctx);
expect(mockFetch).toHaveBeenCalledTimes(1);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.selectedModel).toMatchObject({
provider: "newapiwg",
modelId: "doubao-seedance-2-0-260128",
});
expect(body.parameters).toEqual({ duration: 5 });
expect(ctx.updateNodeData).toHaveBeenCalledWith("vid-1", expect.objectContaining({
selectedModel: expect.objectContaining({
provider: "newapiwg",
modelId: "doubao-seedance-2-0-260128",
}),
parameters: { duration: 5 },
fallbackModel: undefined,
fallbackParameters: undefined,
}));
});
it("uses NewApiWG default video model when Gemini is unavailable and no fallback is set", async () => {
const node = makeNode({
selectedModel: {
provider: "gemini",
modelId: "veo-3.1-fast/image-to-video",
displayName: "VEO 3.1 Fast I2V",
},
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, videoUrl: "https://cdn.example.com/video.mp4" }),
});
const ctx = makeCtx(node, {
providerSettings: {
providers: {
...defaultProviderSettings.providers,
gemini: { apiKey: "" },
newapiwg: { apiKey: "newapi-key" },
},
} as any,
});
await executeGenerateVideo(ctx);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.selectedModel).toMatchObject({
provider: "newapiwg",
modelId: "doubao-seedance-2-0-260128",
});
});
it("uses NewApiWG default video model when NewApiWG is configured via env", async () => {
const node = makeNode({
selectedModel: {
provider: "gemini",
modelId: "veo-3.1-fast/image-to-video",
displayName: "VEO 3.1 Fast I2V",
},
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, videoUrl: "https://cdn.example.com/video.mp4" }),
});
const ctx = makeCtx(node, {
providerEnvStatus: {
newapiwg: true,
gemini: false,
openai: false,
anthropic: false,
replicate: false,
fal: false,
kie: false,
wavespeed: false,
},
});
await executeGenerateVideo(ctx);
const request = mockFetch.mock.calls[0];
const body = JSON.parse(request[1].body);
expect(body.selectedModel).toMatchObject({
provider: "newapiwg",
modelId: "doubao-seedance-2-0-260128",
});
expect(request[1].headers["X-NewApiWG-Key"]).toBeUndefined();
});
it("should update node with video result on success", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
});
const ctx = makeCtx(node);
await executeGenerateVideo(ctx);
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
const completeCall = calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "complete"
);
expect(completeCall).toBeDefined();
expect((completeCall![1] as Record<string, unknown>).outputVideo).toBe("data:video/mp4;base64,output");
});
it("should handle videoUrl field in response", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, videoUrl: "https://cdn.fal.media/video.mp4" }),
});
const ctx = makeCtx(node);
await executeGenerateVideo(ctx);
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
const completeCall = calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "complete"
);
expect((completeCall![1] as Record<string, unknown>).outputVideo).toBe("https://cdn.fal.media/video.mp4");
expect((completeCall![1] as Record<string, unknown>).outputVideoRemoteUrl).toBe("https://cdn.fal.media/video.mp4");
expect((completeCall![1] as Record<string, unknown>).outputVideoStorageStatus).toBe("remote-only");
});
it("should localize remote video outputs when a generations path is configured", async () => {
const node = makeNode();
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, videoUrl: "https://cdn.fal.media/video.mp4" }),
})
.mockResolvedValueOnce({
json: () => Promise.resolve({ success: true, imageId: "localized-video-id" }),
});
const ctx = makeCtx(node, {
generationsPath: "/tmp/workflow/generations",
});
await executeGenerateVideo(ctx);
expect(ctx.trackSaveGeneration).toHaveBeenCalled();
const savePromise = (ctx.trackSaveGeneration as ReturnType<typeof vi.fn>).mock.calls[0][1] as Promise<void>;
await savePromise;
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
const completeCall = calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "complete"
);
expect(completeCall?.[1]).toMatchObject({
outputVideo: "https://cdn.fal.media/video.mp4",
outputVideoRemoteUrl: "https://cdn.fal.media/video.mp4",
outputVideoStorageStatus: "localizing",
});
const localizedCall = calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).outputVideoStorageStatus === "localized"
);
expect(localizedCall?.[1]).toMatchObject({
outputVideoRef: "localized-video-id",
outputVideoStorageStatus: "localized",
});
});
it("should handle image fallback response", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, image: "data:image/png;base64,preview" }),
});
const ctx = makeCtx(node);
await executeGenerateVideo(ctx);
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
const completeCall = calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "complete"
);
expect((completeCall![1] as Record<string, unknown>).outputVideo).toBe("data:image/png;base64,preview");
});
it("should track cost for fal provider", async () => {
const node = makeNode({
selectedModel: { provider: "fal", modelId: "fal-vid", displayName: "Fal", pricing: { amount: 0.25 } },
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,out" }),
});
const ctx = makeCtx(node, {
getFreshNode: vi.fn().mockReturnValue(node),
});
await executeGenerateVideo(ctx);
expect(ctx.addIncurredCost).toHaveBeenCalledWith(0.25);
});
it("should throw on HTTP error", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Internal Server Error",
text: () => Promise.resolve('{"error": "Video gen failed"}'),
});
const ctx = makeCtx(node);
await expect(executeGenerateVideo(ctx)).rejects.toThrow("Video gen failed");
});
it("should throw on API failure", async () => {
const node = makeNode();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: false, error: "Bad video" }),
});
const ctx = makeCtx(node);
await expect(executeGenerateVideo(ctx)).rejects.toThrow("Bad video");
});
it("should use stored fallback in regenerate mode", async () => {
const node = makeNode({
inputImages: ["stored-img.png"],
inputPrompt: "stored video prompt",
});
const ctx = makeCtx(node, {
getConnectedInputs: vi.fn().mockReturnValue({
images: [],
videos: [],
audio: [],
text: null,
dynamicInputs: {},
easeCurve: null,
}),
getFreshNode: vi.fn().mockReturnValue(node),
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,out" }),
});
await executeGenerateVideo(ctx, { useStoredFallback: true });
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.images).toEqual(["stored-img.png"]);
expect(body.prompt).toBe("stored video prompt");
});
it("should add to video history with 50-item limit", async () => {
const existingHistory = Array.from({ length: 50 }, (_, i) => ({
id: `old-${i}`,
timestamp: i,
prompt: `old-${i}`,
model: "m",
}));
const node = makeNode({ videoHistory: existingHistory });
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,out" }),
});
const ctx = makeCtx(node, {
getFreshNode: vi.fn().mockReturnValue(node),
});
await executeGenerateVideo(ctx);
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
const completeCall = calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "complete"
);
const videoHistory = (completeCall![1] as Record<string, unknown>).videoHistory as unknown[];
expect(videoHistory.length).toBe(50); // capped at 50
expect(videoHistory[0]).toMatchObject({
model: "fal-video-model",
modelDisplayName: "Fal Video",
modelProvider: "fal",
});
expect(completeCall![1]).toMatchObject({
lastUsedModel: {
provider: "fal",
modelId: "fal-video-model",
},
});
});
it("falls back on primary failure and stamps metadata", async () => {
const node = makeNode({
fallbackModel: {
provider: "replicate",
modelId: "video-fallback",
displayName: "Replicate Video Fallback",
},
});
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: false, error: "Primary video boom" }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,fallback" }),
});
const ctx = makeCtx(node);
await executeGenerateVideo(ctx);
expect(mockFetch).toHaveBeenCalledTimes(2);
const secondBody = JSON.parse(mockFetch.mock.calls[1][1].body);
expect(secondBody.selectedModel.modelId).toBe("video-fallback");
const calls = (ctx.updateNodeData as ReturnType<typeof vi.fn>).mock.calls;
const stampCall = calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).__usedFallback === true
);
expect(stampCall).toBeDefined();
expect((stampCall![1] as Record<string, unknown>).__fallbackModelUsed).toBe("Replicate Video Fallback");
expect((stampCall![1] as Record<string, unknown>).__primaryError).toBe("Primary video boom");
const completeCall = calls.find(
(c: unknown[]) => (c[1] as Record<string, unknown>).status === "complete"
);
expect(completeCall?.[1]).toMatchObject({
lastUsedModel: {
provider: "replicate",
modelId: "video-fallback",
},
videoHistory: [
{
model: "video-fallback",
modelDisplayName: "Replicate Video Fallback",
modelProvider: "replicate",
},
],
});
});
});