From 383dbaa7eb27a33962d3d1011e74014343ca1846 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Tue, 28 Jul 2026 15:26:50 +0700 Subject: [PATCH] test: zero-dependency harness for userscript adapter logic Loads the IIFE in bare Node behind a minimal localStorage/document stub, with document.body left undefined so the boot block defers and the UI never builds. Adds a module.exports hook that is inert in every real userscript engine. Co-Authored-By: Claude Opus 5 --- userscript/manga-bookmark.user.js | 12 ++ userscript/test/logic.test.js | 186 ++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 userscript/test/logic.test.js 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"); +});