diff --git a/tests/js/api-client.test.js b/tests/js/api-client.test.js
new file mode 100644
index 0000000..b226404
--- /dev/null
+++ b/tests/js/api-client.test.js
@@ -0,0 +1,168 @@
+// Run with: node --test tests/js/
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { createApiClient, ApiError, latestOnly, STALE } = require("../../web/js/api-client.js");
+
+/** 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("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);
+});
diff --git a/tests/js/list-state.test.js b/tests/js/list-state.test.js
new file mode 100644
index 0000000..0c2186e
--- /dev/null
+++ b/tests/js/list-state.test.js
@@ -0,0 +1,51 @@
+// Run with: node --test tests/js/
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { nextPageState, shouldSaveEdit, hasMorePages } = require("../../web/js/list-state.js");
+
+test("hasMorePages follows pagination.has_more only while pages keep arriving", () => {
+ assert.equal(hasMorePages({ pagination: { has_more: true } }, 5000), true);
+ assert.equal(hasMorePages({ pagination: { has_more: false } }, 5000), false);
+ assert.equal(hasMorePages({ pagination: { has_more: true } }, 0), false, "an empty page ends the loop");
+ assert.equal(hasMorePages({}, 10), false, "no pagination block means a single page");
+ assert.equal(hasMorePages(null, 10), false);
+});
+
+const current = { page: 2, limit: 50, total: 120, totalPages: 3 };
+
+test("nextPageState moves to the requested page once the fetch succeeded", () => {
+ assert.deepEqual(nextPageState(current, { page: 3, total: 120 }), { page: 3, limit: 50, total: 120, totalPages: 3 });
+});
+
+test("nextPageState clamps a page beyond the end when the total shrank", () => {
+ assert.deepEqual(nextPageState(current, { page: 3, total: 60 }), { page: 2, limit: 50, total: 60, totalPages: 2 });
+});
+
+test("nextPageState never goes below page 1 and has at least one page", () => {
+ assert.deepEqual(nextPageState(current, { page: 0, total: 0 }), { page: 1, limit: 50, total: 0, totalPages: 1 });
+ assert.equal(nextPageState(current, { page: -5, total: 10 }).page, 1);
+});
+
+test("nextPageState keeps the current page and total when the response omits them", () => {
+ assert.deepEqual(nextPageState(current, {}), current);
+ assert.deepEqual(nextPageState(current, { total: "many", page: "x" }), current);
+});
+
+test("nextPageState accepts a new limit and recomputes totalPages", () => {
+ assert.deepEqual(nextPageState(current, { page: 1, limit: 25, total: 120 }), { page: 1, limit: 25, total: 120, totalPages: 5 });
+ assert.equal(nextPageState(current, { limit: 0 }).limit, 50, "a zero limit is ignored");
+});
+
+test("nextPageState does not mutate its input", () => {
+ const before = { ...current };
+ nextPageState(current, { page: 1, total: 5 });
+ assert.deepEqual(current, before);
+});
+
+test("shouldSaveEdit is true only for a non-empty text that changed", () => {
+ assert.equal(shouldSaveEdit("old", "new"), true);
+ assert.equal(shouldSaveEdit("same", "same"), false);
+ assert.equal(shouldSaveEdit("same", " same \n"), false, "whitespace-only changes are not saved");
+ assert.equal(shouldSaveEdit("old", " "), false, "an emptied prompt is not saved");
+ assert.equal(shouldSaveEdit("old", null), false);
+});
diff --git a/tests/js/sse-stream.test.js b/tests/js/sse-stream.test.js
new file mode 100644
index 0000000..038c4f4
--- /dev/null
+++ b/tests/js/sse-stream.test.js
@@ -0,0 +1,118 @@
+// Run with: node --test tests/js/
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { parseSseChunk, connect } = require("../../web/js/sse-stream.js");
+
+test("parseSseChunk splits complete events and keeps the unfinished tail", () => {
+ const { events, rest } = parseSseChunk('data: {"a":1}\n\ndata: {"b":2}\n\ndata: {"c"');
+ assert.deepEqual(events.map((e) => e.data), ['{"a":1}', '{"b":2}']);
+ assert.equal(rest, 'data: {"c"');
+});
+
+test("parseSseChunk joins multi-line data, reads event names and ignores comments", () => {
+ const { events, rest } = parseSseChunk(": keep-alive\nevent: progress\ndata: line1\ndata: line2\nid: 7\n\n");
+ assert.deepEqual(events, [{ event: "progress", data: "line1\nline2" }]);
+ assert.equal(rest, "");
+});
+
+test("parseSseChunk accepts CRLF line endings and a missing space after the colon", () => {
+ const { events } = parseSseChunk("data:x\r\n\r\ndata: y\r\n\r\n");
+ assert.deepEqual(events.map((e) => e.data), ["x", "y"]);
+});
+
+test("parseSseChunk returns nothing for an empty or whitespace buffer", () => {
+ assert.deepEqual(parseSseChunk(""), { events: [], rest: "" });
+ assert.deepEqual(parseSseChunk("\n\n"), { events: [], rest: "" });
+});
+
+function streamOf(chunks) {
+ const encoder = new TextEncoder();
+ return new ReadableStream({
+ start(controller) {
+ for (const c of chunks) controller.enqueue(encoder.encode(c));
+ controller.close();
+ },
+ });
+}
+
+function until(check, timeoutMs = 500) {
+ return new Promise((resolve, reject) => {
+ const started = Date.now();
+ (function poll() {
+ if (check()) return resolve();
+ if (Date.now() - started > timeoutMs) return reject(new Error("timed out"));
+ setTimeout(poll, 2);
+ })();
+ });
+}
+
+test("connect POSTs and delivers each event to onmessage, then onclose", async () => {
+ const seen = [];
+ let closed = false;
+ const calls = [];
+ const fetchImpl = async (url, init) => {
+ calls.push({ url, init });
+ return new Response(streamOf(['data: {"n":1}\n\ndata: {"n', '":2}\n\n']), { status: 200 });
+ };
+ const source = connect("/prompt_manager/autotag/start?x=1", { method: "POST", fetchImpl });
+ source.onmessage = (e) => seen.push(JSON.parse(e.data).n);
+ source.onclose = () => { closed = true; };
+ await until(() => closed);
+ assert.deepEqual(seen, [1, 2]);
+ assert.equal(calls[0].init.method, "POST");
+ assert.equal(calls[0].url, "/prompt_manager/autotag/start?x=1");
+ assert.equal(source.readyState, source.CLOSED);
+});
+
+test("connect reports a non-2xx response through onerror and never onmessage", async () => {
+ let error = null;
+ const fetchImpl = async () => new Response("nope", { status: 405 });
+ const source = connect("/x", { fetchImpl });
+ source.onmessage = () => assert.fail("no message expected");
+ source.onerror = (err) => { error = err; };
+ await until(() => error !== null);
+ assert.match(error.message, /405/);
+ assert.equal(source.readyState, source.CLOSED);
+});
+
+test("connect reports a network failure through onerror", async () => {
+ let error = null;
+ const source = connect("/x", { fetchImpl: async () => { throw new TypeError("Failed to fetch"); } });
+ source.onerror = (err) => { error = err; };
+ await until(() => error !== null);
+ assert.equal(error.message, "Failed to fetch");
+});
+
+test("close() aborts the request and suppresses later callbacks", async () => {
+ let aborted = false;
+ let errors = 0;
+ const fetchImpl = (url, init) =>
+ new Promise((resolve, reject) => {
+ init.signal.addEventListener("abort", () => {
+ aborted = true;
+ reject(new DOMException("aborted", "AbortError"));
+ });
+ resolve(new Response(new ReadableStream({ start() {} }), { status: 200 }));
+ });
+ const source = connect("/x", { fetchImpl });
+ source.onerror = () => errors++;
+ await until(() => source.readyState === source.OPEN);
+ source.close();
+ assert.equal(aborted, true);
+ assert.equal(source.readyState, source.CLOSED);
+ await new Promise((r) => setTimeout(r, 10));
+ assert.equal(errors, 0);
+});
+
+test("connect defaults to GET and the global fetch", async () => {
+ const original = globalThis.fetch;
+ let init = null;
+ globalThis.fetch = async (_url, i) => { init = i; return new Response(streamOf([]), { status: 200 }); };
+ try {
+ const source = connect("/x");
+ await until(() => source.readyState === source.CLOSED);
+ assert.equal(init.method, "GET");
+ } finally {
+ globalThis.fetch = original;
+ }
+});
diff --git a/web/admin.html b/web/admin.html
index efd35bb..3edd812 100644
--- a/web/admin.html
+++ b/web/admin.html
@@ -6,6 +6,9 @@
PromptManager - Admin Dashboard
+
+
+
@@ -444,6 +447,11 @@
Get your key from civitai.com/user/account
+
+