* feat(library): manage native editor drafts and recovery * fix(library): fence pair archive retries against restored storage * feat(library): add native recipe editor dialogs * fix(library): read archive counts through the editor controller * feat(library): reconcile editor saves and archived pairs Wire the concept's heading actions (Library Export and + New prompt, Images New prompt, history ← Prompt library, Edit metadata and pair Send to ComfyUI) and the Negative variants + Add negative and Show archived controls. Saves navigate or reconcile in place through createLibraryDetail.accept, invalidate cached shelf state by write generation, browse and restore archived pairs, and offer an explicit Library reload when storage was restored. * fix(library): refresh archived pairs and align editor outcomes * test(library): enforce approved native editor presentation * test(library): verify editor host and coverage compatibility Adds the rejected connected-target host assertion, pins the stock host listing at 80 cases, documents the native editor, and keeps Save/Archive focus after a failed write. * docs(testing): correct host case totals and cover archive focus * fix(library): fence editor reloads after restore and settle outcome copy
286 lines
13 KiB
JavaScript
286 lines
13 KiB
JavaScript
// Run with: node --test tests/js/
|
|
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const { createApiClient, resolveUrl, fetchUrl, ApiError, latestOnly, STALE } = require("../../web/js/api-client.js");
|
|
|
|
test("Prompt Manager URLs follow the page's proxy mount without losing query strings", () => {
|
|
const url = "/prompt_manager/images/output?limit=100&subfolder=2025%2F04%2F02";
|
|
assert.equal(resolveUrl(url, "/ComfyBackendDirect/prompt_manager/gallery"),
|
|
"/ComfyBackendDirect" + url);
|
|
assert.equal(resolveUrl(url, "/prompt_manager/gallery"), url);
|
|
assert.equal(resolveUrl(url, "/nested/proxy/prompt_manager/admin"), "/nested/proxy" + url);
|
|
assert.equal(resolveUrl("/other/api?x=1", "/ComfyBackendDirect/prompt_manager/gallery"), "/other/api?x=1");
|
|
assert.equal(resolveUrl("/ComfyBackendDirect/prompt_manager/images/serve/a.png", "/ComfyBackendDirect/prompt_manager/gallery"),
|
|
"/ComfyBackendDirect/prompt_manager/images/serve/a.png");
|
|
assert.equal(resolveUrl("/prompt_manager/images/serve/a.png?root=1", "/ComfyBackendDirect/prompt_manager/gallery"),
|
|
"/ComfyBackendDirect/prompt_manager/images/serve/a.png?root=1");
|
|
});
|
|
|
|
test("fetchUrl sends Prompt Manager requests under the current proxy mount", async () => {
|
|
const previousFetch = globalThis.fetch;
|
|
const previousLocation = globalThis.location;
|
|
const calls = [];
|
|
globalThis.location = { pathname: "/ComfyBackendDirect/prompt_manager/admin" };
|
|
globalThis.fetch = async (url, init) => {
|
|
calls.push([url, init]);
|
|
return { ok: true };
|
|
};
|
|
try {
|
|
await fetchUrl("/prompt_manager/settings?show=all", { method: "GET" });
|
|
assert.deepEqual(calls, [["/ComfyBackendDirect/prompt_manager/settings?show=all", { method: "GET" }]]);
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
globalThis.location = previousLocation;
|
|
}
|
|
});
|
|
|
|
/** A fetch stand-in whose responses the test resolves by hand, in any order. */
|
|
function fakeFetch() {
|
|
const calls = [];
|
|
const impl = (url, init = {}) =>
|
|
new Promise((resolve, reject) => {
|
|
const call = { url, init, resolve, reject, aborted: false };
|
|
if (init.signal) {
|
|
init.signal.addEventListener("abort", () => {
|
|
call.aborted = true;
|
|
reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
});
|
|
}
|
|
calls.push(call);
|
|
});
|
|
impl.calls = calls;
|
|
return impl;
|
|
}
|
|
|
|
function jsonResponse(status, body, { invalid = false } = {}) {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
statusText: status === 200 ? "OK" : `Status ${status}`,
|
|
json: async () => {
|
|
if (invalid) throw new SyntaxError("Unexpected token");
|
|
return body;
|
|
},
|
|
};
|
|
}
|
|
|
|
test("get returns the parsed body of a successful response", async () => {
|
|
const fetch = fakeFetch();
|
|
const api = createApiClient(fetch);
|
|
const pending = api.get("/prompt_manager/recent?page=1");
|
|
fetch.calls[0].resolve(jsonResponse(200, { success: true, results: [1, 2] }));
|
|
assert.deepEqual(await pending, { success: true, results: [1, 2] });
|
|
assert.equal(fetch.calls[0].init.method, "GET");
|
|
});
|
|
|
|
test("get forwards an AbortSignal to fetch", async () => {
|
|
const fetch = fakeFetch();
|
|
const api = createApiClient(fetch);
|
|
const controller = new AbortController();
|
|
const pending = api.get("/x", { signal: controller.signal });
|
|
assert.equal(fetch.calls[0].init.signal, controller.signal);
|
|
controller.abort();
|
|
await assert.rejects(pending, (err) => err.name === "AbortError");
|
|
});
|
|
|
|
test("non-2xx responses throw ApiError carrying the status and server message", async () => {
|
|
const fetch = fakeFetch();
|
|
const api = createApiClient(fetch);
|
|
const pending = api.get("/x");
|
|
fetch.calls[0].resolve(jsonResponse(404, { success: false, error: "Prompt not found" }));
|
|
await assert.rejects(pending, (err) => {
|
|
assert.ok(err instanceof ApiError);
|
|
assert.ok(err instanceof Error);
|
|
assert.equal(err.status, 404);
|
|
assert.equal(err.message, "Prompt not found");
|
|
return true;
|
|
});
|
|
});
|
|
|
|
test("non-2xx responses without a JSON body fall back to the status text", async () => {
|
|
const fetch = fakeFetch();
|
|
const api = createApiClient(fetch);
|
|
const pending = api.get("/x");
|
|
fetch.calls[0].resolve(jsonResponse(500, null, { invalid: true }));
|
|
await assert.rejects(pending, (err) => err.status === 500 && err.message === "Status 500");
|
|
});
|
|
|
|
test("a 2xx body with success:false throws ApiError", async () => {
|
|
const fetch = fakeFetch();
|
|
const api = createApiClient(fetch);
|
|
const pending = api.post("/x", {});
|
|
fetch.calls[0].resolve(jsonResponse(200, { success: false, error: "Nope" }));
|
|
await assert.rejects(pending, (err) => err instanceof ApiError && err.status === 200 && err.message === "Nope");
|
|
});
|
|
|
|
test("ApiError preserves only a bounded machine code without changing status or message", async () => {
|
|
assert.equal(new ApiError(409, "unchanged").code, null);
|
|
for (const [code, expected] of [
|
|
["source-changed", "source-changed"], ["a".repeat(64), "a".repeat(64)],
|
|
[undefined, null], [null, null], [4, null], [{ code: "x" }, null],
|
|
["", null], ["Source-changed", null], ["source_changed", null],
|
|
["source-changed\n", null], ["a".repeat(65), null],
|
|
]) {
|
|
for (const status of [409, 200]) {
|
|
const api = createApiClient(async () => jsonResponse(status, {
|
|
success: false, error: "unchanged", code, private: "never expose",
|
|
}));
|
|
await assert.rejects(api.get("/x"), error => {
|
|
assert.ok(error instanceof ApiError);
|
|
assert.equal(error.status, status);
|
|
assert.equal(error.message, "unchanged");
|
|
assert.equal(error.code, expected);
|
|
assert.equal(error.private, undefined);
|
|
assert.equal(error.body, undefined);
|
|
return true;
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
test("a 2xx body that is not JSON throws ApiError", async () => {
|
|
const fetch = fakeFetch();
|
|
const api = createApiClient(fetch);
|
|
const pending = api.get("/x");
|
|
fetch.calls[0].resolve(jsonResponse(200, null, { invalid: true }));
|
|
await assert.rejects(pending, (err) => err instanceof ApiError && err.status === 200);
|
|
});
|
|
|
|
test("post, put and del send JSON bodies with the right method", async () => {
|
|
const fetch = fakeFetch();
|
|
const api = createApiClient(fetch);
|
|
const p1 = api.post("/a", { text: "x" });
|
|
const p2 = api.put("/b", { rating: 3 });
|
|
const p3 = api.del("/c");
|
|
assert.equal(fetch.calls[0].init.method, "POST");
|
|
assert.equal(fetch.calls[0].init.body, JSON.stringify({ text: "x" }));
|
|
assert.equal(fetch.calls[0].init.headers["Content-Type"], "application/json");
|
|
assert.equal(fetch.calls[1].init.method, "PUT");
|
|
assert.equal(fetch.calls[1].init.body, JSON.stringify({ rating: 3 }));
|
|
assert.equal(fetch.calls[2].init.method, "DELETE");
|
|
assert.equal(fetch.calls[2].init.body, undefined);
|
|
for (const c of fetch.calls) c.resolve(jsonResponse(200, { success: true }));
|
|
await Promise.all([p1, p2, p3]);
|
|
});
|
|
|
|
test("network failures propagate as ordinary errors", async () => {
|
|
const fetch = fakeFetch();
|
|
const api = createApiClient(fetch);
|
|
const pending = api.get("/x");
|
|
fetch.calls[0].reject(new TypeError("Failed to fetch"));
|
|
await assert.rejects(pending, (err) => err instanceof TypeError && !(err instanceof ApiError));
|
|
});
|
|
|
|
test("createApiClient without an argument uses the global fetch", async () => {
|
|
const original = globalThis.fetch;
|
|
globalThis.fetch = async () => jsonResponse(200, { success: true, via: "global" });
|
|
try {
|
|
assert.deepEqual(await createApiClient().get("/x"), { success: true, via: "global" });
|
|
} finally {
|
|
globalThis.fetch = original;
|
|
}
|
|
});
|
|
|
|
test("latestOnly: a newer call aborts the older one and the older result is STALE", async () => {
|
|
const fetch = fakeFetch();
|
|
const api = createApiClient(fetch);
|
|
const latest = latestOnly();
|
|
|
|
const first = latest((signal) => api.get("/search?q=a", { signal }));
|
|
const second = latest((signal) => api.get("/search?q=ab", { signal }));
|
|
|
|
assert.equal(fetch.calls[0].aborted, true, "first request aborted");
|
|
assert.equal(fetch.calls[1].aborted, false);
|
|
|
|
fetch.calls[1].resolve(jsonResponse(200, { success: true, q: "ab" }));
|
|
assert.deepEqual(await second, { success: true, q: "ab" });
|
|
assert.equal(await first, STALE);
|
|
});
|
|
|
|
test("latestOnly: an older response that arrives after a newer request is STALE even without abort", async () => {
|
|
const latest = latestOnly();
|
|
let resolveFirst;
|
|
const first = latest(() => new Promise((r) => { resolveFirst = r; }));
|
|
const second = latest(async () => "new");
|
|
assert.equal(await second, "new");
|
|
resolveFirst("old");
|
|
assert.equal(await first, STALE);
|
|
});
|
|
|
|
test("latestOnly: sequential calls each return their own result", async () => {
|
|
const latest = latestOnly();
|
|
assert.equal(await latest(async () => 1), 1);
|
|
assert.equal(await latest(async () => 2), 2);
|
|
});
|
|
|
|
test("latestOnly: errors from the current call propagate, errors from superseded calls do not", async () => {
|
|
const latest = latestOnly();
|
|
let rejectFirst;
|
|
const first = latest(() => new Promise((_, rej) => { rejectFirst = rej; }));
|
|
const second = latest(async () => { throw new Error("boom"); });
|
|
await assert.rejects(second, /boom/);
|
|
rejectFirst(new Error("old failure"));
|
|
assert.equal(await first, STALE);
|
|
});
|
|
|
|
test("ApiError exposes only validated history recovery actions on all error paths", async () => {
|
|
const permitted = [null, { action: "refresh-history" }, { action: "reload-outputs" },
|
|
{ action: "retry-read" }, { action: "smaller-limit", limit: 1 },
|
|
{ action: "smaller-limit", limit: 100 }];
|
|
const denied = [undefined, false, "refresh-history", [], {}, { action: "unknown" },
|
|
{ action: "refresh-history", private: "secret" }, { action: "retry-read", limit: 1 },
|
|
{ action: "smaller-limit" }, { action: "smaller-limit", limit: 0 },
|
|
{ action: "smaller-limit", limit: 101 }, { action: "smaller-limit", limit: true },
|
|
{ action: "smaller-limit", limit: "1" }, { action: "smaller-limit", limit: 1.5 },
|
|
{ action: "smaller-limit", limit: NaN }, { action: "smaller-limit", limit: Infinity },
|
|
{ action: "smaller-limit", limit: 10, url: "/private" },
|
|
Object.assign(Object.create({ action: "retry-read" }), { private: "secret" })];
|
|
assert.equal(new ApiError(409, "old caller").recovery, null);
|
|
for (const recovery of permitted.concat(denied)) {
|
|
for (const status of [409, 200]) {
|
|
const api = createApiClient(async () => jsonResponse(status, {
|
|
success: false, error: "bounded", code: "history-changed", recovery,
|
|
}));
|
|
await assert.rejects(api.get("/x"), error => {
|
|
assert.ok(error instanceof ApiError);
|
|
assert.equal(error.code, "history-changed");
|
|
assert.deepEqual(error.recovery, permitted.includes(recovery) ? recovery : null);
|
|
if (recovery && permitted.includes(recovery)) {
|
|
assert.notEqual(error.recovery, recovery, "server objects must be copied");
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
test("patch sends a JSON body with the PATCH method and forwards an AbortSignal", async () => {
|
|
const fetch = fakeFetch();
|
|
const api = createApiClient(fetch);
|
|
assert.equal(typeof api.patch, "function");
|
|
const controller = new AbortController();
|
|
const pending = api.patch("/prompt_manager/library/7", { metadata: { rating: null } }, { signal: controller.signal });
|
|
assert.equal(fetch.calls[0].init.method, "PATCH");
|
|
assert.equal(fetch.calls[0].init.body, JSON.stringify({ metadata: { rating: null } }));
|
|
assert.equal(fetch.calls[0].init.headers["Content-Type"], "application/json");
|
|
assert.equal(fetch.calls[0].init.signal, controller.signal);
|
|
fetch.calls[0].resolve(jsonResponse(409, { success: false, code: "metadata-conflict", error: "changed" }));
|
|
await assert.rejects(pending, error => error instanceof ApiError && error.status === 409 && error.code === "metadata-conflict");
|
|
});
|
|
|
|
test("get forwards an explicit cache mode and keeps the default request otherwise", async () => {
|
|
const fetch = fakeFetch();
|
|
const api = createApiClient(fetch);
|
|
const fresh = api.get("/prompt_manager/library/options", { cache: "no-store" });
|
|
const plain = api.get("/prompt_manager/library/options");
|
|
assert.equal(fetch.calls[0].init.cache, "no-store");
|
|
assert.equal("cache" in fetch.calls[1].init, false);
|
|
assert.deepEqual(fetch.calls[1].init, { method: "GET", headers: {} });
|
|
for (const call of fetch.calls) call.resolve(jsonResponse(200, { success: true }));
|
|
await Promise.all([fresh, plain]);
|
|
const writes = api.post("/x", {}, { cache: "no-store" });
|
|
assert.equal("cache" in fetch.calls[2].init, false, "only reads take a cache mode");
|
|
fetch.calls[2].resolve(jsonResponse(200, { success: true }));
|
|
await writes;
|
|
});
|