"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 = {}; // document.title. comix's SPA rewrites this on client routing but never // og:title, so the comix adapter reads it instead. Reassigned per test. let docTitle = ""; 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() {}, get title() { return docTitle; }, body: undefined, }; const { stripBuildHash, comixSeriesId, asura, demonic, comix, kagane, anchorsFromHTML, statusOf, kindOf, } = 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" }; 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.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" }; 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" }; 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" }; 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" }); }); // ============================================================ // comix — the id prefix is the stable identity; the slug tail is a title // rendering that changes when a series is renamed. // ============================================================ test("comixSeriesId keeps only the prefix before the first dash", () => { assert.equal(comixSeriesId("n8we-dungeons-and-crayons"), "n8we"); assert.equal(comixSeriesId("20xzd-the-baddest-villainess-is-back"), "20xzd"); }); test("comixSeriesId leaves a bare id untouched", () => { assert.equal(comixSeriesId("n8we"), "n8we"); }); // comix is an SPA that rewrites document.title on client routing but leaves the // server-rendered og:title untouched, so every test here pins og:title to a // STALE value — the homepage title on first hop, the previous series after // that. Captured live 2026-08-08. const COMIX_STALE_HOME = "Comix - Read Comics online for free"; test("comix detects a series page", () => { metaTags = { "og:title": COMIX_STALE_HOME }; docTitle = "Dungeons and Crayons"; const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons")); assert.equal(p.type, "series"); assert.equal(p.site, "comix"); assert.equal(p.seriesId, "n8we"); assert.equal(p.title, "Dungeons and Crayons"); assert.equal(p.seriesUrl, "https://comix.to/title/n8we-dungeons-and-crayons"); assert.equal(p.chapterNum, null); }); test("comix ignores a previous series' stale og:title", () => { metaTags = { "og:title": "Full-Time Awakening" }; docTitle = "Dungeons and Crayons"; const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons")); assert.equal(p.title, "Dungeons and Crayons"); }); test("comix detects a chapter page and strips the Ch. suffix from the title", () => { metaTags = { "og:title": COMIX_STALE_HOME }; docTitle = "Dungeons and Crayons · Ch.80"; const p = comix.detect( loc("https://comix.to/title/n8we-dungeons-and-crayons/11139891-chapter-80") ); assert.equal(p.type, "chapter"); assert.equal(p.seriesId, "n8we"); assert.equal(p.title, "Dungeons and Crayons"); assert.equal(p.chapterNum, 80); assert.equal(p.chapterLabel, "Chapter 80"); assert.equal(p.seriesUrl, "https://comix.to/title/n8we-dungeons-and-crayons"); }); test("comix parses decimal chapter numbers", () => { metaTags = {}; docTitle = "Dungeons and Crayons · Ch.80.5"; const p = comix.detect( loc("https://comix.to/title/n8we-dungeons-and-crayons/11139891-chapter-80.5") ); assert.equal(p.chapterNum, 80.5); }); test("comix ignores unrelated paths", () => { assert.equal(comix.detect(loc("https://comix.to/browse")).type, "other"); }); test("comix takes the max chapter and ignores other series' links", () => { const anchors = anchorsFromHTML(` Chapter 79 Chapter 80 Chapter 78 Chapter 999 `); assert.deepEqual(comix.latestChapterFromAnchors(anchors, "n8we"), { num: 80, label: "Chapter 80", }); }); test("comix latest returns null when no chapter links are present", () => { assert.equal(comix.latestChapterFromAnchors(anchorsFromHTML("x"), "n8we"), null); }); test("asura and demonic ignore the seriesId argument", () => { const asuraAnchors = anchorsFromHTML( `Chapter 12` ); assert.deepEqual(asura.latestChapterFromAnchors(asuraAnchors, "ignored"), { num: 12, label: "Chapter 12", }); }); // ============================================================ // kagane — reader URLs carry uuids and no chapter number, so the number has to // come out of og:title. When that fails, chapterNum is null and the existing // progress logic records the current chapter rather than guessing. // ============================================================ const KAGANE_SERIES = "019f84bc-9ba0-7ed9-86f5-8b905ec7c28b"; const KAGANE_BOOK = "019fa2e0-6dbd-73ca-b40b-fe06ab75eb0e"; test("kagane detects a series page", () => { metaTags = { "og:title": "Infinite Decryption: The Strongest Level 0" }; const p = kagane.detect(loc("https://kagane.to/series/" + KAGANE_SERIES)); assert.equal(p.type, "series"); assert.equal(p.site, "kagane"); assert.equal(p.seriesId, KAGANE_SERIES); assert.equal(p.title, "Infinite Decryption: The Strongest Level 0"); assert.equal(p.seriesUrl, "https://kagane.to/series/" + KAGANE_SERIES); }); test("kagane reads the chapter number out of og:title", () => { metaTags = { "og:title": "Infinite Decryption: The Strongest Level 0 - Chapter 41 - Episode 41", }; const p = kagane.detect( loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK) ); assert.equal(p.type, "chapter"); assert.equal(p.seriesId, KAGANE_SERIES); assert.equal(p.title, "Infinite Decryption: The Strongest Level 0"); assert.equal(p.chapterNum, 41); assert.equal(p.chapterLabel, "Chapter 41"); assert.equal(p.seriesUrl, "https://kagane.to/series/" + KAGANE_SERIES); }); // Volume-numbered series render the suffix as "- Volume Chapter " with // no episode name, because the book carries volume_no and an empty title. // Captured live 2026-08-08 from SP Baby. test("kagane reads through a Volume-numbered chapter suffix", () => { metaTags = { "og:title": "SP Baby - Volume 1 Chapter 1" }; const p = kagane.detect( loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK) ); assert.equal(p.title, "SP Baby"); assert.equal(p.chapterNum, 1); assert.equal(p.chapterLabel, "Chapter 1"); }); // A book with neither a volume nor an episode name ends the title right after // the number, which the old trailing-\s regex could not match. test("kagane reads a chapter suffix with no episode name", () => { metaTags = { "og:title": "Some Series - Chapter 7.5" }; const p = kagane.detect( loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK) ); assert.equal(p.title, "Some Series"); assert.equal(p.chapterNum, 7.5); }); test("kagane yields a null chapterNum when og:title has no chapter", () => { metaTags = { "og:title": "Infinite Decryption: The Strongest Level 0" }; const p = kagane.detect( loc("https://kagane.to/series/" + KAGANE_SERIES + "/reader/" + KAGANE_BOOK) ); assert.equal(p.type, "chapter"); assert.equal(p.chapterNum, null); }); test("kagane ignores unrelated paths", () => { assert.equal(kagane.detect(loc("https://kagane.to/search")).type, "other"); }); test("kagane anchor scanning is structurally impossible and returns null", () => { const anchors = anchorsFromHTML( `Chapter 41` ); assert.equal(kagane.latestChapterFromAnchors(anchors, KAGANE_SERIES), null); }); test("kagane latestChapterFromApi takes the max chapter_no", async () => { globalThis.fetch = async (url) => { assert.equal(url, "/api/v2/series/" + KAGANE_SERIES); return { ok: true, json: async () => ({ series_books: [ { chapter_no: "1", title: "Episode 1" }, { chapter_no: "41", title: "Episode 41" }, { chapter_no: "40.5", title: "Episode 40.5" }, ], }), }; }; assert.deepEqual(await kagane.latestChapterFromApi(KAGANE_SERIES), { num: 41, label: "Chapter 41", }); }); test("kagane latestChapterFromApi returns null on a challenge or error", async () => { globalThis.fetch = async () => ({ ok: false, status: 403 }); assert.equal(await kagane.latestChapterFromApi(KAGANE_SERIES), null); }); // ============================================================ // 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"); }); // ============================================================ // kindOf — a row written before the kind column existed has none, and every // one of those is manga. // ============================================================ test("kindOf defaults a missing kind to manga", () => { assert.equal(kindOf({}), "manga"); assert.equal(kindOf({ kind: "" }), "manga"); }); test("kindOf passes through an explicit kind", () => { assert.equal(kindOf({ kind: "novel" }), "novel"); });