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

421 lines
22 KiB
TypeScript

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<string, unknown> = { id_str: U, following, followers_count: 21 };
const normalize = (result: Record<string, any>) => {
const en: Record<string, unknown> = { 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<string, any>[]) => {
if (Array.isArray(node)) { for (const item of node) walk(item, out); return; }
if (!node || typeof node !== 'object') return;
const obj = node as Record<string, any>;
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<string, any>[] = []; 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<XMLHttpRequest, (status: number, text: string) => 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<typeof gqlTweet>[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<TweetKind, [string, string, string, string, string]> = {
// [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<string, unknown> = {
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<string, unknown> = { id_str: POST, [flag]: on, [count]: 5 };
const server = { on, count: 5 };
const pending = new Map<XMLHttpRequest, (status: number, text: string) => 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<string, any>;
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();
});