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