import assert from 'node:assert/strict'; import { test } from 'node:test'; import { installMutationGate } from '../src/fixes/mutationGate/index.ts'; import { FOLLOW_QUIET_MS, FOLLOW_STALE_MAX_MS, createFollowHold, filterRelationshipText, isRelationshipReadUrl, stripRelationship, } from '../src/fixes/mutationGate/staleFollow.ts'; const U = '2086263555364691968'; const OTHER = '1790000000000000001'; const BASE = 'https://x.com/playfishies'; // Shapes as X sends them (live 2026-09-28): GraphQL user result (UserByScreenName, URT timelines) // and a REST 1.1 user object (friendships/create.json, friends/following/list.json). const gqlUser = (id: string, following: boolean, followers = 21) => ({ __typename: 'User', rest_id: id, follow_request_sent: false, privacy: { protected: false }, core: { name: 'Fishies', screen_name: 'playfishies' }, relationship_counts: { followers, following: 31 }, relationship_perspectives: { followed_by: true, following, live_following: false, blocking: false, muting: false, blocked_by: false }, }); const ubsn = (following: boolean, followers = 21) => JSON.stringify({ data: { user: { result: gqlUser(U, following, followers) } } }); const timeline = (following: boolean) => JSON.stringify({ data: { user: { result: { timeline: { instructions: [{ entries: [ { content: { itemContent: { tweet_results: { result: { rest_id: '2103816196386852995', core: { user_results: { result: gqlUser(U, following) } } } } } } }, { content: { itemContent: { tweet_results: { result: { rest_id: '2103816196386852996', core: { user_results: { result: gqlUser(OTHER, false) } } } } } } }, ] }] } } } } }); // X's own merge, as in its bundle: the GraphQL user normalizer copies every field of // `relationship_perspectives` when that object exists (missing fields as undefined) and // `relationship_counts.followers` when defined; the users entity reducer merges `{...stored, ...incoming}`. function xStore(following: boolean) { let user: Record = { id_str: U, following, followers_count: 21 }; const normalize = (result: Record) => { const en: Record = { id_str: result.rest_id }; if (result.relationship_counts?.followers !== undefined) en.followers_count = result.relationship_counts.followers; if (result.follow_request_sent !== undefined) en.follow_request_sent = result.follow_request_sent; const rp = result.relationship_perspectives; if (rp !== undefined) Object.assign(en, { blocked_by: rp.blocked_by, blocking: rp.blocking, followed_by: rp.followed_by, following: rp.following, live_following: rp.live_following, muting: rp.muting }); return en; }; const walk = (node: unknown, out: Record[]) => { if (Array.isArray(node)) { for (const item of node) walk(item, out); return; } if (!node || typeof node !== 'object') return; const obj = node as Record; if (obj.__typename === 'User' && typeof obj.rest_id === 'string') out.push(obj); for (const value of Object.values(obj)) walk(value, out); }; return { get following() { return user.following; }, get followers() { return user.followers_count; }, /** `_REQUEST` optimistic reducer of X's follow/unfollow. */ optimistic(want: boolean) { user = { ...user, following: want, followers_count: (user.followers_count as number) + (want ? 1 : -1) }; }, /** redux-optimist REVERT of that transaction (the only in flight in these tests). */ revert(want: boolean) { user = { ...user, following: !want, followers_count: (user.followers_count as number) - (want ? 1 : -1) }; }, /** A read X merges into the users entity (ADD_ENTITIES / urt FETCH_SUCCESS). */ merge(text: string) { const users: Record[] = []; walk(JSON.parse(text), users); for (const r of users) if (r.rest_id === U) user = { ...user, ...normalize(r) }; }, }; } // ---------- pure parts ---------- test('follow hold: in flight, quiet window, reads opened before the end, expiry', () => { let clock = 1000; const hold = createFollowHold(() => clock); assert.deepEqual(hold.stale(0), []); hold.begin(U); assert.deepEqual(hold.stale(clock), [U], 'in flight: every read is stale'); clock += 300; hold.end(U); const ended = clock; clock += FOLLOW_QUIET_MS - 1; assert.deepEqual(hold.stale(clock), [U], 'quiet window after the end'); clock += 2; assert.deepEqual(hold.stale(clock), [], 'a read opened after the quiet window is fresh'); assert.deepEqual(hold.stale(ended - 1), [U], 'a read opened before the end is stale'); clock = ended + FOLLOW_STALE_MAX_MS; assert.deepEqual(hold.stale(ended - 1), [], 'up to FOLLOW_STALE_MAX_MS'); assert.equal(hold.size(), 0, 'expired entries are dropped'); hold.end('404'); assert.equal(hold.size(), 0); // Overlapping requests for one user: stale until the last one ended. hold.begin(U); hold.begin(U); hold.end(U); clock += FOLLOW_QUIET_MS * 2; assert.deepEqual(hold.stale(clock), [U]); hold.end(U); assert.deepEqual(hold.stale(clock), [U]); }); test('strip: GraphQL and REST user objects of the listed users only', () => { const body = JSON.parse(timeline(false)); assert.equal(stripRelationship(body, new Set([`follow:${U}`])), 1); const entries = body.data.user.result.timeline.instructions[0].entries; const mine = entries[0].content.itemContent.tweet_results.result.core.user_results.result; const other = entries[1].content.itemContent.tweet_results.result.core.user_results.result; assert.equal('relationship_perspectives' in mine, false); assert.equal('follow_request_sent' in mine, false); assert.deepEqual(mine.relationship_counts, { following: 31 }); assert.deepEqual(mine.core, { name: 'Fishies', screen_name: 'playfishies' }, 'other fields untouched'); assert.deepEqual(other, gqlUser(OTHER, false), 'other users untouched'); const rest = { id: 1, id_str: U, screen_name: 'playfishies', following: false, followers_count: 21, follow_request_sent: false, followed_by: true, legacy: { following: false, followers_count: 21, name: 'x' } }; assert.equal(stripRelationship([rest], new Set([`follow:${U}`])), 1); assert.deepEqual(rest, { id: 1, id_str: U, screen_name: 'playfishies', followed_by: true, legacy: { name: 'x' } }); assert.equal(stripRelationship({ id_str: U, name: 'no relationship fields' }, new Set([`follow:${U}`])), 0); }); test('filter text: same string unless a listed user lost fields; big integers keep their digits', () => { const plain = ubsn(false); assert.equal(filterRelationshipText(plain, []), plain); assert.equal(filterRelationshipText(plain, [`follow:${OTHER}`]), plain, 'user not named: no parse'); assert.equal(filterRelationshipText('{not json ' + U, [`follow:${U}`]), '{not json ' + U); assert.equal(filterRelationshipText(JSON.stringify({ id_str: U }), [`follow:${U}`]), JSON.stringify({ id_str: U }), 'nothing to drop: same string'); let dropped = 0; const out = filterRelationshipText(plain, [`follow:${U}`], n => { dropped += n; }); assert.equal(dropped, 1); assert.equal(JSON.parse(out).data.user.result.relationship_perspectives, undefined); const rest = `{"id":${U},"id_str":"${U}","following":false,"followers_count":21,"status":{"id":2103816196386852995}}`; assert.equal(filterRelationshipText(rest, [`follow:${U}`]), `{"id":${U},"id_str":"${U}","status":{"id":2103816196386852995}}`); }); test('read URLs: X API calls except the lane-gated mutations', () => { assert.equal(isRelationshipReadUrl('https://x.com/i/api/graphql/Kyb/UserByScreenName?variables=%7B%7D'), true); assert.equal(isRelationshipReadUrl('/i/api/1.1/friends/following/list.json'), true); assert.equal(isRelationshipReadUrl('https://x.com/i/api/1.1/friendships/create.json'), false); assert.equal(isRelationshipReadUrl('https://x.com/i/api/1.1/friendships/destroy.json'), false); assert.equal(isRelationshipReadUrl('https://x.com/i/api/graphql/q/FavoriteTweet'), false); assert.equal(isRelationshipReadUrl('https://abs.twimg.com/responsive-web/client-web/main.js'), false); }); // ---------- the sequences, through the real gate on a fake XMLHttpRequest ---------- function env() { const server = { following: false, wire: [] as string[] }; const pending = new Map void>(); class FakeXHR extends EventTarget { #s = { readyState: 0, status: 0, text: '', method: '', url: '' }; responseType: XMLHttpRequestResponseType = ''; get readyState() { return this.#s.readyState; } get status() { return this.#s.status; } get responseText() { return this.#s.text; } get response() { return this.#s.text; } get responseURL() { return this.#s.readyState === 4 ? new URL(this.#s.url, BASE).href : ''; } getResponseHeader() { return 'application/json'; } getAllResponseHeaders() { return 'content-type: application/json\r\n'; } open(method: string, url: string) { Object.assign(this.#s, { method, url, readyState: 1, status: 0, text: '' }); } send(body?: string) { server.wire.push(`${this.#s.method} ${this.#s.url.split('?')[0]!.split('/').pop()}${body ? ' ' + body : ''}`); pending.set(this as unknown as XMLHttpRequest, (status, text) => { Object.assign(this.#s, { readyState: 4, status, text }); for (const type of ['readystatechange', 'load', 'loadend']) this.dispatchEvent(new Event(type)); }); } abort() {} } const win = { fetch: (async () => new Response('{}')) as typeof fetch, location: { href: BASE }, XMLHttpRequest: FakeXHR as unknown as typeof XMLHttpRequest }; const gate = installMutationGate(win); const store = xStore(false); /** X's read: the response text X gets is merged into its store when the read completes. */ const read = (op: 'UserByScreenName' | 'UserOriginalsTimeline') => { const xhr = new win.XMLHttpRequest(); xhr.addEventListener('load', () => store.merge(xhr.responseText)); xhr.open('GET', `https://x.com/i/api/graphql/q/${op}?variables=%7B%7D`); xhr.send(); // The server answers with the relationship as it is now (the snapshot a stale read carries). const snapshot = server.following; return (following = snapshot) => pending.get(xhr)!(200, op === 'UserByScreenName' ? ubsn(following) : timeline(following)); }; /** X's follow button: optimistic update, its own XHR, revert on failure (redux-optimist). */ const toggle = (want: boolean) => { store.optimistic(want); const xhr = new win.XMLHttpRequest(); xhr.addEventListener('loadend', () => { if (xhr.status < 200 || xhr.status >= 300) store.revert(want); }); xhr.open('POST', `https://x.com/i/api/1.1/friendships/${want ? 'create' : 'destroy'}.json`); xhr.send(`include_profile_interstitial_type=1&user_id=${U}&skip_status=1`); return { ok() { server.following = want; pending.get(xhr)!(200, JSON.stringify({ id_str: U, following: !want, followers_count: 21 })); }, limited() { pending.get(xhr)!(429, '{"errors":[{"code":88,"message":"Rate limit exceeded."}]}'); }, }; }; return { gate, server, store, read, toggle }; } const settle = () => new Promise(resolve => setImmediate(resolve)); test('sequence (e): a read requested before the follow and answered after it no longer reverts the button', async () => { const { gate, server, store, read, toggle } = env(); const profile = read('UserOriginalsTimeline'); // profile still loading const follow = toggle(true); assert.equal(store.following, true, 'optimistic'); follow.ok(); await settle(); profile(false); // stale snapshot: following false assert.equal(server.following, true); assert.equal(store.following, true, 'X keeps its own state; was "Follow back" until F5 before the fix'); assert.equal(store.followers, 22); assert.equal(gate.live.staleStripped, 1); gate.dispose(); }); test('sequence (e) control: without the fix the same read reverts the button (X on its own)', async () => { const { gate, server, store, read, toggle } = env(); gate.dispose(); const profile = read('UserByScreenName'); const follow = toggle(true); follow.ok(); await settle(); profile(false); assert.equal(server.following, true); assert.equal(store.following, false, 'the owner symptom'); }); test('sequence (a): 429 on a follow, then a successful follow, with a read spanning both', async () => { const { gate, server, store, read, toggle } = env(); const early = read('UserByScreenName'); const first = toggle(true); first.limited(); await settle(); assert.equal(store.following, false, 'X reverted the rate-limited follow'); const second = toggle(true); second.ok(); await settle(); early(false); assert.equal(server.following, true); assert.equal(store.following, true); // A read opened after the quiet window would carry the server's state and be merged as-is (see the // hold test); a read opened now is still inside it and changes nothing either way. const fresh = read('UserByScreenName'); fresh(true); assert.equal(store.following, true); gate.dispose(); }); test('sequence (b): follow succeeds, a later unfollow is rate-limited, stale reads on both sides', async () => { const { gate, server, store, read, toggle } = env(); const r1 = read('UserOriginalsTimeline'); toggle(true).ok(); await settle(); const r2 = read('UserByScreenName'); const unfollow = toggle(false); assert.equal(store.following, false, 'optimistic unfollow'); unfollow.limited(); await settle(); assert.equal(store.following, true, 'X reverted the unfollow'); r2(false); // answered from an older replica r1(false); assert.equal(server.following, true); assert.equal(store.following, true); gate.dispose(); }); test('sequence (c): slow 200, reads opened while the follow is in flight', async () => { const { gate, server, store, read, toggle } = env(); const follow = toggle(true); const during = read('UserByScreenName'); during(false); // answered before the follow landed assert.equal(store.following, true, 'the optimistic value survives a read that raced the request'); follow.ok(); await settle(); assert.equal(server.following, true); assert.equal(store.following, true); gate.dispose(); }); test('sequence (d): lane pair-cancel (follow, unfollow, follow) answered by synthesized responses', async () => { const { gate, server, store, read, toggle } = env(); const early = read('UserOriginalsTimeline'); const a = toggle(true); toggle(false); // held behind the follow toggle(true); // pairs with the held unfollow: neither reaches the wire assert.equal(server.wire.filter(line => line.startsWith('POST')).length, 1); a.ok(); await settle(); await settle(); early(false); assert.equal(server.following, true); assert.equal(store.following, true); assert.equal(gate.lanes.stats.synthesized, 2); gate.dispose(); }); test('reads of other users and reads with no follow in progress are byte-identical', async () => { const { gate, store, read } = env(); const r = read('UserByScreenName'); r(true); assert.equal(store.following, true, 'no follow in progress: X merges the server state as before'); assert.equal(gate.live.staleStripped, 0); gate.dispose(); }); // ---------- likes, reposts, bookmarks: the same stale merge on the post ---------- const POST = '2103816196386852995'; const OTHER_POST = '2103816196386852996'; // A post as TweetDetail/timelines send it (live 2026-09-28): counts and flags in `legacy`. const gqlTweet = (id: string, s: { favorited?: boolean; likes?: number; retweeted?: boolean; reposts?: number; bookmarked?: boolean; bookmarks?: number }) => ({ __typename: 'Tweet', rest_id: id, legacy: { id_str: id, full_text: 'hi', favorited: s.favorited ?? false, favorite_count: s.likes ?? 5, retweeted: s.retweeted ?? false, retweet_count: s.reposts ?? 0, bookmarked: s.bookmarked ?? false, bookmark_count: s.bookmarks ?? 1, reply_count: 0 }, }); const tweetDetail = (s: Parameters[1]) => JSON.stringify({ data: { threaded_conversation_with_injections_v2: { instructions: [{ entries: [ { content: { itemContent: { tweet_results: { result: gqlTweet(POST, s) } } } }, { content: { itemContent: { tweet_results: { result: gqlTweet(OTHER_POST, { favorited: true, likes: 9 }) } } } }, ] }] } } }); test('strip: a post loses only the toggled flag and count', () => { const body = JSON.parse(tweetDetail({ favorited: false, likes: 5, retweeted: true, reposts: 2 })); assert.equal(stripRelationship(body, new Set([`like:${POST}`])), 1, 'one post object (its legacy fields)'); const entries = body.data.threaded_conversation_with_injections_v2.instructions[0].entries; const legacy = entries[0].content.itemContent.tweet_results.result.legacy; assert.deepEqual(legacy, { id_str: POST, full_text: 'hi', retweeted: true, retweet_count: 2, bookmarked: false, bookmark_count: 1, reply_count: 0 }); assert.deepEqual(entries[1].content.itemContent.tweet_results.result, gqlTweet(OTHER_POST, { favorited: true, likes: 9 }), 'other posts untouched'); const both = JSON.parse(tweetDetail({})); stripRelationship(both, new Set([`retweet:${POST}`, `bookmark:${POST}`])); assert.deepEqual(Object.keys(both.data.threaded_conversation_with_injections_v2.instructions[0].entries[0].content.itemContent.tweet_results.result.legacy), ['id_str', 'full_text', 'favorited', 'favorite_count', 'reply_count']); assert.equal(stripRelationship(JSON.parse(tweetDetail({})), new Set([`follow:${POST}`])), 0, 'a follow key never touches a post'); }); type TweetKind = 'like' | 'retweet' | 'bookmark'; const OPS: Record = { // [do, undo, flag, count, request variable of undo] like: ['FavoriteTweet', 'UnfavoriteTweet', 'favorited', 'favorite_count', 'tweet_id'], retweet: ['CreateRetweet', 'DeleteRetweet', 'retweeted', 'retweet_count', 'source_tweet_id'], bookmark: ['CreateBookmark', 'DeleteBookmark', 'bookmarked', 'bookmark_count', 'tweet_id'], }; const DONE: Record = { FavoriteTweet: { favorite_tweet: 'Done' }, UnfavoriteTweet: { unfavorite_tweet: 'Done' }, CreateBookmark: { tweet_bookmark_put: 'Done' }, DeleteBookmark: { tweet_bookmark_delete: 'Done' }, CreateRetweet: { create_retweet: { retweet_results: { result: { rest_id: '1', legacy: {} } } } }, DeleteRetweet: { unretweet: { source_tweet_results: { result: { rest_id: POST, legacy: {} } } } }, }; /** X's tweet entity: optimistic flag ±1 on the toggle; reads merged `{...stored, ...legacy}` (X spreads `legacy`). */ function tweetEnv(kind: TweetKind, on: boolean) { const [doOp, undoOp, flag, count, undoVar] = OPS[kind]; let tweet: Record = { id_str: POST, [flag]: on, [count]: 5 }; const server = { on, count: 5 }; const pending = new Map void>(); class FakeXHR extends EventTarget { #s = { readyState: 0, status: 0, text: '', method: '', url: '' }; responseType: XMLHttpRequestResponseType = ''; get readyState() { return this.#s.readyState; } get status() { return this.#s.status; } get responseText() { return this.#s.text; } get response() { return this.#s.text; } get responseURL() { return this.#s.readyState === 4 ? new URL(this.#s.url, BASE).href : ''; } getResponseHeader() { return 'application/json'; } getAllResponseHeaders() { return 'content-type: application/json\r\n'; } open(method: string, url: string) { Object.assign(this.#s, { method, url, readyState: 1, status: 0, text: '' }); } send() { pending.set(this as unknown as XMLHttpRequest, (status, text) => { Object.assign(this.#s, { readyState: 4, status, text }); for (const type of ['readystatechange', 'load', 'loadend']) this.dispatchEvent(new Event(type)); }); } abort() {} } const win = { fetch: (async () => new Response('{}')) as typeof fetch, location: { href: BASE }, XMLHttpRequest: FakeXHR as unknown as typeof XMLHttpRequest }; const gate = installMutationGate(win); const merge = (text: string) => { const walk = (node: unknown) => { if (Array.isArray(node)) { node.forEach(walk); return; } if (!node || typeof node !== 'object') return; const obj = node as Record; if (obj.__typename === 'Tweet' && obj.rest_id === POST) tweet = { ...tweet, ...obj.legacy }; Object.values(obj).forEach(walk); }; walk(JSON.parse(text)); }; const read = () => { const xhr = new win.XMLHttpRequest(); xhr.addEventListener('load', () => merge(xhr.responseText)); xhr.open('GET', 'https://x.com/i/api/graphql/q/TweetDetail?variables=%7B%7D'); xhr.send(); const snapshot = { ...server }; return () => pending.get(xhr)!(200, tweetDetail(kind === 'like' ? { favorited: snapshot.on, likes: snapshot.count } : kind === 'retweet' ? { retweeted: snapshot.on, reposts: snapshot.count } : { bookmarked: snapshot.on, bookmarks: snapshot.count })); }; const toggle = () => { const want = !(tweet[flag] as boolean); tweet = { ...tweet, [flag]: want, [count]: (tweet[count] as number) + (want ? 1 : -1) }; const op = want ? doOp : undoOp; const xhr = new win.XMLHttpRequest(); xhr.open('POST', `https://x.com/i/api/graphql/q/${op}`); xhr.send(JSON.stringify({ variables: { [want ? 'tweet_id' : undoVar]: POST }, queryId: 'q' })); return () => { server.on = want; server.count += want ? 1 : -1; pending.get(xhr)!(200, JSON.stringify({ data: DONE[op] })); }; }; return { gate, server, read, toggle, get ui() { return { on: tweet[flag], count: tweet[count] }; } }; } for (const kind of ['like', 'retweet', 'bookmark'] as const) { test(`sequence (${kind}): a post read requested before the toggle and answered after it no longer reverts it`, async () => { const h = tweetEnv(kind, false); const detail = h.read(); // TweetDetail still loading const ok = h.toggle(); ok(); await settle(); detail(); // stale: not toggled, old count assert.deepEqual(h.server, { on: true, count: 6 }); assert.deepEqual(h.ui, { on: true, count: 6 }); assert.equal(h.gate.live.staleStripped, 1); h.gate.dispose(); }); } test('sequence (like) control: without the fix the stale TweetDetail reverts the heart and the count (X on its own)', async () => { const h = tweetEnv('like', true); h.gate.dispose(); const detail = h.read(); h.toggle()(); // unlike, confirmed await settle(); detail(); assert.deepEqual(h.server, { on: false, count: 4 }); assert.deepEqual(h.ui, { on: true, count: 5 }, 'the heart comes back until F5'); }); test('a like hold strips only the liked post, not another post in the same response', async () => { const h = tweetEnv('like', false); const detail = h.read(); const ok = h.toggle(); ok(); await settle(); detail(); assert.equal(h.gate.live.staleStripped, 1, 'only the liked post, not the other post in the same response'); h.gate.dispose(); });