import { describe, it, expect, vi, beforeEach } from "vitest"; import { executeGenerateVideo } from "../generateVideoExecutor"; import type { NodeExecutionContext } from "../types"; import type { WorkflowNode } from "@/types"; import { POPI_VIDEO_SUBTYPE_IMAGE_TO_VIDEO, POPI_VIDEO_SUBTYPE_OMNI_REFERENCE, } from "@/constants/generationTask"; 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(), appendOutputGalleryImage: vi.fn(), get: vi.fn(), ...overrides, }; } beforeEach(() => { vi.clearAllMocks(); }); function mockPopiTaskSuccess() { mockFetch.mockImplementation(async (url: string) => { if (url === "/api/generate/task/create") { return { ok: true, json: () => Promise.resolve({ success: true, taskId: "task-1" }), }; } if (url.startsWith("/api/generate/task/poll")) { return { ok: true, json: () => Promise.resolve({ status: "completed", result: { success: true, video: "data:video/mp4;base64,output" }, }), }; } throw new Error(`Unexpected fetch URL: ${url}`); }); } async function withProviderMode(mode: "multi" | "popi", run: () => Promise): Promise { const originalNextPublicMode = process.env.NEXT_PUBLIC_PROVIDER_MODE; const originalProviderMode = process.env.PROVIDER_MODE; process.env.NEXT_PUBLIC_PROVIDER_MODE = mode; process.env.PROVIDER_MODE = mode; try { return await run(); } finally { if (originalNextPublicMode === undefined) { delete process.env.NEXT_PUBLIC_PROVIDER_MODE; } else { process.env.NEXT_PUBLIC_PROVIDER_MODE = originalNextPublicMode; } if (originalProviderMode === undefined) { delete process.env.PROVIDER_MODE; } else { process.env.PROVIDER_MODE = originalProviderMode; } } } describe("executeGenerateVideo", () => { it("submits the task when inputs are empty", async () => { mockPopiTaskSuccess(); const node = makeNode(); const ctx = makeCtx(node, { getConnectedInputs: vi.fn().mockReturnValue({ images: [], videos: [], audio: [], text: null, dynamicInputs: {}, easeCurve: null, }), }); await executeGenerateVideo(ctx); expect(mockFetch).toHaveBeenCalledWith( "/api/generate/task/create", expect.objectContaining({ method: "POST" }) ); }); it("should throw when no model selected", async () => { await withProviderMode("multi", 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("joins multiple connected text inputs with the local prompt without storing the joined text", async () => { const node = makeNode({ inputPrompt: "local prompt" }); 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: [], audio: [], text: "first text", textItems: ["first text", "second text"], batchTextItems: [], dynamicInputs: {}, easeCurve: null, }), }); await executeGenerateVideo(ctx); const body = JSON.parse(mockFetch.mock.calls[0][1].body); expect(body.prompt).toBe("first text,second text,local prompt"); expect(ctx.updateNodeData).toHaveBeenCalledWith("vid-1", expect.objectContaining({ inputPrompt: "local 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("passes connected audio inputs as voices for video generation", async () => { const node = makeNode(); mockFetch.mockImplementation(async (url: string) => { if (url === "/api/generate/task/create") { return { ok: true, json: () => Promise.resolve({ success: true, taskId: "task-1" }), }; } if (url.startsWith("/api/generate/task/poll")) { return { ok: true, json: () => Promise.resolve({ status: "completed", result: { success: true, video: "data:video/mp4;base64,output" }, }), }; } throw new Error(`Unexpected fetch URL: ${url}`); }); const ctx = makeCtx(node, { getConnectedInputs: vi.fn().mockReturnValue({ images: [], videos: ["https://cdn.example.com/unsupported-video.mp4"], audio: ["https://cdn.example.com/input-audio.mp3"], text: null, dynamicInputs: { videos: ["https://cdn.example.com/legacy-video.mp4"], video_url: "https://cdn.example.com/legacy-video-url.mp4", audios: ["https://cdn.example.com/legacy-audio.mp3"], audio_url: "https://cdn.example.com/legacy-audio-url.mp3", motion: "cinematic", }, easeCurve: null, }), }); await executeGenerateVideo(ctx); const body = JSON.parse(mockFetch.mock.calls[0][1].body); expect(body.dynamicInputs).toEqual({ voices: ["https://cdn.example.com/input-audio.mp3"], motion: "cinematic", }); }); 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("uploads large NewApiWG video inputs before sending the generate request", async () => { const largeVideo = `data:video/mp4;base64,${"a".repeat(400_000)}`; const node = makeNode({ selectedModel: { provider: "newapiwg", modelId: "doubao-seedance-2-0-260128", displayName: "Seedance", capabilities: ["text-to-video", "image-to-video"], }, }); const ctx = makeCtx(node, { getConnectedInputs: vi.fn().mockReturnValue({ images: [], videos: [largeVideo], audio: [], text: null, dynamicInputs: {}, easeCurve: null, }), }); mockFetch.mockImplementation(async (url: string) => { if (url === "/api/images/upload") { return { ok: true, json: () => Promise.resolve({ success: true, id: "tmp-video-1", url: "http://localhost:3000/api/images/tmp-video-1", }), }; } if (url === "/api/generate") { return { ok: true, json: () => Promise.resolve({ success: true, videoUrl: "https://cdn.example.com/out.mp4" }), }; } throw new Error(`Unexpected fetch URL: ${url}`); }); await executeGenerateVideo(ctx); const generateCall = mockFetch.mock.calls.find(([url]) => url === "/api/generate"); const cleanupCall = mockFetch.mock.calls.find( ([url, init]) => url === "/api/images/upload" && init?.method === "DELETE" ); const body = JSON.parse(generateCall?.[1].body); expect(body.dynamicInputs).toEqual({ video: "http://localhost:3000/api/images/tmp-video-1", videos: ["http://localhost:3000/api/images/tmp-video-1"], }); expect(JSON.stringify(body)).not.toContain("base64"); expect(cleanupCall?.[1].body).toBe(JSON.stringify({ ids: ["tmp-video-1"] })); }); it("passes stored video parameters without filling legacy fields", async () => { const node = makeNode({ selectedModel: { provider: "popiserver", modelId: "bytedance/seedance-2/text-to-video", displayName: "Seedance" }, aspectRatio: "16:9", resolution: "1080p", durationSeconds: 8, fps: 24, soundEnabled: true, parameters: { motion: "cinematic", duration: 4, }, }); mockPopiTaskSuccess(); const ctx = makeCtx(node); await executeGenerateVideo(ctx); const body = JSON.parse(mockFetch.mock.calls[0][1].body); expect(body.parameters).toEqual({ motion: "cinematic", duration: 4, }); expect(body.subType).toBeUndefined(); }); it("keeps stored PopiServer schema parameters instead of overriding from legacy fields", async () => { const node = makeNode({ selectedModel: { provider: "popiserver", modelId: "37", displayName: "Ernie Image Turbo" }, resolution: "1080p", durationSeconds: 8, parameters: { ratio: "9:16", resolution: "1024", duration: 4, videoRatio: 10, }, }); mockPopiTaskSuccess(); const ctx = makeCtx(node); await executeGenerateVideo(ctx); const body = JSON.parse(mockFetch.mock.calls[0][1].body); expect(body.parameters).toEqual({ ratio: "9:16", resolution: "1024", duration: 4, videoRatio: 10, }); expect(body.subType).toBeUndefined(); }); it("passes persisted PopiServer subtype through without executor-side normalization", async () => { const node = makeNode({ selectedModel: { provider: "popiserver", modelId: "44", displayName: "Seedance" }, subType: POPI_VIDEO_SUBTYPE_OMNI_REFERENCE, parameters: { duration: 4, }, }); mockPopiTaskSuccess(); const ctx = makeCtx(node, { getConnectedInputs: vi.fn().mockReturnValue({ images: [ "https://static.popi.art/1.png", "https://static.popi.art/2.png", "https://static.popi.art/3.png", ], videos: [], audio: [], text: "video prompt", dynamicInputs: {}, easeCurve: null, }), }); await executeGenerateVideo(ctx); const body = JSON.parse(mockFetch.mock.calls[0][1].body); expect(body.parameters?.subType).toBeUndefined(); expect(body.subType).toBe(POPI_VIDEO_SUBTYPE_OMNI_REFERENCE); }); it("uses persisted config over legacy video fields when executing", async () => { const node = makeNode({ selectedModel: { provider: "kie", modelId: "legacy-video", displayName: "Legacy Video" }, inputPrompt: "legacy prompt", aspectRatio: "1:1", resolution: "720p", durationSeconds: 4, parameters: { motion: "legacy", resolution: "720p", duration: 4, }, config: { prompt: "config prompt", aspectRatio: "9:16", resolution: "1080p", durationSeconds: 8, selectedModel: { provider: "kie", modelId: "bytedance/seedance-2/text-to-video", displayName: "Seedance", capabilities: ["text-to-video"], }, inputImages: [], batchSize: 1, soundEnabled: true, parameters: { motion: "config", }, }, }); 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: [], audio: [], text: null, dynamicInputs: {}, easeCurve: null, }), }); await executeGenerateVideo(ctx, { useStoredFallback: true }); const body = JSON.parse(mockFetch.mock.calls[0][1].body); expect(body.prompt).toBe("config prompt"); expect(body.selectedModel).toMatchObject({ modelId: "bytedance/seedance-2/text-to-video" }); expect(body.parameters).toEqual({ motion: "config", aspectRatio: "9:16", aspect_ratio: "9:16", resolution: "1080p", duration: 8, generate_audio: true, }); }); it("submits video durations without local validation", async () => { mockPopiTaskSuccess(); const node = makeNode({ durationSeconds: 3 }); const ctx = makeCtx(node); await executeGenerateVideo(ctx); const body = JSON.parse(mockFetch.mock.calls[0][1].body); expect(body.parameters).toMatchObject({ duration: 3 }); }); it("uses the selected video model even when legacy fallback fields are present", async () => { const node = makeNode({ selectedModel: { provider: "newapiwg", modelId: "viduq3-turbo", displayName: "Vidu Q3 Turbo", }, }); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, videoUrl: "https://cdn.example.com/video.mp4" }), }); const ctx = makeCtx(node); await executeGenerateVideo(ctx); expect(mockFetch).toHaveBeenCalledTimes(1); const body = JSON.parse(mockFetch.mock.calls[0][1].body); expect(body.selectedModel).toMatchObject({ provider: "newapiwg", modelId: "viduq3-turbo", }); }); 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 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({ video: "data:video/mp4;base64,out", model: "fal-video-model", modelDisplayName: "Fal Video", modelProvider: "fal", }); expect(completeCall![1]).toMatchObject({ lastUsedModel: { provider: "fal", modelId: "fal-video-model", }, }); }); it("does not retry with another model when primary video generation fails", async () => { const node = makeNode({ selectedModel: { provider: "replicate", modelId: "primary-video", displayName: "Primary Video", }, }); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: false, error: "Primary video boom" }), }); const ctx = makeCtx(node); await expect(executeGenerateVideo(ctx)).rejects.toThrow("Primary video boom"); expect(mockFetch).toHaveBeenCalledTimes(1); }); });