import { describe, expect, test } from "bun:test"; import { TOOL_USER_AGENT, TransportError } from "../src/core/transport"; import { gmStore, gmTransport, parseHeaders, type GmRequest } from "../src/userscript/gm"; import { userscriptBanner } from "../scripts/userscript-banner"; function fakeGm(respond: (req: GmRequest) => void) { const requests: GmRequest[] = []; const gm = (req: GmRequest) => { requests.push(req); queueMicrotask(() => respond(req)); return { abort: () => req.onabort() }; }; return { gm, requests }; } describe("gmTransport", () => { test("wraps GM_xmlhttpRequest into a Response", async () => { const body = new TextEncoder().encode("").buffer; const { gm, requests } = fakeGm((r) => r.onload({ status: 200, statusText: "OK", responseHeaders: "Content-Type: text/html\r\nX-Two: a: b\r\n", response: body })); const res = await gmTransport(gm).fetch("https://www.webtoons.com/-/-/-/list?title_no=95"); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toBe("text/html"); expect(await res.text()).toBe(""); expect(requests[0]).toMatchObject({ method: "GET", responseType: "arraybuffer", anonymous: true }); expect(requests[0]!.headers!["user-agent"]).toMatch(/^Mozilla/); }); test("Wayback gets the honest User-Agent", async () => { const { gm, requests } = fakeGm((r) => r.onload({ status: 200, statusText: "OK", responseHeaders: "", response: new ArrayBuffer(0) })); await gmTransport(gm).fetch("https://web.archive.org/cdx/search/cdx?url=x"); expect(requests[0]!.headers!["user-agent"]).toBe(TOOL_USER_AGENT); }); test("network errors and timeouts reject with TransportError; odd statuses become 502", async () => { await expect(gmTransport(fakeGm((r) => r.onerror({})).gm).fetch("https://x/")).rejects.toBeInstanceOf(TransportError); await expect(gmTransport(fakeGm((r) => r.ontimeout()).gm).fetch("https://x/")).rejects.toBeInstanceOf(TransportError); const res = await gmTransport(fakeGm((r) => r.onload({ status: 0, statusText: "", responseHeaders: "", response: null })).gm).fetch("https://x/"); expect(res.status).toBe(502); }); test("an abort signal aborts the request", async () => { const controller = new AbortController(); const { gm } = fakeGm(() => controller.abort()); await expect(gmTransport(gm).fetch("https://x/", { signal: controller.signal })).rejects.toThrow(); }); }); test("parseHeaders keeps colons inside values", () => { expect(parseHeaders("A: 1\r\nB: x: y\r\nbroken\r\n").get("b")).toBe("x: y"); }); test("gmStore maps to GM_getValue / GM_setValue / GM_deleteValue", () => { const values = new Map(); const store = gmStore((k) => values.get(k), (k, v) => void values.set(k, v), (k) => void values.delete(k)); store.set("a", "1"); expect(store.get("a")).toBe("1"); store.remove("a"); expect(store.get("a")).toBeNull(); }); test("banner: matches mangabaka.org, grants GM APIs, connects to every host it needs", () => { const banner = userscriptBanner("0.1.0", "https://mbseed.example.org"); expect(banner).toStartWith("// ==UserScript==\n"); expect(banner).toContain("// @version 0.1.0\n"); expect(banner).toContain("// @match https://mangabaka.org/*\n"); for (const grant of ["GM_xmlhttpRequest", "GM_getValue", "GM_setValue", "GM_deleteValue"]) expect(banner).toContain(`// @grant ${grant}\n`); for (const host of ["api.mangabaka.org", "webtoons.com", "pstatic.net", "web.archive.org", "catbox.moe", "mbseed.example.org"]) { expect(banner).toContain(`// @connect ${host}\n`); } expect(banner).toEndWith("// ==/UserScript==\n\n"); expect(userscriptBanner("0.1.0")).not.toContain("mbseed.example.org"); });