176 lines
6.0 KiB
TypeScript
176 lines
6.0 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
||
import {
|
||
buildAliyunCanvasVideoRequest,
|
||
executeAliyunCanvasVideo,
|
||
} from "@/lib/ai/providers/aliyun-canvas-video";
|
||
import { filterCanvasModels } from "@/lib/video-canvas/model-matching";
|
||
const input = {
|
||
modelId: "wan2.6-i2v-flash",
|
||
prompt: "red mug",
|
||
mode: "first-frame" as const,
|
||
images: ["https://example.com/mug.png"],
|
||
duration: 5,
|
||
resolution: "720p" as const,
|
||
ratio: "adaptive" as const,
|
||
apiKey: "test",
|
||
baseURL: "https://dashscope.aliyuncs.com/api/v1",
|
||
};
|
||
test("maps Wan first frame to official synthesis contract, preserving audio false", () => {
|
||
expect(buildAliyunCanvasVideoRequest({ ...input, generateAudio: false }, input.modelId)).toEqual({
|
||
model: input.modelId,
|
||
input: { prompt: "red mug", img_url: input.images[0] },
|
||
parameters: {
|
||
resolution: "720P",
|
||
duration: 5,
|
||
audio: false,
|
||
prompt_extend: false,
|
||
watermark: false,
|
||
},
|
||
});
|
||
});
|
||
test("unsupported modes, models and parameter combinations fail before submission", () => {
|
||
for (const patch of [
|
||
{ mode: "first-last-frame", images: [...input.images, ...input.images] },
|
||
{ duration: 1 },
|
||
{ duration: 5.5 },
|
||
{ resolution: "480p" },
|
||
{ audioUrls: ["https://example.com/audio.mp3"] },
|
||
{ returnLastFrame: true },
|
||
])
|
||
expect(() =>
|
||
buildAliyunCanvasVideoRequest({ ...input, ...patch } as any, input.modelId),
|
||
).toThrow();
|
||
expect(() => buildAliyunCanvasVideoRequest(input, "wan3-unknown")).toThrow();
|
||
});
|
||
test("only the implemented Wan model is selectable, only for first frame", () => {
|
||
const model = {
|
||
modelId: input.modelId,
|
||
providerType: "qianwen-video",
|
||
capabilities: { kind: "video", supportedModes: ["first-frame"] },
|
||
};
|
||
expect(filterCanvasModels([model], "video", "first-frame")).toEqual([model]);
|
||
expect(filterCanvasModels([model], "video", "first-last-frame")).toEqual([]);
|
||
expect(filterCanvasModels([{ ...model, modelId: "wan3-unknown" }], "video")).toEqual([]);
|
||
});
|
||
test("polls accepted task without resubmitting, returns upstream artifact and metadata", async () => {
|
||
const calls: Array<{ url: string; init: any }> = [];
|
||
let polls = 0;
|
||
const fetch: typeof globalThis.fetch = async (url, init) => {
|
||
calls.push({ url: String(url), init });
|
||
return Response.json(
|
||
init?.method === "POST"
|
||
? { output: { task_id: "task-1" } }
|
||
: {
|
||
output:
|
||
++polls === 1
|
||
? { task_status: "RUNNING" }
|
||
: { task_status: "SUCCEEDED", video_url: "https://example.com/video.mp4" },
|
||
request_id: "req-1",
|
||
},
|
||
);
|
||
};
|
||
const result = await executeAliyunCanvasVideo(input, { fetch, sleep: async () => {} });
|
||
expect(calls.map((c) => [c.url, c.init.method || "GET"])).toEqual([
|
||
[
|
||
"https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis",
|
||
"POST",
|
||
],
|
||
["https://dashscope.aliyuncs.com/api/v1/tasks/task-1", "GET"],
|
||
["https://dashscope.aliyuncs.com/api/v1/tasks/task-1", "GET"],
|
||
]);
|
||
expect(result).toMatchObject({
|
||
url: "https://example.com/video.mp4",
|
||
metadata: { taskId: "task-1", duration: 5, generateAudio: false },
|
||
});
|
||
});
|
||
test("task failure is not returned as a successful video", async () => {
|
||
const fetch: typeof globalThis.fetch = async (_url, init) =>
|
||
Response.json(
|
||
init?.method === "POST"
|
||
? { output: { task_id: "task-failed" } }
|
||
: { output: { task_status: "FAILED", code: "InvalidParameter" } },
|
||
);
|
||
await expect(executeAliyunCanvasVideo(input, { fetch, sleep: async () => {} })).rejects.toThrow(
|
||
"InvalidParameter,任务 task-failed",
|
||
);
|
||
});
|
||
test("completed task without output is rejected and API auth errors do not expose secrets", async () => {
|
||
const missing: typeof globalThis.fetch = async (_url, init) =>
|
||
Response.json(
|
||
init?.method === "POST"
|
||
? { output: { task_id: "empty" } }
|
||
: { output: { task_status: "SUCCEEDED" } },
|
||
);
|
||
await expect(
|
||
executeAliyunCanvasVideo(input, { fetch: missing, sleep: async () => {} }),
|
||
).rejects.toThrow("没有返回视频");
|
||
const denied: typeof globalThis.fetch = async () =>
|
||
Response.json(
|
||
{ code: "InvalidApiKey", message: "private upstream diagnostic" },
|
||
{ status: 401 },
|
||
);
|
||
await expect(
|
||
executeAliyunCanvasVideo(input, { fetch: denied, sleep: async () => {} }),
|
||
).rejects.toThrow("阿里云视频请求失败(401):InvalidApiKey");
|
||
});
|
||
|
||
test("resumes a persisted Aliyun task without POST or saving a new task ID", async () => {
|
||
const calls: string[] = [];
|
||
const result = await executeAliyunCanvasVideo(
|
||
{
|
||
...input,
|
||
lifecycle: {
|
||
resume: { taskId: "existing" },
|
||
accepted: async () => {
|
||
throw Error("unexpected create");
|
||
},
|
||
assertActive: async () => {},
|
||
},
|
||
},
|
||
{
|
||
sleep: async () => {},
|
||
fetch: async (url, init) => {
|
||
calls.push(init?.method || "GET");
|
||
expect(String(url)).toContain("/tasks/existing");
|
||
return Response.json({
|
||
output: { task_status: "SUCCEEDED", video_url: "https://example.com/old.mp4" },
|
||
});
|
||
},
|
||
},
|
||
);
|
||
expect(calls).toEqual(["GET"]);
|
||
expect(result.url).toContain("old.mp4");
|
||
});
|
||
test("saves accepted video ID before first GET and stops when execution ownership changes", async () => {
|
||
let saved = false;
|
||
let gets = 0;
|
||
await expect(
|
||
executeAliyunCanvasVideo(
|
||
{
|
||
...input,
|
||
lifecycle: {
|
||
accepted: async ({ taskId }) => {
|
||
expect(taskId).toBe("accepted");
|
||
saved = true;
|
||
},
|
||
assertActive: async () => {
|
||
if (saved) throw Error("lease lost");
|
||
},
|
||
},
|
||
},
|
||
{
|
||
sleep: async () => {},
|
||
fetch: async (_url, init) => {
|
||
if (init?.method !== "POST") {
|
||
gets++;
|
||
expect(saved).toBe(true);
|
||
}
|
||
return Response.json({ output: { task_id: "accepted" } });
|
||
},
|
||
},
|
||
),
|
||
).rejects.toThrow("lease lost");
|
||
expect(saved).toBe(true);
|
||
expect(gets).toBe(0);
|
||
});
|