Files
ComfyAssets-ComfyUI_PromptM…/tests/js/api-client.test.js
T
Vito ecd21165a7 feat(history): add chronological runs and exact output inspection (#227)
* feat(history): add bounded chronological read projections (#209)

* fix(history): validate internal continuation positions (#209)

* feat(history): expose fenced history windows and run details (#209)

* fix(history): release retired projection connections (#209)

* feat(history): resolve reported outputs through current authorization (#209)

* fix(history): reject invalid inspection targets (#209)

* feat(history): add scoped history state and current-file adapter (#209)

* fix(history): preserve slideshow and clear stale reentry actions (#209)

* feat(history): render recorded executions and frozen run details (#209)

* fix(history): recover receipts and refresh capture evidence (#209)

* test(history): verify chronological history across restore and host modes (#209)

* fix(history): enforce chronological marker visual parity (#209)

* fix(history): resolve final chronological review findings

Address #209 recovery, bounded visit restoration, exact receipt availability, and portable regression coverage.

* test(history): repair Windows fixtures and review feedback

Address #209 and PR #227 native path, cleanup, media and review-comment expectations. Document Windows portability preflight without changing production behavior.

* ci: raise the viewerjs job budget for the larger browser suite

The cross-browser workflows suite grew from 396 to 496 tests on this
branch, which pushed the whole viewerjs job to about 25.5 minutes
against the 25 minute cap set in #181. Two exact-head runs on this
branch timed out without any test failing: run 39173019283 was killed
on test 474 of 496, and run 39175125474 printed "496 passed (12.8m)"
and was then cancelled during Playwright shutdown.

Raise only the job budget. Browser projects, workers and per-test
timeouts are unchanged. Splitting the browsers into parallel jobs is
tracked separately.
2026-10-18 17:17:05 -07:00

256 lines
11 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;
});
}
}
});