// Integration test for the standalone "人衣图生成" admin tool // (admin-dressgen.html + admin-dressgen-task.html): however many person // reference photos the admin actually uploads (any subset of full-body/face // × front/side/back, at least 1) + 1 garment image -> 3 new full-body images // (front/side/back) of that person wearing the garment. This is a real // background task: POST /api/admin/dressgen returns the row (status // "queued") immediately and the 3 sequential gpt-image-2 calls run // fire-and-forget after the response — a blocking synchronous version of // this endpoint hit a real 502 from a front-end proxy timing out on the // ~3.5 minutes 3 sequential ~70s calls can take (CODEX-IMAGEGEN.md), which // is why this is async now. Tests poll GET /api/admin/dressgen/:id until // the task reaches a terminal status, the same way a real client would. // // Runs server.js against a mock gpt-image-2 relay (IMAGE2_API_BASE points // at it, over plain http — insecureHttpsRequest dispatches on the URL's own // protocol for exactly this reason) so the whole upload -> task -> 3x // edit-call -> save-outputs path runs offline. This does NOT prove the real // 万界方舟 relay actually speaks this contract at /api/v1/images/edits — see // generateDressImage()'s doc comment in server.js: that path's *existence* // and the model-id/host/TLS quirks are confirmed (an OPTIONS/GET probe plus // a working curl for the sibling /images/generations endpoint), but nobody // has yet made a real authenticated call to /images/edits itself. Its // request shape here mirrors OpenAI's own public /v1/images/edits contract, // which the vendor is expected (per the user, not yet verified end-to-end) // to mirror too. const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const os = require("node:os"); const http = require("node:http"); const { spawn } = require("node:child_process"); const PORT = 25934 + Math.floor(Math.random() * 1000); const BASE = `http://localhost:${PORT}`; const ADMIN_PASSWORD = "test-password-only-for-this-run"; const IMAGE2_API_KEY = "sk-mock-image2-key"; let cookie, child, cwd, mockImage2, mockImage2Port; let editRequests; const TINY_VALID_PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC", "base64" ); // A second, distinguishable 1x1 PNG, so the "generated" output isn't // byte-identical to an input (would mask a wiring bug that echoes an input // back instead of the mock's own response). const OUTPUT_PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAAg+lmCAAAAAXNSR0IArs4c6QAAAAtJREFUCNdj+M8AAAICAQD8p9y9AAAAAElFTkSuQmCC", "base64" ); function waitForServer(url, timeoutMs = 10000) { const deadline = Date.now() + timeoutMs; return (async function poll() { while (Date.now() < deadline) { try { await fetch(url); return; } catch { await new Promise((r) => setTimeout(r, 100)); } } throw new Error("server did not come up in time"); })(); } async function waitUntilTerminal(id, timeoutMs = 8000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const record = await fetch(`${BASE}/api/admin/dressgen/${id}`, { headers: { Cookie: cookie } }).then((r) => r.json()); if (["done", "partial", "failed"].includes(record.status)) return record; await new Promise((r) => setTimeout(r, 50)); } throw new Error("task did not reach a terminal status in time"); } // "success": every call succeeds. "fail-all": every call fails. "fail-2nd": // only the 2nd call (by arrival order — calls are sequential, so this is // deterministic) fails. let editMode = "success"; let concurrentCallsSeen = false; let inFlight = 0; // wavespeed mock state — a separate provider path exercised by its own // tests (provider:"wavespeed"), sharing the same mock http server as // IMAGE2 (routing is by path, same trick as the rest of this file). let wavespeedUploadCount = 0; let wavespeedSubmitBody = null; let wavespeedShouldFail = false; function BASE_MOCK_URL() { return `http://localhost:${mockImage2Port}`; } function startMockImage2() { return new Promise((resolve) => { mockImage2 = http.createServer((req, res) => { const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => { if (req.method === "POST" && req.url === "/api/v3/media/upload/binary") { wavespeedUploadCount++; res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ code: 200, message: "success", data: { type: "image", download_url: `${BASE_MOCK_URL()}/uploaded-${wavespeedUploadCount}.png`, filename: `uploaded-${wavespeedUploadCount}.png`, size: 123 } })); return; } if (req.method === "POST" && req.url === "/api/v3/bytedance/seedream-v4/edit") { wavespeedSubmitBody = JSON.parse(Buffer.concat(chunks).toString("utf8")); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ code: 200, message: "success", data: { id: "ws-task-1", status: "created", urls: { get: `${BASE_MOCK_URL()}/api/v3/predictions/ws-task-1/result` } } })); return; } if (req.method === "GET" && req.url === "/api/v3/predictions/ws-task-1/result") { if (wavespeedShouldFail) { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ code: 200, message: "success", data: { id: "ws-task-1", status: "failed", error: "mock: wavespeed content rejected" } })); return; } res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ code: 200, message: "success", data: { id: "ws-task-1", status: "completed", outputs: [`${BASE_MOCK_URL()}/wavespeed-output.png`] } })); return; } if (req.method === "GET" && req.url === "/wavespeed-output.png") { res.writeHead(200, { "Content-Type": "image/png" }); res.end(OUTPUT_PNG); return; } if (req.method === "POST" && req.url === "/api/v1/images/edits") { inFlight++; if (inFlight > 1) concurrentCallsSeen = true; const bodyText = Buffer.concat(chunks).toString("latin1"); editRequests.push({ headers: req.headers, imageCount: (bodyText.match(/name="image\[\]"/g) || []).length, promptMatch: bodyText.match(/name="prompt"\r\n\r\n([^\r]*)/), modelMatch: bodyText.match(/name="model"\r\n\r\n([^\r]*)/), sizeMatch: bodyText.match(/name="size"\r\n\r\n([^\r]*)/), }); const callNumber = editRequests.length; const shouldFail = editMode === "fail-all" || (editMode === "fail-2nd" && callNumber === 2); setTimeout(() => { inFlight--; if (shouldFail) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "mock: content rejected" })); return; } res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ data: [{ b64_json: OUTPUT_PNG.toString("base64") }] })); }, 20); // small delay so a concurrent implementation would overlap calls return; } res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "not found in mock" })); }); }); mockImage2.listen(0, () => { mockImage2Port = mockImage2.address().port; resolve(); }); }); } test.before(async () => { await startMockImage2(); cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressgen-generate-")); for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } fs.writeFileSync(path.join(cwd, "dress-gen.json"), "[]"); child = spawn(process.execPath, ["server.js"], { cwd, env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD, IMAGE2_API_KEY, IMAGE2_API_BASE: `http://localhost:${mockImage2Port}`, WAVESPEED_API_KEY: "ws-mock-key", WAVESPEED_API_BASE: `http://localhost:${mockImage2Port}`, WAVESPEED_POLL_INTERVAL_MS: "50", }, stdio: "pipe", }); await waitForServer(BASE); const loginRes = await fetch(`${BASE}/api/admin/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: ADMIN_PASSWORD }), }); cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0]; }); test.after(() => { child.kill(); mockImage2.close(); fs.rmSync(cwd, { recursive: true, force: true }); }); async function uploadTinyPng(name) { const fd = new FormData(); fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), name); const res = await fetch(`${BASE}/api/admin/upload`, { method: "POST", headers: { Cookie: cookie }, body: fd }); assert.equal(res.status, 200); return (await res.json()).url; } async function createTask(body) { const res = await fetch(`${BASE}/api/admin/dressgen`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify(body), }); return { status: res.status, record: await res.json() }; } test("GET /api/admin/dressgen/prompts returns one default prompt per view, front/side/back all distinct", async () => { const res = await fetch(`${BASE}/api/admin/dressgen/prompts`, { headers: { Cookie: cookie } }); assert.equal(res.status, 200); const prompts = await res.json(); assert.ok(prompts.front && prompts.side && prompts.back); assert.notEqual(prompts.front, prompts.side); assert.notEqual(prompts.side, prompts.back); }); test("POST /api/admin/dressgen returns immediately with a queued task id (doesn't block on the 3 upstream calls)", async () => { editMode = "success"; editRequests = []; const bodyFront = await uploadTinyPng("body-front.png"); const faceFront = await uploadTinyPng("face-front.png"); const garmentImage = await uploadTinyPng("garment.png"); const { status, record } = await createTask({ bodyFront, faceFront, garmentImage }); assert.equal(status, 200); assert.ok(record.id); assert.equal(record.status, "queued"); assert.equal(record.outputImages.front, null, "must not have waited for generation to finish"); assert.equal(editRequests.length, 0, "the edit calls happen after the response, not before it"); const final = await waitUntilTerminal(record.id); assert.equal(final.status, "done"); assert.equal(editRequests.length, 3, "one call per view even with fewer person refs"); for (const r of editRequests) { assert.equal(r.imageCount, 3, "2 uploaded person refs + garment = 3 images per call"); assert.equal(r.modelMatch && r.modelMatch[1], "GPT Image 2", "model id must be the literal vendor string, with spaces"); assert.equal(r.sizeMatch && r.sizeMatch[1], "1024x1536"); } assert.equal(concurrentCallsSeen, false, "calls must be sequential, not concurrent"); for (const view of ["front", "side", "back"]) { assert.equal(final.viewStatus[view], "done"); const served = await fetch(`${BASE}${final.outputImages[view]}`); assert.ok(Buffer.from(await served.arrayBuffer()).equals(OUTPUT_PNG)); } const list = await fetch(`${BASE}/api/admin/dressgen?pageSize=100`, { headers: { Cookie: cookie } }).then((r) => r.json()); assert.ok(list.items.some((r) => r.id === record.id)); assert.ok(list.total >= 1); const fetched = await fetch(`${BASE}/api/admin/dressgen/${record.id}`, { headers: { Cookie: cookie } }); assert.equal(fetched.status, 200); }); test("GET /api/admin/dressgen/:id 404s for an unknown id", async () => { const res = await fetch(`${BASE}/api/admin/dressgen/does-not-exist`, { headers: { Cookie: cookie } }); assert.equal(res.status, 404); }); test("POST /api/admin/dressgen: a missing garmentImage is rejected synchronously, no task created", async () => { const bodyFront = await uploadTinyPng("body-front2.png"); const { status } = await createTask({ bodyFront }); assert.equal(status, 400); }); test("POST /api/admin/dressgen: zero person refs is rejected synchronously (need at least one)", async () => { const garmentImage = await uploadTinyPng("garment2.png"); const { status } = await createTask({ garmentImage }); assert.equal(status, 400); }); test("POST /api/admin/dressgen: a per-view custom prompt is sent verbatim to that view's edit call, others keep their own default", async () => { editMode = "success"; editRequests = []; const bodyFront = await uploadTinyPng("body-front3.png"); const garmentImage = await uploadTinyPng("garment3.png"); // ASCII-only on purpose: the mock decodes the raw multipart body as // latin1 (binary-safe for the image bytes elsewhere in the same body), // which would mangle a UTF-8/Chinese string — not a real server // limitation, just this test's own byte-extraction trick. const customSidePrompt = "CUSTOM_SIDE_PROMPT_ONLY_FOR_THIS_TEST"; const { status, record } = await createTask({ bodyFront, garmentImage, prompts: { side: customSidePrompt } }); assert.equal(status, 200); assert.equal(record.prompts.side, customSidePrompt); assert.notEqual(record.prompts.front, record.prompts.side); await waitUntilTerminal(record.id); const sidePromptSent = editRequests.map((r) => r.promptMatch && r.promptMatch[1]).find((p) => p === customSidePrompt); assert.ok(sidePromptSent, "the custom side prompt must actually reach the edit call, not just the stored record"); }); test("one view failing (2nd, sequential) doesn't lose the other two — task settles as 'partial'", async () => { // Both providers must fail for this view to genuinely fail, now that // image2 auto-falls-back to wavespeed (see the fallback test above). editMode = "fail-2nd"; wavespeedShouldFail = true; editRequests = []; const bodyFront = await uploadTinyPng("body-front4.png"); const garmentImage = await uploadTinyPng("garment4.png"); const { record } = await createTask({ bodyFront, garmentImage }); const final = await waitUntilTerminal(record.id); assert.equal(final.status, "partial"); assert.ok(final.outputImages.front); assert.equal(final.outputImages.side, null); assert.equal(final.viewStatus.side, "failed"); assert.match(final.errors.side, /wavespeed generation failed/, "fallback ran and its failure is what's recorded"); assert.ok(final.outputImages.back); editMode = "success"; wavespeedShouldFail = false; }); test("all 3 views failing -> task settles as 'failed'", async () => { editMode = "fail-all"; wavespeedShouldFail = true; // both providers must fail — see note above editRequests = []; const bodyFront = await uploadTinyPng("body-front5.png"); const garmentImage = await uploadTinyPng("garment5.png"); const { record } = await createTask({ bodyFront, garmentImage }); const final = await waitUntilTerminal(record.id); assert.equal(final.status, "failed"); for (const view of ["front", "side", "back"]) { assert.equal(final.outputImages[view], null); assert.match(final.errors[view], /wavespeed generation failed/); } wavespeedShouldFail = false; }); test("POST /api/admin/dressgen: rejects a non-/uploads/ path (defense against arbitrary local file reads)", async () => { const garmentImage = await uploadTinyPng("garment6.png"); const { status } = await createTask({ bodyFront: "/etc/passwd", garmentImage }); assert.equal(status, 400); }); test("POST /api/admin/dressgen/:id/retry: whole-task retry reuses the original images/prompts, only redoes the failed view", async () => { // Pick up the failed-all task from the previous test and fix the mock. const failedRes = await fetch(`${BASE}/api/admin/dressgen?pageSize=100`, { headers: { Cookie: cookie } }).then((r) => r.json()); const failedTask = failedRes.items.find((r) => r.status === "failed"); // items are already newest-first assert.ok(failedTask, "expected a failed task from the earlier all-fail test"); editMode = "success"; editRequests = []; const res = await fetch(`${BASE}/api/admin/dressgen/${failedTask.id}/retry`, { method: "POST", headers: { Cookie: cookie } }); assert.equal(res.status, 200); const kicked = await res.json(); assert.equal(kicked.status, "generating"); const final = await waitUntilTerminal(failedTask.id); assert.equal(final.status, "done"); assert.equal(editRequests.length, 3, "retry reprocesses all 3 views since all 3 had failed"); // No new images were uploaded for the retry — it must be the original ones. assert.equal(final.garmentImage, failedTask.garmentImage); assert.equal(final.bodyFront, failedTask.bodyFront); }); test("POST /api/admin/dressgen/:id/retry/:view: regenerates only that one view, leaves the others untouched", async () => { editMode = "fail-2nd"; wavespeedShouldFail = true; // both providers must fail to get a genuinely partial task editRequests = []; const bodyFront = await uploadTinyPng("body-front6.png"); const garmentImage = await uploadTinyPng("garment7.png"); const { record } = await createTask({ bodyFront, garmentImage }); const partial = await waitUntilTerminal(record.id); assert.equal(partial.status, "partial"); const originalFront = partial.outputImages.front; wavespeedShouldFail = false; editMode = "success"; editRequests = []; const res = await fetch(`${BASE}/api/admin/dressgen/${record.id}/retry/side`, { method: "POST", headers: { Cookie: cookie } }); assert.equal(res.status, 200); const final = await waitUntilTerminal(record.id); assert.equal(final.status, "done"); assert.equal(editRequests.length, 1, "per-view retry only calls the API once, for that view"); assert.equal(final.outputImages.front, originalFront, "the already-succeeded front view must be untouched by a side-only retry"); assert.ok(final.outputImages.side); }); test("POST /api/admin/dressgen/:id/retry/:view: an optional prompt override is used for that call and persisted on the record", async () => { editMode = "fail-2nd"; editRequests = []; const bodyFront = await uploadTinyPng("body-front7.png"); const garmentImage = await uploadTinyPng("garment8.png"); const { record } = await createTask({ bodyFront, garmentImage }); await waitUntilTerminal(record.id); editMode = "success"; editRequests = []; const overridePrompt = "REGENERATE_SIDE_WITH_THIS_EXACT_PROMPT"; const res = await fetch(`${BASE}/api/admin/dressgen/${record.id}/retry/side`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ prompt: overridePrompt }), }); assert.equal(res.status, 200); const kicked = await res.json(); assert.equal(kicked.prompts.side, overridePrompt, "the override must be persisted on the record immediately"); const final = await waitUntilTerminal(record.id); assert.equal(final.prompts.side, overridePrompt); const sidePromptSent = editRequests.map((r) => r.promptMatch && r.promptMatch[1]).find((p) => p === overridePrompt); assert.ok(sidePromptSent, "the override must actually reach the edit call"); }); test("POST /api/admin/dressgen/:id/retry: unknown id 404s", async () => { const res = await fetch(`${BASE}/api/admin/dressgen/does-not-exist/retry`, { method: "POST", headers: { Cookie: cookie } }); assert.equal(res.status, 404); }); // provider:"wavespeed" — the second provider (2026-09-03), pickable per // task via the admin UI, or used automatically as an image2-failure // fallback (see the auto-fallback test below, and server.js's // WAVESPEED_API_KEY doc comment for the history of that decision). // Exercises the real upload -> submit -> poll -> fetch-output contract // against the mock wavespeed routes added to the same mock server. test("POST /api/admin/dressgen with provider:'wavespeed' uploads every image, submits once per view, and downloads the completed output", async () => { editMode = "success"; wavespeedShouldFail = false; wavespeedUploadCount = 0; wavespeedSubmitBody = null; const bodyFront = await uploadTinyPng("ws-body-front.png"); const garmentImage = await uploadTinyPng("ws-garment.png"); const { status, record } = await createTask({ bodyFront, garmentImage, provider: "wavespeed" }); assert.equal(status, 200); assert.equal(record.provider, "wavespeed"); const final = await waitUntilTerminal(record.id); assert.equal(final.status, "done"); assert.equal(wavespeedUploadCount, 6, "2 images (body+garment) uploaded once per each of the 3 sequential view calls"); assert.ok(Array.isArray(wavespeedSubmitBody.images) && wavespeedSubmitBody.images.length === 2, "submit body must carry the uploaded image URLs, not local paths"); assert.ok(wavespeedSubmitBody.prompt, "submit body must carry the view's prompt"); for (const view of ["front", "side", "back"]) { const served = await fetch(`${BASE}${final.outputImages[view]}`); assert.ok(Buffer.from(await served.arrayBuffer()).equals(OUTPUT_PNG)); } }); test("POST /api/admin/dressgen with provider:'wavespeed': a failed generation is recorded per view same as the image2 path", async () => { wavespeedShouldFail = true; const bodyFront = await uploadTinyPng("ws-body-front2.png"); const garmentImage = await uploadTinyPng("ws-garment2.png"); const { record } = await createTask({ bodyFront, garmentImage, provider: "wavespeed" }); const final = await waitUntilTerminal(record.id); assert.equal(final.status, "failed"); for (const view of ["front", "side", "back"]) { assert.equal(final.outputImages[view], null); assert.match(final.errors[view], /wavespeed generation failed/); } wavespeedShouldFail = false; }); test("POST /api/admin/dressgen: default provider (image2) failing auto-falls-back to wavespeed per view, no admin action needed", async () => { editMode = "fail-all"; wavespeedShouldFail = false; const bodyFront = await uploadTinyPng("fallback-body-front.png"); const garmentImage = await uploadTinyPng("fallback-garment.png"); const { status, record } = await createTask({ bodyFront, garmentImage }); // no provider -> defaults to image2 assert.equal(status, 200); assert.equal(record.provider, "image2"); const final = await waitUntilTerminal(record.id); assert.equal(final.status, "done", "image2 failed on every view but wavespeed picked up each one"); for (const view of ["front", "side", "back"]) { assert.ok(final.outputImages[view], `${view} must have an output despite image2 failing`); assert.equal(final.errors[view], null); assert.equal(final.viewProvider[view], "wavespeed", "record must reflect which provider actually produced this view"); } editMode = "success"; }); test("POST /api/admin/dressgen: an unrecognized provider value falls back to the default (image2), not an error", async () => { editMode = "success"; editRequests = []; const bodyFront = await uploadTinyPng("ws-body-front3.png"); const garmentImage = await uploadTinyPng("ws-garment3.png"); const { status, record } = await createTask({ bodyFront, garmentImage, provider: "not-a-real-provider" }); assert.equal(status, 200); assert.equal(record.provider, "image2"); await waitUntilTerminal(record.id); }); // Pagination (2026-09-03) — GET /api/admin/dressgen used to return the // whole file as a bare array; by this point in the suite there are well // over 2 records, so this doesn't need to create its own. test("GET /api/admin/dressgen: paginates, newest first, page 1 and page 2 don't overlap", async () => { const page1 = await fetch(`${BASE}/api/admin/dressgen?page=1&pageSize=2`, { headers: { Cookie: cookie } }).then((r) => r.json()); assert.equal(page1.items.length, 2); assert.equal(page1.page, 1); assert.equal(page1.pageSize, 2); assert.ok(page1.total >= 10, "the suite has created well more than 2 tasks by now"); const page2 = await fetch(`${BASE}/api/admin/dressgen?page=2&pageSize=2`, { headers: { Cookie: cookie } }).then((r) => r.json()); assert.equal(page2.items.length, 2); const page1Ids = new Set(page1.items.map((r) => r.id)); assert.ok(page2.items.every((r) => !page1Ids.has(r.id)), "page 2 must not repeat page 1's items"); assert.ok(page1.items[0].createdAt >= page1.items[1].createdAt, "newest-first within a page"); assert.ok(page1.items[1].createdAt >= page2.items[0].createdAt, "newest-first across pages too"); }); // Stale-task recovery (2026-09-03): a record can be left sitting in // "generating" forever if the process dies mid-view — nothing resumes an // in-flight HTTP call across a restart. Own isolated server + mock (a mock // that never responds, so the task is provably still "generating" when // killed) sharing one on-disk dress-gen.json across two child processes, // simulating a crash-and-restart. test("a task stuck in 'generating' when the process restarts is marked failed on the next boot, not stuck forever", async () => { let hangingMock; const hangingPort = await new Promise((resolve) => { hangingMock = http.createServer((req, res) => { if (req.method === "POST" && req.url === "/api/v1/images/edits") return; // never responds res.writeHead(404).end(); }); hangingMock.listen(0, () => resolve(hangingMock.address().port)); }); const port2 = 26934 + Math.floor(Math.random() * 1000); const base2 = `http://localhost:${port2}`; const cwd2 = fs.mkdtempSync(path.join(os.tmpdir(), "dressgen-restart-")); for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd2, f)); } fs.writeFileSync(path.join(cwd2, "dress-gen.json"), "[]"); fs.mkdirSync(path.join(cwd2, "uploads"), { recursive: true }); fs.writeFileSync(path.join(cwd2, "uploads", "restart-body.png"), TINY_VALID_PNG); fs.writeFileSync(path.join(cwd2, "uploads", "restart-garment.png"), TINY_VALID_PNG); const env2 = { ...process.env, PORT: String(port2), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD, IMAGE2_API_KEY, IMAGE2_API_BASE: `http://localhost:${hangingPort}`, WAVESPEED_API_KEY: "", // no fallback — this test wants a plain stuck "generating" view }; let child2 = spawn(process.execPath, ["server.js"], { cwd: cwd2, env: env2, stdio: "pipe" }); try { await waitForServer(base2); const loginRes = await fetch(`${base2}/api/admin/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: ADMIN_PASSWORD }), }); const cookie2 = (loginRes.headers.get("set-cookie") || "").split(";")[0]; const created = await fetch(`${base2}/api/admin/dressgen`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie2 }, body: JSON.stringify({ bodyFront: "/uploads/restart-body.png", garmentImage: "/uploads/restart-garment.png" }), }).then((r) => r.json()); // Wait for the front view to actually reach "generating" (i.e. the // hanging mock has been called) before killing — proves this is a // genuinely in-flight task, not a "queued" one that never started. const deadline = Date.now() + 5000; let record; do { record = await fetch(`${base2}/api/admin/dressgen/${created.id}`, { headers: { Cookie: cookie2 } }).then((r) => r.json()); if (record.viewStatus.front === "generating") break; await new Promise((r) => setTimeout(r, 20)); } while (Date.now() < deadline); assert.equal(record.viewStatus.front, "generating", "front view must genuinely be in flight before the simulated crash"); child2.kill("SIGKILL"); // simulate a crash — no graceful shutdown, no chance to mark anything failed on the way out await new Promise((r) => setTimeout(r, 200)); child2 = spawn(process.execPath, ["server.js"], { cwd: cwd2, env: env2, stdio: "pipe" }); await waitForServer(base2); // adminSessions lives in memory — a fresh process means the old cookie // is gone too, not just the in-flight generation. const loginRes2 = await fetch(`${base2}/api/admin/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: ADMIN_PASSWORD }), }); const cookie3 = (loginRes2.headers.get("set-cookie") || "").split(";")[0]; const recovered = await fetch(`${base2}/api/admin/dressgen/${created.id}`, { headers: { Cookie: cookie3 } }).then((r) => r.json()); assert.equal(recovered.viewStatus.front, "failed"); assert.match(recovered.errors.front, /进程重启/); assert.equal(recovered.status, "failed", "no view ever succeeded, so the whole task is 'failed'"); } finally { child2.kill(); hangingMock.close(); fs.rmSync(cwd2, { recursive: true, force: true }); } });