"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 = {};
// img[alt] elements comix's coverFromPage() scans. Reassigned per test; each
// entry is {alt, src}.
let pageImages = [];
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 };
},
querySelectorAll(sel) {
if (sel !== "img[alt]") return [];
return pageImages.map((img) => ({
getAttribute: (attr) => img[attr] ?? null,
}));
},
addEventListener() {},
body: undefined,
};
const {
stripBuildHash,
comixSeriesId,
asura,
demonic,
comix,
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" });
});
// ============================================================
// 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");
});
test("comix detects a series page", () => {
metaTags = { "og:title": "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 detects a chapter page and strips the Ch. suffix from the title", () => {
metaTags = { "og:title": "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 = { "og:title": "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.detect reads the cover from an img whose alt matches the cleaned title", () => {
metaTags = { "og:title": "Dungeons and Crayons · Ch.80" };
pageImages = [
{ alt: "Some Other Series", src: "https://cdn.example/other.jpg" },
{ alt: "Dungeons and Crayons", src: "https://cdn.example/cover.jpg" },
];
const p = comix.detect(
loc("https://comix.to/title/n8we-dungeons-and-crayons/11139891-chapter-80")
);
assert.equal(p.cover, "https://cdn.example/cover.jpg");
});
test("comix.detect leaves cover empty when no img alt matches the title", () => {
metaTags = { "og:title": "Dungeons and Crayons" };
pageImages = [{ alt: "Some Other Series", src: "https://cdn.example/other.jpg" }];
const p = comix.detect(loc("https://comix.to/title/n8we-dungeons-and-crayons"));
assert.equal(p.cover, "");
pageImages = [];
});
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",
});
});
// ============================================================
// 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");
});