e72765df85
comix.to is an SPA whose client router rewrites document.title but never
touches the server-rendered og:title. The adapter read og:title, so a
bookmark taken after a cold load got the homepage's title ("Comix - Read
Comics online for free") and one taken after an in-page hop got the
previous series' title. Titles now come from document.title, with the
chapter page's " - Ch.<n>" tail stripped.
comix also serves no og:image at all, which is why every comix bookmark
fell back to the monogram placeholder. The cover is now the img whose alt
matches the cleaned title.
Both fixes need the page to have finished its client-side route change,
and comix fills document.title a beat after the URL changes - later than
the nav watcher's 300ms snapshot. The watcher therefore also re-detects
when the detect() signature changes, not only when the URL does.
kagane reader URLs carry no chapter number, so it comes out of og:title.
Volume-numbered series render "<Series> - Volume <v> Chapter <n>" with no
episode name, a shape the suffix regex did not match. One unmatched title
caused both reported symptoms: the volume tail stayed in the stored title
("SP Baby - Volume 1 Chapter 1"), and chapterNum came back null so no
chapter was ever recorded for the series. The regex now takes an optional
"Volume <v> " segment.
All three page shapes were captured live on 2026-08-08 and are pinned as
regression tests in userscript/test/logic.test.js.
444 lines
17 KiB
JavaScript
444 lines
17 KiB
JavaScript
"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 = "";
|
|
// 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() {},
|
|
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", "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");
|
|
});
|
|
|
|
// 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.detect reads the cover from an img whose alt matches the cleaned title", () => {
|
|
metaTags = { "og:title": COMIX_STALE_HOME };
|
|
docTitle = "Dungeons and Crayons";
|
|
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"));
|
|
assert.equal(p.cover, "https://cdn.example/cover.jpg");
|
|
});
|
|
|
|
test("comix.detect leaves cover empty when no img alt matches the title", () => {
|
|
metaTags = {};
|
|
docTitle = "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(`
|
|
<a href="/title/n8we-dungeons-and-crayons/11123327-chapter-79">Chapter 79</a>
|
|
<a href="/title/n8we-dungeons-and-crayons/11139891-chapter-80">Chapter 80</a>
|
|
<a href="/title/n8we-dungeons-and-crayons/10794753-chapter-78">Chapter 78</a>
|
|
<a href="/title/qqwrm-full-time-awakening/99999999-chapter-999">Chapter 999</a>
|
|
`);
|
|
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("<a href='/browse'>x</a>"), "n8we"), null);
|
|
});
|
|
|
|
test("asura and demonic ignore the seriesId argument", () => {
|
|
const asuraAnchors = anchorsFromHTML(
|
|
`<a href="/comics/x-aabbccdd/chapter/12"><span>Chapter 12</span></a>`
|
|
);
|
|
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",
|
|
"og:image": "https://kagane.to/api/v2/image/abc/compressed",
|
|
};
|
|
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.cover, "https://kagane.to/api/v2/image/abc/compressed");
|
|
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",
|
|
"og:image": "https://kagane.to/api/v2/image/abc/compressed",
|
|
};
|
|
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 <v> Chapter <n>" 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",
|
|
"og:image": "https://kagane.to/api/v2/image/abc/compressed",
|
|
};
|
|
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(
|
|
`<a href="/series/${KAGANE_SERIES}/reader/${KAGANE_BOOK}">Chapter 41</a>`
|
|
);
|
|
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('<a href="/a">One</a><a class="x" href="/b"><span>Two</span> more</a>');
|
|
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");
|
|
});
|