// End-to-end check of the exact sequence admin-characters.html / // admin-garments.html / admin-dance-refs.html perform in the browser: // login -> upload a real file -> create the record with the returned URL // -> list -> confirm the shape has everything the page renders. Doesn't // render actual HTML/JS (no browser tooling available here), but exercises // the same API calls in the same order with real files, which is where a // field-name mismatch between a page's JS and the backend would surface. 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 { spawn } = require("node:child_process"); const PORT = 21934 + Math.floor(Math.random() * 1000); const BASE = `http://localhost:${PORT}`; const ADMIN_PASSWORD = "test-password-only-for-this-run"; let cookie; let child; let cwd; const TINY_VALID_PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC", "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"); })(); } test.before(async () => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), "admin-pages-flow-")); for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } child = spawn(process.execPath, ["server.js"], { cwd, env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD }, 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(); fs.rmSync(cwd, { recursive: true, force: true }); }); test("admin-characters.html flow: upload image then create, list shows imageUrl reachable via GET /uploads/*", async () => { const fd = new FormData(); fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "person.png"); const uploadRes = await fetch(`${BASE}/api/admin/upload`, { method: "POST", headers: { Cookie: cookie }, body: fd }); assert.equal(uploadRes.status, 200); const { url: imageUrl } = await uploadRes.json(); const createRes = await fetch(`${BASE}/api/admin/characters`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ name: "小美", imageUrl, appearanceDescriptor: "black hair", tags: ["seed"], isDefault: true }), }); assert.equal(createRes.status, 200); const created = await createRes.json(); const list = await fetch(`${BASE}/api/admin/characters`, { headers: { Cookie: cookie } }).then((r) => r.json()); const row = list.find((c) => c.id === created.id); assert.ok(row); assert.equal(row.imageUrl, imageUrl); const served = await fetch(`${BASE}${row.imageUrl}`); assert.equal(served.status, 200); }); test("admin-garments.html flow: upload image then create, thumbnailUrl falls back to imageUrl for the table's ", async () => { const fd = new FormData(); fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "garment.png"); const uploadRes = await fetch(`${BASE}/api/admin/upload`, { method: "POST", headers: { Cookie: cookie }, body: fd }); const { url: imageUrl } = await uploadRes.json(); const createRes = await fetch(`${BASE}/api/admin/garments`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ name: "红旗袍", category: "古风", imageUrl, appearanceDescriptor: "red qipao" }), }); const created = await createRes.json(); assert.equal(created.thumbnailUrl, imageUrl); const served = await fetch(`${BASE}${created.thumbnailUrl}`); assert.equal(served.status, 200); }); test("admin-dance-refs.html flow: upload real mp4 then create, videoUrl reachable and motionDescriptor round-trips", async () => { const realMp4 = fs.readFileSync(path.join(__dirname, "spike", "s1-s2-s4-2026-09-02", "danceref.mp4")); const fd = new FormData(); fd.append("file", new Blob([realMp4], { type: "video/mp4" }), "dance.mp4"); const uploadRes = await fetch(`${BASE}/api/admin/upload-video`, { method: "POST", headers: { Cookie: cookie }, body: fd }); assert.equal(uploadRes.status, 200); const { url: videoUrl } = await uploadRes.json(); const createRes = await fetch(`${BASE}/api/admin/dance-refs`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ name: "拉丁舞", videoUrl, motionDescriptor: "hip sways and raised-arm turns", styleTags: ["latin"] }), }); assert.equal(createRes.status, 200); const created = await createRes.json(); assert.equal(created.videoUrl, videoUrl); assert.equal(created.motionDescriptor, "hip sways and raised-arm turns"); const served = await fetch(`${BASE}${created.videoUrl}`); assert.equal(served.status, 200); assert.equal(served.headers.get("content-type"), "video/mp4"); });