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

328 lines
20 KiB
TypeScript

import assert from 'node:assert/strict';
import { test } from 'node:test';
import { routeKind, statusIdFromHref } from '../src/fixes/route.ts';
import { compensation, pickAnchor, restoreStep, tabToRestore, type TimelineItem } from '../src/fixes/timeline.ts';
import { isGifSource, isLoopWrap, parseVolume } from '../src/fixes/video.ts';
import { badgeCount, nextBadge, stripTitleCount } from '../src/fixes/badge.ts';
import { installReplySort, rewriteReplySort } from '../src/fixes/replySort.ts';
import { LOST_TTL_MS, isLoss, parseLost } from '../src/fixes/composerRestore.ts';
import { classifyResponse, matchMutation, synthesizedBody, type MutationMatch } from '../src/fixes/mutationGate/matchers.ts';
import { installMutationGate } from '../src/fixes/mutationGate/index.ts';
import { X_FEATURES } from '../src/areas/table.ts';
import { FEATURE_DEFAULTS, formatFullCount, fullCountFromLabel, isCompactCount } from '../src/inject/features.ts';
const BASE = 'https://x.com/home';
const post = (body: BodyInit | null | undefined): RequestInit => ({ method: 'POST', body });
const vars = (variables: Record<string, unknown>) => JSON.stringify({ variables, queryId: 'q' });
const tick = () => new Promise(resolve => setImmediate(resolve));
// ---------- routes ----------
test('routeKind classifies home, posts, profiles, notifications and X pages', () => {
assert.equal(routeKind('/home'), 'home');
assert.equal(routeKind('/jack/status/20'), 'status');
assert.equal(routeKind('/jack/status/20/photo/1'), 'status');
assert.equal(routeKind('/jack'), 'profile');
assert.equal(routeKind('/jack/with_replies'), 'profile');
assert.equal(routeKind('/jack/media/'), 'profile');
assert.equal(routeKind('/notifications'), 'notifications');
assert.equal(routeKind('/notifications/mentions'), 'notifications');
for (const path of ['/explore', '/messages', '/i/bookmarks', '/settings/account', '/search', '/jack/followers', '/jack/lists', '/', '/a_name_that_is_too_long_x']) {
assert.equal(routeKind(path), 'other', path);
}
});
test('statusIdFromHref reads relative and absolute x.com status links only', () => {
assert.equal(statusIdFromHref('/jack/status/20'), '20');
assert.equal(statusIdFromHref('https://x.com/jack/status/20/analytics'), '20');
assert.equal(statusIdFromHref('/jack/status/20?s=1'), '20');
assert.equal(statusIdFromHref('https://evil.example/jack/status/20'), null);
assert.equal(statusIdFromHref('/jack/status/abc'), null);
assert.equal(statusIdFromHref(null), null);
});
// ---------- timeline ----------
const item = (id: string, top: number, height = 200): TimelineItem => ({ id, top, bottom: top + height });
test('pickAnchor prefers the first post whose top is on screen', () => {
assert.deepEqual(pickAnchor([item('1', -300), item('2', -50), item('3', 150)]), { id: '3', top: 150 });
// One tall post covering the whole viewport: it is the anchor.
assert.deepEqual(pickAnchor([item('1', -900), item('2', -100, 2000)]), { id: '2', top: -100 });
assert.equal(pickAnchor([]), null);
assert.equal(pickAnchor([item('1', -500)]), null);
});
test('restoreStep moves the anchor back, waits for it to settle, and jumps once for virtualized posts', () => {
const obs = { targetTop: 120, scrollY: 0, targetScrollY: 5000, viewport: 800 };
assert.deepEqual(restoreStep({ fallback: true, jumped: false, stable: 0 }, { ...obs, top: 420 }), { kind: 'scrollBy', dy: 300 });
assert.deepEqual(restoreStep({ fallback: true, jumped: false, stable: 0 }, { ...obs, top: 121 }), { kind: 'wait' });
assert.deepEqual(restoreStep({ fallback: true, jumped: false, stable: 2 }, { ...obs, top: 121 }), { kind: 'done' });
assert.deepEqual(restoreStep({ fallback: true, jumped: false, stable: 0 }, { ...obs, top: null }), { kind: 'scrollTo', y: 5000 });
assert.deepEqual(restoreStep({ fallback: true, jumped: true, stable: 0 }, { ...obs, top: null }), { kind: 'wait' });
// Tab switches never jump blindly: only a found post is restored.
assert.deepEqual(restoreStep({ fallback: false, jumped: false, stable: 0 }, { ...obs, top: null }), { kind: 'wait' });
// Already near the old offset: nothing to jump.
assert.deepEqual(restoreStep({ fallback: true, jumped: false, stable: 0 }, { ...obs, top: null, scrollY: 4800 }), { kind: 'wait' });
});
test('compensation only undoes a downward push of the anchor while the page did not scroll', () => {
const prev = { key: '/home|Following', anchor: { id: '5', top: 100 }, scrollY: 900 };
const pushed = [item('9', -400), item('5', 700)];
assert.equal(compensation(prev, { key: prev.key, items: pushed, scrollY: 900 }), 600);
assert.equal(compensation(prev, { key: prev.key, items: pushed, scrollY: 950 }), 0, 'X or the user scrolled');
assert.equal(compensation(prev, { key: '/home|For you', items: pushed, scrollY: 900 }), 0, 'another page or tab');
assert.equal(compensation(prev, { key: prev.key, items: [item('9', 100)], scrollY: 900 }), 0, 'anchor gone');
assert.equal(compensation(prev, { key: prev.key, items: [item('5', 40)], scrollY: 900 }), 0, 'moved up: not an insertion above');
assert.equal(compensation(prev, { key: prev.key, items: [item('5', 101)], scrollY: 900 }), 0, 'sub-pixel noise');
assert.equal(compensation({ ...prev, scrollY: 0 }, { key: prev.key, items: pushed, scrollY: 0 }), 0, 'at the very top X inserts on purpose');
assert.equal(compensation(null, { key: prev.key, items: pushed, scrollY: 900 }), 0);
});
test('tabToRestore undoes X landing on "For you" only, at most twice per tab bar', () => {
const labels = ['For you', 'Following', 'Tech list'];
assert.equal(tabToRestore(labels, 0, 'Following', 0), 1);
assert.equal(tabToRestore(labels, 0, 'Tech list', 1), 2);
assert.equal(tabToRestore(labels, 0, 'Following', 2), -1);
assert.equal(tabToRestore(labels, 1, 'Tech list', 0), -1, 'the user is on another tab; leave it');
assert.equal(tabToRestore(labels, 0, 'For you', 0), -1);
assert.equal(tabToRestore(labels, 0, 'Gone list', 0), -1);
assert.equal(tabToRestore(labels, 0, null, 0), -1);
});
// ---------- video ----------
test('isLoopWrap detects a jump from the end to the start only', () => {
assert.equal(isLoopWrap(29.8, 0.1, 30), true);
assert.equal(isLoopWrap(15, 0.1, 30), false, 'seek back mid-video');
assert.equal(isLoopWrap(29.8, 12, 30), false);
assert.equal(isLoopWrap(0.9, 0.1, 1), false, 'too short to judge');
assert.equal(isLoopWrap(29.8, 0.1, Number.NaN), false);
assert.equal(isLoopWrap(29.8, 0.1, Number.POSITIVE_INFINITY), false, 'live stream');
});
test('GIF sources and stored volume parsing', () => {
assert.equal(isGifSource('https://video.twimg.com/tweet_video/AbC.mp4'), true);
assert.equal(isGifSource('blob:https://x.com/1234'), false);
assert.equal(isGifSource('https://video.twimg.com/ext_tw_video/1/pu/vid/avc1/720x1280/a.mp4'), false);
assert.equal(parseVolume('0.35'), 0.35);
assert.equal(parseVolume('0'), 0);
for (const raw of [null, '', 'loud', '1.5', '-0.1', 'NaN']) assert.equal(parseVolume(raw), null, String(raw));
});
// ---------- notification badge ----------
test('badge: a count seen on Notifications stays hidden until it changes', () => {
assert.equal(badgeCount('3'), 3);
assert.equal(badgeCount('20+'), 20);
assert.equal(badgeCount(''), null);
assert.equal(badgeCount('new'), null);
let state = nextBadge({ stale: null }, false, 3);
assert.deepEqual(state, { stale: null, hide: false }, 'never visited: shown');
state = nextBadge(state, true, 3);
assert.deepEqual(state, { stale: 3, hide: true });
state = nextBadge(state, false, 3);
assert.deepEqual(state, { stale: 3, hide: true }, 'left the page, X kept the old count');
state = nextBadge(state, false, 4);
assert.deepEqual(state, { stale: null, hide: false }, 'new notifications show again');
assert.deepEqual(nextBadge({ stale: 3 }, false, null), { stale: null, hide: false });
});
test('stripTitleCount drops only a leading unread prefix', () => {
assert.equal(stripTitleCount('(3) Home / X'), 'Home / X');
assert.equal(stripTitleCount('(20+) Notifications / X'), 'Notifications / X');
assert.equal(stripTitleCount('Home / X'), 'Home / X');
assert.equal(stripTitleCount('jack (@jack) / X'), 'jack (@jack) / X');
});
// ---------- reply sort ----------
const detail = (variables: Record<string, unknown>) => `/i/api/graphql/abc_-1/TweetDetail?variables=${encodeURIComponent(JSON.stringify(variables))}&features=%7B%7D`;
test('rewriteReplySort switches Relevance to Recency on TweetDetail reads only', () => {
const rewritten = rewriteReplySort(detail({ focalTweetId: '20', rankingMode: 'Relevance' }), BASE);
assert.ok(rewritten?.startsWith('/i/api/graphql/abc_-1/TweetDetail?'), rewritten ?? '');
const params = new URL(rewritten!, BASE).searchParams;
assert.deepEqual(JSON.parse(params.get('variables')!), { focalTweetId: '20', rankingMode: 'Recency' });
assert.equal(params.get('features'), '{}', 'other parameters are kept');
assert.match(rewriteReplySort(new URL(detail({ rankingMode: 'Relevance' }), BASE), BASE)!, /^https:\/\/x\.com\//);
for (const input of [
detail({ focalTweetId: '20', rankingMode: 'Likes' }),
detail({ focalTweetId: '20', rankingMode: 'Recency' }),
detail({ focalTweetId: '20' }),
'/i/api/graphql/abc/TweetDetail?variables=%7Bbad',
'/i/api/graphql/abc/TweetDetail',
'/i/api/graphql/abc/HomeTimeline?variables=%7B%22rankingMode%22%3A%22Relevance%22%7D',
`https://api.example${detail({ rankingMode: 'Relevance' })}`,
new Request(`https://x.com${detail({ rankingMode: 'Relevance' })}`),
42,
]) assert.equal(rewriteReplySort(input, BASE), null, String(input));
});
test('reply sort wrapper passes everything else through and restores fetch on dispose', async () => {
const calls: unknown[][] = [];
const inner = ((...args: unknown[]) => { calls.push(args); return Promise.resolve(new Response('ok')); }) as typeof fetch;
const target = { fetch: inner, location: { href: BASE } };
const handle = installReplySort(target);
assert.equal(handle.installed, true);
const init = { method: 'GET', headers: { a: 'b' } };
await target.fetch(detail({ rankingMode: 'Relevance' }), init);
assert.match(String(calls[0]![0]), /Recency/);
assert.equal(calls[0]![1], init, 'init object is passed through untouched');
await target.fetch(detail({ rankingMode: 'Relevance' }), { method: 'POST' });
assert.match(decodeURIComponent(String(calls[1]![0])), /Relevance/, 'non-GET untouched');
handle.dispose();
assert.equal(target.fetch, inner);
});
test('reply sort rewrites the XHR TweetDetail reads too and unhooks XHR on dispose', () => {
const opened: { method: string; url: string }[] = [];
const sent: unknown[] = [];
class FakeXHR {
open(method: string, url: string) { opened.push({ method, url }); }
send(body?: unknown) { sent.push(body ?? null); }
}
const nativeOpen = FakeXHR.prototype.open;
const nativeSend = FakeXHR.prototype.send;
const target = { fetch: (async () => new Response('ok')) as typeof fetch, location: { href: BASE }, XMLHttpRequest: FakeXHR as unknown as typeof XMLHttpRequest };
const handle = installReplySort(target);
const xhr = new target.XMLHttpRequest();
xhr.open('GET', detail({ focalTweetId: '20', rankingMode: 'Relevance' }));
xhr.send();
assert.equal(sent.length, 1, 'the request is sent exactly once');
const url = new URL(opened[0]!.url, BASE);
assert.equal(url.pathname, '/i/api/graphql/abc_-1/TweetDetail');
assert.deepEqual(JSON.parse(url.searchParams.get('variables')!), { focalTweetId: '20', rankingMode: 'Recency' });
for (const [method, input] of [
['GET', detail({ focalTweetId: '20', rankingMode: 'Likes' })],
['POST', detail({ focalTweetId: '20', rankingMode: 'Relevance' })],
['GET', '/i/api/graphql/abc/HomeTimeline?variables=%7B%22rankingMode%22%3A%22Relevance%22%7D'],
['GET', 'https://pbs.twimg.com/media/a.jpg'],
] as const) {
const other = new target.XMLHttpRequest();
other.open(method, input);
other.send('b');
assert.equal(opened.at(-1)!.url, input, `${method} ${input} untouched`);
assert.equal(sent.at(-1), 'b');
}
handle.dispose();
assert.equal(FakeXHR.prototype.open, nativeOpen, 'dispose restores open');
assert.equal(FakeXHR.prototype.send, nativeSend, 'dispose restores send');
});
// ---------- composer restore ----------
test('lost composer map: validated, unexpired, newest first, capped', () => {
const now = 10_000_000;
const raw = JSON.stringify({
'https://x.com/a/status/1': now - 1000,
'https://x.com/home': now - LOST_TTL_MS - 1,
'https://evil.example/x': now,
'https://x.com/b/status/2': 'soon',
'https://x.com/c/status/3': now + 5000,
});
assert.deepEqual(parseLost(raw, now), { 'https://x.com/a/status/1': now - 1000 });
assert.deepEqual(parseLost('{bad', now), {});
assert.deepEqual(parseLost('[1]', now), {});
assert.deepEqual(parseLost(null, now), {});
const many = Object.fromEntries(Array.from({ length: 30 }, (_, i) => [`https://x.com/u/status/${i}`, now - i]));
const kept = parseLost(JSON.stringify(many), now);
assert.equal(Object.keys(kept).length, 20);
assert.ok('https://x.com/u/status/0' in kept && !('https://x.com/u/status/25' in kept));
});
test('isLoss: text gone without Post and without X\'s Save/Discard sheet', () => {
const now = 100_000;
assert.equal(isLoss({ hadText: true, now, postedAt: 0, confirmedAt: 0 }), true);
assert.equal(isLoss({ hadText: false, now, postedAt: 0, confirmedAt: 0 }), false);
assert.equal(isLoss({ hadText: true, now, postedAt: now - 1000, confirmedAt: 0 }), false, 'just posted');
assert.equal(isLoss({ hadText: true, now, postedAt: 0, confirmedAt: now - 500 }), false, 'saved or discarded in X');
});
// ---------- full numbers ----------
test('full counts come from the aria-label and replace only abbreviated text', () => {
assert.equal(fullCountFromLabel('1234 Likes. Like'), 1234);
assert.equal(fullCountFromLabel('1,234,567 views. View post analytics'), 1234567);
assert.equal(fullCountFromLabel('12.345 Gefällt mir'), 12345);
assert.equal(fullCountFromLabel('12 345 J’aime'), 12345);
assert.equal(fullCountFromLabel('5 Replies. Reply'), 5);
assert.equal(fullCountFromLabel('Like'), null);
assert.equal(fullCountFromLabel('Reply'), null);
assert.equal(fullCountFromLabel(null), null);
for (const text of ['1.2K', '12K', '3,4 Mio.', '1.5M', '7 B']) assert.equal(isCompactCount(text), true, text);
for (const text of ['12', '1,234', '1.234', 'Like', '']) assert.equal(isCompactCount(text), false, text);
assert.equal(formatFullCount(1234567, 'en'), '1,234,567');
});
test('new tweak flags are off by default and every one has a panel row', () => {
const tweaks = ['disableVideoAutoplay', 'stopVideoLoop', 'sortRepliesRecent', 'fullNumbers', 'hideVerifiedBadges', 'hideSubscribeButtons', 'fullWidthTimeline', 'hideSidebar', 'hideRelevantPeople', 'hideProfileWhoToFollow'] as const;
for (const key of tweaks) assert.equal(FEATURE_DEFAULTS[key], false, key);
assert.equal(FEATURE_DEFAULTS.rememberVideoVolume, true);
const rows = new Set(X_FEATURES.map(feature => feature.id));
for (const key of [...tweaks, 'rememberVideoVolume'] as const) assert.ok(rows.has(key), key);
});
// ---------- repost / bookmark lanes ----------
test('matcher: repost and bookmark mutations', () => {
assert.deepEqual(matchMutation('/i/api/graphql/ojPdsZsimiJrUGLR1sjUtA/CreateRetweet', post(vars({ tweet_id: '20', dark_request: false })), BASE),
{ kind: 'retweet', id: '20', key: 'retweet:20', want: true });
assert.deepEqual(matchMutation('/i/api/graphql/iQtK4dl5hBmXewYZuEOKVw/DeleteRetweet', post(vars({ source_tweet_id: '20', dark_request: false })), BASE),
{ kind: 'retweet', id: '20', key: 'retweet:20', want: false });
assert.deepEqual(matchMutation('/i/api/graphql/aoDbu3RHznuiSkQ9aNM67Q/CreateBookmark', post(vars({ tweet_id: '20' })), BASE),
{ kind: 'bookmark', id: '20', key: 'bookmark:20', want: true });
assert.deepEqual(matchMutation('https://x.com/i/api/graphql/Wlmlj2-xzyS1GN3a6cj-mQ/DeleteBookmark', post(vars({ tweet_id: '20' })), BASE),
{ kind: 'bookmark', id: '20', key: 'bookmark:20', want: false });
// DeleteRetweet only accepts source_tweet_id; the like lane for the same post is separate.
assert.equal(matchMutation('/i/api/graphql/x/DeleteRetweet', post(vars({ tweet_id: '20' })), BASE), null);
assert.equal(matchMutation('/i/api/graphql/x/CreateRetweet', { body: vars({ tweet_id: '20' }) }, BASE), null);
assert.equal(matchMutation('/i/api/graphql/x/CreateBookmark', post(vars({ tweet_id: '2x' })), BASE), null);
});
test('classifier and synthesized bodies for reposts and bookmarks', () => {
const rt: MutationMatch = { kind: 'retweet', id: '20', key: 'retweet:20', want: true };
const unrt: MutationMatch = { ...rt, want: false };
const bm: MutationMatch = { kind: 'bookmark', id: '20', key: 'bookmark:20', want: true };
const unbm: MutationMatch = { ...bm, want: false };
const created = '{"data":{"create_retweet":{"retweet_results":{"result":{"rest_id":"99","legacy":{"full_text":"RT"}}}}}}';
assert.deepEqual(classifyResponse(rt, 200, created), { outcome: 'confirmed', state: true });
assert.deepEqual(classifyResponse(unrt, 200, '{"data":{"unretweet":{"source_tweet_results":{"result":{"rest_id":"20"}}}}}'), { outcome: 'confirmed', state: false });
assert.deepEqual(classifyResponse(rt, 200, '{"errors":[{"code":327}],"data":{}}'), { outcome: 'unknown' });
assert.deepEqual(classifyResponse(rt, 500, created), { outcome: 'unknown' });
assert.deepEqual(classifyResponse(bm, 200, '{"data":{"tweet_bookmark_put":"Done"}}'), { outcome: 'confirmed', state: true });
assert.deepEqual(classifyResponse(unbm, 200, '{"data":{"tweet_bookmark_delete":"Done"}}'), { outcome: 'confirmed', state: false });
assert.deepEqual(classifyResponse(unbm, 200, '{"data":{"tweet_bookmark_put":"Done"}}'), { outcome: 'unknown' });
assert.equal(synthesizedBody(bm, undefined), '{"data":{"tweet_bookmark_put":"Done"}}');
assert.equal(synthesizedBody(unbm, undefined), '{"data":{"tweet_bookmark_delete":"Done"}}');
assert.deepEqual(JSON.parse(synthesizedBody(rt, created)), JSON.parse(created), 'reuses the real repost id');
assert.deepEqual(JSON.parse(synthesizedBody(unrt, created)), { data: { unretweet: { source_tweet_results: { result: { rest_id: '20', legacy: { full_text: '' } } } } } });
assert.deepEqual(JSON.parse(synthesizedBody(rt, 'garbage')), { data: { create_retweet: { retweet_results: { result: { rest_id: '20', legacy: { full_text: '' } } } } } });
});
test('gate: repost/unrepost/repost burst sends one request; bookmarks and likes use separate lanes', async () => {
const sent: string[] = [];
const pending: (() => void)[] = [];
const inner = ((input: string) => new Promise<Response>(resolve => {
sent.push(input.split('/').pop()!);
const name = input.split('/').pop();
const body = name === 'CreateRetweet' ? '{"data":{"create_retweet":{"retweet_results":{"result":{"rest_id":"99"}}}}}'
: name === 'CreateBookmark' ? '{"data":{"tweet_bookmark_put":"Done"}}' : '{"data":{"favorite_tweet":"Done"}}';
pending.push(() => resolve(new Response(body, { status: 200 })));
})) as unknown as typeof fetch;
const target = { fetch: inner, location: { href: BASE } };
const gate = installMutationGate(target);
const a = target.fetch('/i/api/graphql/x/CreateRetweet', post(vars({ tweet_id: '20' })));
const b = target.fetch('/i/api/graphql/x/DeleteRetweet', post(vars({ source_tweet_id: '20' })));
const c = target.fetch('/i/api/graphql/x/CreateRetweet', post(vars({ tweet_id: '20' })));
const d = target.fetch('/i/api/graphql/x/CreateBookmark', post(vars({ tweet_id: '20' })));
const e = target.fetch('/i/api/graphql/x/FavoriteTweet', post(vars({ tweet_id: '20' })));
await tick();
assert.deepEqual(sent, ['CreateRetweet', 'CreateBookmark', 'FavoriteTweet'], 'three different lanes, one wire request each');
for (const resolve of pending) resolve();
const results = await Promise.all([a, b, c, d, e]);
assert.ok(results.every(r => r.status === 200));
assert.ok((await results[1]!.json()).data.unretweet, 'the pair-cancelled unrepost gets a success body of its own shape');
assert.deepEqual(sent, ['CreateRetweet', 'CreateBookmark', 'FavoriteTweet']);
gate.dispose();
});