331 lines
15 KiB
TypeScript
331 lines
15 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { navigationCss } from '../src/areas/navigation/css.ts';
|
|
import { profileCss } from '../src/areas/profile/css.ts';
|
|
import { READER_CSS, readerCss } from '../src/areas/reader/css.ts';
|
|
import { EXPLORE_CSS } from '../src/areas/search/hideExploreContent.ts';
|
|
import { FEATURE_DEFAULTS, buildFeatureCss, installFeatures, normalizeFeatures } from '../src/inject/features.ts';
|
|
import type { FeatureConfig } from '../src/inject/features.ts';
|
|
|
|
const ALL_FALSE: FeatureConfig = Object.fromEntries(
|
|
Object.keys(FEATURE_DEFAULTS).map(key => [key, false]),
|
|
) as unknown as FeatureConfig;
|
|
|
|
function withOnly(key: keyof FeatureConfig): FeatureConfig {
|
|
return { ...ALL_FALSE, [key]: true };
|
|
}
|
|
|
|
/** Every rule a config turns on: the runtime's own, Navigation+'s (src/areas/navigation/css.ts) and Reader+'s (src/areas/reader/css.ts). */
|
|
const allCss = (config: FeatureConfig) => [buildFeatureCss(config), navigationCss(config), readerCss(config), profileCss(config), config.hideExploreContent ? EXPLORE_CSS : ''].filter(Boolean).join('\n\n');
|
|
|
|
test('FEATURE_DEFAULTS matches the specified defaults exactly', () => {
|
|
assert.deepEqual(FEATURE_DEFAULTS, {
|
|
logoBrush: true,
|
|
replaceLogo: false,
|
|
twitterWording: true,
|
|
hidePromotedPosts: true,
|
|
hidePremiumUpsells: false,
|
|
hideGrok: false,
|
|
hideSidebarTrends: false,
|
|
hideWhoToFollow: false,
|
|
hideLiveOnX: false,
|
|
hideTodaysNews: false,
|
|
hideViewCounts: false,
|
|
hideEngagementCounts: false,
|
|
hideBookmarkButton: false,
|
|
hideExploreContent: false,
|
|
followingTimelineDefault: false,
|
|
hideForYouTab: false,
|
|
hidePremiumNav: false,
|
|
hideChatNav: false,
|
|
hideCommunitiesNav: false,
|
|
hideGrokNav: false,
|
|
singleSplitNav: true,
|
|
disableTweetTextFormatting: false,
|
|
mediaDownload: true,
|
|
composerDrafts: true,
|
|
searchOperators: true,
|
|
fixLikeFollow: true,
|
|
fixScrollRestore: true,
|
|
fixTimelineJump: true,
|
|
fixHomeTab: true,
|
|
fixNotificationBadge: true,
|
|
fixNewPostsPill: true,
|
|
fixMediaUpload: true,
|
|
followingChronological: true,
|
|
composerRestorePrompt: true,
|
|
fixChatButton: true,
|
|
rememberVideoVolume: true,
|
|
disableVideoAutoplay: false,
|
|
stopVideoLoop: false,
|
|
sortRepliesRecent: false,
|
|
fullNumbers: false,
|
|
hideVerifiedBadges: false,
|
|
hideSubscribeButtons: false,
|
|
fullWidthTimeline: false,
|
|
hideSidebar: false,
|
|
hideRelevantPeople: false,
|
|
hideProfileWhoToFollow: false,
|
|
});
|
|
});
|
|
|
|
test('normalizeFeatures drops unknown keys and falls back to defaults for non-boolean values', () => {
|
|
const input = {
|
|
replaceLogo: false,
|
|
hidePromotedPosts: 'yes',
|
|
hideGrok: 1,
|
|
hideBookmarkButton: null,
|
|
unknownFeature: true,
|
|
__proto__: { polluted: true },
|
|
};
|
|
const result = normalizeFeatures(input);
|
|
assert.equal(result.replaceLogo, false);
|
|
assert.equal(result.hidePromotedPosts, FEATURE_DEFAULTS.hidePromotedPosts);
|
|
assert.equal(result.hideGrok, FEATURE_DEFAULTS.hideGrok);
|
|
assert.equal(result.hideBookmarkButton, FEATURE_DEFAULTS.hideBookmarkButton);
|
|
assert.equal(Object.keys(result).length, Object.keys(FEATURE_DEFAULTS).length);
|
|
assert.equal((result as Record<string, unknown>).unknownFeature, undefined);
|
|
assert.equal(Object.hasOwn(Object.prototype, 'polluted'), false);
|
|
});
|
|
|
|
test('normalizeFeatures returns full defaults for non-object input', () => {
|
|
for (const input of [null, undefined, 'config', 42, true, []]) {
|
|
assert.deepEqual(normalizeFeatures(input), FEATURE_DEFAULTS);
|
|
}
|
|
});
|
|
|
|
test('normalizeFeatures preserves every valid boolean value untouched', () => {
|
|
const allTrue = Object.fromEntries(Object.keys(FEATURE_DEFAULTS).map(key => [key, true]));
|
|
assert.deepEqual(normalizeFeatures(allTrue), Object.fromEntries(Object.keys(FEATURE_DEFAULTS).map(key => [key, true])));
|
|
assert.deepEqual(normalizeFeatures(ALL_FALSE), ALL_FALSE);
|
|
});
|
|
|
|
test('buildFeatureCss emits nothing for an all-false config', () => {
|
|
assert.equal(buildFeatureCss(ALL_FALSE), '');
|
|
});
|
|
|
|
test('buildFeatureCss is pure and includes only the selectors for enabled flags', () => {
|
|
const markers: Record<keyof FeatureConfig, string> = {
|
|
logoBrush: 'background: url("data:image/png',
|
|
replaceLogo: 'bt-logo-patched',
|
|
twitterWording: '',
|
|
hidePromotedPosts: 'placementTracking',
|
|
hidePremiumUpsells: 'premium_sign_up',
|
|
hideGrok: 'grokImgGen',
|
|
hideSidebarTrends: '[data-testid="trend"]',
|
|
hideWhoToFollow: 'UserCell',
|
|
hideLiveOnX: 'Live on X',
|
|
hideTodaysNews: 'news_sidebar',
|
|
hideViewCounts: '/analytics',
|
|
hideEngagementCounts: 'visibility: hidden',
|
|
hideBookmarkButton: 'ookmark',
|
|
hideExploreContent: 'primaryColumn',
|
|
followingTimelineDefault: '',
|
|
hideForYouTab: 'data-bt-home',
|
|
hidePremiumNav: '/i/verified',
|
|
hideChatNav: '/i/chat',
|
|
hideCommunitiesNav: '/communities',
|
|
hideGrokNav: 'a[href="/i/grok"]',
|
|
singleSplitNav: '',
|
|
disableTweetTextFormatting: 'tweetText',
|
|
mediaDownload: '',
|
|
composerDrafts: '',
|
|
searchOperators: '',
|
|
fixLikeFollow: '',
|
|
fixScrollRestore: '',
|
|
fixTimelineJump: '',
|
|
fixHomeTab: '',
|
|
fixNotificationBadge: '',
|
|
fixNewPostsPill: '',
|
|
fixMediaUpload: '',
|
|
followingChronological: '',
|
|
composerRestorePrompt: '',
|
|
fixChatButton: '',
|
|
rememberVideoVolume: '',
|
|
disableVideoAutoplay: '',
|
|
stopVideoLoop: '',
|
|
sortRepliesRecent: '',
|
|
fullNumbers: '',
|
|
hideVerifiedBadges: 'icon-verified',
|
|
hideSubscribeButtons: 'creator-subscriptions',
|
|
fullWidthTimeline: 'flex-grow: 1',
|
|
hideSidebar: '[data-testid="sidebarColumn"] {',
|
|
hideRelevantPeople: 'data-bt-route="status"',
|
|
hideProfileWhoToFollow: 'connect_people',
|
|
};
|
|
for (const key of Object.keys(FEATURE_DEFAULTS) as (keyof FeatureConfig)[]) {
|
|
const marker = markers[key];
|
|
if (!marker) continue; // flags with no marker are DOM/JS-only, not CSS
|
|
const cssWithFlag = allCss(withOnly(key));
|
|
assert.ok(cssWithFlag.includes(marker), `expected the rules of ${key}=true to include ${JSON.stringify(marker)}`);
|
|
assert.equal(allCss(ALL_FALSE).includes(marker), false, `expected the rules of all-false to exclude ${JSON.stringify(marker)}`);
|
|
}
|
|
});
|
|
|
|
test('hidePromotedPosts hides ad posts, never the placementTracking wrapper of an ordinary video', () => {
|
|
const css = READER_CSS.hidePromotedPosts();
|
|
assert.ok(css.includes('[data-testid="placementTracking"]:has(article)'));
|
|
// A bare wrapper selector would also hide X's video players inside normal posts (live, 2026-09-27).
|
|
assert.doesNotMatch(css, /\[data-testid="placementTracking"\](?!:has\(article\)| article)/);
|
|
});
|
|
|
|
test('buildFeatureCss keeps hideGrok and hideGrokNav selectors disjoint', () => {
|
|
const grokContentOnly = allCss(withOnly('hideGrok'));
|
|
const grokNavOnly = allCss(withOnly('hideGrokNav'));
|
|
assert.ok(grokContentOnly.includes('grokImgGen'));
|
|
assert.equal(grokContentOnly.includes('header nav a[href="/i/grok"]'), false);
|
|
assert.ok(grokNavOnly.includes('header nav a[href="/i/grok"]'));
|
|
assert.equal(grokNavOnly.includes('grokImgGen'), false);
|
|
});
|
|
|
|
test('buildFeatureCss default config hides promoted posts and shows the brush logo, nothing else', () => {
|
|
const css = allCss(FEATURE_DEFAULTS);
|
|
assert.ok(css.includes('placementTracking'));
|
|
assert.ok(css.includes('data:image/png'));
|
|
assert.equal(css.includes('bt-logo-patched'), false, 'brush logo wins over the Twitter bird');
|
|
assert.equal(css.includes('news_sidebar'), false);
|
|
assert.equal(css.includes('UserCell'), false);
|
|
assert.equal(css.includes('grokImgGen'), false);
|
|
});
|
|
|
|
function fakeWindow(overrides: { top?: unknown; origin?: string; pathname?: string } = {}): Window {
|
|
const elements = new Map<string, { id: string; textContent: string; remove: () => void; setAttribute?: (n: string, v: string) => void; getAttribute?: (n: string) => string | null }>();
|
|
const documentElement = { attrs: new Map<string, string>(), setAttribute(n: string, v: string) { this.attrs.set(n, v); }, removeAttribute(n: string) { this.attrs.delete(n); }, getAttribute(n: string) { return this.attrs.get(n) ?? null; } };
|
|
const head = { appendChild(el: { id: string }) { elements.set(el.id, el as never); } };
|
|
const doc = {
|
|
title: 'Home / X',
|
|
head,
|
|
documentElement,
|
|
body: {},
|
|
createElement(_tag: string) {
|
|
const el = { id: '', textContent: '', remove() { elements.delete(el.id); } };
|
|
return el;
|
|
},
|
|
getElementById(id: string) { return elements.get(id) ?? null; },
|
|
querySelector(_sel: string) { return null; },
|
|
querySelectorAll(_sel: string) { return [] as unknown[]; },
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
};
|
|
class FakeObserver { disconnected = false; constructor(_cb: () => void) {} observe() {} disconnect() { this.disconnected = true; } }
|
|
const win: Record<string, unknown> = {
|
|
document: doc,
|
|
location: { origin: overrides.origin ?? 'https://x.com', pathname: overrides.pathname ?? '/home' },
|
|
MutationObserver: FakeObserver,
|
|
requestAnimationFrame(cb: () => void) { cb(); return 0; },
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
};
|
|
win.top = overrides.top ?? win;
|
|
return win as unknown as Window;
|
|
}
|
|
|
|
test('installFeatures is a no-op outside the top-level https://x.com page', () => {
|
|
const framed = installFeatures(fakeWindow({ top: {} }));
|
|
const wrongOrigin = installFeatures(fakeWindow({ origin: 'https://example.com' }));
|
|
assert.doesNotThrow(() => framed.apply({ replaceLogo: false }));
|
|
assert.doesNotThrow(() => framed.dispose());
|
|
assert.doesNotThrow(() => wrongOrigin.apply({}));
|
|
assert.doesNotThrow(() => wrongOrigin.dispose());
|
|
});
|
|
|
|
test('installFeatures is idempotent per window and dispose cleans up the style tag', () => {
|
|
const win = fakeWindow();
|
|
const first = installFeatures(win);
|
|
const second = installFeatures(win);
|
|
assert.equal(first, second);
|
|
first.apply({ logoBrush: true });
|
|
const style = (win.document as unknown as { getElementById(id: string): { textContent: string } | null }).getElementById('bt-features');
|
|
assert.ok(style);
|
|
assert.ok(style!.textContent.includes('background: url("data:image/png'));
|
|
first.dispose();
|
|
const afterDispose = (win.document as unknown as { getElementById(id: string): unknown }).getElementById('bt-features');
|
|
assert.equal(afterDispose, null);
|
|
});
|
|
|
|
test('logo choice is exclusive: brush wins, bird needs brush off, both off keeps X', () => {
|
|
const bird = buildFeatureCss({ ...ALL_FALSE, replaceLogo: true });
|
|
assert.ok(bird.includes('bt-logo-patched'));
|
|
assert.equal(bird.includes('data:image/png'), false);
|
|
const both = buildFeatureCss({ ...ALL_FALSE, replaceLogo: true, logoBrush: true });
|
|
assert.ok(both.includes('data:image/png'));
|
|
assert.equal(both.includes('bt-logo-patched'), false);
|
|
assert.equal(buildFeatureCss(ALL_FALSE).includes('path[d='), false);
|
|
});
|
|
|
|
// Minimal DOM stand-ins for the runtime patches (no jsdom in this suite).
|
|
type FakeNode = { nodeType: number; nodeValue: string | null };
|
|
class FakeEl {
|
|
attrs = new Map<string, string>();
|
|
classes = new Set<string>();
|
|
childNodes: FakeNode[] = [];
|
|
children: FakeEl[] = [];
|
|
tag: string;
|
|
constructor(tag: string, text?: string) { this.tag = tag; if (text != null) this.childNodes.push({ nodeType: 3, nodeValue: text }); }
|
|
get classList() { return { add: (c: string) => this.classes.add(c), remove: (c: string) => this.classes.delete(c) }; }
|
|
add(...kids: FakeEl[]) { this.children.push(...kids); return this; }
|
|
all(): FakeEl[] { return this.children.flatMap(kid => [kid, ...kid.all()]); }
|
|
setAttribute(n: string, v: string) { this.attrs.set(n, v); }
|
|
getAttribute(n: string) { return this.attrs.get(n) ?? null; }
|
|
hasAttribute(n: string) { return this.attrs.has(n); }
|
|
removeAttribute(n: string) { this.attrs.delete(n); }
|
|
matches(sel: string): boolean {
|
|
const attr = /^(\w+)?\[([\w-]+)(?:~?="([^"]*)")?\]$/.exec(sel);
|
|
if (attr) return (!attr[1] || attr[1] === this.tag) && this.attrs.has(attr[2]!) && (attr[3] == null || (this.attrs.get(attr[2]!) ?? '').split(' ').includes(attr[3]));
|
|
return sel === this.tag;
|
|
}
|
|
querySelectorAll(sel: string) { return this.all().filter(el => el.matches(sel)); }
|
|
querySelector(sel: string) { return this.querySelectorAll(sel)[0] ?? null; }
|
|
}
|
|
|
|
function domWindow(root: FakeEl, byTestId: Record<string, FakeEl | null>): Window {
|
|
const win = fakeWindow() as unknown as Record<string, unknown>;
|
|
const doc = win.document as Record<string, unknown>;
|
|
doc.querySelector = (sel: string) => {
|
|
const id = /data-testid(?:\^)?="([^"]+)"/.exec(sel)?.[1];
|
|
return id != null ? byTestId[id] ?? null : root.querySelector(sel);
|
|
};
|
|
doc.querySelectorAll = (sel: string) => sel === 'h1 svg path' ? root.querySelectorAll('path') : sel.includes('data-testid') ? [] : root.querySelectorAll(sel);
|
|
return win as unknown as Window;
|
|
}
|
|
|
|
test('twitterWording no longer retexts labels in the page: the wording is a built-in string pack', () => {
|
|
const label = new FakeEl('span', 'Post');
|
|
const composer = new FakeEl('button').add(label);
|
|
const root = new FakeEl('body').add(composer);
|
|
const win = domWindow(root, { tweetButton: composer });
|
|
const handle = installFeatures(win);
|
|
handle.apply({ ...ALL_FALSE, twitterWording: true });
|
|
assert.equal(label.childNodes[0]!.nodeValue, 'Post', 'the label node is left as the catalog gave it');
|
|
assert.equal(label.getAttribute('data-bt-retext'), null);
|
|
handle.dispose();
|
|
});
|
|
|
|
test('logo choices apply live and revert both ways: brush, bird and X', () => {
|
|
const X_D = 'M21.742 21.75l-7.563-11.179 7.056-8.321h-2.456l-5.691 6.714-4.54-6.714H2.359l7.29 10.776L2.25 21.75h2.456l6.035-7.118 4.818 7.118h6.191-.008zM7.739 3.818L18.81 20.182h-2.447L5.29 3.818h2.447z';
|
|
const path = new FakeEl('path');
|
|
path.setAttribute('d', X_D);
|
|
const icon = new FakeEl('link');
|
|
icon.setAttribute('rel', 'shortcut icon');
|
|
icon.setAttribute('href', '//abs.twimg.com/favicons/twitter.3.ico');
|
|
const root = new FakeEl('html').add(new FakeEl('svg').add(path), icon);
|
|
const handle = installFeatures(domWindow(root, {}));
|
|
|
|
handle.apply({ ...ALL_FALSE, logoBrush: true });
|
|
assert.equal(path.getAttribute('d'), X_D, 'brush is CSS-only and needs X\'s glyph in place');
|
|
assert.match(icon.getAttribute('href')!, /^data:image\/png/);
|
|
|
|
handle.apply({ logoBrush: false, replaceLogo: true });
|
|
assert.notEqual(path.getAttribute('d'), X_D);
|
|
assert.match(icon.getAttribute('href')!, /^data:image\/svg\+xml/);
|
|
|
|
handle.apply({ replaceLogo: false, logoBrush: true });
|
|
assert.equal(path.getAttribute('d'), X_D, 'bird -> brush restores X\'s glyph for the brush CSS');
|
|
assert.match(icon.getAttribute('href')!, /^data:image\/png/);
|
|
|
|
handle.apply({ logoBrush: false, replaceLogo: true });
|
|
handle.apply({ replaceLogo: false });
|
|
assert.equal(path.getAttribute('d'), X_D, 'bird -> X restores X\'s logo');
|
|
assert.equal(icon.getAttribute('href'), '//abs.twimg.com/favicons/twitter.3.ico', 'X gets its own favicon back');
|
|
handle.dispose();
|
|
});
|