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

+ +