"use strict"; const test = require("node:test"); const assert = require("node:assert"); // ============================================================ // Minimal browser stub. Same shape as logic.test.js. // document.body stays UNDEFINED so the boot block waits for a DOMContentLoaded // that never fires and no network call is ever made. // ============================================================ 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), }; globalThis.location = { href: "about:blank", hostname: "", pathname: "/", origin: "" }; let metaTags = {}; let elements = {}; // Attribute selectors (the lightnovelworld Series pointer) answer with an // element exposing getAttribute, like the meta branch below. let attrEls = {}; globalThis.document = { querySelector(sel) { const attr = attrEls[sel]; if (attr != null) return attr; const m = sel.match(/^meta\[property="([^"]+)"\]$/); if (m) { const v = metaTags[m[1]]; return v == null ? null : { getAttribute: () => v }; } const text = elements[sel]; return text == null ? null : { textContent: text }; }, querySelectorAll() { return []; }, addEventListener() {}, body: undefined, }; const { novelfull, lightnovelworld, computeLatestChapter, kindOf, maxChapter, repairLnwStaleRow, finishedLabel, } = require("../novel-bookmark.user.js"); function loc(href) { const u = new URL(href); return { pathname: u.pathname, origin: u.origin, href: u.href, hostname: u.hostname }; } function reset() { metaTags = {}; elements = {}; attrEls = {}; } // ============================================================ // novelfull adapter // ============================================================ test("novelfull.detect reads a series page", () => { reset(); elements = { "h3.title": "Reverend Insanity" }; const p = novelfull.detect(loc("https://novelfull.com/reverend-insanity.html")); assert.equal(p.type, "series"); assert.equal(p.site, "novelfull"); assert.equal(p.seriesId, "reverend-insanity"); assert.equal(p.title, "Reverend Insanity"); assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html"); assert.equal(p.chapterNum, null); }); test("novelfull.detect reads a chapter page and points seriesUrl at the series", () => { reset(); elements = { "a.truyen-title": "Reverend Insanity" }; const url = "https://novelfull.com/reverend-insanity/chapter-2334-fang-yuan.html"; const p = novelfull.detect(loc(url)); assert.equal(p.type, "chapter"); assert.equal(p.seriesId, "reverend-insanity"); assert.equal(p.chapterNum, 2334); assert.equal(p.chapterLabel, "Chapter 2334"); assert.equal(p.chapterUrl, url); assert.equal(p.seriesUrl, "https://novelfull.com/reverend-insanity.html"); assert.equal(p.title, "Reverend Insanity"); }); test("novelfull.detect returns other for non-series paths", () => { reset(); assert.equal(novelfull.detect(loc("https://novelfull.com/")).type, "other"); assert.equal(novelfull.detect(loc("https://novelfull.com/genre/Fantasy")).type, "other"); }); test("novelfull.latestChapterFromAnchors takes the max and ignores other series", () => { const best = novelfull.latestChapterFromAnchors( [ { href: "/reverend-insanity/chapter-2334-fang-yuan.html", text: "Chapter 2334" }, { href: "/reverend-insanity/chapter-1.html", text: "Chapter 1" }, { href: "/reverend-insanity/chapter-2.html", text: "Chapter 2" }, { href: "/release-that-witch/chapter-9999.html", text: "Chapter 9999" }, ], "reverend-insanity" ); assert.deepEqual(best, { num: 2334, label: "Chapter 2334" }); }); // ============================================================ // lightnovelworld adapter // ============================================================ test("lightnovelworld.detect reads a series page", () => { reset(); elements = { "h1.entry-title": "A Will Eternal" }; const p = lightnovelworld.detect(loc("https://lightnovelworld.net/novel/a-will-eternal/")); assert.equal(p.type, "series"); assert.equal(p.site, "lightnovelworld"); assert.equal(p.seriesId, "a-will-eternal"); assert.equal(p.title, "A Will Eternal"); // A series page's address *is* its identity; there is no Chapter Slug. assert.equal(p.chapterSlug, null); }); test("lightnovelworld.detect strips the chapter suffix off the heading", () => { reset(); elements = { "h1.entry-title": "A Will Eternal Chapter 1298" }; attrEls = { "a[aria-label='All Chapter']": { getAttribute: () => "https://lightnovelworld.net/novel/a-will-eternal/", }, }; const url = "https://lightnovelworld.net/a-will-eternal-chapter-1298/"; const p = lightnovelworld.detect(loc(url)); assert.equal(p.type, "chapter"); assert.equal(p.seriesId, "a-will-eternal"); assert.equal(p.chapterNum, 1298); assert.equal(p.chapterLabel, "Chapter 1298"); assert.equal(p.title, "A Will Eternal"); assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/"); }); test("lightnovelworld.detect reads the Series identity from the page pointer on a chapter page", () => { reset(); elements = { "h1.entry-title": "A Will Eternal Chapter 1298" }; attrEls = { // Verbatim from the real page (research note §3): the All Chapter anchor // carries the absolute Series address. "a[aria-label='All Chapter']": { getAttribute: () => "https://lightnovelworld.net/novel/a-will-eternal/", }, }; const url = "https://lightnovelworld.net/a-will-eternal-chapter-1298/"; const p = lightnovelworld.detect(loc(url)); assert.equal(p.type, "chapter"); assert.equal(p.seriesId, "a-will-eternal"); assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/"); assert.equal(p.chapterSlug, "a-will-eternal"); assert.equal(p.chapterNum, 1298); }); test("lightnovelworld.detect falls back to the breadcrumb when the pointer is absent", () => { reset(); elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" }; const url = "https://lightnovelworld.net/my-longevity-simulation-chapter-1/"; // Same divergent page, both pointers from the real markup (research note // §3): breadcrumb position 2 must say what the All Chapter anchor says. attrEls = { "a[aria-label='All Chapter']": { getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/", }, }; const viaPointer = lightnovelworld.detect(loc(url)); attrEls = { '[itemtype="http://schema.org/BreadcrumbList"] a[itemprop="item"][href*="/novel/"]': { getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/", }, }; const viaBreadcrumb = lightnovelworld.detect(loc(url)); assert.equal(viaBreadcrumb.type, "chapter"); assert.equal(viaBreadcrumb.seriesId, viaPointer.seriesId); assert.equal(viaBreadcrumb.seriesUrl, viaPointer.seriesUrl); assert.equal(viaBreadcrumb.seriesId, "immortality-simulator"); assert.equal(viaBreadcrumb.chapterSlug, "my-longevity-simulation"); }); test("lightnovelworld.detect resolves a relative pointer against the page address", () => { // Defensive: every measured page ships an absolute pointer href, but a // theme change could go relative — the pointer is still this page's own // link back to its Series. reset(); elements = { "h1.entry-title": "A Will Eternal Chapter 1298" }; attrEls = { "a[aria-label='All Chapter']": { getAttribute: () => "/novel/a-will-eternal/" }, }; const p = lightnovelworld.detect(loc("https://lightnovelworld.net/a-will-eternal-chapter-1298/")); assert.equal(p.type, "chapter"); assert.equal(p.seriesId, "a-will-eternal"); assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/a-will-eternal/"); }); test("lightnovelworld.detect rejects a pointer that is not a /novel/ address on its own host", () => { // Counterfactual pointers, exercising the criterion that a pointer "present // but not parseable as /novel// on lightnovelworld.net" must not // become a seriesUrl the backend is later asked to Poll: off-host, and // on-host but the wrong path shape. reset(); elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" }; const url = "https://lightnovelworld.net/my-longevity-simulation-chapter-1/"; attrEls = { "a[aria-label='All Chapter']": { getAttribute: () => "https://evil.example/novel/immortality-simulator/", }, }; assert.equal(lightnovelworld.detect(loc(url)).type, "other"); attrEls = { "a[aria-label='All Chapter']": { getAttribute: () => "https://lightnovelworld.net/fiction/immortality-simulator/", }, }; assert.equal(lightnovelworld.detect(loc(url)).type, "other"); }); test("lightnovelworld.detect resolves to other when the page carries no pointer", () => { reset(); elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" }; const p = lightnovelworld.detect( loc("https://lightnovelworld.net/my-longevity-simulation-chapter-1/") ); assert.equal(p.type, "other"); }); test("lightnovelworld pins the divergent novel: the pointer's slug wins over the address's", () => { // Regression pin for the derivation defect (research note §2/§3): the // chapter address is built from "my-longevity-simulation" but the Series // is published as "immortality-simulator". If the adapter ever derives the // identity from the address again, this test goes red. reset(); elements = { "h1.entry-title": "My Longevity Simulation Chapter 1" }; attrEls = { "a[aria-label='All Chapter']": { getAttribute: () => "https://lightnovelworld.net/novel/immortality-simulator/", }, }; const url = "https://lightnovelworld.net/my-longevity-simulation-chapter-1/"; const p = lightnovelworld.detect(loc(url)); assert.equal(p.type, "chapter"); assert.equal(p.seriesId, "immortality-simulator"); assert.equal(p.seriesUrl, "https://lightnovelworld.net/novel/immortality-simulator/"); assert.equal(p.chapterSlug, "my-longevity-simulation"); assert.notEqual(p.chapterSlug, p.seriesId); }); test("lightnovelworld.detect resolves a novel whose heading ends in a chapter number", () => { // The split novel from research §4.4, chapter 200 (published under the // current slug). Its heading ends "…Not Them All Chapter 200", which would // false-match a selector that looks for the text "All Chapter". reset(); elements = { "h1.entry-title": "All Jobs and Classes I Just Wanted One Skill Not Them All Chapter 200", }; attrEls = { "a[aria-label='All Chapter']": { getAttribute: () => "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/", }, }; const url = "https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-200/"; const p = lightnovelworld.detect(loc(url)); assert.equal(p.type, "chapter"); assert.equal(p.seriesId, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all"); assert.equal(p.title, "All Jobs and Classes I Just Wanted One Skill Not Them All"); assert.equal(p.chapterNum, 200); assert.equal(p.chapterSlug, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all"); }); test("lightnovelworld.detect resolves both Chapter Slugs of the split novel to one Series", () => { // Research note §4.4: this Series serves chapters 1-99 under one Chapter // Slug and 100-423 under another; both chapter addresses are live and both // point back at the same Series. An old deep link must not get a different // identity than a current one. reset(); elements = { "h1.entry-title": "All Jobs and Classes I Just Wanted One Skill Not Chapter 1" }; attrEls = { "a[aria-label='All Chapter']": { getAttribute: () => "https://lightnovelworld.net/novel/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all/", }, }; const oldSlug = lightnovelworld.detect( loc("https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-chapter-1/") ); const currentSlug = lightnovelworld.detect( loc( "https://lightnovelworld.net/all-jobs-and-classes-i-just-wanted-one-skill-not-them-all-chapter-404/" ) ); assert.equal(oldSlug.type, "chapter"); assert.equal(oldSlug.seriesId, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all"); assert.equal(oldSlug.chapterSlug, "all-jobs-and-classes-i-just-wanted-one-skill-not"); assert.equal(currentSlug.type, "chapter"); assert.equal(currentSlug.seriesId, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all"); assert.equal(currentSlug.chapterSlug, "all-jobs-and-classes-i-just-wanted-one-skill-not-them-all"); }); test("lightnovelworld.detect returns other for non-series paths", () => { reset(); assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/")).type, "other"); assert.equal(lightnovelworld.detect(loc("https://lightnovelworld.net/az-lists/")).type, "other"); }); test("computeLatestChapter yields nothing for lightnovelworld, comment anchors included", () => { // A realistic Series-page anchor set: current-slug chapters, a second // Chapter Slug's chapters, and a wpdiscuz comment pasting a high-numbered // chapter of another novel (the poisoning vector). The client never scans // this Site — the Poll's cooldown dominates the client throttle and the // comment thread is a public write surface — so this must fail the moment // a scan is reintroduced, scoped or not. const latest = computeLatestChapter( "lightnovelworld", [ { href: "https://lightnovelworld.net/a-will-eternal-chapter-1/", text: "Chapter 1" }, { href: "https://lightnovelworld.net/a-will-eternal-chapter-1317/", text: "Chapter 1317" }, { href: "https://lightnovelworld.net/a-will-eternal-chapter-1298/", text: "Chapter 1298" }, // a divergent novel's second Chapter Slug { href: "https://lightnovelworld.net/my-longevity-simulation-chapter-400/", text: "Chapter 400" }, // a wpdiscuz comment anchor { href: "https://lightnovelworld.net/overgeared-chapter-9999/", text: "Chapter 9999" }, ], "a-will-eternal" ); assert.equal(latest, null); }); test("computeLatestChapter still scans novelfull and ignores other series", () => { const best = computeLatestChapter( "novelfull", [ { href: "/reverend-insanity/chapter-2334-fang-yuan.html", text: "Chapter 2334" }, { href: "/reverend-insanity/chapter-1.html", text: "Chapter 1" }, { href: "/release-that-witch/chapter-9999.html", text: "Chapter 9999" }, ], "reverend-insanity" ); assert.deepEqual(best, { num: 2334, label: "Chapter 2334" }); }); test("latestChapterFromAnchors returns null when nothing matches", () => { assert.equal(novelfull.latestChapterFromAnchors([{ href: "/about", text: "About" }], "x"), null); assert.equal(maxChapter([], /chapter-([0-9.]+)/), null); }); // ============================================================ // repairLnwStaleRow — the row migration (spec seam 3) // ============================================================ // A row as bookmarkCurrent builds it, keyed under the invented Chapter Slug // identity the old adapter derived from the chapter address. function staleRow(over) { return Object.assign( { key: "lightnovelworld:my-longevity-simulation", site: "lightnovelworld", kind: "novel", series_id: "my-longevity-simulation", title: "My Longevity Simulation", series_url: "https://lightnovelworld.net/novel/my-longevity-simulation/", last_chapter: "Chapter 7", last_chapter_num: 7, last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-7/", updated_at: 1000, }, over ); } // The divergent novel from research §3: the address's slug is a Chapter Slug, // the page pointer names the real Series. function lnwPage(over) { return Object.assign( { type: "chapter", site: "lightnovelworld", seriesId: "immortality-simulator", chapterSlug: "my-longevity-simulation", seriesUrl: "https://lightnovelworld.net/novel/immortality-simulator/", }, over ); } test("repairLnwStaleRow rewrites the row, the queue entry and the last-checked map together", () => { const list = [staleRow()]; const queue = [ { key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: false, attempts: 2 }, ]; const lastChecked = { "lightnovelworld:my-longevity-simulation": 12345 }; const out = repairLnwStaleRow(list, queue, lastChecked, lnwPage()); assert.equal(out.list.length, 1); assert.equal(out.list[0].key, "lightnovelworld:immortality-simulator"); assert.equal(out.list[0].series_id, "immortality-simulator"); assert.equal(out.list[0].series_url, "https://lightnovelworld.net/novel/immortality-simulator/"); assert.deepEqual(out.queue, [ { key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 2 }, ]); assert.deepEqual(out.lastChecked, { "lightnovelworld:immortality-simulator": 12345 }); }); test("repairLnwStaleRow keeps Progress, Favourite and Lifecycle bucket on the rewritten row", () => { const stale = staleRow({ last_chapter: "Chapter 42", last_chapter_num: 42, last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-42/", favorite: true, status: "archived", updated_at: 777, }); const out = repairLnwStaleRow([stale], [], {}, lnwPage()); const b = out.list[0]; assert.equal(b.last_chapter, "Chapter 42"); assert.equal(b.last_chapter_num, 42); assert.equal( b.last_chapter_url, "https://lightnovelworld.net/my-longevity-simulation-chapter-42/" ); assert.equal(b.favorite, true); assert.equal(b.status, "archived"); assert.equal(b.updated_at, 777); assert.equal(b.title, "My Longevity Simulation"); }); test("repairLnwStaleRow changes nothing when the Chapter Slug and Series slug agree", () => { const list = [ staleRow({ key: "lightnovelworld:a-will-eternal", series_id: "a-will-eternal" }), ]; const queue = [{ key: "lightnovelworld:a-will-eternal", op: "put", sendStatus: true, attempts: 1 }]; const lastChecked = { "lightnovelworld:a-will-eternal": 99 }; const out = repairLnwStaleRow( list, queue, lastChecked, lnwPage({ chapterSlug: "a-will-eternal", seriesId: "a-will-eternal", seriesUrl: "https://lightnovelworld.net/novel/a-will-eternal/", }) ); assert.equal(out.list, list); // same references, nothing rewritten assert.equal(out.queue, queue); assert.equal(out.lastChecked, lastChecked); }); test("repairLnwStaleRow changes nothing when no row sits under the old key", () => { const list = [ staleRow({ key: "lightnovelworld:immortality-simulator", series_id: "immortality-simulator", series_url: "https://lightnovelworld.net/novel/immortality-simulator/", }), ]; const lastChecked = { "lightnovelworld:immortality-simulator": 99 }; const out = repairLnwStaleRow(list, [], lastChecked, lnwPage()); assert.equal(out.list, list); assert.equal(out.queue.length, 0); assert.equal(out.lastChecked, lastChecked); }); test("repairLnwStaleRow ignores a page that carries no Chapter Slug", () => { const list = [ staleRow({ key: "lightnovelworld:immortality-simulator", series_id: "immortality-simulator", }), ]; const lastChecked = { "lightnovelworld:immortality-simulator": 99 }; // A series page (chapterSlug null) and another site both stay untouched. const series = repairLnwStaleRow(list, [], lastChecked, lnwPage({ chapterSlug: null })); const other = repairLnwStaleRow(list, [], lastChecked, { type: "chapter", site: "novelfull", seriesId: "x", }); assert.equal(series.list, list); assert.equal(other.list, list); }); test("repairLnwStaleRow still moves the row and the last-checked map when the queue has no entry", () => { const out = repairLnwStaleRow( [staleRow()], [], { "lightnovelworld:my-longevity-simulation": 5 }, lnwPage() ); assert.equal(out.list[0].key, "lightnovelworld:immortality-simulator"); assert.deepEqual(out.queue, []); assert.deepEqual(out.lastChecked, { "lightnovelworld:immortality-simulator": 5 }); }); test("repairLnwStaleRow carries a queued change to the repaired identity with its marker intact", () => { const queue = [ { key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: true, attempts: 3 }, ]; const out = repairLnwStaleRow([staleRow()], queue, {}, lnwPage()); assert.deepEqual(out.queue, [ { key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: true, attempts: 3 }, ]); }); test("repairLnwStaleRow merges a duplicate under the repaired key, keeping the farther-ahead progress", () => { const canonical = staleRow({ key: "lightnovelworld:immortality-simulator", series_id: "immortality-simulator", series_url: "https://lightnovelworld.net/novel/immortality-simulator/", last_chapter: "Chapter 5", last_chapter_num: 5, last_chapter_url: "https://lightnovelworld.net/immortality-simulator-chapter-5/", favorite: false, }); const stale = staleRow({ last_chapter: "Chapter 42", last_chapter_num: 42, last_chapter_url: "https://lightnovelworld.net/my-longevity-simulation-chapter-42/", favorite: true, status: "archived", }); const queue = [ { key: "lightnovelworld:my-longevity-simulation", op: "put", sendStatus: true, attempts: 3 }, { key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: false, attempts: 0 }, ]; const out = repairLnwStaleRow([canonical, stale], queue, {}, lnwPage()); assert.equal(out.list.length, 1); const merged = out.list[0]; assert.equal(merged.key, "lightnovelworld:immortality-simulator"); assert.equal(merged.last_chapter_num, 42); // the stale row is ahead — progress must not be lost assert.equal(merged.favorite, true); // favourite survives from either row assert.equal(merged.status, "archived"); // the stronger bucket survives // One marker under the repaired key: sendStatus is sticky (the archive // intent from the stale-key marker survives) and the worse attempts count // wins — the queue's own coalescing rules. assert.deepEqual(out.queue, [ { key: "lightnovelworld:immortality-simulator", op: "put", sendStatus: true, attempts: 3 }, ]); }); test("repairLnwStaleRow does not regress progress when the repaired-key row is ahead", () => { const canonical = staleRow({ key: "lightnovelworld:immortality-simulator", series_id: "immortality-simulator", series_url: "https://lightnovelworld.net/novel/immortality-simulator/", last_chapter: "Chapter 100", last_chapter_num: 100, last_chapter_url: "https://lightnovelworld.net/immortality-simulator-chapter-100/", }); const stale = staleRow({ last_chapter: "Chapter 42", last_chapter_num: 42 }); const out = repairLnwStaleRow([canonical, stale], [], {}, lnwPage()); assert.equal(out.list.length, 1); assert.equal(out.list[0].last_chapter_num, 100); }); // ============================================================ // Merge rank — exactly two values: archived beats reading, and nothing else // can ever win (issue #157 retired the finished bucket). // ============================================================ test("repairLnwStaleRow merge keeps archived over reading and lets no third value win", () => { const canonical = (over) => staleRow(Object.assign({ key: "lightnovelworld:immortality-simulator" }, over)); // archived beats reading whether it arrives as the stale row or the repaired one let out = repairLnwStaleRow([canonical({ status: "archived" }), staleRow({ status: "reading" })], [], {}, lnwPage()); assert.equal(out.list[0].status, "archived"); out = repairLnwStaleRow([staleRow({ status: "archived" }), canonical({ status: "reading" })], [], {}, lnwPage()); assert.equal(out.list[0].status, "archived"); // "finished" is not a bucket anymore: a row carrying it reads as the // default, so it can never win (issue #157). out = repairLnwStaleRow([staleRow({ status: "finished" }), canonical({ status: "reading" })], [], {}, lnwPage()); assert.equal(out.list[0].status, "reading"); }); // ============================================================ // kindOf // ============================================================ test("kindOf defaults a missing kind to manga", () => { assert.equal(kindOf({}), "manga"); }); test("kindOf passes through novel", () => { assert.equal(kindOf({ kind: "novel" }), "novel"); }); // ============================================================ // finishedLabel — finished is a fact about the Series (issue #157), which is // the server's business; the panel only names it. The label is text through // the el() helper, never markup. // ============================================================ test("finishedLabel names a finished series and nothing else", () => { assert.equal(finishedLabel({ finished: true }), "Finished"); assert.equal(finishedLabel({ finished: false }), ""); assert.equal(finishedLabel({}), ""); }); // ============================================================ // Source guard // // Issue #74: the novel script was split off the manga one and lost four // module-scope constants. The reads sit inside try/catch or a fire-and-forget // promise, so the ReferenceError never surfaced — nothing but a static check // catches this class. // ============================================================ test("every SCREAMING_CASE constant the script uses is declared in it", () => { const fs = require("node:fs"); for (const f of ["novel-bookmark.user.js", "manga-bookmark.user.js"]) { const src = fs.readFileSync(require.resolve("../" + f), "utf8") // comments and strings carry prose and SVG path data in the same shape .replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\/|"[^"\n]*"|'[^'\n]*'|`[\s\S]*?`/g, " "); const declared = new Set( [...src.matchAll(/\b(?:const|let|var|function)\s+([A-Z][A-Z0-9_]{2,})\b/g)].map((m) => m[1]), ); for (const name of new Set(src.match(/\b[A-Z][A-Z0-9_]{2,}\b/g) || [])) { if (name.startsWith("GM_") || name in globalThis) continue; assert.ok(declared.has(name), `${f} uses ${name} but never declares it`); } } });