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

190 lines
13 KiB
TypeScript

// Navigation+ (src/areas/navigation): the reference area. Its hide and layout rules, Focus Mode
// and Keyboard Plus as feature modules, compared with the plugins they replace. The table-level
// checks (every module starts and stops clean, one observer, the budget) are in areas.test.ts.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { NAVIGATION_CSS, navigationCss } from '../src/areas/navigation/css.ts';
import { FEATURES } from '../src/areas/navigation/features.ts';
import { FOCUS_SCHEMA, focusCss, type FocusSettings } from '../src/areas/navigation/focusMode.ts';
import { MODULES } from '../src/areas/navigation/index.ts';
import { keyboardActions } from '../src/areas/navigation/keyboardPlus.ts';
import type { PluginApi } from '../src/inject/pluginApi.ts';
import { normalizeSettings } from '../../../waybetter/packages/runtime/src/settingsForm.ts';
import { areaPage, flagsOnly, messages } from './helpers/areaPage.ts';
const BUNDLED = new URL('../src-tauri/bundled-plugins/', import.meta.url);
/** The old plugin, loaded the way native code loads it, to compare the port against. */
function oldPlugin(file: string): Record<string, unknown> {
const module = { exports: {} as unknown };
new Function('module', 'exports', readFileSync(new URL(file, BUNDLED), 'utf8'))(module, module.exports);
return module.exports as Record<string, unknown>;
}
test('Navigation+: its fourteen features are modules, and each hide rule only names its own part of X', () => {
assert.equal(FEATURES.length, 14);
assert.deepEqual(MODULES.map(module => module.id).sort(), FEATURES.map(feature => feature.id).sort());
assert.equal(Object.keys(NAVIGATION_CSS).length, 12);
const markers: Record<keyof typeof NAVIGATION_CSS, string> = {
hidePremiumNav: '/i/verified', hideChatNav: '/i/chat', hideCommunitiesNav: '/communities', hideGrokNav: 'a[href="/i/grok"]',
hideSidebarTrends: '[data-testid="trend"]', hideWhoToFollow: 'UserCell', hideLiveOnX: 'Live on X', hideTodaysNews: 'news_sidebar',
hidePremiumUpsells: 'premium_sign_up', hideRelevantPeople: 'data-bt-route="status"', hideSidebar: '[data-testid="sidebarColumn"] {', fullWidthTimeline: 'flex-grow: 1',
};
for (const [flag, marker] of Object.entries(markers)) {
const css = navigationCss({ [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);
}
assert.equal(navigationCss({}), '');
// The rail rules never reach into posts, and the right-column ones stay in the right column.
for (const flag of ['hidePremiumNav', 'hideChatNav', 'hideCommunitiesNav', 'hideGrokNav'] as const) assert.doesNotMatch(NAVIGATION_CSS[flag](), /article|tweet|sidebarColumn/, flag);
assert.doesNotMatch(NAVIGATION_CSS.hideSidebarTrends(), /(^|,\n)section\[role="region"\]/, 'Explore\'s own trends are Search+\'s');
});
test('Navigation+: "Relevant people" only runs on post pages, and nothing of it on chat', () => {
const env = areaPage('/home');
env.registry.setFlags(flagsOnly({ hideRelevantPeople: true, hideSidebarTrends: true }));
assert.deepEqual(env.registry.active(), ['hideSidebarTrends']);
env.go('/someone/status/1');
assert.deepEqual(env.registry.active().sort(), ['hideRelevantPeople', 'hideSidebarTrends']);
assert.deepEqual(env.live(), ['wbf-hideRelevantPeople', 'wbf-hideSidebarTrends']);
env.go('/i/chat');
assert.deepEqual(env.registry.active(), []);
env.registry.dispose();
});
test('Focus Mode: the same settings and the same rules as the plugin it replaces', () => {
const Old = oldPlugin('focus-mode.plugin.js') as unknown as { DEFAULTS: FocusSettings; normalize(saved: unknown): FocusSettings; css(settings: FocusSettings): string };
assert.deepEqual(normalizeSettings(FOCUS_SCHEMA, null), Old.DEFAULTS, 'someone who enabled the plugin and never opened its settings sees no change');
for (const saved of [{ metrics: false, trends: 'yes', extra: true }, { enabled: false }, { sidebar: true, followCounts: true }]) {
assert.deepEqual(normalizeSettings(FOCUS_SCHEMA, saved), Old.normalize(saved), JSON.stringify(saved));
}
const keys = Object.keys(Old.DEFAULTS) as (keyof FocusSettings)[];
// Every combination of the six switches gives byte-identical CSS.
for (let bits = 0; bits < 2 ** keys.length; bits += 1) {
const settings = Object.fromEntries(keys.map((key, index) => [key, !!(bits & (1 << index))])) as FocusSettings;
assert.equal(focusCss(settings), Old.css(settings), JSON.stringify(settings));
}
assert.equal(focusCss({ ...Old.DEFAULTS, enabled: false }), '', 'off: nothing hidden');
// Its words are the plugin's own keys (`words: 'focus-mode'`): every label and hint of the form exists.
for (const field of FOCUS_SCHEMA) if (field.type !== 'divider') {
assert.ok(messages[`plugin.focus-mode.${field.label}`], field.label);
if (field.hint) assert.ok(messages[`plugin.focus-mode.${field.hint}`], field.hint);
}
});
test('Focus Mode: reads the plugin\'s stored settings, toggles with its command and Alt+Shift+F, saves where the plugin saved', () => {
const env = areaPage();
env.store.set('bt-plugin:focus-mode:settings', JSON.stringify({ enabled: true, metrics: false, trends: true, whoToFollow: false, followCounts: false, sidebar: false }));
env.registry.setFlags(flagsOnly({ 'navigation.focusMode': true }));
assert.deepEqual(env.live(), ['wbf-navigation.focusMode']);
assert.deepEqual(env.api.commands.list().map(command => command.id).sort(), ['bt-navigation-plus.plugin.js:focusMode.toggle', 'bt-navigation-plus.plugin.js:settings']);
assert.equal(env.api.commands.list().find(command => command.id.endsWith('focusMode.toggle'))!.title, 'Toggle Focus Mode');
assert.equal(env.api.commands.list().find(command => command.id.endsWith(':settings'))!.title, 'Navigation+ settings');
// (The confirming toast needs a real DOM; here the kit reports that it cannot draw, which is fine.)
const original = console.error;
console.error = () => {};
try {
// Alt+Shift+F is the command's shortcut now, so the palette lists it; it still works from the page.
let prevented = false;
env.doc.emit('keydown', { key: 'F', code: 'KeyF', altKey: true, shiftKey: true, ctrlKey: false, metaKey: false, target: null, preventDefault() { prevented = true; }, stopPropagation() {} });
assert.ok(prevented);
assert.equal(JSON.parse(env.store.get('bt-plugin:focus-mode:settings')!).enabled, false);
assert.deepEqual(env.live(), [], 'off: no rule');
env.api.commands.run('bt-navigation-plus.plugin.js:focusMode.toggle');
assert.deepEqual(JSON.parse(env.store.get('bt-plugin:focus-mode:settings')!), { enabled: true, metrics: false, trends: true, whoToFollow: false, followCounts: false, sidebar: false });
assert.deepEqual(env.live(), ['wbf-navigation.focusMode']);
} finally { console.error = original; }
env.registry.dispose();
});
test('Keyboard Plus: keys act on the focused post, copy or navigate, and never click an action', async () => {
const clicked: string[] = [];
const copied: string[] = [];
const said: string[] = [];
const node = (name: string) => ({ click() { clicked.push(name); }, closest: () => null });
const article = {
contains: () => false,
querySelector: (selector: string) => (selector.includes('User-Name') ? node('author') : null),
querySelectorAll: (selector: string) => (selector.includes('/photo/') ? [node('photo')] : []),
} as unknown as Element;
const tweet = { id: '1', url: 'https://x.com/someone/status/1', handle: 'someone', name: 'Someone', text: 'hello', time: null, media: [], element: article };
const api = { tweet: () => tweet, copyText: async (text: string) => { copied.push(text); return true; }, toast: (message: string) => { said.push(message); } } as unknown as PluginApi;
const actions = keyboardActions({ api, text: key => messages[`plugin.keyboard-plus.${key}`] ?? key, win: { location: { assign() {} } } as unknown as Window & typeof globalThis });
assert.deepEqual(Object.keys(actions), ['c', 'C', 'v', 'a', 'q']);
await actions.c!(article);
await actions.C!(article);
actions.v!(article);
actions.a!(article);
actions.q!(article);
assert.deepEqual(copied, ['https://x.com/someone/status/1', 'hello']);
assert.deepEqual(clicked, ['photo', 'author']);
assert.deepEqual(said, ['Link copied', 'Text copied', 'No quoted post']);
// In the page: a key without a focused post does nothing.
const env = areaPage();
env.registry.setFlags(flagsOnly({ 'navigation.keyboardPlus': true }));
let prevented = 0;
env.doc.emit('keydown', { key: 'c', target: null, preventDefault() { prevented += 1; }, stopPropagation() {} });
assert.equal(prevented, 0, 'no post is focused');
env.registry.dispose();
});
/** Selector specificity [ids, classes/attributes/pseudo-classes, types] of one complex selector
* (enough for the rules here: :is/:not/:has count their most specific argument, :where nothing). */
function specificity(selector: string): [number, number, number] {
const total: [number, number, number] = [0, 0, 0];
const add = (s: [number, number, number]) => { total[0] += s[0]; total[1] += s[1]; total[2] += s[2]; };
let rest = selector;
for (;;) {
const match = /:(is|not|has|where)\(/.exec(rest);
if (!match) break;
let depth = 1; let i = match.index + match[0].length;
for (; i < rest.length && depth; i++) { if (rest[i] === '(') depth++; else if (rest[i] === ')') depth--; }
const inner = rest.slice(match.index + match[0].length, i - 1);
if (match[1] !== 'where') {
const args = inner.split(/,(?![^(]*\))/).map(arg => specificity(arg.trim()));
add(args.reduce((best, s) => (s[0] - best[0] || s[1] - best[1] || s[2] - best[2]) > 0 ? s : best));
}
rest = rest.slice(0, match.index) + ' ' + rest.slice(i);
}
rest = rest.replace(/\[[^\]]*\]/g, () => { total[1]++; return ' '; });
total[0] += (rest.match(/#[\w-]+/g) ?? []).length;
total[1] += (rest.match(/\.[\w-]+|:[\w-]+/g) ?? []).length;
total[2] += (rest.replace(/[#.:][\w-]+/g, ' ').match(/(^|[\s>+~])[a-z][\w-]*/gi) ?? []).length;
return total;
}
const beats = (a: [number, number, number], b: [number, number, number]) => (a[0] - b[0] || a[1] - b[1] || a[2] - b[2]) > 0;
test('Navigation+: full-width timeline wins over a theme with a layout of its own', () => {
// The Classic Twitter theme's layout rules as the theme engine served them on live x.com
// (2026-10-01): both !important, so only specificity decides against ours.
const theme = [
'[data-testid="primaryColumn"]:is(.r-1ye8kvj)',
'main[role="main"] > div:is(.r-113js5t):has([data-testid="primaryColumn"]:is(.r-1ye8kvj)):has([data-testid="sidebarColumn"]:is(.r-1hycxz))',
'main[role="main"] > div:is(.r-33ulu8):has([data-testid="primaryColumn"]:is(.r-1ye8kvj)):not(:has([data-testid="sidebarColumn"]:is(.r-1hycxz)))',
].map(specificity);
assert.deepEqual(specificity('[data-testid="primaryColumn"]:is(.r-1ye8kvj)'), [0, 2, 0]);
const css = NAVIGATION_CSS.fullWidthTimeline();
const selectors = [...css.matchAll(/^([^{}\n]+)\{/gm)].flatMap(match => match[1]!.split(/,(?![^(]*\))/).map(item => item.trim()));
assert.equal(selectors.length, 3);
for (const selector of selectors) for (const rule of theme) assert.ok(beats(specificity(selector), rule), `${selector} outranks ${rule.join(',')}`);
// The id only lifts the rules: no element carries it, and the chat row stays excluded.
assert.match(css, /#bt-navigation-full-width/);
assert.equal((css.match(/dm-container/g) ?? []).length, 2);
assert.doesNotMatch(css, /:has\([^)]*:has\(/);
});
test('Navigation+: Today\'s News goes with its card (live 2026-10-01: the testid is only the card\'s empty tracker)', () => {
// Live shape of the right column's news card: <div card><div data-testid="news_sidebar"> (an empty
// impression tracker)</div><div>heading "Today's News", the stories</div></div>, next to an NFL card.
// Hiding only the testid left the heading and the stories on screen.
const css = NAVIGATION_CSS.hideTodaysNews();
const selectors = css.replace(/\{[^}]*\}/, '').split(/,\n/).map(item => item.trim());
assert.ok(selectors.includes('[data-testid="sidebarColumn"] div:has(> [data-testid="news_sidebar"])'), 'the card holding the tracker as a direct child');
// Only the card: every selector names the tracker, and the card one only as a direct child in the right column.
for (const selector of selectors) assert.match(selector, /news_sidebar/, selector);
assert.doesNotMatch(css, /div:has\([^>]*\[data-testid="news_sidebar"\]\)/, 'never "any div that contains it somewhere" (that would take the whole column)');
assert.doesNotMatch(css, /:has\([^)]*:has\(/);
});