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"; test("box cutout submits exactly the padded selection and keeps the original URL as provenance", async () => { const pixels = Buffer.alloc(120 * 80 * 3); for (let y = 0; y < 80; y++) for (let x = 0; x < 120; x++) { const offset = (y * 120 + x) * 3; pixels[offset] = x * 2; pixels[offset + 1] = y * 3; pixels[offset + 2] = x + y; } const original = await sharp(pixels, { raw: { width: 120, height: 80, channels: 3 } }).png().toBuffer(); let submitted: any; const box = serverLoader({ "@/lib/ai/rmbg/service": { fetchImageBufferFromUrl: async () => original, executeRmbgJob: async (params: any) => { submitted = params; return { original_url: `${cdn}/selected.png` }; }, }, })("lib/ai/box-cutout/service.ts"); const result = await box.executeBoxCutoutJob({ userId: "user-a", requestId: "box-a", sourceNodeId: "source", imageUrl: `${cdn}/original.png`, cropRect: { x: 0.25, y: 0.25, width: 0.5, height: 0.5 }, paddingRatio: 0.1 }); expect(result.cropRectPixels).toEqual({ left: 30, top: 20, width: 60, height: 40 }); expect(result.paddedCropRectPixels).toEqual({ left: 24, top: 16, width: 72, height: 48 }); expect(submitted).toMatchObject({ requestId: "box-a", sourceImageUrl: `${cdn}/original.png` }); expect(submitted.inputImageUrl).toBeUndefined(); const expected = await sharp(original).extract(result.paddedCropRectPixels).raw().toBuffer(); expect((await sharp(submitted.imageBuffer).raw().toBuffer()).equals(expected)).toBe(true); }); test("box cutout shares the configured charge once after success and never charges a rejected input", async () => { class InputError extends Error { status = 400; } for (const rejected of [false, true]) { let creates = 0, charges = 0; const operation = serverLoader({ "@/lib/ai/box-cutout/service": { BoxCutoutServiceError: InputError, executeBoxCutoutJob: async (params: any) => { creates++; expect(params.requestId).toBe("box-a"); if (rejected) throw new InputError("抠图参考图分辨率超限(5000×3000 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。"); return { success: true, original_url: `${cdn}/selected.png` }; } }, "@/lib/ai/rmbg/service": { RmbgServiceError: InputError }, "@/lib/db/queries": { getPointsSystemConfig: async () => ({ enabled: true, creditCostPerImageBoxCutout: 7 }) }, "@/lib/points/feature-charge": { prepareFeaturePointsCharge: async (params: any) => { expect(params).toMatchObject({ userId: "user-a", unitCost: 7, reason: "image_box_cutout", modelId: "box-cutout" }); return { ok: true, chargeable: true, consume: async () => { charges++; } }; } }, })("lib/canvas/operations/box-cutout.ts"); const response = await operation.executeBoxCutoutPOST(new Request("https://example.com/api/canvas/box-cutout", { method: "POST", headers: { "Content-Type": "application/json", "X-Request-ID": "box-a" }, body: JSON.stringify({ imageUrl: `${cdn}/original.png`, cropRect: { x: 0, y: 0, width: 0.5, height: 0.5 } }) }), "user-a"); const result = await response.json(); expect({ creates, charges }).toEqual({ creates: 1, charges: rejected ? 0 : 1 }); expect(response.status).toBe(rejected ? 400 : 200); if (rejected) { expect(result.failure).toMatchObject({ code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" }); expect(result.error).toContain("5000×3000"); expect(result.error).toContain("本次未提交上游抠图任务"); expect(result.error).toContain("请求编号:box-a"); } } }); test("headless box cutout keeps its task identity, pixel bounds and input-rejection status", async () => { class InputError extends Error { status: number; constructor(message: string, status: number) { super(message); this.status = status; } } let calls = 0; let rejectInput = false; const bounds = { left: 20, top: 30, width: 200, height: 300 }; const executor = serverLoader({ "@/lib/ai/box-cutout/service": {}, "@/lib/canvas/operations/box-cutout": { executeBoxCutoutPOST: async (request: Request, userId: string) => { calls++; expect(userId).toBe("user-a"); expect(request.headers.get("x-request-id")).toBe("box-a"); expect(await request.json()).toMatchObject({ sourceNodeId: "source", cropRect: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, paddingRatio: 0.08 }); return rejectInput ? Response.json({ success: false, detail: "抠图参考图分辨率超限(5000×3000 像素)" }, { status: 400 }) : Response.json({ success: true, original_url: `${cdn}/cutout.png`, paddedCropRectPixels: bounds }); } }, "@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, "@/lib/ai/rmbg/service": { RmbgServiceError: InputError }, "@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {}, "@/lib/canvas/generate-image": {}, "@/lib/style-pro/model-options": {}, })("lib/canvas/capabilities/headless-executor.ts"); const params = { userId: "user-a", requestUrl: "https://example.com/api/canvas/project-a/capabilities", requestHeaders: new Headers(), command: { requestId: "box-a", capabilityId: "image.box_cutout", source: "toolbar", args: { x: 0.1, y: 0.2, width: 0.3, height: 0.4, paddingRatio: 0.08 } }, targets: [{ nodeId: "source", title: "原图", sourceImageUrl: `${cdn}/original.png` }] }; const results = await executor.executeCanvasCapabilityHeadlessly(params); expect(calls).toBe(1); expect(results).toMatchObject([{ sourceNodeId: "source", url: `${cdn}/cutout.png`, metadata: { editMode: "box-cutout", paddedCropRectPixels: bounds } }]); rejectInput = true; await expect(executor.executeCanvasCapabilityHeadlessly(params)).rejects.toMatchObject({ status: 400 }); expect(calls).toBe(2); }); test("Agent and MCP box cutouts retain their existing billing path", async () => { let jobs = 0, featureCharges = 0; const executor = serverLoader({ "@/lib/ai/box-cutout/service": { executeBoxCutoutJob: async (params: any) => { jobs++; expect(params).toMatchObject({ userId: "user-a", requestId: "box-agent", sourceNodeId: "source" }); return { original_url: `${cdn}/cutout.png` }; } }, "@/lib/canvas/operations/box-cutout": { executeBoxCutoutPOST: async () => { featureCharges++; throw new Error("Unexpected toolbar charge"); } }, "@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, "@/lib/ai/rmbg/service": {}, "@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {}, "@/lib/canvas/generate-image": {}, "@/lib/style-pro/model-options": {}, })("lib/canvas/capabilities/headless-executor.ts"); for (const source of ["agent", "mcp", "system"]) { await executor.executeCanvasCapabilityHeadlessly({ userId: "user-a", command: { requestId: "box-agent", capabilityId: "image.box_cutout", source, args: { x: 0, y: 0, width: 0.5, height: 0.5 } }, targets: [{ nodeId: "source", title: "原图", sourceImageUrl: `${cdn}/source.png` }] }); } expect({ jobs, featureCharges }).toEqual({ jobs: 3, featureCharges: 0 }); }); 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}` }) }, "@/lib/observability/active-work": { trackRuntimeWork: async (execute: () => unknown) => execute() }, 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, inputImageUrl: source, 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, inputImageUrl: source, sourceImageUrl: source, skipGallerySave: true }); } }); test("a cropped input uses its own bytes instead of the original provenance URL", async () => { const original = await sharp({ create: { width: 120, height: 80, channels: 3, background: "#abcdef" } }).png().toBuffer(); const cropped = await sharp(original).extract({ left: 20, top: 10, width: 40, height: 20 }).png().toBuffer(); let submitted!: FormData; const service = rmbgLoader(async (input, options) => { if (options?.method === "POST") { submitted = options.body as FormData; return Response.json({ data: { task_id: "cropped-task" } }); } if (String(input).endsWith("/cropped-task")) return Response.json({ data: { state: 1, image: "https://output.example/cropped.png" } }); return new Response(cropped, { headers: { "content-type": "image/png" } }); }); await service.executeRmbgJob({ userId: "test", imageBuffer: cropped, sourceImageUrl: `${cdn}/original.png`, skipGallerySave: true }); expect(submitted.has("image_url")).toBe(false); const bytes = Buffer.from(await (submitted.get("image_file") as File).arrayBuffer()); expect(bytes.equals(cropped)).toBe(true); expect(await sharp(bytes).metadata()).toMatchObject({ width: 40, height: 20 }); }); test("scale adjustment does not start a new matting job after an unconfirmed result", async () => { let creates = 0; const image = await sharp({ create: { width: 64, height: 64, channels: 3, background: "white" } }).png().toBuffer(); const scale = serverLoader({ "@/lib/ai/rmbg/service": { executeRmbgJob: async () => { creates++; throw new Error("AOS查询任务超时,结果尚未确认"); } }, setTimeout: (fn: () => void) => setTimeout(fn, 0), })("lib/canvas/scale-adjust-product-lock.ts"); await expect(scale.buildScaleAdjustProductLockAssets({ userId: "test", sourceBuffer: image, sourceWidth: 64, sourceHeight: 64, sourceImageUrl: `${cdn}/source.png`, regions: [{ index: 0, rect: { x: 0.1, y: 0.1, width: 0.5, height: 0.5 }, targetRect: { x: 0.2, y: 0.2, width: 0.5, height: 0.5 } }] })).rejects.toThrow("结果尚未确认"); expect(creates).toBe(1); }); test("scene preprocessing invokes matting once even when its result is unconfirmed or the call is interrupted", async () => { for (const interrupted of [false, true]) { let calls = 0, networkCalls = 0; const scene = serverLoader({ "@/lib/canvas/operations/rmbg": { executeRemoveBackgroundPOST: async (request: Request, userId: string) => { calls++; expect(userId).toBe("user-a"); expect(request.headers.get("x-request-id")).toBe("scene-a:pre-rmbg"); expect(await request.json()).toMatchObject({ imageUrl: `${cdn}/source.png`, skipGallerySave: true }); if (interrupted) throw new TypeError("fetch failed"); return Response.json({ error: "抠图已完成,但结果图片下载失败,请核对原任务。", failure: { outcome: "unknown" } }, { status: 500 }); } }, fetch: async () => { networkCalls++; throw new Error("Unexpected HTTP resubmission"); }, })("lib/scene-fusion/preprocess-product.ts"); const result = await scene.preprocessProductImageWithRmbg({ request: new Request("https://example.com/api/scene-fusion"), requestId: "scene-a", userId: "user-a", productImageUrl: `${cdn}/source.png`, preRmbgEnabled: true }); expect(result).toMatchObject({ productImageUrl: `${cdn}/source.png`, usedPreRmbg: false }); expect({ calls, networkCalls }).toEqual({ calls: 1, networkCalls: 0 }); } }); test("scene preprocessing retains the disabled setting and delivers a successful original result", async () => { let calls = 0; const scene = serverLoader({ "@/lib/canvas/operations/rmbg": { executeRemoveBackgroundPOST: async () => { calls++; return Response.json({ original_url: `${cdn}/result.png`, provider: "aoscdn" }); } } })("lib/scene-fusion/preprocess-product.ts"); const params = { request: new Request("https://example.com/api/scene-fusion"), requestId: "scene-a", userId: "user-a", productImageUrl: `${cdn}/source.png` }; expect(await scene.preprocessProductImageWithRmbg({ ...params, preRmbgEnabled: false })).toMatchObject({ usedPreRmbg: false }); expect(calls).toBe(0); expect(await scene.preprocessProductImageWithRmbg({ ...params, preRmbgEnabled: true })).toMatchObject({ usedPreRmbg: true, productImageUrl: `${cdn}/result.png` }); expect(calls).toBe(1); }); test("the standalone matting API returns the limit, recommendation and request ID", async () => { class InputError extends Error { status = 400; } let submitted: any; const operation = serverLoader({ "@/lib/ai/rmbg/service": { RmbgServiceError: InputError, fetchImageBufferFromUrl: async () => Buffer.from("original"), executeRmbgJob: async (params: any) => { submitted = params; throw new InputError("抠图参考图分辨率超限(5056×3392 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。"); } }, "@/lib/db/queries": { getPointsSystemConfig: async () => ({ enabled: false }) }, "@/lib/points/feature-charge": { prepareFeaturePointsCharge: async () => ({ ok: true, chargeable: false }) }, })("lib/canvas/operations/rmbg.ts"); const response = await operation.executeRemoveBackgroundPOST(new Request("https://example.com/api/rmbg", { method: "POST", headers: { "Content-Type": "application/json", "X-Request-ID": "request-a" }, body: JSON.stringify({ imageUrl: `${cdn}/source.png` }) }), "user-a"); const result = await response.json(); expect(response.status).toBe(400); expect(submitted).toMatchObject({ requestId: "request-a", inputImageUrl: `${cdn}/source.png` }); expect(result).toMatchObject({ success: false, requestId: "request-a", failure: { code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" } }); expect(result.error).toContain("5056×3392"); expect(result.error).toContain("本次未提交上游抠图任务"); expect(result.error).toContain("请求编号:request-a"); }); 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, inputImageUrl: `${cdn}/boundary.png`, 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" }); });