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

204 lines
11 KiB
TypeScript

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<Kind, [string, string]> = { like: ['like', 'unlike'], bookmark: ['bookmark', 'removeBookmark'], follow: [`${USER}-follow`, `${USER}-unfollow`], repost: ['retweet', 'unretweet'] };
const OPS: Record<Kind, [string, string]> = { 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<string, string> = {
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<number>() } = {}) {
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<typeof page>[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);
});