import assert from 'node:assert/strict'; import { test } from 'node:test'; import { CLICK_GUARD_MS, QUEUE_MAX_MS, installClickGuard, installMutationGate } from '../src/fixes/mutationGate/index.ts'; // A deterministic model of X's toggle buttons in a page, on a virtual clock: // - the button's click handler decides from the props of its LAST RENDER (stale until X re-renders), // applies the optimistic change to X's store and sends X's own XHR through the real mutation gate; // - X re-renders the button RENDER_MS after a click (its data-testid flips then); // - the server answers every request SERVER_MS after it is sent, in order; a failure makes X revert. // Clicks reach the window capture listener first (the guard), then X's handler, like in the page. const POST = '2103816196386852995', USER = '2086263555364691968'; const BASE = 'https://x.com/playfishies/status/' + POST; function clock() { let now = 0, seq = 0; const queue: { t: number; n: number; fn: () => void }[] = []; const at = (ms: number, fn: () => void) => { const item = { t: now + Math.max(0, ms), n: seq++, fn }; queue.push(item); return item; }; const flush = () => new Promise(resolve => setImmediate(resolve)); return { now: () => now, timers: { set: (fn: () => void, ms: number) => at(ms, fn), clear: (id: unknown) => { const i = queue.indexOf(id as never); if (i >= 0) queue.splice(i, 1); } }, at, async run(until = Infinity) { for (;;) { await flush(); await flush(); queue.sort((a, b) => a.t - b.t || a.n - b.n); const next = queue[0]; if (!next || next.t > until) { if (until !== Infinity) now = Math.max(now, until); return; } queue.shift(); now = next.t; next.fn(); } }, }; } type Kind = 'like' | 'bookmark' | 'follow' | 'repost'; const TESTIDS: Record = { like: ['like', 'unlike'], bookmark: ['bookmark', 'removeBookmark'], follow: [`${USER}-follow`, `${USER}-unfollow`], repost: ['retweet', 'unretweet'] }; const OPS: Record = { like: ['graphql/q/FavoriteTweet', 'graphql/q/UnfavoriteTweet'], bookmark: ['graphql/q/CreateBookmark', 'graphql/q/DeleteBookmark'], follow: ['1.1/friendships/create.json', '1.1/friendships/destroy.json'], repost: ['graphql/q/CreateRetweet', 'graphql/q/DeleteRetweet'] }; const DONE: Record = { FavoriteTweet: '{"data":{"favorite_tweet":"Done"}}', UnfavoriteTweet: '{"data":{"unfavorite_tweet":"Done"}}', CreateBookmark: '{"data":{"tweet_bookmark_put":"Done"}}', DeleteBookmark: '{"data":{"tweet_bookmark_delete":"Done"}}', CreateRetweet: '{"data":{"create_retweet":{"retweet_results":{"result":{"rest_id":"1","legacy":{}}}}}}', DeleteRetweet: '{"data":{"unretweet":{"source_tweet_results":{"result":{"rest_id":"1","legacy":{}}}}}}', 'create.json': `{"id_str":"${USER}","following":false}`, 'destroy.json': `{"id_str":"${USER}","following":true}`, }; function page(kind: Kind, { renderMs = 90, serverMs = 150, guard = true, fail = new Set() } = {}) { const t = clock(); const server = { on: false, requests: [] as string[], open: 0, maxOpen: 0 }; let sent = 0; class FakeXHR extends EventTarget { #s = { readyState: 0, status: 0, text: '', 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.url; } getResponseHeader() { return 'application/json'; } getAllResponseHeaders() { return 'content-type: application/json\r\n'; } open(_m: string, url: string) { this.#s.url = url; this.#s.readyState = 1; } send() { const n = sent++; const name = this.#s.url.split('/').pop()!; server.requests.push(name); server.open++; server.maxOpen = Math.max(server.maxOpen, server.open); t.at(serverMs, () => { server.open--; const ok = !fail.has(n); if (ok) server.on = /Favorite|CreateB|CreateR|create/.test(name) && !/Unfavorite/.test(name); Object.assign(this.#s, { readyState: 4, status: ok ? 200 : 429, text: ok ? DONE[name]! : '{"errors":[{"code":88}]}' }); for (const type of ['readystatechange', 'load', 'loadend']) this.dispatchEvent(new Event(type)); }); } abort() { Object.assign(this.#s, { readyState: 4, status: 0 }); for (const type of ['readystatechange', 'abort', 'loadend']) this.dispatchEvent(new Event(type)); } } const win = { fetch: (async () => new Response('{}')) as typeof fetch, location: { href: BASE }, XMLHttpRequest: FakeXHR as unknown as typeof XMLHttpRequest }; const gate = installMutationGate(win); // Window capture listeners (the guard) and a MutationObserver on the button's data-testid. const captures: ((e: Event) => void)[] = []; const observers = new Set<{ cb: () => void; el: unknown }>(); class FakeObserver { cb: () => void; entry: { cb: () => void; el: unknown } | null = null; constructor(cb: () => void) { this.cb = cb; } observe(el: unknown) { this.entry = { cb: this.cb, el }; observers.add(this.entry); } disconnect() { if (this.entry) observers.delete(this.entry); } } // X's store (redux-optimist, absolute value per transaction) and the rendered props. const tx: { want: boolean; status: 'pending' | 'committed' | 'reverted' }[] = []; const storeOn = () => [...tx].reverse().find(x => x.status !== 'reverted')?.want ?? false; let props = { on: false }; let menu: { want: boolean } | null = null; const button = { isConnected: true, testid: TESTIDS[kind][0], getAttribute: (name: string) => (name === 'data-testid' ? button.testid : null), closest: (sel: string) => (sel.startsWith('article') ? null : kind === 'repost' ? null : button), querySelectorAll: () => [], dispatchEvent: (event: Event) => { click(event); return true; }, }; const render = () => { props = { on: storeOn() }; const next = TESTIDS[kind][props.on ? 1 : 0]; if (next !== button.testid) { button.testid = next; for (const o of [...observers]) if (o.el === button) queueMicrotask(o.cb); } }; const request = (want: boolean) => { const entry = { want, status: 'pending' as 'pending' | 'committed' | 'reverted' }; tx.push(entry); const xhr = new win.XMLHttpRequest(); xhr.addEventListener('loadend', () => { entry.status = xhr.status >= 200 && xhr.status < 300 ? 'committed' : 'reverted'; t.at(renderMs, render); }); xhr.open('POST', `https://x.com/i/api/${OPS[kind][want ? 0 : 1]}`); const body = kind === 'follow' ? `include_profile_interstitial_type=1&user_id=${USER}` : JSON.stringify({ variables: { [want || kind !== 'repost' ? 'tweet_id' : 'source_tweet_id']: POST }, queryId: 'q' }); xhr.send(body); t.at(renderMs, render); }; // X's handler: reads the props of the last render. Unfollow opens a sheet whose confirm the user clicks. const xHandler = () => { const want = !props.on; if (kind === 'repost') { menu = { want }; return; } // opens the menu; the user picks the item request(want); }; const click = (event: Event) => { let stopped = false; Object.defineProperty(event, 'target', { configurable: true, value: button }); Object.defineProperty(event, 'stopImmediatePropagation', { configurable: true, value: () => { stopped = true; } }); for (const listener of captures) { listener(event); if (stopped) return; } xHandler(); }; const handle = guard ? installClickGuard({ addEventListener: (_type, fn) => { captures.push(fn); }, removeEventListener: (_type, fn) => { const i = captures.indexOf(fn); if (i >= 0) captures.splice(i, 1); }, now: t.now, timers: t.timers, MutationObserver: FakeObserver as unknown as typeof MutationObserver, }) : null; /** The user's click (a trusted click on the button; a repost also picks the menu item once it shows). */ const userClick = () => { click(new Event('click', { bubbles: true, cancelable: true })); if (kind === 'repost' && menu) { const m = menu; menu = null; request(m.want); } }; return { t, server, gate, handle, userClick, get ui() { return props.on; }, get store() { return storeOn(); } }; } async function scenario(kind: Kind, clicks: number, gapMs: number, opts: Parameters[1] = {}) { const p = page(kind, opts); for (let i = 0; i < clicks; i++) p.t.at(i * gapMs, () => p.userClick()); await p.t.run(); return p; } test('click queue: every click counts — final UI == intent (parity) == server, for 1–8 clicks at 30/60/100/150 ms', async () => { for (const kind of ['like', 'bookmark', 'follow', 'repost'] as const) { for (const gap of [30, 60, 100, 150]) { for (let clicks = 1; clicks <= 8; clicks++) { // A repost needs its menu: the user can pick the item only once it rendered, so no faster than a render. const p = await scenario(kind, clicks, kind === 'repost' ? Math.max(gap, 100) : gap); const intent = clicks % 2 === 1; const where = `${kind} ${clicks} clicks @${gap}ms`; assert.equal(p.ui, intent, `${where}: UI`); assert.equal(p.server.on, intent, `${where}: server`); assert.equal(p.store, intent, `${where}: X's store`); assert.ok(p.server.maxOpen <= 1, `${where}: concurrent requests`); assert.ok(p.server.requests.length <= clicks, `${where}: more requests than clicks`); if (p.handle) assert.equal(p.handle.stats.lost, 0, `${where}: a click was lost`); p.gate.dispose(); p.handle?.dispose(); } } } }); test('click queue: the take on-2 sequence (like, unlike 60 ms later, X re-renders after 90 ms) ends not liked', async () => { const p = await scenario('like', 2, 60, { renderMs: 90 }); assert.equal(p.ui, false); assert.equal(p.server.on, false); assert.deepEqual(p.server.requests, ['FavoriteTweet', 'UnfavoriteTweet']); assert.equal(p.handle!.stats.held, 1); assert.equal(p.handle!.stats.delivered, 1); }); test('control: without the click queue, X reads the second click with stale props (two likes)', async () => { const p = await scenario('like', 2, 60, { renderMs: 90, guard: false }); assert.equal(p.ui, true, 'the unlike became a second like'); assert.equal(p.server.on, true); }); test('click queue: X never re-renders the button: the held click still goes out after QUEUE_MAX_MS', async () => { const p = page('like', { renderMs: 10 * QUEUE_MAX_MS }); p.t.at(0, () => p.userClick()); p.t.at(40, () => p.userClick()); await p.t.run(QUEUE_MAX_MS - 1); assert.equal(p.handle!.stats.delivered, 0); await p.t.run(); assert.equal(p.handle!.stats.delivered, 1); assert.equal(p.handle!.stats.lost, 0); }); test('click queue: a failed request (429) is reverted by X and later clicks still count', async () => { const p = await scenario('like', 3, 60, { fail: new Set([0]) }); assert.equal(p.ui, p.server.on); assert.equal(p.store, p.server.on); }); test('click queue: a click after the stale window on a button X did not re-render is not held', async () => { const p = page('like', { renderMs: 5000 }); p.t.at(0, () => p.userClick()); p.t.at(CLICK_GUARD_MS + 10, () => p.userClick()); await p.t.run(CLICK_GUARD_MS + 20); assert.equal(p.handle!.stats.held, 0); });