waybetter-for-x/apps/desktop/tests/features.test.ts
2026-10-02 02:54:04 +07:00

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();
});