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

149 lines
9 KiB
TypeScript

// Regression tests for the visual-bug audit (scripts/visual-bugs.mjs): what gets hidden with a
// hidden count or sidebar module, and when X's Chat button steps aside. Layout itself (overlaps,
// gaps, empty boxes) is checked in a browser by the audit script.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { navigationCss } from '../src/areas/navigation/css.ts';
import { profileCss } from '../src/areas/profile/css.ts';
import { readerCss } from '../src/areas/reader/css.ts';
import { FEATURE_DEFAULTS, VIEWS_ATTR, buildFeatureCss, focalViewsParts, sidebarModuleSelectors, type FeatureConfig, type ShapeNode } from '../src/inject/features.ts';
import { LAUNCHER_MAX_HEIGHT, overlaps, shouldTuck, unionRect, type Rect } from '../src/fixes/chatButton.ts';
type PluginClass = { new(): { start(): void; stop(): void }; [key: string]: unknown };
function load(file: string): PluginClass {
const module = { exports: {} as unknown };
new Function('module', 'exports', readFileSync(new URL(`../src-tauri/bundled-plugins/${file}`, import.meta.url), 'utf8'))(module, module.exports);
return module.exports as PluginClass;
}
const FocusMode = load('focus-mode.plugin.js') as PluginClass & {
css(settings: Record<string, boolean>): string;
sidebarModule(base: string, inside?: string): string[];
viewsParts(time: ShapeNode): { unit: ShapeNode[]; separators: ShapeNode[] } | null;
};
// -- a DOM-shaped tree without a DOM: tag, attributes, text, parent/sibling links --
type Fake = ShapeNode & { children: Fake[]; name?: string };
function el(tag: string, attrs: Record<string, string> = {}, ...kids: (Fake | string)[]): Fake {
const children = kids.filter((kid): kid is Fake => typeof kid !== 'string');
const own = kids.filter(kid => typeof kid === 'string').join('');
const node: Fake = {
tagName: tag.toUpperCase(),
parentElement: null,
nextElementSibling: null,
children,
get textContent() { return own + children.map(child => child.textContent).join(''); },
getAttribute: (name: string) => (Object.hasOwn(attrs, name) ? attrs[name]! : null),
name: attrs['data-name'],
};
children.forEach((child, i) => { child.parentElement = node; child.nextElementSibling = children[i + 1] ?? null; });
return node;
}
const find = (root: Fake, name: string): Fake => {
if (root.name === name) return root;
for (const child of root.children) { const hit = find(child, name) as Fake | null; if (hit) return hit; }
return null as unknown as Fake;
};
/** X's opened-post date line: [date link box] [·] [views], views either a link (own post) or text. */
function dateLine({ own, split = false }: { own: boolean; split?: boolean }) {
const count = el('span', { 'data-testid': 'app-text-transition-container' }, el('span', {}, '1,234'));
const words = [el('span', {}, ' '), el('span', { 'data-name': 'word' }, 'Views')];
const unit = own
? el('a', { href: '/CRTkafa/status/1/analytics', 'data-name': 'views' }, count, ...words)
: el('div', { 'data-name': 'views' }, count, ...words);
const row = el('div', {},
el('div', {}, el('a', { href: '/CRTkafa/status/1' }, el('time', { datetime: '2026-09-28T19:06:00.000Z', 'data-name': 'time' }, '10:06 PM · Sep 28, 2026'))),
el('div', { 'aria-hidden': 'true', 'data-name': 'sep' }, el('span', {}, '·')),
...(split ? [el('div', { 'data-name': 'views' }, count), ...words] : [unit]),
);
return el('article', { 'data-testid': 'tweet' }, el('div', {}, row));
}
for (const [label, parts] of [['features.ts', focalViewsParts], ['Focus Mode', (t: ShapeNode) => FocusMode.viewsParts(t)]] as const) {
test(`${label}: an opened post's views unit is found with its separator, for own and others' posts`, () => {
for (const own of [true, false]) {
const tree = dateLine({ own });
const result = parts(find(tree, 'time'));
assert.ok(result, `own=${own}`);
assert.deepEqual(result.unit, [find(tree, 'views')]);
assert.deepEqual(result.separators, [find(tree, 'sep')]);
}
});
test(`${label}: a count and "Views" that are separate siblings go together`, () => {
const tree = dateLine({ own: false, split: true });
const result = parts(find(tree, 'time'));
assert.ok(result);
assert.equal(result.unit.length, 3, 'count, space and word');
assert.ok(result.unit.includes(find(tree, 'word')));
});
test(`${label}: a date line without a count (or a timeline header) marks nothing`, () => {
const header = el('article', {}, el('div', {},
el('div', {}, el('a', { href: '/jack' }, '@jack')),
el('div', {}, '·'),
el('div', {}, el('a', { href: '/jack/status/20' }, el('time', { datetime: 'x', 'data-name': 'time' }, '2h'))),
));
assert.equal(parts(find(header, 'time')), null);
});
}
test('sidebar modules hide with their bordered card, and only a card that holds nothing else', () => {
const [module, card, outer] = sidebarModuleSelectors('section[role="region"]', '[data-testid="trend"]');
assert.equal(module, '[data-testid="sidebarColumn"] section[role="region"]:has([data-testid="trend"])');
assert.match(card!, /div:has\(> section\[role="region"\] \[data-testid="trend"\]\):not\(:has\(> :not\(section\[role="region"\]\)\)\)/);
assert.match(outer!, /:not\(:has\(> :not\(div\)\)\)/);
// No :has() inside :has() (invalid CSS: the whole rule would be dropped).
for (const selector of [module, card, outer]) assert.doesNotMatch(selector!, /:has\([^)]*:has\(/);
});
test('every sidebar-module tweak also hides the module card; Trends stay scoped to the right column', () => {
// The right-column modules are Navigation+'s (src/areas/navigation/css.ts), the view counts Reader+'s, the profile one Profile+'s.
const only = (key: keyof FeatureConfig) => {
const config = { ...Object.fromEntries(Object.keys(FEATURE_DEFAULTS).map(k => [k, false])), [key]: true } as FeatureConfig;
return [buildFeatureCss(config), navigationCss(config), readerCss(config), profileCss(config)].filter(Boolean).join('\n\n');
};
for (const key of ['hideSidebarTrends', 'hideWhoToFollow', 'hideLiveOnX', 'hideTodaysNews', 'hidePremiumUpsells', 'hideRelevantPeople', 'hideProfileWhoToFollow'] as const) {
assert.match(only(key), /\[data-testid="sidebarColumn"\] div:has\(> /, key);
}
// Explore's own trends are hideExploreContent's business, not the sidebar tweak's.
assert.doesNotMatch(only('hideSidebarTrends'), /(^|,\n)section\[role="region"\]/);
const views = only('hideViewCounts');
assert.ok(views.includes(`[${VIEWS_ATTR}]`), 'the opened post views unit');
assert.ok(views.includes('a[href$="/analytics"]:not([role="group"] a)'));
});
test('Focus Mode: counts go without leaving words, separators, gaps or empty cards', () => {
const css = FocusMode.css({ enabled: true, metrics: true, trends: true, whoToFollow: true, followCounts: true, sidebar: false });
assert.ok(css.includes('[data-bt-fm-views]') && css.includes('[data-bt-fm-views-sep]'), 'views unit and its separator');
// Follower numbers: display none (no blank gap before "Followers").
assert.match(css, /a\[href\$="\/followers"\][^{]*> span:first-child[^{]*\{ display: none !important; \}/);
// Sidebar cards of hidden trends / suggestions go too.
assert.ok(css.includes('[data-testid="sidebarColumn"] div:has(> section[role="region"] [data-testid="trend"])'));
assert.ok(css.includes('[data-testid="sidebarColumn"] div:has(> aside[role="complementary"] [data-testid="UserCell"])'));
for (const rule of css.split('\n')) assert.doesNotMatch(rule, /:has\([^)]*:has\(/, 'no nested :has()');
assert.equal(FocusMode.css({ enabled: false, metrics: true, trends: true, whoToFollow: true, followCounts: true, sidebar: false }), '');
});
const R = (left: number, top: number, right: number, bottom: number): Rect => ({ left, top, right, bottom });
const VIEW = { width: 600, height: 800 };
test('Chat button: steps aside only while its collapsed button overlaps a composer on screen', () => {
const button = R(524, 724, 580, 780);
assert.equal(shouldTuck(button, [R(0, 700, 600, 790)], VIEW), true, 'reply box under the button');
assert.equal(shouldTuck(button, [R(0, 100, 600, 180)], VIEW), false, 'composer elsewhere');
assert.equal(shouldTuck(button, [R(0, 900, 600, 980)], VIEW), false, 'composer below the fold');
assert.equal(shouldTuck(button, [R(0, 700, 600, 790)], VIEW, true), false, 'the button has the focus');
assert.equal(shouldTuck(null, [R(0, 700, 600, 790)], VIEW), false, 'no chat button');
const drawer = R(200, 300, 580, 780);
assert.ok(drawer.bottom - drawer.top > LAUNCHER_MAX_HEIGHT);
assert.equal(shouldTuck(drawer, [R(0, 700, 600, 790)], VIEW), false, 'an open chat drawer is never hidden');
});
test('Chat button: the launcher is what it paints (zero-size fixed wrappers add nothing)', () => {
assert.deepEqual(unionRect([R(600, 800, 600, 800), R(524, 724, 580, 780)]), R(524, 724, 580, 780));
assert.equal(unionRect([R(0, 0, 0, 0)]), null);
assert.equal(overlaps(R(0, 0, 10, 10), R(20, 0, 30, 10), 8), false);
assert.equal(overlaps(R(0, 0, 10, 10), R(15, 0, 30, 10), 8), true, 'within the 8px breathing room');
});