ai-chatbot/tests/routes/canvas-agent-runtime-image-failover.test.ts

95 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test } from "@playwright/test";
import { agentImageProviderHarness as harness } from "../helpers/agent-image-provider-harness";
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
test("automatic image generation switches within the same provider and skips a paused model on the next task", async () => {
const h = harness({ generate: id => { if (id === "primary") throw Object.assign(new Error("channel unavailable"), { status: 503 }); return "![image](https://fixture.invalid/ok.png)"; } });
const result = await h.generate(h.request);
expect(result.modelRef).toBe("backup"); expect(h.calls.map(c => c.id)).toEqual(["primary", "backup"]);
expect([...h.paused]).toEqual(["primary"]);
expect(h.calls[1].imageParams).toMatchObject({ imageSize: "2K", aspectRatio: "3:4", negativePrompt: "no text", seed: 0, watermark: false });
await h.generate(h.request);
expect(h.calls.map(c => c.id)).toEqual(["primary", "backup", "backup"]);
});
test("a pinned model failure never calls any backup or silently substitutes a missing model", async () => {
const h = harness({ generate: () => { throw Object.assign(new Error("invalid authentication"), { status: 401 }); } });
await expect(h.generate({ ...h.request, agentImageMode: "pinned", allowFailover: true })).rejects.toThrow();
expect(h.calls.map(c => c.id)).toEqual(["primary"]);
await expect(h.generate({ ...h.request, agentImageMode: "pinned", model: "missing" })).rejects.toThrow("指定的图片模型不可用");
expect(h.calls).toHaveLength(1);
});
test("a partly successful batch keeps its delivered images and only generates the missing count", async () => {
const h = harness({ generate: (id, _n, call) => { if (id === "primary" && call === 2) throw new Error("VIP98K task failed"); return `![image](https://fixture.invalid/${id}-${call}.png)`; } });
const result = await h.generate({ ...h.request, imageCount: 3 });
expect(h.calls.map(c => c.id)).toEqual(["primary", "primary", "backup", "backup"]);
expect(result.images).toHaveLength(3);
expect(result.images.map((i: any) => i.modelRef)).toEqual(["primary", "backup", "backup"]);
expect(result.images[0].originalUrl).toContain("primary-1");
});
test("if every backup fails, previously delivered images are retained", async () => {
const h = harness({ generate: (_id, _n, call) => { if (call > 1) throw new Error("VIP98K task failed"); return "![image](https://fixture.invalid/keep.png)"; } });
const result = await h.generate({ ...h.request, imageCount: 3 });
expect(result.images).toHaveLength(1); expect(result.images[0].originalUrl).toContain("keep.png");
});
test("a submitted task with an unknown outcome is not submitted again through a backup", async () => {
const h = harness({ generate: () => { throw new Error("VIP98K task timed out; it may still be running"); } });
await expect(h.generate(h.request)).rejects.toThrow();
expect(h.calls.map(c => c.id)).toEqual(["primary"]); expect(h.paused.size).toBe(0);
expect(agentImageFailurePolicy(Object.assign(new Error("timeout"), { providerOutcomeUnknown: true })).retry).toBe(false);
expect(agentImageFailurePolicy(new Error("VIP98K task failed: timeout")).retry).toBe(true);
});
test("content restrictions and account limits cannot be bypassed by switching services", () => {
for (const error of [new Error("content policy rejection"), new Error("用户积分不足")]) expect(agentImageFailurePolicy(error)).toEqual({ retry: false, pause: null });
});
test("automatic fallback respects requested dimensions, disabled candidates and the user's available credit", async () => {
const models = [
{ id: "primary", creditCost: 1, supportedImageSizes: ["2K"] },
{ id: "wrong-size", creditCost: 1, supportedImageSizes: ["1K"] },
{ id: "too-expensive", creditCost: 10, supportedImageSizes: ["2K"] },
{ id: "disabled-health", creditCost: 1, supportedImageSizes: ["2K"], healthStatus: "auto_disabled" },
{ id: "backup", creditCost: 2, supportedImageSizes: ["2K"] },
].map(({ id, healthStatus, ...config }) => ({ model: { id, modelId: id, displayName: id, isEnabled: true, healthStatus, supportsImageGen: true, supportsVision: true, imageGenConfig: config }, provider: { type: "openai-image", isEnabled: true } }));
const h = harness({ models, generate: id => { if (id === "primary") throw new Error("VIP98K task failed"); return "![image](https://fixture.invalid/ok.png)"; } });
const result = await h.generate({ ...h.request, agentImageMaxCreditCost: 3 });
expect(result.modelRef).toBe("backup"); expect(h.calls.map(c => c.id)).toEqual(["primary", "backup"]);
});
test("the existing non-Agent auto alias continues to select its configured default", async () => {
const h = harness();
const result = await h.generate({ ...h.request, model: "auto", agentImageMode: undefined, allowFailover: false });
expect(result.modelRef).toBe("primary"); expect(h.calls).toHaveLength(1);
});
test("provider adapters that include HTTP status or quota only in their message still recover", () => {
for (const message of ["API request failed: 503", "Internal server error", "Service Unavailable", "Unauthorized", "API request failed: HTTP 503", "server busy(HTTP 429)", "insufficient balance", "模型配额不足", "No images in response", "BananaImage 生成失败"]) {
expect(agentImageFailurePolicy(new Error(message)).retry).toBe(true);
}
expect(agentImageFailurePolicy(new Error("invalid input (HTTP 400)"))).toEqual({ retry: true, pause: null });
});
test("exhausted automatic channels and pinned failures show useful messages without provider diagnostics", async () => {
const h = harness({ generate: () => { throw Object.assign(new Error("provider-internal-trace HTTP 503"), { status: 503 }); } });
await expect(h.generate(h.request)).rejects.toThrow("暂时未能完成本次出图");
await expect(h.generate({ ...h.request, agentImageMode: "pinned" })).rejects.toThrow("系统未自动切换");
});
test("a parameter rejection can recover on a compatible alternate without pausing the first model", async () => {
const h = harness({ generate: id => { if (id === "primary") throw Object.assign(new Error("parameter not supported (HTTP 400)"), { status: 400 }); return "![image](https://fixture.invalid/ok.png)"; } });
const result = await h.generate(h.request);
expect(result.modelRef).toBe("backup"); expect(h.calls.map(c => c.id)).toEqual(["primary", "backup"]);
expect(h.paused.size).toBe(0);
expect(h.calls[1].imageParams.aspectRatio).toBe("3:4");
});