diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js
index 625ac0c..544b391 100644
--- a/userscript/manga-bookmark.user.js
+++ b/userscript/manga-bookmark.user.js
@@ -1357,6 +1357,18 @@
.toast.err { background: #7f1d1d; }
`;
+ // ============================================================
+ // Test hook
+ //
+ // Node's require() defines `module`; Bromite's native engine, Tampermonkey,
+ // and Violentmonkey do not, so this is inert everywhere the script actually
+ // ships. Exposes pure logic only — see userscript/test/logic.test.js, which
+ // loads this file with document.body undefined so the UI never boots.
+ // ============================================================
+ if (typeof module === "object" && module.exports) {
+ module.exports = { stripBuildHash, asura, demonic, anchorsFromHTML, statusOf };
+ }
+
// ============================================================
// Boot — MUST run last: init() -> buildUI() reads TEMPLATE/CSS,
// which are const (TDZ) and only initialized above. At
diff --git a/userscript/test/logic.test.js b/userscript/test/logic.test.js
new file mode 100644
index 0000000..00990cf
--- /dev/null
+++ b/userscript/test/logic.test.js
@@ -0,0 +1,186 @@
+"use strict";
+
+const test = require("node:test");
+const assert = require("node:assert");
+
+// ============================================================
+// Minimal browser stub
+//
+// manga-bookmark.user.js is a browser IIFE. At module scope it touches
+// localStorage (loadCache, loadQueue, the key-migration IIFE) and, on its very
+// last lines, document. Nothing else runs under test: document.body is left
+// UNDEFINED, so the boot block takes its `else` branch and waits for a
+// DOMContentLoaded that never fires. buildUI, init, and every network call stay
+// dormant with no further stubbing.
+// ============================================================
+
+const store = new Map();
+globalThis.localStorage = {
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
+ setItem: (k, v) => store.set(k, String(v)),
+ removeItem: (k) => store.delete(k),
+};
+
+// location stub. The IIFE reads location.href during boot; set it to a dummy value.
+globalThis.location = {
+ href: "about:blank",
+ hostname: "",
+ pathname: "/",
+ origin: "",
+};
+
+// og: meta tags the adapters read through meta(). Reassigned per test.
+let metaTags = {};
+globalThis.document = {
+ querySelector(sel) {
+ const m = sel.match(/^meta\[property="([^"]+)"\]$/);
+ if (!m) return null;
+ const v = metaTags[m[1]];
+ return v == null ? null : { getAttribute: () => v };
+ },
+ addEventListener() {},
+ body: undefined,
+};
+
+const {
+ stripBuildHash,
+ asura,
+ demonic,
+ anchorsFromHTML,
+ statusOf,
+} = require("../manga-bookmark.user.js");
+
+// detect() reads only these four properties off location.
+function loc(href) {
+ const u = new URL(href);
+ return { pathname: u.pathname, origin: u.origin, href: u.href, hostname: u.hostname };
+}
+
+// ============================================================
+// stripBuildHash — Asura's slug suffix rotates on every redeploy, so it must
+// not be part of the series identity. Must stay in sync with asuraBuildHash in
+// backend/store.go.
+// ============================================================
+
+test("stripBuildHash removes a trailing 8-hex suffix", () => {
+ assert.equal(stripBuildHash("solo-leveling-059befe1"), "solo-leveling");
+});
+
+test("stripBuildHash leaves a slug with no suffix alone", () => {
+ assert.equal(stripBuildHash("solo-leveling"), "solo-leveling");
+});
+
+test("stripBuildHash ignores suffixes that are not exactly 8 hex chars", () => {
+ assert.equal(stripBuildHash("solo-leveling-abc1234"), "solo-leveling-abc1234");
+ assert.equal(stripBuildHash("solo-leveling-059befe1a"), "solo-leveling-059befe1a");
+ assert.equal(stripBuildHash("solo-leveling-059befeg1"), "solo-leveling-059befeg1");
+});
+
+// ============================================================
+// Asura adapter
+// ============================================================
+
+test("asura.detect reads a series page, stripping the hash from the id only", () => {
+ metaTags = { "og:title": "Solo Leveling | Asura Scans", "og:image": "https://cdn.example/x.jpg" };
+ const p = asura.detect(loc("https://asurascans.com/comics/solo-leveling-059befe1"));
+ assert.equal(p.type, "series");
+ assert.equal(p.site, "asura");
+ assert.equal(p.seriesId, "solo-leveling");
+ // seriesUrl keeps the hash: navigation needs the current one (stale ones 302).
+ assert.equal(p.seriesUrl, "https://asurascans.com/comics/solo-leveling-059befe1");
+ assert.equal(p.title, "Solo Leveling");
+ assert.equal(p.cover, "https://cdn.example/x.jpg");
+ assert.equal(p.chapterNum, null);
+});
+
+test("asura.detect reads a chapter page including a decimal number", () => {
+ metaTags = { "og:title": "Solo Leveling Chapter 12.5 - Read Online | Asura Scans", "og:image": "" };
+ const url = "https://asurascans.com/comics/solo-leveling-059befe1/chapter/12.5";
+ const p = asura.detect(loc(url));
+ assert.equal(p.type, "chapter");
+ assert.equal(p.seriesId, "solo-leveling");
+ assert.equal(p.chapterNum, 12.5);
+ assert.equal(p.chapterLabel, "Chapter 12.5");
+ assert.equal(p.chapterUrl, url);
+ assert.equal(p.title, "Solo Leveling");
+});
+
+test("asura.detect returns other for non-series paths", () => {
+ metaTags = {};
+ assert.equal(asura.detect(loc("https://asurascans.com/")).type, "other");
+ assert.equal(asura.detect(loc("https://asurascans.com/bookmarks")).type, "other");
+});
+
+test("asura.latestChapterFromAnchors takes the highest and skips the First Chapter shortcut", () => {
+ const best = asura.latestChapterFromAnchors([
+ { href: "/comics/solo-leveling-059befe1/chapter/1", text: "Chapter 1" },
+ { href: "/comics/solo-leveling-059befe1/chapter/10", text: "Chapter 10" },
+ { href: "/comics/solo-leveling-059befe1/chapter/2", text: "Chapter 2" },
+ // Points at a chapter but is not a list entry — the text check excludes it.
+ { href: "/comics/solo-leveling-059befe1/chapter/1", text: "First Chapter" },
+ ]);
+ assert.deepEqual(best, { num: 10, label: "Chapter 10" });
+});
+
+test("asura.latestChapterFromAnchors returns null when nothing matches", () => {
+ assert.equal(asura.latestChapterFromAnchors([{ href: "/about", text: "About" }]), null);
+});
+
+// ============================================================
+// Demonic adapter
+// ============================================================
+
+test("demonic.detect reads a series page", () => {
+ metaTags = { "og:title": "The World After The Fall", "og:image": "https://cdn.example/y.jpg" };
+ const p = demonic.detect(loc("https://demonicscans.org/manga/the-world-after-the-fall"));
+ assert.equal(p.type, "series");
+ assert.equal(p.site, "demonic");
+ assert.equal(p.seriesId, "the-world-after-the-fall");
+ assert.equal(p.title, "The World After The Fall");
+});
+
+test("demonic.detect reads a chapter page and strips the suffix from the title", () => {
+ metaTags = { "og:title": "The World After The Fall Chapter 3", "og:image": "" };
+ const p = demonic.detect(loc("https://demonicscans.org/title/the-world-after-the-fall/chapter/3/1"));
+ assert.equal(p.type, "chapter");
+ assert.equal(p.chapterNum, 3);
+ assert.equal(p.chapterLabel, "Chapter 3");
+ assert.equal(p.seriesUrl, "https://demonicscans.org/manga/the-world-after-the-fall");
+ assert.equal(p.title, "The World After The Fall");
+});
+
+test("demonic percent-encoded slugs decode to the same id on /manga/ and /title/", () => {
+ metaTags = {};
+ const series = demonic.detect(loc("https://demonicscans.org/manga/i%2527m-the-max-level-newbie"));
+ const chapter = demonic.detect(loc("https://demonicscans.org/title/i%2527m-the-max-level-newbie/chapter/3/1"));
+ assert.equal(series.seriesId, chapter.seriesId);
+ assert.equal(series.seriesId, "i%27m-the-max-level-newbie");
+});
+
+test("demonic.latestChapterFromAnchors parses chaptered.php links, including &", () => {
+ const best = demonic.latestChapterFromAnchors([
+ { href: "chaptered.php?manga=5&chapter=3", text: "Chapter 3" },
+ { href: "chaptered.php?manga=5&chapter=12", text: "Chapter 12" },
+ { href: "/manga/something", text: "Series" },
+ ]);
+ assert.deepEqual(best, { num: 12, label: "Chapter 12" });
+});
+
+// ============================================================
+// Shared helpers
+// ============================================================
+
+test("anchorsFromHTML pulls href/text pairs and flattens nested tags", () => {
+ const out = anchorsFromHTML('OneTwo more');
+ assert.equal(out.length, 2);
+ assert.deepEqual(out[0], { href: "/a", text: "One" });
+ assert.equal(out[1].href, "/b");
+ assert.equal(out[1].text.replace(/\s+/g, " ").trim(), "Two more");
+});
+
+test("statusOf defaults a missing status to reading", () => {
+ assert.equal(statusOf({}), "reading");
+ assert.equal(statusOf({ status: "" }), "reading");
+ assert.equal(statusOf({ status: "archived" }), "archived");
+ assert.equal(statusOf({ status: "finished" }), "finished");
+});