const assert = require("node:assert/strict"); const fs = require("node:fs"); const vm = require("node:vm"); const source = fs.readFileSync("Chaturbate/chaturbate-thumbnails-2x.user.js", "utf8"); let capturedCss = ""; let gmAddStyleCalls = 0; let menuCommandName = ""; let appendedStyle = null; let observedTarget = null; let domContentLoadedHandler = null; const fakeElement = { id: "", textContent: "", setAttribute() {}, }; const fakeDocument = { documentElement: { setAttribute() {}, }, head: { appendChild(node) { appendedStyle = node; }, }, createElement() { return fakeElement; }, getElementById() { return null; }, addEventListener(type, handler) { if (type === "DOMContentLoaded") { domContentLoadedHandler = handler; } }, }; vm.runInNewContext(source, { console, document: fakeDocument, GM_addStyle(css) { gmAddStyleCalls += 1; capturedCss = css; return fakeElement; }, GM_registerMenuCommand(name) { menuCommandName = name; }, MutationObserver: class { observe(target) { observedTarget = target; } }, }); if (!capturedCss && appendedStyle) { capturedCss = appendedStyle.textContent; } assert.equal(gmAddStyleCalls, 1, "script should use GM_addStyle for stable Tampermonkey injection"); assert.equal(menuCommandName, "清除 Chaturbate 缩略图探测缓存", "script should expose a Tampermonkey cache-clear menu command"); assert.equal(appendedStyle, null, "script should not manually append a duplicate style when GM_addStyle succeeds"); assert.equal(fakeElement.id, "tm-thumb-scale-style"); assert.equal(observedTarget, fakeDocument.head, "style reinjection observer should watch document.head only"); assert.equal(domContentLoadedHandler, null, "script should not add extra DOMContentLoaded work when head already exists"); assert.match( capturedCss, /\.FollowedDropdown__rooms\s*(?:,|\{)/, "followed dropdown rooms grid should be resized", ); assert.match( capturedCss, /\.FollowedDropdown__room-image\s*(?:,|\{)/, "followed dropdown preview images should fill enlarged cards", ); assert.match( capturedCss, /#roomlist_root\s+ul\.list:has\(li\.roomCard\)/, "current homepage room lists should be enlarged", ); assert.match( capturedCss, /#roomlist_root\s+ul\.RoomCardGrid/, "current homepage RoomCardGrid lists should also be enlarged", ); assert.match( capturedCss, /data-tm-thumb-scale-following-list="1"[\s\S]*--tm-thumb-following-list-min-width/, "followed-cams list should use its own detected minimum column width", ); assert.match( capturedCss, /data-tm-thumb-scale-following-list="1"[\s\S]*grid-template-columns:\s*repeat\(auto-fill,\s*minmax\(var\(--tm-thumb-following-list-min-width\),\s*1fr\)\)\s*!important/, "followed-cams list should reduce columns responsively using scaled minimum width", ); assert.match( capturedCss, /#main\.roomPage\s+ul\.list:has\(li\.roomCard\)\s+\.room_thumbnail_container[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*auto\s*!important/, "room page related rooms thumbnail containers should follow card width and automatic height", ); assert.match( capturedCss, /\.BaseRoomContents\s+ul\.list:has\(li\.roomCard\)\s+\.room_thumbnail[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*auto\s*!important/, "base room related thumbnails should follow card width and automatic height", ); assert.match( capturedCss, /\.BaseRoomContents\s+ul\.RoomCardGrid[\s\S]*grid-template-columns:\s*repeat\(auto-fill,\s*minmax\(var\(--tm-thumb-related-width\),\s*1fr\)\)\s*!important/, "base room RoomCardGrid related rooms should fill the row using detected scaled minimum width", ); assert.match( capturedCss, /\.BaseRoomContents\s+ul\.RoomCardGrid\s+\.RoomCardThumbnail[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*auto\s*!important/, "base room RoomCardGrid thumbnails should follow card width and automatic height", ); assert.match( capturedCss, /\.BaseRoomContents\s+ul\.RoomCardGrid\s*>\s*li\.RoomCard[\s\S]*height:\s*auto\s*!important[\s\S]*max-height:\s*none\s*!important/, "base room RoomCardGrid related cards should use automatic height", ); assert.match( capturedCss, /\.BaseRoomContents\s+ul\.RoomCardGrid\s*>\s*li\.RoomCard[\s\S]*width:\s*auto\s*!important[\s\S]*justify-self:\s*stretch\s*!important[\s\S]*max-width:\s*none\s*!important/, "base room RoomCardGrid related cards should stretch to fill responsive columns", ); assert.doesNotMatch(capturedCss, /--tm-thumb-min-root:\s*\d+px;/, "script should not hard-code a root thumbnail fallback width"); assert.doesNotMatch(capturedCss, /--tm-thumb-height-root:\s*\d+px;/, "script should not hard-code a root thumbnail fallback height"); assert.match( capturedCss, /\.carousel-root\s+\.room-list-carousel\s+ul\.list\s*>\s*li/, "discover carousel ul.list items should be enlarged", ); assert.match( capturedCss, /#discover_root\s+\.room-list-carousel\s+\.room_thumbnail_container\s+img/, "discover carousel thumbnail images should fill enlarged items", ); assert.match( capturedCss, /#discover_root\s+\.room-list-carousel\s+\.room_thumbnail_container[\s\S]*height:\s*var\(--tm-thumb-discover-height\)\s*!important/, "discover carousel thumbnail containers should use detected scaled height", ); assert.match( capturedCss, /height:\s*var\(--tm-thumb-discover-triple-ul\)\s*!important/, "discover carousel container heights should use detected original heights", ); assert.match( capturedCss, /height:\s*var\(--tm-thumb-discover-double-arrow\)\s*!important/, "discover carousel arrow heights should use detected original heights", ); assert.match( capturedCss, /data-tm-thumb-scale-followed-page="1"[\s\S]*\.carousel-root\s+\.single-row\s+ul\.list[\s\S]*height:\s*auto\s*!important[\s\S]*max-height:\s*none\s*!important[\s\S]*overflow:\s*visible\s*!important/, "followed-cams recommendations should release carousel list height after scaling", ); assert.match( capturedCss, /data-tm-thumb-scale-followed-page="1"[\s\S]*grid-template-columns:\s*repeat\(auto-fill,\s*minmax\(var\(--tm-thumb-discover-width\),\s*1fr\)\)\s*!important/, "followed-cams legacy recommendations should use responsive scaled minimum width", ); assert.doesNotMatch( capturedCss, /data-tm-thumb-scale-followed-page="1"[\s\S]*grid-template-columns:\s*repeat\(2,/, "followed-cams recommendations should not force a hard-coded two-column layout", ); assert.match( capturedCss, /data-tm-thumb-scale-followed-recommendations="1"[\s\S]*data-tm-thumb-followed-recommendations-list="1"[\s\S]*grid-template-columns:\s*repeat\(auto-fill,\s*minmax\(var\(--tm-thumb-followed-recommendations-width\),\s*1fr\)\)\s*!important[\s\S]*grid-auto-rows:\s*auto\s*!important/, "title-detected followed recommendations should use responsive scaled minimum columns and automatic row height", ); assert.match( capturedCss, /data-tm-thumb-scale-followed-recommendations="1"[\s\S]*data-tm-thumb-followed-recommendations-card="1"[\s\S]*width:\s*auto\s*!important[\s\S]*max-width:\s*none\s*!important[\s\S]*height:\s*auto\s*!important[\s\S]*max-height:\s*none\s*!important/, "title-detected followed recommendation cards should stretch responsively and grow naturally", ); assert.match( capturedCss, /data-tm-thumb-scale-followed-recommendations="1"[\s\S]*data-tm-thumb-followed-recommendations-thumb="1"[\s\S]*height:\s*auto\s*!important/, "title-detected followed recommendation thumbnails should keep automatic height", ); assert.match( capturedCss, /#discover_root\s+\.room-list-carousel\s+\.room_thumbnail[\s\S]*height:\s*var\(--tm-thumb-discover-height\)\s*!important/, "discover carousel images should use detected original height", ); assert.match( capturedCss, /#discover_root\s+\.room-list-carousel\s+\.room_thumbnail[\s\S]*width:\s*var\(--tm-thumb-discover-thumb-width\)\s*!important/, "discover carousel images should use detected original thumb width", ); assert.match( capturedCss, /\.FollowedDropdown__room-image[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*var\(--tm-thumb-follow-thumb-height\)\s*!important/, "follow dropdown images should follow fixed card width and use detected height", ); assert.match( capturedCss, /react-tooltip[\s\S]*width:\s*var\(--tm-thumb-tooltip-width\)\s*!important[\s\S]*height:\s*var\(--tm-thumb-tooltip-height\)\s*!important/, "tooltip preview should use detected original image width and height", ); assert.match( capturedCss, /#discover_root\s+\.room-list-carousel\s+ul\.list\s*>\s*li[\s\S]*height:\s*var\(--tm-thumb-discover-card-height\)\s*!important/, "discover carousel cards should use detected original card height", ); assert.doesNotMatch(source, /withOwnStyleDisabled/, "script should not disable its own style while measuring"); assert.match(source, /detectAndApplySizes/, "script should detect original sizes before applying scaled variables"); assert.match(source, /moduleReady/, "script should lock each module after detecting its original size"); assert.match(source, /CARD_HEIGHT_SCALE/, "script should use a separate scale for card heights"); assert.match(source, /setCardHeightVar/, "script should apply card height scaling separately from thumbnail scaling"); assert.match(source, /setDiscoverStackHeightVars/, "discover carousel stack heights should be derived from scaled card rows"); assert.match(source, /setCardHeightFromThumbVar\("--tm-thumb-discover-card-height"/, "discover card height should scale the thumbnail area and keep metadata height"); assert.match(source, /stackHeight == null\) return true/, "discover should not require every carousel row type to exist before enabling"); assert.match(source, /TAG_TRANSLATIONS/, "script should include a centralized tag translation table"); assert.match(source, /translateTagsPage/, "script should translate the Chaturbate tags page"); assert.doesNotMatch(source, /localStorage\.setItem/, "script should not write detected sizes to localStorage"); assert.match(source, /LEGACY_CACHE_PREFIX/, "script should only keep legacy cache cleanup support"); assert.match(source, /scheduleMeasure\("after-800ms"\)/, "script should delay the first page measurement until original layout can render"); assert.doesNotMatch( capturedCss, /#desktop-spa-header\s*>\s*div\s*>\s*nav:nth-child/, "script should avoid brittle header child-index selectors", ); assert.doesNotMatch(capturedCss, /\.followedDropdown\b/, "old lowercase followed dropdown selectors should be removed"); const earlySource = source; let earlyAppendedTarget = null; let earlyDomContentLoadedHandler = null; let earlyObservedTarget = null; const earlyFakeDocument = { documentElement: { appendChild(node) { earlyAppendedTarget = "documentElement"; this.node = node; }, setAttribute() {}, }, head: null, createElement() { return { id: "", textContent: "", setAttribute() {} }; }, getElementById() { return null; }, addEventListener(type, handler) { if (type === "DOMContentLoaded") { earlyDomContentLoadedHandler = handler; } }, }; vm.runInNewContext(earlySource, { console, document: earlyFakeDocument, GM_addStyle() { return { id: "", textContent: "", setAttribute() {} }; }, GM_registerMenuCommand() {}, MutationObserver: class { observe(target) { earlyObservedTarget = target; } }, }); assert.equal(earlyAppendedTarget, null, "GM_addStyle should avoid manual documentElement injection before head exists"); assert.equal(typeof earlyDomContentLoadedHandler, "function", "script should reinject after head becomes available"); assert.equal(earlyObservedTarget, null, "script should not observe a missing head"); const tagAnchors = [ { href: "https://zh-hans.chaturbate.com/tag/asian/", textContent: "#asian", dataset: {}, title: "", getAttribute(name) { return name === "href" ? this.href : ""; }, }, { href: "https://zh-hans.chaturbate.com/tag/bigboobs/", textContent: "#bigboobs", dataset: {}, title: "", getAttribute(name) { return name === "href" ? this.href : ""; }, }, { href: "https://zh-hans.chaturbate.com/tag/ebony/female/", textContent: "#ebony", dataset: {}, title: "", getAttribute(name) { return name === "href" ? this.href : ""; }, }, { href: "https://zh-hans.chaturbate.com/tag/not-in-table/", textContent: "Not In Table", dataset: {}, title: "", getAttribute(name) { return name === "href" ? this.href : ""; }, }, { href: "https://zh-hans.chaturbate.com/tag/new-custom-tag/", textContent: "#new-custom-tag", dataset: {}, title: "", getAttribute(name) { return name === "href" ? this.href : ""; }, }, { href: "https://zh-hans.chaturbate.com/tag/colombiana/female/", textContent: "#colombiana", dataset: {}, title: "", getAttribute(name) { return name === "href" ? this.href : ""; }, }, { href: "https://zh-hans.chaturbate.com/tag/untranslated-example/", textContent: "#untranslated-example", dataset: {}, title: "", getAttribute(name) { return name === "href" ? this.href : ""; }, }, { href: "https://zh-hans.chaturbate.com/tags/female/?page=2", textContent: "2", dataset: {}, title: "", getAttribute(name) { return name === "href" ? this.href : ""; }, }, ]; const tagFakeDocument = { documentElement: { setAttribute() {}, }, head: { appendChild() {}, }, body: {}, createElement() { return { id: "", textContent: "", setAttribute() {} }; }, getElementById() { return null; }, querySelectorAll(selector) { return selector.includes("/tag") ? tagAnchors : []; }, addEventListener() {}, }; const runTagsPageTranslationTest = (pathname = "/tags/") => vm.runInNewContext(source, { console, document: tagFakeDocument, location: { href: `https://zh-hans.chaturbate.com${pathname}`, pathname, }, URL, GM_addStyle() { return { id: "", textContent: "", setAttribute() {} }; }, GM_registerMenuCommand() {}, MutationObserver: class { observe() {} }, }); runTagsPageTranslationTest(); assert.equal(tagAnchors[0].textContent, "亚洲", "tags page should translate known tag link text"); assert.equal(tagAnchors[0].dataset.tmTagOriginal, "#asian", "translated tags should keep the original label in dataset"); assert.equal(tagAnchors[0].title, "#asian", "translated tags should expose the original label in title"); assert.equal(tagAnchors[1].textContent, "大胸", "tags page should translate compact multi-word tag slugs"); assert.equal(tagAnchors[2].textContent, "黑人", "gender-scoped tag links should translate by the first slug segment"); assert.equal(tagAnchors[3].textContent, "Not In Table", "unknown tags should remain unchanged"); assert.equal(tagAnchors[4].textContent, "新自定义标签", "untranslated hashtag-style tags should use slug word translation"); assert.equal(tagAnchors[5].textContent, "哥伦比亚女性", "known gendered nationality tags should be translated"); assert.equal(tagAnchors[6].textContent, "#untranslated-example", "untranslated hashtag-style tags should keep the original slug"); assert.equal(tagAnchors[7].textContent, "2", "tags pagination links should not be translated"); tagAnchors.forEach((anchor, index) => { anchor.textContent = ["#asian", "#bigboobs", "#ebony", "Not In Table", "#new-custom-tag", "#colombiana", "#untranslated-example", "2"][index]; anchor.dataset = {}; anchor.title = ""; }); runTagsPageTranslationTest("/tags/female/"); assert.equal(tagAnchors[0].textContent, "亚洲", "tag category pages under /tags/ should also translate links"); assert.equal(tagAnchors[1].textContent, "大胸", "tag category pages should translate compact slugs too"); assert.equal(tagAnchors[2].textContent, "黑人", "tag category pages should translate /tag/slug/gender/ links"); assert.equal(tagAnchors[5].textContent, "哥伦比亚女性", "tag category pages should translate gendered nationality links"); assert.equal(tagAnchors[6].textContent, "#untranslated-example", "tag category pages should preserve unknown hashtag-style tags"); assert.equal(tagAnchors[7].textContent, "2", "tag category page pagination should keep page numbers"); const followingVars = new Map(); const followingAttrs = new Map(); const followingFakeDocument = { documentElement: { style: { setProperty(name, value) { followingVars.set(name, value); }, }, setAttribute(name, value) { followingAttrs.set(name, value); }, removeAttribute(name) { followingAttrs.delete(name); }, }, head: {}, body: {}, createElement() { return { id: "", textContent: "", setAttribute() {} }; }, getElementById() { return null; }, querySelectorAll() { return []; }, addEventListener() {}, }; vm.runInNewContext(source, { console, document: followingFakeDocument, location: { pathname: "/followed-cams/" }, localStorage: { getItem() { return JSON.stringify({ schema: 15, scale: 2, cardHeightScale: 1.55, modules: { home: { homeMinWidth: 190, homeCard: { width: 174, height: 120 }, homeThumb: { width: 174, height: 98 }, }, followingList: { followingListMinWidth: 190, followingListCard: { width: 190, height: 120 }, followingListThumb: { width: 188, height: 106 }, }, }, }); }, setItem() {}, removeItem() {}, }, getComputedStyle() { return { getPropertyValue(name) { return followingVars.get(name) || ""; }, }; }, GM_addStyle() { return { id: "", textContent: "", setAttribute() {} }; }, GM_registerMenuCommand() {}, MutationObserver: class { observe() {} }, }); assert.equal(followingAttrs.get("data-tm-thumb-scale-following-list"), undefined, "followed-cams should not apply legacy cached list module"); assert.equal(followingAttrs.get("data-tm-thumb-scale-home"), undefined, "followed-cams should not apply legacy homepage cache"); assert.equal(followingVars.get("--tm-thumb-following-list-min-width"), undefined); assert.equal(followingVars.get("--tm-thumb-following-list-card-height"), undefined); assert.equal(followingVars.get("--tm-thumb-following-list-thumb-width"), undefined); assert.equal(followingVars.get("--tm-thumb-following-list-thumb-height"), undefined); assert.equal(followingVars.get("--tm-thumb-home-min-width"), undefined);