204 lines
11 KiB
TypeScript
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);
|
|
});
|