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 = {}): 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 { 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).mock.calls; const loadingCall = calls.find( (c: unknown[]) => (c[1] as Record).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).mock.calls; const completeCall = calls.find( (c: unknown[]) => (c[1] as Record).status === "complete" ); expect(completeCall).toBeDefined(); expect((completeCall![1] as Record).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).mock.calls; const completeCall = calls.find( (c: unknown[]) => (c[1] as Record).status === "complete" ); expect((completeCall![1] as Record).outputVideo).toBe("https://cdn.fal.media/video.mp4"); expect((completeCall![1] as Record).outputVideoRemoteUrl).toBe("https://cdn.fal.media/video.mp4"); expect((completeCall![1] as Record).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).mock.calls[0][1] as Promise; await savePromise; const calls = (ctx.updateNodeData as ReturnType).mock.calls; const completeCall = calls.find( (c: unknown[]) => (c[1] as Record).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).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).mock.calls; const completeCall = calls.find( (c: unknown[]) => (c[1] as Record).status === "complete" ); expect((completeCall![1] as Record).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).mock.calls; const completeCall = calls.find( (c: unknown[]) => (c[1] as Record).status === "complete" ); const videoHistory = (completeCall![1] as Record).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).mock.calls; const stampCall = calls.find( (c: unknown[]) => (c[1] as Record).__usedFallback === true ); expect(stampCall).toBeDefined(); expect((stampCall![1] as Record).__fallbackModelUsed).toBe("Replicate Video Fallback"); expect((stampCall![1] as Record).__primaryError).toBe("Primary video boom"); const completeCall = calls.find( (c: unknown[]) => (c[1] as Record).status === "complete" ); expect(completeCall?.[1]).toMatchObject({ lastUsedModel: { provider: "replicate", modelId: "video-fallback", }, videoHistory: [ { model: "video-fallback", modelDisplayName: "Replicate Video Fallback", modelProvider: "replicate", }, ], }); }); });