diff --git a/tests/js/browser-globals.test.js b/tests/js/browser-globals.test.js new file mode 100644 index 0000000..135cde6 --- /dev/null +++ b/tests/js/browser-globals.test.js @@ -0,0 +1,74 @@ +// Run with: node --test tests/js/ +// Each page module is a UMD-style IIFE: CommonJS export under Node, a window +// global in the browser. This loads every module the way a browser would and +// checks the global name the pages rely on. +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +const MODULES = { + "html-escape.js": ["HtmlEscape", "escapeHtml", "escapeAttr"], + "data-actions.js": ["DataActions"], + "api-client.js": ["ApiClient"], + "list-state.js": ["ListState"], + "sse-stream.js": ["SseStream"], + "png-metadata.js": ["PngMetadata"], + "view-router.js": ["ViewRouter"], + "prompt-list-sort.js": ["PromptListSort"], + "comfy-metadata.js": ["ComfyMetadata"], +}; + +function loadInFakeBrowser(file) { + const source = fs.readFileSync(path.join(__dirname, "../../web/js", file), "utf8"); + const window = {}; + window.window = window; + const context = vm.createContext({ + window, + globalThis: window, + console, + TextDecoder, + TextEncoder, + AbortController, + URLSearchParams, + Symbol, + Object, + Array, + Number, + String, + Math, + Set, + Map, + JSON, + Error, + Promise, + DataView, + Uint8Array, + ArrayBuffer, + }); + vm.runInContext(source, context, { filename: file }); + return window; +} + +for (const [file, globals] of Object.entries(MODULES)) { + test(`${file} exposes ${globals.join(", ")} on window when module is undefined`, () => { + const window = loadInFakeBrowser(file); + for (const name of globals) { + assert.ok(window[name], `${name} missing`); + assert.ok(["object", "function"].includes(typeof window[name]), `${name} is a ${typeof window[name]}`); + } + }); +} + +test("the browser escapeHtml global is the same function as HtmlEscape.escapeHtml", () => { + const window = loadInFakeBrowser("html-escape.js"); + assert.equal(window.escapeHtml, window.HtmlEscape.escapeHtml); + assert.equal(window.escapeAttr, window.escapeHtml); + assert.equal(window.escapeHtml(``), "<a href="x">"); +}); + +test("the browser ViewRouter global resolves the tags deep link", () => { + const window = loadInFakeBrowser("view-router.js"); + assert.equal(window.ViewRouter.resolveView("#/tags/a"), "tags"); +}); diff --git a/tests/js/sse-stream.test.js b/tests/js/sse-stream.test.js index 038c4f4..2c5465f 100644 --- a/tests/js/sse-stream.test.js +++ b/tests/js/sse-stream.test.js @@ -104,6 +104,50 @@ test("close() aborts the request and suppresses later callbacks", async () => { assert.equal(errors, 0); }); +test("connect forwards a request body and an Accept header", async () => { + let init = null; + const fetchImpl = async (_url, i) => { init = i; return new Response(streamOf([]), { status: 200 }); }; + const source = connect("/x", { method: "POST", body: '{"a":1}', fetchImpl }); + await until(() => source.readyState === source.CLOSED); + assert.equal(init.body, '{"a":1}'); + assert.equal(init.headers.Accept, "text/event-stream"); +}); + +test("events arriving with no onmessage handler are dropped without error", async () => { + const fetchImpl = async () => new Response(streamOf(["data: 1\n\n", "data: 2\n\n"]), { status: 200 }); + const source = connect("/x", { fetchImpl }); + let errors = 0; + source.onerror = () => errors++; + await until(() => source.readyState === source.CLOSED); + assert.equal(errors, 0); +}); + +test("close() during delivery stops further messages and skips onclose", async () => { + const seen = []; + let closed = false; + const fetchImpl = async () => new Response(streamOf(["data: 1\n\ndata: 2\n\ndata: 3\n\n"]), { status: 200 }); + const source = connect("/x", { fetchImpl }); + source.onmessage = (e) => { seen.push(e.data); if (e.data === "1") source.close(); }; + source.onclose = () => { closed = true; }; + await until(() => source.readyState === source.CLOSED); + await new Promise((r) => setTimeout(r, 10)); + assert.deepEqual(seen, ["1"]); + assert.equal(closed, false); +}); + +test("close() before the response arrives discards it silently", async () => { + let resolveFetch; + let errors = 0; + const fetchImpl = () => new Promise((resolve) => { resolveFetch = resolve; }); + const source = connect("/x", { fetchImpl }); + source.onerror = () => errors++; + source.close(); + resolveFetch(new Response(streamOf(["data: late\n\n"]), { status: 200 })); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(errors, 0); + assert.equal(source.readyState, source.CLOSED); +}); + test("connect defaults to GET and the global fetch", async () => { const original = globalThis.fetch; let init = null;