web/js/api-client.js wraps fetch with one failure path (ApiError for non-2xx,
success:false and non-JSON bodies) and latestOnly(), which aborts the previous
in-flight request and resolves superseded calls to STALE. The admin search and
recent-page loads share one guard, the gallery page load has its own, so fast
typing or clicking can no longer paint an older result over a newer one.
In-place prompt editing used a blur listener with {once:true} plus a keydown
listener added on every edit, which sent the PUT twice and stacked handlers.
The list now has one keydown/focusout pair bound at construction and
finishEdit() clears the edit state before touching the element, so a save is
sent exactly once. Mutations re-fetch the page currently shown (refreshList)
instead of calling search(), and delete, bulk delete, edit, restore and scan no
longer reload the whole page.
Pagination state is committed only after a successful fetch via the pure
ListState.nextPageState(current, response) in both admin and gallery, so a
failed or aborted page request leaves the current page intact. The gallery
lightbox removed its keydown handlers on a non-existent 'remove' event; a
MutationObserver now drops them when the modal leaves the document.
web/js/sse-stream.js reads server-sent events over fetch so POST-only progress
endpoints can stream.
119 lines
4.6 KiB
JavaScript
119 lines
4.6 KiB
JavaScript
// 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;
|
|
}
|
|
});
|