224 lines
14 KiB
TypeScript
224 lines
14 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
import sharp from "sharp";
|
||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||
import { getRequestFailureDetails } from "@/lib/request-error";
|
||
|
||
const cdn = "https://scqnycdn.jiansun.vip";
|
||
|
||
function rmbgLoader(fetch: typeof globalThis.fetch, timer?: typeof setTimeout) {
|
||
return serverLoader({
|
||
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "aoscdn", aosApiKey: "test-key" } }) },
|
||
"@/lib/db": { db: {} },
|
||
"@/lib/db/schema": { generatedImage: {} },
|
||
"@/lib/storage/service": { uploadFile: async (name: string) => ({ url: `${cdn}/${name}` }) },
|
||
fetch,
|
||
setTimeout: timer || ((callback: () => void, ms: number) => setTimeout(callback, ms <= 2000 ? 0 : ms)),
|
||
}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: cdn })("lib/ai/rmbg/service.ts");
|
||
}
|
||
|
||
test("AOS uses a public Qiniu original URL without uploading the image file again", async () => {
|
||
const image = await sharp({ create: { width: 1024, height: 768, channels: 3, background: "#abcdef" } }).png().toBuffer();
|
||
let creates = 0;
|
||
const source = `${cdn}/uploads/original.png`;
|
||
const service = rmbgLoader(async (input, options) => {
|
||
const url = String(input);
|
||
if (options?.method === "POST") {
|
||
creates++;
|
||
const form = options.body as FormData;
|
||
expect(form.get("image_url")).toBe(source);
|
||
expect(form.has("image_file")).toBe(false);
|
||
expect(form.get("sync")).toBe("0");
|
||
return Response.json({ data: { task_id: "task-url" } });
|
||
}
|
||
if (url.endsWith("/task-url")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } });
|
||
return new Response(image, { headers: { "content-type": "image/png" } });
|
||
});
|
||
const result = await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: source, skipGallerySave: true });
|
||
expect(creates).toBe(1);
|
||
expect(result.provider).toBe("aoscdn");
|
||
expect(result.original_url).toContain(cdn);
|
||
});
|
||
|
||
test("non-CDN sources upload the original file without changing its bytes or dimensions", async () => {
|
||
for (const [width, source] of [[4096, "http://127.0.0.1/private.png"], [1000, "https://other.example/image.png"]] as const) {
|
||
const image = await sharp({ create: { width, height: 20, channels: 3, background: "#abcdef" } }).png().toBuffer();
|
||
const service = rmbgLoader(async (input, options) => {
|
||
if (options?.method === "POST") {
|
||
const form = options.body as FormData;
|
||
expect(form.has("image_url")).toBe(false);
|
||
const file = form.get("image_file") as File;
|
||
const submitted = Buffer.from(await file.arrayBuffer());
|
||
expect(submitted.equals(image)).toBe(true);
|
||
expect((await sharp(submitted).metadata()).width).toBe(width);
|
||
return Response.json({ data: { task_id: "task-file" } });
|
||
}
|
||
if (String(input).endsWith("/task-file")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } });
|
||
return new Response(image, { headers: { "content-type": "image/png" } });
|
||
});
|
||
await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: source, skipGallerySave: true });
|
||
}
|
||
});
|
||
|
||
test("AOS rejects either oversized dimension before creating a task and gives the exact dimensions", async () => {
|
||
for (const [width, height] of [[4097, 20], [20, 4097]]) {
|
||
const image = await sharp({ create: { width, height, channels: 3, background: "white" } }).png().toBuffer();
|
||
let creates = 0;
|
||
const service = rmbgLoader(async () => { creates++; throw new Error("Unexpected provider call"); });
|
||
let failure: Error | undefined;
|
||
try { await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/large.png`, skipGallerySave: true }); }
|
||
catch (error) { failure = error as Error; }
|
||
expect(creates).toBe(0);
|
||
expect(failure).toMatchObject({ status: 400 });
|
||
expect(failure?.message).toContain(`${width}×${height}`);
|
||
const details = getRequestFailureDetails(failure);
|
||
expect(details).toMatchObject({ code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" });
|
||
expect(details.message).toContain("4096");
|
||
expect(details.recommendation).toContain("本次未提交上游抠图任务");
|
||
}
|
||
});
|
||
|
||
test("AOS accepts 4096 pixels and 50 MB but rejects a larger file without submitting it", async () => {
|
||
const output = await sharp({ create: { width: 4096, height: 20, channels: 3, background: "white" } }).png().toBuffer();
|
||
const boundary = Buffer.alloc(50 * 1024 * 1024);
|
||
output.copy(boundary);
|
||
let creates = 0;
|
||
const service = rmbgLoader(async (input, options) => {
|
||
if (options?.method === "POST") {
|
||
creates++;
|
||
const form = options.body as FormData;
|
||
expect(form.has("image_url")).toBe(false);
|
||
expect((form.get("image_file") as File).size).toBe(boundary.length);
|
||
return Response.json({ data: { task_id: "boundary-task" } });
|
||
}
|
||
if (String(input).endsWith("/boundary-task")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } });
|
||
return new Response(output, { headers: { "content-type": "image/png" } });
|
||
});
|
||
await service.executeRmbgJob({ userId: "test", imageBuffer: boundary, sourceImageUrl: `${cdn}/boundary.png`, skipGallerySave: true });
|
||
expect(creates).toBe(1);
|
||
let failure: Error | undefined;
|
||
try { await service.executeRmbgJob({ userId: "test", imageBuffer: Buffer.alloc(boundary.length + 1), skipGallerySave: true }); }
|
||
catch (error) { failure = error as Error; }
|
||
expect(creates).toBe(1);
|
||
expect(failure).toMatchObject({ status: 400 });
|
||
expect(getRequestFailureDetails(failure)).toMatchObject({ code: "RMBG_INPUT_TOO_LARGE", outcome: "failed" });
|
||
expect(getRequestFailureDetails(failure).message).toContain("50 MB");
|
||
});
|
||
|
||
test("formats unsupported by AOS are rejected explicitly without converting or submitting", async () => {
|
||
for (const format of ["gif", "avif"] as const) {
|
||
const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).toFormat(format).toBuffer();
|
||
let creates = 0;
|
||
const service = rmbgLoader(async () => { creates++; throw new Error("Unexpected provider call"); });
|
||
let failure: Error | undefined;
|
||
try { await service.executeRmbgJob({ userId: "test", imageBuffer: image, skipGallerySave: true }); }
|
||
catch (error) { failure = error as Error; }
|
||
expect(creates).toBe(0);
|
||
const details = getRequestFailureDetails(failure);
|
||
expect(details).toMatchObject({ code: "RMBG_INPUT_FORMAT_UNSUPPORTED", outcome: "failed" });
|
||
expect(details.message).toContain(format.toUpperCase());
|
||
expect(details.recommendation).toContain("JPG、PNG 或 WebP");
|
||
}
|
||
});
|
||
|
||
test("AOS input rejections retain their cause instead of becoming a generic processing failure", () => {
|
||
const resolution = getRequestFailureDetails("抠图处理失败:图片分辨率超限(5000x3000),AOS要求宽高都不超过 4096px。请缩小后重试。");
|
||
expect(resolution).toMatchObject({ code: "RMBG_PROVIDER_RESOLUTION_EXCEEDED", outcome: "failed" });
|
||
expect(resolution.message).toContain("5000×3000");
|
||
expect(getRequestFailureDetails("抠图处理失败:图片文件无法识别,请尝试转换为 JPG/PNG 后重试。")).toMatchObject({ code: "RMBG_PROVIDER_INVALID_FILE", outcome: "failed" });
|
||
expect(getRequestFailureDetails("抠图处理失败:AOS任务创建失败(HTTP 413): Payload Too Large")).toMatchObject({ code: "RMBG_PROVIDER_FILE_TOO_LARGE", outcome: "failed" });
|
||
for (const [state, code] of [[-3, "RMBG_PROVIDER_SOURCE_DOWNLOAD_FAILED"], [-5, "RMBG_PROVIDER_URL_TOO_LARGE"], [-7, "RMBG_PROVIDER_INVALID_FILE"], [-8, "RMBG_PROVIDER_PROCESSING_TIMEOUT"], [-11, "RMBG_PROVIDER_EMPTY_RESULT"]] as const) {
|
||
const details = getRequestFailureDetails(`抠图处理失败:AOS任务失败(状态 ${state},任务 original-task): Failed`);
|
||
expect(details).toMatchObject({ code, outcome: "failed" });
|
||
expect(details.recommendation).not.toBe("");
|
||
}
|
||
});
|
||
|
||
test("the canvas API returns input-limit errors as HTTP 400 and preserves the same advice when polling", async () => {
|
||
const service = rmbgLoader(async () => { throw new Error("Unexpected provider call"); });
|
||
const error = new service.RmbgServiceError("抠图参考图分辨率超限(5056×3392 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。", 400);
|
||
const route = serverLoader({
|
||
"@/app/(auth)/auth": { auth: async () => ({ user: { id: "user-a" } }) },
|
||
"@/lib/ai/rmbg/service": { RmbgServiceError: service.RmbgServiceError },
|
||
"@/lib/canvas/capabilities/client-command": { canvasCapabilityClientCommandSchema: { parse: (v: unknown) => v } },
|
||
"@/lib/canvas/capabilities/context": { CanvasCapabilityRuntimeError: class extends Error {} },
|
||
"@/lib/canvas/agent-mutations": { CanvasAgentMutationError: class extends Error {} },
|
||
"@/lib/canvas/capabilities/registry": { getCanvasCapabilityManifest: () => [] },
|
||
"@/lib/canvas/capabilities/runtime": { executeCanvasCapabilityRuntime: async () => { throw error; } },
|
||
"@/lib/canvas/capabilities/task-store": { getCanvasCapabilityTask: async () => ({ id: "task-a", status: "failed", error: error.message }) },
|
||
})("app/api/canvas/[id]/capabilities/route.ts");
|
||
const params = { params: Promise.resolve({ id: "project-a" }) };
|
||
const response = await route.POST(new Request("https://example.com/api/canvas/project-a/capabilities", { method: "POST", body: JSON.stringify({ requestId: "request-a" }) }), params);
|
||
expect(response.status).toBe(400);
|
||
const submitted = await response.json();
|
||
expect(submitted.failure).toMatchObject({ code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" });
|
||
expect(submitted.error).toContain("5056×3392");
|
||
expect(submitted.error).toContain("本次未提交上游抠图任务");
|
||
const polled = await (await route.GET(new Request("https://example.com/api/canvas/project-a/capabilities?requestId=request-a"), params)).json();
|
||
expect(polled.task.failure).toEqual(submitted.failure);
|
||
expect(polled.task.error).toContain(submitted.failure.recommendation);
|
||
});
|
||
|
||
test("a create timeout preserves its cause and never creates another paid task", async () => {
|
||
const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).png().toBuffer();
|
||
let creates = 0;
|
||
let abort: Error;
|
||
const service = rmbgLoader(async () => { creates++; throw abort; });
|
||
abort = new service.RmbgServiceError("request timed out");
|
||
abort.name = "AbortError";
|
||
let failure: Error | undefined;
|
||
try {
|
||
await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/source.png`, skipGallerySave: true });
|
||
} catch (error) { failure = error as Error; }
|
||
expect(creates).toBe(1);
|
||
expect(failure?.message).toContain("AOS创建任务超时(120秒)");
|
||
expect(getRequestFailureDetails(failure)).toMatchObject({ code: "RMBG_CREATE_TIMEOUT", outcome: "unknown" });
|
||
});
|
||
|
||
test("poll and result download timeouts retain their original task and distinct advice", () => {
|
||
const poll = getRequestFailureDetails("抠图处理失败:AOS查询任务超时,结果尚未确认(任务 task-1)");
|
||
const download = getRequestFailureDetails("抠图处理失败:AOS结果下载超时,任务已完成(任务 task-1)");
|
||
expect(poll).toMatchObject({ code: "RMBG_POLL_TIMEOUT", outcome: "unknown" });
|
||
expect(download).toMatchObject({ code: "RMBG_RESULT_DOWNLOAD_TIMEOUT", outcome: "unknown" });
|
||
expect(download.recommendation).toContain("取回原任务结果");
|
||
expect(getRequestFailureDetails("抠图处理失败:AOS任务查询失败(HTTP 404,任务 task-1): Not Found")).toMatchObject({ code: "RMBG_PROVIDER_POLL_FAILED", outcome: "unknown" });
|
||
expect(getRequestFailureDetails("抠图处理失败:AOS结果下载失败(HTTP 500,任务 task-1): Internal Error")).toMatchObject({ code: "RMBG_PROVIDER_RESULT_DOWNLOAD_FAILED", outcome: "unknown" });
|
||
expect(getRequestFailureDetails("抠图处理失败:AOS任务创建失败(HTTP 500): Internal Error")).toMatchObject({ code: "RMBG_PROVIDER_CREATE_UNCONFIRMED", outcome: "unknown" });
|
||
});
|
||
|
||
test("network and body interruptions retry only the original task reads", async () => {
|
||
const image = await sharp({ create: { width: 32, height: 32, channels: 4, background: "#abcdef00" } }).png().toBuffer();
|
||
let creates = 0, polls = 0, downloads = 0;
|
||
let networkError: Error;
|
||
const service = rmbgLoader(async (input, options) => {
|
||
if (options?.method === "POST") { creates++; return Response.json({ data: { task_id: "original-task" } }); }
|
||
if (String(input).endsWith("/original-task")) {
|
||
if (++polls === 1) throw networkError;
|
||
return Response.json({ data: { state: 1, image: "https://output.example/original.png" } });
|
||
}
|
||
if (++downloads === 1) return new Response(new ReadableStream({ start(controller) { controller.error(networkError); } }));
|
||
return new Response(image, { headers: { "content-type": "image/png" } });
|
||
});
|
||
networkError = new service.RmbgServiceError("fetch failed"); networkError.name = "TypeError";
|
||
const result = await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/source.png`, skipGallerySave: true });
|
||
expect(result.original_url).toContain(cdn);
|
||
expect({ creates, polls, downloads }).toEqual({ creates: 1, polls: 2, downloads: 2 });
|
||
});
|
||
|
||
test("exhausted result reads preserve the completed task and never re-create it", async () => {
|
||
const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).png().toBuffer();
|
||
let creates = 0, downloads = 0;
|
||
let networkError: Error;
|
||
const service = rmbgLoader(async (input, options) => {
|
||
if (options?.method === "POST") { creates++; return Response.json({ data: { task_id: "completed-task" } }); }
|
||
if (String(input).endsWith("/completed-task")) return Response.json({ data: { state: 1, image: "https://output.example/original.png" } });
|
||
downloads++; throw networkError;
|
||
});
|
||
networkError = new service.RmbgServiceError("fetch failed"); networkError.name = "TypeError";
|
||
let failure: Error | undefined;
|
||
try { await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/source.png`, skipGallerySave: true }); }
|
||
catch (error) { failure = error as Error; }
|
||
expect({ creates, downloads }).toEqual({ creates: 1, downloads: 3 });
|
||
expect(failure?.message).toContain("completed-task");
|
||
expect(getRequestFailureDetails(failure)).toMatchObject({ code: "RMBG_RESULT_DOWNLOAD_CONNECTION_FAILED", outcome: "unknown" });
|
||
});
|