waybetter-for-x/apps/desktop/tests/area-reader.test.ts
2026-10-01 06:01:13 +07:00

975 lines
63 KiB
TypeScript

// Reader+ (src/areas/reader): the timeline and post rules that were Tweaks, the seven plugins it
// replaces (compared with their files), the three fixes of the port (the visited profile and the
// opened post are never collapsed, a bookmark can be removed on the Bookmarks page, nothing depends
// on an English label) and its new features (read marker, language filter, quick mute). The
// table-level checks (every module starts and stops clean, one observer) are in areas.test.ts.
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import test, { afterEach } from 'node:test';
import { cleanDestination } from '../src/areas/reader/cleanLinks.ts';
import { COLLAPSE_SCHEMA, isUnknown, parseAllowlist, shouldCollapse, type CollapseSettings } from '../src/areas/reader/collapseVerified.ts';
import { postMarkdown, postPlainText } from '../src/areas/reader/copyAsMarkdown.ts';
import { GROK_GLYPH, READER_CSS, readerCss } from '../src/areas/reader/css.ts';
import { timePlacement } from '../src/areas/reader/exactTimestamps.ts';
import { FEATURES, POWER } from '../src/areas/reader/features.ts';
import { countFromLabel } from '../src/areas/reader/fullNumbers.ts';
import { MODULES } from '../src/areas/reader/index.ts';
import { LANGUAGE_SCHEMA, languageOf, readLanguages, shouldCollapseLanguage } from '../src/areas/reader/languageFilter.ts';
import { mutedHaystack, parsePatterns } from '../src/areas/reader/mutedRegex.ts';
import { focalPost, visitedProfile } from '../src/areas/reader/pages.ts';
import { appendPattern, quickMuteLine } from '../src/areas/reader/quickMute.ts';
import { READ_MARKER_SCHEMA, maySave, readMarkers, readingPost, tabKey } from '../src/areas/reader/readMarker.ts';
import { readingMinutes } from '../src/areas/reader/readingTime.ts';
import { focalIdOf, threadMarkdown, threadOrder } from '../src/areas/reader/threadReader.ts';
import { surfacesOf } from '../src/areas/surfaces.ts';
import { X_TABLE } from '../src/areas/table.ts';
import { FEATURE_DEFAULTS } from '../src/inject/features.ts';
import { createPluginApi, type PluginApi } from '../src/inject/pluginApi.ts';
import type { UserInfo, UsersApi } from '../src/inject/userInfo.ts';
import { createFeatureRegistry } from '../../../waybetter/packages/runtime/src/featureModules.ts';
import { normalizeSettings } from '../../../waybetter/packages/runtime/src/settingsForm.ts';
import { areaPage as plainPage, flagsWith as tableFlags, messages } from './helpers/areaPage.ts';
/** The flags with only Reader+ on (other areas have modules that are on by default). */
const flagsWith = (patch: Record<string, boolean> = {}) =>
tableFlags({ ...Object.fromEntries(X_TABLE.areas.filter(area => area.id !== 'reader').map(area => [`area.${area.id}`, false])), ...patch });
const BUNDLED = new URL('../src-tauri/bundled-plugins/', import.meta.url);
const SOURCES = new URL('../src/areas/reader/', import.meta.url);
const OWNER = 'bt-reader-plus.plugin.js';
// A registry polls the route with a timer: one a failed test left running would keep the process alive.
const running: Array<{ dispose(): void }> = [];
afterEach(() => { for (const registry of running.splice(0)) registry.dispose(); });
const areaPage = (path: string) => { const env = plainPage(path); running.push(env.registry); return env; };
/** The old plugin, loaded the way native code loads it, to compare the port against. */
function oldPlugin<T>(file: string): T {
const module = { exports: {} as unknown };
new Function('module', 'exports', readFileSync(new URL(file, BUNDLED), 'utf8'))(module, module.exports);
return module.exports as T;
}
// ---- A small DOM: elements, text, attributes and the selectors these modules use ----------------
type Compound = { tag: string | null; scope: boolean; classes: string[]; attrs: { name: string; op: string; value: string; insensitive: boolean }[] };
type Step = { compound: Compound; combinator: ' ' | '>' | null };
function splitTop(selector: string, separators: string): string[] {
const out: string[] = [];
let depth = 0;
let quoted = false;
let current = '';
for (const char of selector) {
if (char === '"') quoted = !quoted;
if (!quoted && char === '[') depth += 1;
if (!quoted && char === ']') depth -= 1;
if (!quoted && depth === 0 && separators.includes(char)) { out.push(current); out.push(char); current = ''; continue; }
current += char;
}
out.push(current);
return out;
}
function parseCompound(source: string): Compound {
const compound: Compound = { tag: null, scope: false, classes: [], attrs: [] };
let rest = source;
const tag = /^[a-zA-Z][\w-]*/.exec(rest);
if (tag) { compound.tag = tag[0].toUpperCase(); rest = rest.slice(tag[0].length); }
while (rest) {
const attr = /^\[([\w-]+)(?:([\^$*]?=)"([^"]*)"(\s+i)?)?\]/.exec(rest);
const cls = /^\.([\w-]+)/.exec(rest);
if (attr) { compound.attrs.push({ name: attr[1]!, op: attr[2] ?? '', value: attr[3] ?? '', insensitive: !!attr[4] }); rest = rest.slice(attr[0].length); }
else if (cls) { compound.classes.push(cls[1]!); rest = rest.slice(cls[0].length); }
else if (rest.startsWith(':scope')) { compound.scope = true; rest = rest.slice(6); }
else throw new Error(`test DOM: cannot parse ${JSON.stringify(source)}`);
}
return compound;
}
const parsed = new Map<string, Step[][]>();
function parseSelector(selector: string): Step[][] {
let list = parsed.get(selector);
if (!list) {
list = splitTop(selector, ',').filter(part => part !== ',').map(complex => {
const steps: Step[] = [];
let combinator: ' ' | '>' | null = null;
for (const token of splitTop(complex.trim(), ' >')) {
if (token === '>') { combinator = '>'; continue; }
if (token === ' ' || token === '') { combinator ??= steps.length ? ' ' : null; continue; }
steps.push({ compound: parseCompound(token), combinator: steps.length ? combinator ?? ' ' : null });
combinator = null;
}
return steps;
});
parsed.set(selector, list);
}
return list;
}
class Text {
nodeType = 3;
parentElement: El | null = null;
nodeValue: string;
constructor(value: string) { this.nodeValue = value; }
}
type Kid = El | Text | string | null;
class El {
nodeType = 1;
tagName: string;
nodeName: string;
parentElement: El | null = null;
childNodes: (El | Text)[] = [];
attrs = new Map<string, string>();
listeners = new Map<string, Array<(event: Record<string, unknown>) => void>>();
/** What getComputedStyle and getBoundingClientRect say about it. */
css: Record<string, string> = { display: 'flex', flexDirection: 'column' };
rect = { top: 0, bottom: 0 };
scrolled = 0;
[key: string]: unknown;
constructor(tag: string, attrs: Record<string, string> = {}, ...kids: Kid[]) {
this.tagName = this.nodeName = tag.toUpperCase();
for (const [name, value] of Object.entries(attrs)) this.attrs.set(name, value);
this.append(...kids);
}
get children() { return this.childNodes.filter((node): node is El => node instanceof El); }
get childElementCount() { return this.children.length; }
get firstElementChild() { return this.children[0] ?? null; }
get parentNode() { return this.parentElement; }
get isConnected(): boolean { return this.tagName === 'BODY' || !!this.parentElement?.isConnected; }
get textContent(): string { return this.childNodes.map(node => (node instanceof El ? node.textContent : node.nodeValue)).join(''); }
set textContent(value: string) { for (const node of this.childNodes) node.parentElement = null; this.childNodes = []; if (value) this.append(value); }
get className() { return this.attrs.get('class') ?? ''; }
set className(value: string) { this.attrs.set('class', value); }
get classList() {
const names = () => this.className.split(/\s+/).filter(Boolean);
return { contains: (name: string) => names().includes(name) };
}
getAttribute(name: string) { return this.attrs.get(name) ?? null; }
setAttribute(name: string, value: string) { this.attrs.set(name, String(value)); }
hasAttribute(name: string) { return this.attrs.has(name); }
removeAttribute(name: string) { this.attrs.delete(name); }
append(...kids: Kid[]) {
for (const kid of kids) {
if (kid == null) continue;
const node = typeof kid === 'string' ? new Text(kid) : kid;
if (node instanceof El) node.remove();
node.parentElement = this;
this.childNodes.push(node);
}
}
appendChild<T extends El | Text>(node: T) { this.append(node); return node; }
replaceChildren(...kids: Kid[]) { this.textContent = ''; this.append(...kids); }
insertBefore(node: El, reference: El) {
node.remove();
node.parentElement = this;
this.childNodes.splice(this.childNodes.indexOf(reference), 0, node);
return node;
}
before(node: El) { this.parentElement!.insertBefore(node, this); }
after(node: El) {
const parent = this.parentElement!;
node.remove();
node.parentElement = parent;
parent.childNodes.splice(parent.childNodes.indexOf(this) + 1, 0, node);
}
remove() {
const parent = this.parentElement;
if (!parent) return;
parent.childNodes.splice(parent.childNodes.indexOf(this), 1);
this.parentElement = null;
}
get previousElementSibling(): El | null { const all = this.parentElement?.children ?? []; return all[all.indexOf(this) - 1] ?? null; }
get nextElementSibling(): El | null { const all = this.parentElement?.children ?? []; const at = all.indexOf(this); return at < 0 ? null : all[at + 1] ?? null; }
contains(node: El | null): boolean { for (let at = node; at; at = at.parentElement) if (at === this) return true; return false; }
private fits(compound: Compound, scope: El | null): boolean {
if (compound.scope && this !== scope) return false;
if (compound.tag && compound.tag !== this.tagName) return false;
if (!compound.classes.every(name => this.classList.contains(name))) return false;
return compound.attrs.every(({ name, op, value, insensitive }) => {
const raw = this.attrs.get(name);
if (raw === undefined) return false;
const [have, want] = insensitive ? [raw.toLowerCase(), value.toLowerCase()] : [raw, value];
return op === '' || (op === '=' && have === want) || (op === '^=' && have.startsWith(want)) || (op === '$=' && have.endsWith(want)) || (op === '*=' && have.includes(want));
});
}
private fitsSteps(steps: Step[], index: number, scope: El | null): boolean {
const step = steps[index]!;
if (!this.fits(step.compound, scope)) return false;
if (index === 0) return true;
if (step.combinator === '>') return !!this.parentElement?.fitsSteps(steps, index - 1, scope);
for (let at = this.parentElement; at; at = at.parentElement) if (at.fitsSteps(steps, index - 1, scope)) return true;
return false;
}
matches(selector: string, scope: El | null = null): boolean { return parseSelector(selector).some(steps => this.fitsSteps(steps, steps.length - 1, scope)); }
closest(selector: string): El | null { for (let at: El | null = this; at; at = at.parentElement) if (at.matches(selector)) return at; return null; }
descendants(): El[] { return this.children.flatMap(child => [child, ...child.descendants()]); }
querySelectorAll(selector: string): El[] { return this.descendants().filter(node => node.matches(selector, this)); }
querySelector(selector: string): El | null { return this.querySelectorAll(selector)[0] ?? null; }
addEventListener(type: string, fn: (event: Record<string, unknown>) => void) { (this.listeners.get(type) ?? this.listeners.set(type, []).get(type)!).push(fn); }
click() { for (const fn of this.listeners.get('click') ?? []) fn({ preventDefault() {}, stopPropagation() {}, shiftKey: false }); this.clicks = ((this.clicks as number) ?? 0) + 1; }
getBoundingClientRect() { return this.rect; }
scrollIntoView() { this.scrolled += 1; }
}
const h = (tag: string, attrs: Record<string, string> = {}, ...kids: Kid[]) => new El(tag, attrs, ...kids);
const asElement = (node: El) => node as unknown as Element;
type PostSpec = { id: string; handle: string; name?: string; text?: string; lang?: string; quoted?: string; reposted?: boolean; focal?: boolean; promoted?: boolean };
/** A timeline cell with a post the way X draws one: header (name, time, Grok, ···), text, an optional quote card, the action bar. */
function post(spec: PostSpec): El {
const time = (owner: string, id: string, label: string) => h('a', { href: `/${owner}/status/${id}` }, h('time', { datetime: '2026-09-27T14:03:00.000Z' }, label));
const header = h('div', {},
h('div', { 'data-testid': 'User-Name' }, h('a', { href: `/${spec.handle}` }, spec.name ?? spec.handle), h('div', {}, time(spec.handle, spec.id, '2h'))),
h('div', {}, h('button', { 'aria-label': 'Grok actions' }), h('button', { 'data-testid': 'caret' })),
);
header.css = { display: 'flex', flexDirection: 'row' };
const article = h('article', { 'data-testid': 'tweet', tabindex: spec.focal ? '-1' : '0' },
spec.reposted ? h('span', { 'data-testid': 'socialContext' }, 'reposted') : null,
header,
spec.text === '' ? null : h('div', { 'data-testid': 'tweetText', lang: spec.lang ?? 'en' }, spec.text ?? 'A post about nothing in particular.'),
spec.quoted ? h('div', { role: 'link' }, time(spec.quoted, `9${spec.id}`, '1d'), h('div', { 'data-testid': 'tweetText', lang: 'en' }, 'The quoted words.')) : null,
h('div', { role: 'group' }, h('div', {}, h('button', { 'data-testid': 'like', 'aria-label': '1234 Likes. Like' }, h('span', {}, '1.2K')))),
);
const wrapped = spec.promoted ? h('div', { 'data-testid': 'placementTracking' }, article) : article;
return h('div', { 'data-testid': 'cellInnerDiv' }, wrapped);
}
const articleOf = (cell: El) => cell.querySelector('article')!;
/** X's page around a timeline: the account button (who is signed in), the Home tabs, the posts. */
function body(viewer: string | null, cells: El[], tabs: boolean[] = []): El {
return h('body', {},
viewer ? h('button', { 'data-testid': 'SideNav_AccountSwitcher_Button' }, h('div', { 'data-testid': `UserAvatar-Container-${viewer}` })) : null,
h('div', { 'data-testid': 'primaryColumn' },
h('div', { role: 'tablist' }, ...tabs.map(selected => h('div', {}, h('a', { role: 'tab', 'aria-selected': String(selected) }, 'a tab')))),
h('section', {}, ...cells),
),
);
}
type Users = Record<string, Partial<Pick<UserInfo, 'blue' | 'verifiedType' | 'following'>>>;
/**
* The area page (helpers/areaPage.ts) over a DOM: the same fake window, with `root` as its body
* and a registry of Reader+'s modules whose API knows `users` (what X's responses told about them).
*/
function readerPage(path: string, root: El, users: Users = {}) {
const env = areaPage(path);
env.registry.dispose();
const makeStyle = env.doc.createElement as (tag: string) => unknown;
let selection = '';
Object.assign(env.doc, {
body: root,
querySelectorAll: (selector: string) => root.querySelectorAll(selector),
querySelector: (selector: string) => root.querySelector(selector),
createElement: (tag: string) => (tag === 'style' ? makeStyle(tag) : new El(tag)),
createTextNode: (value: string) => new Text(value),
dispatchEvent: (event: { type: string }) => { env.doc.emit(event.type, event); return true; },
});
Object.assign(env.win, {
innerHeight: 800, scrollY: 0,
getComputedStyle: (element: El) => element.css,
getSelection: () => ({ toString: () => selection }),
CustomEvent: class { type: string; detail: unknown; constructor(type: string, init: { detail?: unknown } = {}) { this.type = type; this.detail = init.detail; } },
});
const listeners = new Set<(handles: string[]) => void>();
const info = (handle: string): UserInfo | null => {
const known = users[handle.toLowerCase()];
return known ? { xUserId: '1', handle, blue: null, verifiedType: null, following: null, ...known } as UserInfo : null;
};
const usersApi: UsersApi = { get: info, onUpdate: callback => { listeners.add(callback); return () => { listeners.delete(callback); }; } };
const english = { locale: () => 'en', message: (key: string) => messages[key], onChange: () => () => {} };
const toasts: string[] = [];
const base = createPluginApi(env.win as unknown as Window & typeof globalThis, usersApi, undefined, english);
const api = Object.freeze({ ...base, toast: (message: string) => { toasts.push(message); } }) as PluginApi;
const registry = createFeatureRegistry<PluginApi>({ win: env.win as unknown as Window & typeof globalThis, table: X_TABLE, api, label: 'wayBetter for X', surfaces: () => surfacesOf((env.win.location as { pathname: string }).pathname) });
for (const module of MODULES) registry.define(module);
running.push(registry);
return {
...env, registry, api, toasts, root,
/** Only these flags (the one that is on by default, "Hide promoted posts", is off unless named). */
on(flags: Record<string, boolean>) { registry.setFlags(flagsWith({ hidePromotedPosts: false, ...flags })); },
/** X's responses told more about these users. */
learn(more: Users) { Object.assign(users, more); for (const listener of [...listeners]) listener(Object.keys(more)); },
select(text: string) { selection = text; },
/** The kit's toasts and dialogs need a real DOM: they report that on the console, which is not what these tests are about. */
quietly<T>(run: () => T): T {
const original = console.error;
console.error = () => {};
try { return run(); } finally { console.error = original; }
},
};
}
const collapsed = (cell: El, attr: string) => articleOf(cell).hasAttribute(attr);
// ---- The table -----------------------------------------------------------------------------------
test('Reader+: twenty-one features, all modules; the eleven flags that were Tweaks keep their names and defaults', () => {
assert.equal(FEATURES.length, 21);
const moduleRows = FEATURES.filter(feature => feature.backing === 'module');
assert.deepEqual(MODULES.map(module => module.id).sort(), moduleRows.map(feature => feature.id).sort());
// "Sort replies by Recent" rewrites X's own request: a module that stays in place on every page.
assert.deepEqual(FEATURES.filter(feature => feature.backing !== 'module'), []);
assert.equal(FEATURES.find(feature => feature.id === 'sortRepliesRecent')!.heavy, true);
const tweaks = ['hidePromotedPosts', 'followingTimelineDefault', 'hideForYouTab', 'hideGrok', 'hideViewCounts', 'hideEngagementCounts', 'hideBookmarkButton', 'disableTweetTextFormatting', 'sortRepliesRecent', 'fullNumbers', 'hideVerifiedBadges'];
for (const flag of tweaks) assert.equal(FEATURES.find(feature => feature.id === flag)?.default, FEATURE_DEFAULTS[flag as keyof typeof FEATURE_DEFAULTS], flag);
// Everything else is off until someone turns it on, the new features included.
for (const feature of FEATURES) if (!tweaks.includes(feature.id)) assert.equal(feature.default, false, feature.id);
// The seven plugins: each flag replaces its file and keeps the plugin's words.
const ports = { 'reader.cleanLinks': 'clean-links', 'reader.copyAsMarkdown': 'copy-as-markdown', 'reader.exactTimestamps': 'exact-timestamps', 'reader.collapseVerified': 'hide-blue-checks', 'reader.mutedRegex': 'muted-regex', 'reader.readingTime': 'reading-time', 'reader.threadReader': 'thread-reader' };
for (const [flag, name] of Object.entries(ports)) {
assert.equal(FEATURES.find(feature => feature.id === flag)?.supersedes, `bt-${name}.plugin.js`, flag);
assert.equal(MODULES.find(module => module.id === flag)?.words, name, flag);
}
assert.equal(messages[FEATURES.find(feature => feature.id === 'reader.collapseVerified')!.label!], 'Collapse posts from verified accounts');
// Power User adds reading tools only: nothing that hides or collapses.
for (const flag of POWER) assert.doesNotMatch(flag, /hide|collapse|muted|languageFilter|quickMute/i, flag);
});
test('Reader+: no module makes a request, writes markup, observes on its own or uses the page globals; its only clicks are navigation', () => {
const clicks: string[] = [];
for (const file of readdirSync(SOURCES).filter(name => name.endsWith('.ts'))) {
const source = readFileSync(new URL(file, SOURCES), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
assert.doesNotMatch(source, /\bfetch\(|XMLHttpRequest|sendBeacon|WebSocket|EventSource|__TAURI|invoke\(/, `${file} makes no request`);
assert.doesNotMatch(source, /innerHTML|insertAdjacentHTML|eval\(|new MutationObserver/, `${file} builds DOM from text only, through the shared observer`);
assert.doesNotMatch(source, /(?<![.\w])(?:window|document|location|localStorage)\./, `${file} uses ctx.win`);
if (/\.click\(\)/.test(source)) clicks.push(file);
}
// X's own "Following" tab, and X's own link to a post of the thread being read.
assert.deepEqual(clicks.sort(), ['followingTimelineDefault.ts', 'threadReader.ts']);
});
test('Reader+: its words are English in messages.en.json, and every setting has its label', () => {
for (const [id, schema] of [['readMarker', READ_MARKER_SCHEMA], ['languageFilter', LANGUAGE_SCHEMA]] as const) {
for (const field of schema) if (field.type !== 'divider') {
assert.ok(messages[`plugin.reader-plus.${id}.${field.label}`], `${id}.${field.label}`);
if (field.hint) assert.ok(messages[`plugin.reader-plus.${id}.${field.hint}`], `${id}.${field.hint}`);
}
}
for (const field of COLLAPSE_SCHEMA) if (field.type !== 'divider') {
assert.ok(messages[`plugin.hide-blue-checks.${field.label}`], field.label);
if (field.hint) assert.ok(messages[`plugin.hide-blue-checks.${field.hint}`], field.hint);
}
for (const key of ['readMarker.line', 'readMarker.found', 'readMarker.jump', 'readMarker.notLoaded', 'readMarker.none', 'languageFilter.stub', 'mutedRegex.edit',
'quickMute.add', 'quickMute.none', 'quickMute.length', 'quickMute.exists', 'quickMute.failed', 'quickMute.added', 'quickMute.addedOff', 'quickMute.undo']) {
assert.ok(messages[`plugin.reader-plus.${key}`], key);
}
});
// ---- The rules that were Tweaks ------------------------------------------------------------------
test('Reader+ rules: each flag names only its own part of X, and a rule is there only while its flag is on', () => {
const markers: Record<keyof typeof READER_CSS, string> = {
hidePromotedPosts: '[data-testid="placementTracking"]:has(article)', hideForYouTab: 'html[data-bt-home]', hideGrok: 'grokImgGen', hideViewCounts: '/analytics',
hideEngagementCounts: 'visibility: hidden', hideBookmarkButton: 'ookmark', hideVerifiedBadges: 'icon-verified', disableTweetTextFormatting: 'font-weight: normal',
};
assert.equal(readerCss({}), '');
for (const [flag, marker] of Object.entries(markers)) {
const css = readerCss({ [flag]: true });
assert.ok(css.includes(marker), flag);
// No :has() inside :has() (invalid CSS: the whole rule would be dropped).
assert.doesNotMatch(css, /:has\([^)]*:has\(/, flag);
for (const [other, otherMarker] of Object.entries(markers)) if (other !== flag) assert.ok(!css.includes(otherMarker), `${flag} does not carry ${other}`);
}
// An ordinary video's placementTracking wrapper is never hidden (live, 2026-09-27).
assert.doesNotMatch(READER_CSS.hidePromotedPosts(), /\[data-testid="placementTracking"\](?!:has\(article\)| article)/);
// Grok inside posts is this flag's; the rail link is Navigation+'s hideGrokNav.
assert.doesNotMatch(READER_CSS.hideGrok(), /header nav/);
});
test('fix: no rule depends on an English label alone (Grok is found by its name and its glyph in any X language)', () => {
const grok = READER_CSS.hideGrok();
assert.ok(grok.includes('[data-testid="tweet"] button[aria-label*="Grok" i]'), 'the brand name, whatever surrounds it and however it is cased');
assert.ok(grok.includes(`[data-testid="tweet"] button:has(svg path[d^="${GROK_GLYPH}"])`), 'the glyph on a post');
assert.ok(grok.includes(`html[data-bt-route="profile"] [data-testid="primaryColumn"] button:has(svg path[d^="${GROK_GLYPH}"])`), 'the summary button on a profile');
// X's own label is "Grok actions": the old exact-match rule ("Grok Actions") never matched it.
const actions = h('button', { 'aria-label': 'Grok actions' });
const german = h('button', { 'aria-label': 'Grok-Aktionen' });
h('article', { 'data-testid': 'tweet' }, actions, german);
assert.ok(!actions.matches('button[aria-label="Grok Actions"]'));
for (const button of [actions, german]) assert.ok(button.matches('[data-testid="tweet"] button[aria-label*="Grok" i]'));
// Every other selector of the area is a testid, a role, a link target or a position.
for (const [flag, css] of Object.entries(READER_CSS)) if (flag !== 'hideGrok') assert.doesNotMatch(css(), /aria-label/, flag);
for (const file of readdirSync(SOURCES).filter(name => name.endsWith('.ts') && name !== 'css.ts')) {
const labels = readFileSync(new URL(file, SOURCES), 'utf8').match(/aria-label[*^$]?="[^"]*"/g) ?? [];
assert.deepEqual(labels.filter(label => label !== 'aria-label*="Grok"'), [], file);
}
});
test('fix: "Hide verified badges" keeps the badge in the header of the profile being visited', () => {
const css = READER_CSS.hideVerifiedBadges();
assert.match(css, /^svg\[data-testid="icon-verified"\]:not\(\[data-testid="UserName"\] svg\):not\(html\[data-bt-route="profile"\] \[data-testid="primaryColumn"\] h2 svg\) \{/);
// Posts and lists name their author with "User-Name": those badges still go.
assert.ok(!css.includes('User-Name'));
});
test('fix: the bookmark button stays on the Bookmarks page, where it is the way to remove a bookmark', () => {
assert.doesNotMatch(READER_CSS.hideBookmarkButton(), /body/, 'no body class the app never sets');
const env = areaPage('/home');
env.registry.setFlags(flagsWith({ hideBookmarkButton: true, hidePromotedPosts: false }));
assert.deepEqual(env.live(), ['wbf-hideBookmarkButton']);
env.go('/i/bookmarks');
assert.deepEqual(env.registry.active(), ['hideBookmarkButton']);
assert.deepEqual(env.live(), [], 'on Bookmarks: no rule, the button is there');
env.go('/someone/status/1');
assert.deepEqual(env.live(), ['wbf-hideBookmarkButton'], 'and back on every other page');
env.registry.dispose();
});
test('Reader+ surfaces: "For you" and the read marker only on Home, Thread Reader only on a post; on chat only the two rules that reach it', () => {
const env = areaPage('/someone');
env.registry.setFlags(flagsWith({ hideForYouTab: true, 'reader.readMarker': true, 'reader.threadReader': true, hideGrok: true, hideVerifiedBadges: true, hideEngagementCounts: true, hidePromotedPosts: false }));
assert.deepEqual(env.registry.active().sort(), ['hideEngagementCounts', 'hideGrok', 'hideVerifiedBadges']);
env.go('/home');
assert.deepEqual(env.registry.active().sort(), ['hideEngagementCounts', 'hideForYouTab', 'hideGrok', 'hideVerifiedBadges', 'reader.readMarker']);
env.go('/someone/status/1');
assert.deepEqual(env.registry.active().sort(), ['hideEngagementCounts', 'hideGrok', 'hideVerifiedBadges', 'reader.threadReader']);
env.go('/i/chat');
assert.deepEqual(env.registry.active().sort(), ['hideGrok', 'hideVerifiedBadges'], 'names and the Grok drawer are on chat pages too; both are CSS only');
env.registry.dispose();
});
test('Reader+ commands: four actions and the settings entry, inside the budget of five', () => {
const env = areaPage('/someone/status/1');
env.registry.setFlags(flagsWith(Object.fromEntries(MODULES.map(module => [module.id, true]))));
const commands = () => env.api.commands.list().filter(command => command.id.startsWith(`${OWNER}:`)).map(command => command.id.slice(OWNER.length + 1)).sort();
assert.deepEqual(commands(), ['mutedRegex.edit', 'quickMute.add', 'settings', 'threadReader.open']);
env.go('/home');
assert.deepEqual(commands(), ['mutedRegex.edit', 'quickMute.add', 'readMarker.jump', 'settings']);
const title = (id: string) => env.api.commands.list().find(command => command.id === `${OWNER}:${id}`)!.title;
assert.deepEqual(['mutedRegex.edit', 'quickMute.add', 'readMarker.jump', 'settings'].map(title), ['Edit Muted Regex patterns', 'Mute the selected text', 'Jump to where you left off', 'Reader+ settings']);
env.registry.setFlags(flagsWith({ 'area.reader': false }));
assert.deepEqual(env.api.commands.list(), [], 'the area off: nothing of it left');
assert.deepEqual(env.registry.active(), []);
env.registry.dispose();
});
test('Default to Following: one click on X\'s second Home tab, by position, once per page load', () => {
const page = (selected: boolean[], path = '/home') => {
const root = body('me', [], selected);
const env = readerPage(path, root);
return { env, tabs: root.querySelectorAll('[role="tab"]') };
};
// Labels are not read: the tabs could say anything, in any language.
const forYou = page([true, false]);
forYou.env.on({ followingTimelineDefault: true });
forYou.env.mutate();
assert.deepEqual(forYou.tabs.map(tab => tab.clicks ?? 0), [0, 1]);
forYou.env.mutate();
forYou.env.go('/someone');
forYou.env.go('/home');
forYou.env.mutate();
assert.deepEqual(forYou.tabs.map(tab => tab.clicks ?? 0), [0, 1], 'never a second time: the reader may have gone back to "For you"');
forYou.env.registry.dispose();
const following = page([false, true]);
following.env.on({ followingTimelineDefault: true });
following.env.mutate();
assert.deepEqual(following.tabs.map(tab => tab.clicks ?? 0), [0, 0], 'already on Following');
following.env.registry.dispose();
const elsewhere = page([true, false], '/someone');
elsewhere.env.on({ followingTimelineDefault: true });
elsewhere.env.mutate();
assert.deepEqual(elsewhere.tabs.map(tab => tab.clicks ?? 0), [0, 0], 'a profile has tabs too: only Home counts');
elsewhere.env.registry.dispose();
});
test('Full numbers: the exact count from the label in any language, written over X\'s text and put back', () => {
assert.equal(countFromLabel('1234 Likes. Like'), 1234);
assert.equal(countFromLabel('12.345 „Gefällt mir“-Angaben. Gefällt mir'), 12345);
assert.equal(countFromLabel('좋아요 1,234개'), 1234, 'a language that puts the word first');
assert.equal(countFromLabel('Beğen'), null);
assert.equal(countFromLabel('3 of 12 replies'), 3, 'a leading number wins');
assert.equal(countFromLabel('Replies 3 of 12'), null, 'several numbers and none leading: left as X wrote it');
assert.equal(countFromLabel(null), null);
const cell = post({ id: '1', handle: 'someone' });
const span = cell.querySelector('[role="group"] span')!;
const node = span.childNodes[0] as Text;
const env = readerPage('/home', body('me', [cell]));
env.on({ fullNumbers: true });
env.mutate();
assert.equal(node.nodeValue, '1,234');
assert.equal(span.childNodes[0], node, 'the text node React owns is kept');
env.on({});
assert.equal(node.nodeValue, '1.2K');
assert.ok(!span.hasAttribute('data-bt-count'));
env.registry.dispose();
});
test('Hide view counts: the views unit of an opened post is marked with its separator, and unmarked when off', () => {
const count = h('span', { 'data-testid': 'app-text-transition-container' }, h('span', {}, '1,234'));
const views = h('div', {}, count, h('span', {}, ' Views'));
const separator = h('div', { 'aria-hidden': 'true' }, h('span', {}, '·'));
const line = h('div', {}, h('div', {}, h('a', { href: '/someone/status/1' }, h('time', { datetime: '2026-09-28T19:06:00.000Z' }, '10:06 PM · Sep 28, 2026'))), separator, views);
const env = readerPage('/someone/status/1', body('me', [h('article', { 'data-testid': 'tweet' }, h('div', {}, line))]));
env.on({ hideViewCounts: true });
assert.ok(views.hasAttribute('data-bt-views') && separator.hasAttribute('data-bt-views-sep'));
assert.deepEqual(env.live(), ['wbf-hideViewCounts']);
env.on({});
assert.ok(!views.hasAttribute('data-bt-views') && !separator.hasAttribute('data-bt-views-sep'));
env.registry.dispose();
});
// ---- The seven plugins ---------------------------------------------------------------------------
test('Clean Links: the same destination as the plugin; links point there while it is on and back at t.co when off', () => {
const Old = oldPlugin<{ prototype: { destination(link: { textContent: string }): string | null } }>('clean-links.plugin.js');
for (const text of ['https://example.com/a?utm_source=x&id=7&fbclid=abc', 'https://example.com/long/path…', 'example.com/a', 'not a link', 'http://example.com/?ref_src=twsrc', '', 'https://exa mple.com']) {
assert.equal(cleanDestination(text), Old.prototype.destination({ textContent: text }), text);
}
assert.equal(cleanDestination('https://example.com/a?utm_source=x&id=7'), 'https://example.com/a?id=7');
const link = h('a', { href: 'https://t.co/abc' }, 'https://example.com/a?utm_medium=social…');
const plain = h('a', { href: 'https://t.co/def' }, 'a title, not a URL');
const cell = post({ id: '1', handle: 'someone' });
cell.querySelector('[data-testid="tweetText"]')!.append(link, plain);
const env = readerPage('/home', body('me', [cell]));
env.on({ 'reader.cleanLinks': true });
assert.equal(link.getAttribute('href'), 'https://example.com/a');
assert.equal(link.getAttribute('title'), 'https://example.com/a');
assert.equal(plain.getAttribute('href'), 'https://t.co/def', 'a link whose text is not its destination is left alone');
env.on({});
assert.equal(link.getAttribute('href'), 'https://t.co/abc');
assert.deepEqual([...link.attrs.keys()], ['href']);
env.registry.dispose();
});
test('Copy as Markdown: the same Markdown and plain text as the plugin; one button per post, gone when off', () => {
type OldCopy = { prototype: { markdown(tweet: unknown): string; plainText(tweet: unknown): string } };
const Old = oldPlugin<OldCopy>('copy-as-markdown.plugin.js');
const tweets = [
{ text: 'one line', name: 'Some One', handle: 'someone', url: 'https://x.com/someone/status/1', media: [] as string[], time: '2026-09-27T14:03:00.000Z' },
{ text: 'two\n\nlines ', name: 'Some One', handle: 'someone', url: 'https://x.com/someone/status/2', media: ['https://pbs.twimg.com/media/a?name=orig', 'https://pbs.twimg.com/media/b?name=orig'], time: null },
{ text: '', name: 'someone', handle: 'someone', url: 'https://x.com/someone/status/3', media: ['https://pbs.twimg.com/media/c?name=orig'], time: null },
];
for (const tweet of tweets) for (const date of ['Sep 27, 2026, 2:03 PM', '']) {
const self = { date: () => date };
assert.equal(postMarkdown(tweet, date, 'link'), Old.prototype.markdown.call(self, tweet));
assert.equal(postPlainText(tweet, date), Old.prototype.plainText.call(self, tweet));
}
const cells = [post({ id: '1', handle: 'someone' }), post({ id: '2', handle: 'other' })];
const env = readerPage('/home', body('me', cells));
env.on({ 'reader.copyAsMarkdown': true });
for (const cell of cells) {
const buttons = cell.querySelectorAll('.bt-copy-md');
assert.equal(buttons.length, 1);
assert.equal(buttons[0]!.nextElementSibling, cell.querySelector('[data-testid="caret"]'), 'right before X\'s ··· button');
assert.equal(buttons[0]!.getAttribute('aria-label'), 'Copy as Markdown (Shift+click: plain text)');
}
env.mutate();
assert.equal(env.root.querySelectorAll('.bt-copy-md').length, 2, 'never a second one');
env.on({});
assert.equal(env.root.querySelectorAll('.bt-copy-md').length, 0);
env.registry.dispose();
});
test('Exact Timestamps: the date goes where the plugin put it, once per post, and leaves with the feature', () => {
const Old = oldPlugin<{ placement(time: unknown): { after: unknown; header: boolean } }>('exact-timestamps.plugin.js');
const style = (element: Element) => (element as unknown as El).css as { flexDirection: string };
const g = globalThis as Record<string, unknown>;
const saved = g.getComputedStyle;
g.getComputedStyle = style;
try {
const plain = post({ id: '1', handle: 'someone' });
const quoting = post({ id: '2', handle: 'someone', quoted: 'other' });
const column = post({ id: '3', handle: 'someone' });
column.querySelector('[data-testid="User-Name"]')!.parentElement!.css = { display: 'flex', flexDirection: 'column' };
body('me', [plain, quoting, column]);
for (const time of [plain, quoting, column].flatMap(cell => cell.querySelectorAll('time'))) {
const mine = timePlacement(asElement(time), style);
const old = Old.placement(time);
assert.equal(mine.after, old.after);
assert.equal(mine.header, old.header);
}
assert.equal(timePlacement(asElement(plain.querySelector('time')!), style).header, true);
assert.equal(timePlacement(asElement(quoting.querySelectorAll('time')[1]!), style).header, false, 'a quoted post has no header row of its own');
} finally { g.getComputedStyle = saved; }
const cell = post({ id: '1', handle: 'someone' });
const env = readerPage('/home', body('me', [cell]));
env.on({ 'reader.exactTimestamps': true });
const time = cell.querySelector('time')!;
const full = new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date('2026-09-27T14:03:00.000Z'));
assert.equal(time.getAttribute('title'), full);
const dates = cell.querySelectorAll('.bt-exact-time');
assert.deepEqual(dates.map(date => date.textContent), [`· ${full}`]);
assert.equal(dates[0]!.previousElementSibling, cell.querySelector('[data-testid="User-Name"]'), 'after the name block, before X\'s buttons');
env.on({});
assert.equal(cell.querySelectorAll('.bt-exact-time').length, 0);
assert.ok(!time.hasAttribute('title') && !time.hasAttribute('data-bt-exact'));
env.registry.dispose();
});
test('Reading Time: the same minutes as the plugin; a pill on long posts only', () => {
const Old = oldPlugin<{ minutes(text: string): number }>('reading-time.plugin.js');
const words = (count: number) => Array.from({ length: count }, (_, index) => `word${index}`).join(' ');
for (const text of ['', 'short', words(119), words(120), words(230), words(700), '語'.repeat(119), '語'.repeat(500), `${words(100)} ${'한'.repeat(40)}`]) {
assert.equal(readingMinutes(text), Old.minutes(text), `${text.length} characters`);
}
assert.deepEqual([readingMinutes(words(119)), readingMinutes(words(120)), readingMinutes(words(700))], [0, 1, 3]);
const long = post({ id: '1', handle: 'someone', text: words(700) });
const short = post({ id: '2', handle: 'someone' });
const env = readerPage('/someone/status/1', body('me', [long, short]));
env.on({ 'reader.readingTime': true });
assert.deepEqual(env.root.querySelectorAll('.bt-reading-time').map(pill => pill.textContent), ['3 min read']);
assert.equal(long.querySelector('.bt-reading-time')!.nextElementSibling, long.querySelector('[data-testid="tweetText"]'));
env.on({});
assert.equal(env.root.querySelectorAll('.bt-reading-time').length, 0);
env.registry.dispose();
});
test('Muted Regex: the plugin\'s patterns and its stored list; a match collapses into a stub that shows the post again', () => {
const Old = oldPlugin<{ parsePatterns(source: string): { patterns: { source: string; pattern: RegExp }[]; invalid: string[] } }>('muted-regex.plugin.js');
const source = '# comment\n\ncrypto\n/^@spam_bot$/m\n/Exact/g\n(unclosed\n';
const mine = parsePatterns(source);
const old = Old.parsePatterns(source);
assert.deepEqual(mine.invalid, old.invalid);
assert.deepEqual(mine.patterns.map(item => [item.source, item.pattern.source, item.pattern.flags]), old.patterns.map(item => [item.source, item.pattern.source, item.pattern.flags]));
assert.equal(mutedHaystack({ handle: 'a', name: 'B', text: 'c' }, 'd'), '@a\nB\nc\nd');
const spam = post({ id: '1', handle: 'someone', text: 'Huge CRYPTO giveaway today' });
const bot = post({ id: '2', handle: 'spam_bot', text: 'hello there everyone' });
const fine = post({ id: '3', handle: 'other', text: 'a walk in the park' });
const quoting = post({ id: '4', handle: 'other', text: 'look at this', quoted: 'someone' });
quoting.querySelector('[role="link"] [data-testid="tweetText"]')!.textContent = 'free crypto';
const env = readerPage('/home', body('me', [spam, bot, fine, quoting]));
// What the plugin stored, where it stored it.
env.store.set('bt-plugin:muted-regex:patterns', JSON.stringify('crypto\n/^@spam_bot$/m'));
env.on({ 'reader.mutedRegex': true });
assert.deepEqual([spam, bot, fine, quoting].map(cell => collapsed(cell, 'data-bt-muted')), [true, true, false, true]);
const stubs = env.root.querySelectorAll('.bt-muted-stub');
assert.deepEqual(stubs.map(stub => stub.textContent), ['Post hidden by Muted Regex (crypto). Click to show.', 'Post hidden by Muted Regex (/^@spam_bot$/m). Click to show.', 'Post hidden by Muted Regex (crypto). Click to show.']);
assert.equal(stubs[0]!.nextElementSibling, articleOf(spam));
stubs[0]!.click();
assert.ok(!collapsed(spam, 'data-bt-muted') && articleOf(spam).hasAttribute('data-bt-muted-shown'));
assert.equal(env.root.querySelectorAll('.bt-muted-stub').length, 2);
env.on({});
assert.equal(env.root.querySelectorAll('.bt-muted-stub, article[data-bt-muted], article[data-bt-muted-shown]').length, 0, 'off: every post back, no mark left');
assert.equal(env.store.get('bt-plugin:muted-regex:patterns'), JSON.stringify('crypto\n/^@spam_bot$/m'), 'the list is untouched');
env.registry.dispose();
});
test('Thread Reader: the plugin\'s post page rule and Markdown; its button only while the author\'s thread is on the page', () => {
type OldReader = { focalIdOf(path: string): string | null; prototype: { markdown(): string } };
const Old = oldPlugin<OldReader>('thread-reader.plugin.js');
for (const path of ['/jack/status/123', '/jack/status/123/', '/jack/status/123/photo/1', '/jack', '/home', '/i/lists/1', '']) assert.equal(focalIdOf(path), Old.focalIdOf(path), path);
const posts = [
{ id: '30', name: 'Some One', handle: 'someone', url: 'https://x.com/someone/status/30', text: 'third', media: [] as string[] },
{ id: '9', name: 'Some One', handle: 'someone', url: 'https://x.com/someone/status/9', text: 'first', media: ['https://pbs.twimg.com/media/a?name=orig'] },
{ id: '10', name: 'Some One', handle: 'someone', url: 'https://x.com/someone/status/10', text: 'second\nline', media: [] as string[] },
];
assert.deepEqual(threadOrder(posts).map(item => item.id), ['9', '10', '30'], 'by id as a number, not as text');
assert.equal(threadMarkdown(threadOrder(posts)), Old.prototype.markdown.call({ ordered: () => threadOrder(posts) }));
assert.equal(threadMarkdown([]), Old.prototype.markdown.call({ ordered: () => [] }));
const back = h('div', {}, h('div', {}, h('button', { 'data-testid': 'app-bar-back' })), h('h2', {}, 'Post'));
back.css = { display: 'flex', flexDirection: 'row' };
const root = body('me', [post({ id: '1', handle: 'someone', focal: true }), post({ id: '2', handle: 'other' })]);
root.querySelector('[data-testid="primaryColumn"]')!.append(back);
const env = readerPage('/someone/status/1', root);
env.on({ 'reader.threadReader': true });
assert.equal(root.querySelectorAll('.bt-tr-open').length, 0, 'one post by the author is not a thread');
root.querySelector('section')!.append(post({ id: '3', handle: 'Someone' }));
env.mutate();
const button = root.querySelector('.bt-tr-open')!;
assert.equal(button.textContent, 'Read thread');
assert.equal(button.parentElement, back, 'at the end of X\'s own header row');
assert.ok(button.classList.contains('bt-tr-bar'));
assert.deepEqual(env.api.commands.list().map(command => [command.id, command.title]), [[`${OWNER}:threadReader.open`, 'Read this thread']]);
env.go('/home');
assert.equal(root.querySelectorAll('.bt-tr-open').length, 0, 'not on a post page: the button and the command are gone');
assert.deepEqual(env.api.commands.list(), []);
env.registry.dispose();
});
// ---- Collapse posts from verified accounts (was Hide Blue Checks) ---------------------------------
type OldChecks = {
DEFAULTS: CollapseSettings;
shouldHide(settings: CollapseSettings, ctx: Record<string, unknown>): boolean;
isUnknown(settings: CollapseSettings, user: unknown): boolean;
parseAllowlist(source: string): string[];
};
test('Collapse verified: the plugin\'s settings and, away from a profile or an opened post, exactly its decisions', () => {
const Old = oldPlugin<OldChecks>('hide-blue-checks.plugin.js');
assert.deepEqual(normalizeSettings(COLLAPSE_SCHEMA, null), Old.DEFAULTS, 'someone who enabled the plugin and never opened its settings sees no change');
const stored = { hideBlue: false, hideBusiness: true, hideGovernment: true, keepFollowed: false, hideQuotes: true, allowlist: ['jack', 'someone'] };
assert.deepEqual(normalizeSettings(COLLAPSE_SCHEMA, stored), stored, 'what the plugin stored is read as it is');
assert.equal(MODULES.find(module => module.id === 'reader.collapseVerified')!.storage, 'hide-blue-checks');
const toggles = ['hideBlue', 'hideBusiness', 'hideGovernment', 'keepFollowed', 'hideQuotes'] as const;
let compared = 0;
for (let bits = 0; bits < 2 ** toggles.length; bits += 1) {
for (const allowlist of [[], ['Someone']]) {
const settings = { ...Object.fromEntries(toggles.map((key, index) => [key, !!(bits & (1 << index))])), allowlist } as CollapseSettings;
for (const blue of [true, false, null]) for (const verifiedType of ['None', 'Business', 'Government', null]) for (const following of [true, false, null]) for (const viewer of [null, 'me', 'SomeOne']) {
const ctx = { handle: 'someone', blue, verifiedType, following, viewer };
assert.equal(shouldCollapse(settings, ctx), Old.shouldHide(settings, ctx), JSON.stringify({ settings, ctx }));
// On someone else's profile, and for a post that is not the opened one, nothing changes either.
assert.equal(shouldCollapse(settings, { ...ctx, profile: 'other', focal: false }), Old.shouldHide(settings, ctx));
compared += 1;
}
for (const user of [null, { blue: true, verifiedType: 'None', following: false }, { blue: null, verifiedType: 'None', following: false }, { blue: true, verifiedType: null, following: true }, { blue: false, verifiedType: 'None', following: null }]) {
assert.equal(isUnknown(settings, user as never), Old.isUnknown(settings, user));
}
}
}
assert.equal(compared, 32 * 2 * 3 * 4 * 3 * 3);
const list = '@Jack\n\ntoo-many-symbols!\njack\n bad handle \n@someone_else';
assert.deepEqual(parseAllowlist(list), Old.parseAllowlist(list));
});
test('fix: the owner of the visited profile and the opened post are never collapsed (pure)', () => {
const settings = normalizeSettings(COLLAPSE_SCHEMA, null) as CollapseSettings;
const verified = { handle: 'SomeOne', blue: true, verifiedType: 'None', following: false, viewer: 'me' };
assert.ok(shouldCollapse(settings, verified), 'in a timeline: collapsed');
assert.ok(!shouldCollapse(settings, { ...verified, profile: 'someone' }), 'on their own profile, whatever the case of the handle');
assert.ok(!shouldCollapse(settings, { ...verified, profile: 'SOMEONE' }));
assert.ok(shouldCollapse(settings, { ...verified, profile: 'other' }), 'on another profile (a repost): the normal rule');
assert.ok(!shouldCollapse(settings, { ...verified, focal: true }), 'the post the page is about');
// Every tab of a profile is that profile; a post page and X's own pages are not.
for (const path of ['/SomeOne', '/someone/', '/someone/with_replies', '/someone/media', '/someone/highlights', '/someone/articles', '/someone/likes']) assert.equal(visitedProfile(path), 'someone', path);
for (const path of ['/home', '/explore', '/i/bookmarks', '/someone/status/1', '/search', '/notifications', '/settings/account', '/someone/followers']) assert.equal(visitedProfile(path), null, path);
assert.deepEqual(['/someone/status/42', '/someone/status/42/', '/someone/status/42/photo/1', '/someone', '/home'].map(focalPost), ['42', '42', '42', null, null]);
});
const VERIFIED: Users = { verified: { blue: true, verifiedType: 'None', following: false }, other: { blue: true, verifiedType: 'None', following: false }, plain: { blue: false, verifiedType: 'None', following: false } };
test('Collapse verified in a timeline: a known verified stranger collapses into a stub; you, the unknown and the unverified stay', () => {
const cells = [post({ id: '1', handle: 'verified' }), post({ id: '2', handle: 'plain' }), post({ id: '3', handle: 'stranger' }), post({ id: '4', handle: 'me' })];
const env = readerPage('/home', body('me', cells), { ...VERIFIED, me: { blue: true, verifiedType: 'None', following: false } });
env.on({ 'reader.collapseVerified': true });
assert.deepEqual(cells.map(cell => collapsed(cell, 'data-bt-hidden-check')), [true, false, false, false]);
const stub = env.root.querySelector('.bt-hbc-stub')!;
assert.equal(stub.textContent, 'Post hidden by Hide Blue Checks. Click to show.');
assert.equal(stub.nextElementSibling, articleOf(cells[0]!));
// X's responses describe the stranger: a verified account you do not follow.
env.learn({ stranger: { blue: true, verifiedType: 'None', following: false } });
assert.deepEqual(cells.map(cell => collapsed(cell, 'data-bt-hidden-check')), [true, false, true, false], 'checked again once X described the author');
stub.click();
assert.ok(!collapsed(cells[0]!, 'data-bt-hidden-check') && articleOf(cells[0]!).hasAttribute('data-bt-hidden-check-shown'));
env.on({});
assert.equal(env.root.querySelectorAll('.bt-hbc-stub, article[data-bt-hidden-check], article[data-bt-hidden-check-shown]').length, 0, 'off: every post back');
env.registry.dispose();
// While the signed-in account is unknown nothing is collapsed.
const unknown = readerPage('/home', body(null, [post({ id: '1', handle: 'verified' })]), VERIFIED);
unknown.on({ 'reader.collapseVerified': true });
assert.equal(unknown.root.querySelectorAll('article[data-bt-hidden-check]').length, 0);
unknown.registry.dispose();
});
test('fix: on a profile its owner\'s posts and quotes stay, on every tab; a repost follows the normal rule; leaving judges again', () => {
const own = post({ id: '1', handle: 'Verified' });
const quote = post({ id: '2', handle: 'verified', quoted: 'other' });
const repost = post({ id: '3', handle: 'other', reposted: true });
const cells = [own, quote, repost];
const env = readerPage('/verified', body('me', cells), VERIFIED);
env.store.set('bt-plugin:hide-blue-checks:settings', JSON.stringify({ hideBlue: true, hideBusiness: false, hideGovernment: false, keepFollowed: true, hideQuotes: true, allowlist: [] }));
env.on({ 'reader.collapseVerified': true });
const state = () => cells.map(cell => collapsed(cell, 'data-bt-hidden-check'));
assert.deepEqual(state(), [false, false, true], 'the owner and their quote of a verified account stay; what they reposted from one is collapsed');
for (const tab of ['/verified/with_replies', '/VERIFIED/media', '/verified/highlights', '/verified/articles']) {
env.go(tab);
assert.deepEqual(state(), [false, false, true], tab);
}
// X keeps these posts on screen while the page becomes Home: they are judged for the new page.
env.go('/home');
assert.deepEqual(state(), [true, true, true], 'away from the profile: the normal rule');
assert.equal(env.root.querySelectorAll('.bt-hbc-stub').length, 3);
env.go('/verified');
assert.deepEqual(state(), [false, false, true], 'and back');
assert.equal(env.root.querySelectorAll('.bt-hbc-stub').length, 1);
env.registry.dispose();
});
test('fix: on a post page the opened post stays; the same author\'s other posts and the replies follow the normal rule', () => {
const parent = post({ id: '5', handle: 'verified' });
const focal = post({ id: '7', handle: 'verified', focal: true, quoted: 'other' });
const reply = post({ id: '8', handle: 'other' });
const cells = [parent, focal, reply, post({ id: '9', handle: 'plain' })];
const env = readerPage('/verified/status/7', body('me', cells), VERIFIED);
env.store.set('bt-plugin:hide-blue-checks:settings', JSON.stringify({ hideBlue: true, hideBusiness: false, hideGovernment: false, keepFollowed: true, hideQuotes: true, allowlist: [] }));
env.on({ 'reader.collapseVerified': true });
const state = () => cells.map(cell => collapsed(cell, 'data-bt-hidden-check'));
assert.deepEqual(state(), [true, false, true, false]);
env.go('/verified/status/7/photo/1');
assert.deepEqual(state(), [true, false, true, false], 'its photo viewer is the same post');
env.go('/verified/status/5');
assert.deepEqual(state(), [false, true, true, false], 'another post opened: that one stays instead');
env.registry.dispose();
});
// ---- New with Reader+ ----------------------------------------------------------------------------
test('Read marker: what is stored, which post you are at, and when a position may replace the marker (pure)', () => {
assert.deepEqual(readMarkers({ tab0: { id: '12', time: 5 }, tab1: { id: 'x', time: 5 }, other: { id: '1', time: 1 }, tab2: { id: '3' }, tab3: null }), { tab0: { id: '12', time: 5 } });
for (const damaged of [null, 'text', 7, [], undefined]) assert.deepEqual(readMarkers(damaged), {});
assert.deepEqual([tabKey([true, false]), tabKey([false, true]), tabKey([false, false, true]), tabKey([false, false]), tabKey([])], ['tab0', 'tab1', 'tab2', null, null]);
const posts = [{ id: '1', bottom: -200 }, { id: '2', bottom: 40 }, { id: '3', bottom: 300 }, { id: '4', bottom: 700 }];
assert.equal(readingPost(posts), '3', 'the first post that reaches below X\'s header');
assert.equal(readingPost([{ id: '1', bottom: -5 }]), null);
const marker = { id: '9', time: 1 };
assert.ok(maySave(900, marker, false), 'somewhere in the timeline');
assert.ok(!maySave(0, marker, false), 'at the top, before the marker was reached: it is kept');
assert.ok(maySave(0, marker, true), 'caught up');
assert.ok(maySave(0, null, false), 'no marker yet');
});
test('Read marker: the post you left off at carries the line, a scroll saves where you are per Home tab, and off leaves nothing', () => {
const cells = [post({ id: '30', handle: 'a' }), post({ id: '20', handle: 'b', promoted: true }), post({ id: '10', handle: 'c' })];
const env = readerPage('/home', body('me', cells, [false, true]));
env.store.set('bt-plugin:reader-plus.readMarker:markers', JSON.stringify({ tab0: { id: '30', time: 1 }, tab1: { id: '10', time: 1 } }));
articleOf(cells[2]!).rect = { top: 2000, bottom: 2200 };
env.quietly(() => env.on({ 'reader.readMarker': true }));
assert.equal(articleOf(cells[2]!).getAttribute('data-bt-reader-marker'), 'You left off here', 'the marker of the selected tab (Following)');
assert.ok(!articleOf(cells[0]!).hasAttribute('data-bt-reader-marker'), 'the other tab\'s marker is not this timeline\'s');
env.api.commands.run(`${OWNER}:readMarker.jump`);
assert.equal(articleOf(cells[2]!).scrolled, 1);
// At the top of Home the marker is kept until it has been reached.
const timers: Array<() => void> = [];
Object.assign(env.win, { setTimeout: (fn: () => void) => { timers.push(fn); return timers.length; } });
const scroll = (y: number) => { Object.assign(env.win, { scrollY: y }); env.win.emit('scroll'); timers.splice(0).forEach(fn => fn()); };
const markers = () => JSON.parse(env.store.get('bt-plugin:reader-plus.readMarker:markers')!);
articleOf(cells[0]!).rect = { top: 60, bottom: 300 };
scroll(0);
assert.deepEqual(markers().tab1, { id: '10', time: 1 });
// Further down: the first post under X's header is where you are. An ad is never a place.
articleOf(cells[0]!).rect = { top: -400, bottom: -100 };
articleOf(cells[1]!).rect = { top: -100, bottom: 200 };
articleOf(cells[2]!).rect = { top: 200, bottom: 500 };
scroll(900);
assert.equal(markers().tab1.id, '10');
assert.deepEqual(markers().tab0, { id: '30', time: 1 }, 'the other tab keeps its own');
articleOf(cells[0]!).rect = { top: -100, bottom: 200 };
scroll(400);
assert.equal(markers().tab1.id, '30');
assert.equal(articleOf(cells[2]!).getAttribute('data-bt-reader-marker'), 'You left off here', 'the line stays where this visit began');
env.on({});
assert.equal(env.root.querySelectorAll('article[data-bt-reader-marker]').length, 0);
assert.deepEqual(env.live(), []);
env.registry.dispose();
});
test('Language filter: only a language X named, that you do not read, on a text long enough to judge (pure)', () => {
assert.deepEqual(['pt-BR', 'EN', 'zh_Hant', 'in', 'iw', 'und', 'qme', 'zxx', '', 'x-klingon-long', null].map(languageOf), ['pt', 'en', 'zh', 'id', 'he', '', '', '', '', '', '']);
assert.deepEqual([...readLanguages(['EN', 'tr-TR', 'nonsense!'])], ['en', 'tr']);
// No list, no guess (live: the engine's en-US accept-language folded a Turkish reader's posts).
assert.deepEqual([...readLanguages([])], [], 'an empty list: an empty set, so nothing is collapsed');
const read = new Set(['en', 'tr']);
const long = 'これは日本語で書かれた投稿です。';
assert.ok(shouldCollapseLanguage('ja', long, read));
assert.ok(!shouldCollapseLanguage('en', 'An English post of some length.', read));
assert.ok(!shouldCollapseLanguage('tr', 'Türkçe yazılmış bir gönderi.', read));
for (const unknown of ['und', 'qme', 'zxx', 'qht', '', null]) assert.ok(!shouldCollapseLanguage(unknown, long, read), `unknown never hides: ${unknown}`);
assert.ok(!shouldCollapseLanguage('ja', 'すごい', read), 'too short to trust the language X guessed');
assert.ok(!shouldCollapseLanguage('ja', long, new Set()), 'no language known to be read: nothing is collapsed');
});
test('Language filter: foreign posts collapse into a stub; yours, the opened post and the visited profile stay', () => {
const japanese = 'これは日本語で書かれた投稿です。';
const cells = [
post({ id: '1', handle: 'a', text: japanese, lang: 'ja' }),
post({ id: '2', handle: 'b' }),
post({ id: '3', handle: 'c', text: japanese, lang: 'und' }),
post({ id: '4', handle: 'me', text: japanese, lang: 'ja' }),
post({ id: '5', handle: 'd', text: '' }),
post({ id: '6', handle: 'e', quoted: 'a' }),
];
cells[5]!.querySelector('[role="link"] [data-testid="tweetText"]')!.setAttribute('lang', 'ja');
cells[5]!.querySelector('[role="link"] [data-testid="tweetText"]')!.textContent = japanese;
const env = readerPage('/home', body('me', cells));
const state = () => cells.map(cell => collapsed(cell, 'data-bt-reader-lang'));
// Nothing listed: nothing is folded, whatever X's or the engine's language says (the page says
// "en", the engine's accept-language "de": neither stands in for the reader).
(env.win as unknown as { navigator: unknown }).navigator = { languages: ['de'] };
env.on({ 'reader.languageFilter': true });
assert.deepEqual(state(), [false, false, false, false, false, false], 'an empty list folds nothing');
env.on({});
// A reader of English: the Japanese post folds.
env.store.set('bt-plugin:reader-plus.languageFilter:settings', JSON.stringify({ languages: ['en'] }));
env.on({ 'reader.languageFilter': true });
assert.deepEqual(state(), [true, false, false, false, false, false], 'not: no language, your own, no text, an English post quoting a Japanese one');
const stub = env.root.querySelector('.bt-reader-lang-stub')!;
assert.equal(stub.textContent, 'Post in Japanese hidden by the language filter. Click to show.');
stub.click();
assert.deepEqual(state(), [false, false, false, false, false, false]);
assert.ok(articleOf(cells[0]!).hasAttribute('data-bt-reader-lang-shown'));
env.on({});
assert.equal(env.root.querySelectorAll('.bt-reader-lang-stub, article[data-bt-reader-lang], article[data-bt-reader-lang-shown]').length, 0);
// A reader of Japanese only: the list in its settings decides.
env.store.set('bt-plugin:reader-plus.languageFilter:settings', JSON.stringify({ languages: ['ja'] }));
env.on({ 'reader.languageFilter': true });
assert.deepEqual(state(), [false, true, false, false, false, true]);
env.go('/b/status/2');
assert.deepEqual(state(), [false, false, false, false, false, true], 'the post you opened stays');
env.go('/E/media');
assert.deepEqual(state(), [false, true, false, false, false, false], 'the profile you are visiting stays');
env.registry.dispose();
const signedOut = readerPage('/home', body(null, [post({ id: '1', handle: 'a', text: japanese, lang: 'ja' })]));
signedOut.on({ 'reader.languageFilter': true });
assert.equal(signedOut.root.querySelectorAll('article[data-bt-reader-lang]').length, 0, 'while the account is unknown nothing is collapsed');
signedOut.registry.dispose();
});
test('Quick mute: a selection becomes one literal line of the Muted Regex list (pure)', () => {
assert.equal(quickMuteLine(' crypto giveaway \n'), 'crypto\\s+giveaway');
assert.equal(quickMuteLine('#ad'), '\\#ad', 'a leading # would be a comment');
assert.equal(quickMuteLine('/win/'), '\\/win\\/', 'never read as /pattern/flags');
assert.equal(quickMuteLine('1+1=2 (really?)'), '1\\+1=2\\s+\\(really\\?\\)');
for (const bad of ['', ' ', 'a', 'x'.repeat(121), null, undefined]) assert.equal(quickMuteLine(bad), null);
// Each line matches its own selection, as text, and nothing it did not say.
for (const text of ['crypto giveaway', '#ad', '/win/', '1+1=2 (really?)', 'a.b', '[x]']) {
const { patterns, invalid } = parsePatterns(quickMuteLine(text)!);
assert.deepEqual(invalid, []);
assert.equal(patterns.length, 1, text);
assert.ok(patterns[0]!.pattern.test(`before ${text.toUpperCase()} after`), text);
}
assert.ok(!parsePatterns(quickMuteLine('a.b')!).patterns[0]!.pattern.test('axb'));
assert.equal(appendPattern('', 'crypto'), 'crypto\n');
assert.equal(appendPattern('# mine\nspam', 'crypto'), '# mine\nspam\ncrypto\n');
assert.equal(appendPattern('spam\n', 'crypto'), 'spam\ncrypto\n');
assert.equal(appendPattern('spam\n crypto \n', 'crypto'), null, 'already on the list');
});
test('Quick mute: Alt+Shift+U adds the selection to the list the plugin kept, and a running Muted Regex collapses at once', () => {
const spam = post({ id: '1', handle: 'someone', text: 'Huge crypto giveaway today' });
const fine = post({ id: '2', handle: 'other', text: 'a walk in the park' });
const env = readerPage('/home', body('me', [spam, fine]));
env.store.set('bt-plugin:muted-regex:patterns', JSON.stringify('# mine\nspam'));
const list = () => JSON.parse(env.store.get('bt-plugin:muted-regex:patterns')!) as string;
const press = () => {
let prevented = false;
env.doc.emit('keydown', { key: 'U', code: 'KeyU', altKey: true, shiftKey: true, ctrlKey: false, metaKey: false, target: null, preventDefault() { prevented = true; }, stopPropagation() {} });
return prevented;
};
env.quietly(() => {
env.on({ 'reader.quickMute': true, 'reader.mutedRegex': true });
assert.ok(press());
assert.deepEqual(env.toasts, ['Select a word or a phrase first.']);
assert.equal(list(), '# mine\nspam', 'nothing selected: nothing added');
env.select('crypto giveaway');
assert.ok(press());
assert.equal(list(), '# mine\nspam\ncrypto\\s+giveaway\n');
assert.deepEqual([spam, fine].map(cell => collapsed(cell, 'data-bt-muted')), [true, false], 'Muted Regex read the list again');
env.api.commands.run(`${OWNER}:quickMute.add`);
assert.equal(list(), '# mine\nspam\ncrypto\\s+giveaway\n', 'not twice');
assert.equal(env.toasts.at(-1), '“crypto giveaway” is already on the Muted Regex list.');
// Muted Regex off: the line is still saved, for when it is turned on.
env.on({ 'reader.quickMute': true });
assert.ok(!collapsed(spam, 'data-bt-muted'));
env.select('park');
env.api.commands.run(`${OWNER}:quickMute.add`);
assert.equal(list(), '# mine\nspam\ncrypto\\s+giveaway\npark\n');
env.on({ 'reader.mutedRegex': true });
assert.deepEqual([spam, fine].map(cell => collapsed(cell, 'data-bt-muted')), [true, true]);
});
env.registry.dispose();
});