328 lines
20 KiB
TypeScript
328 lines
20 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { routeKind, statusIdFromHref } from '../src/fixes/route.ts';
|
|
import { compensation, pickAnchor, restoreStep, tabToRestore, type TimelineItem } from '../src/fixes/timeline.ts';
|
|
import { isGifSource, isLoopWrap, parseVolume } from '../src/fixes/video.ts';
|
|
import { badgeCount, nextBadge, stripTitleCount } from '../src/fixes/badge.ts';
|
|
import { installReplySort, rewriteReplySort } from '../src/fixes/replySort.ts';
|
|
import { LOST_TTL_MS, isLoss, parseLost } from '../src/fixes/composerRestore.ts';
|
|
import { classifyResponse, matchMutation, synthesizedBody, type MutationMatch } from '../src/fixes/mutationGate/matchers.ts';
|
|
import { installMutationGate } from '../src/fixes/mutationGate/index.ts';
|
|
import { X_FEATURES } from '../src/areas/table.ts';
|
|
import { FEATURE_DEFAULTS, formatFullCount, fullCountFromLabel, isCompactCount } from '../src/inject/features.ts';
|
|
|
|
const BASE = 'https://x.com/home';
|
|
const post = (body: BodyInit | null | undefined): RequestInit => ({ method: 'POST', body });
|
|
const vars = (variables: Record<string, unknown>) => JSON.stringify({ variables, queryId: 'q' });
|
|
const tick = () => new Promise(resolve => setImmediate(resolve));
|
|
|
|
// ---------- routes ----------
|
|
|
|
test('routeKind classifies home, posts, profiles, notifications and X pages', () => {
|
|
assert.equal(routeKind('/home'), 'home');
|
|
assert.equal(routeKind('/jack/status/20'), 'status');
|
|
assert.equal(routeKind('/jack/status/20/photo/1'), 'status');
|
|
assert.equal(routeKind('/jack'), 'profile');
|
|
assert.equal(routeKind('/jack/with_replies'), 'profile');
|
|
assert.equal(routeKind('/jack/media/'), 'profile');
|
|
assert.equal(routeKind('/notifications'), 'notifications');
|
|
assert.equal(routeKind('/notifications/mentions'), 'notifications');
|
|
for (const path of ['/explore', '/messages', '/i/bookmarks', '/settings/account', '/search', '/jack/followers', '/jack/lists', '/', '/a_name_that_is_too_long_x']) {
|
|
assert.equal(routeKind(path), 'other', path);
|
|
}
|
|
});
|
|
|
|
test('statusIdFromHref reads relative and absolute x.com status links only', () => {
|
|
assert.equal(statusIdFromHref('/jack/status/20'), '20');
|
|
assert.equal(statusIdFromHref('https://x.com/jack/status/20/analytics'), '20');
|
|
assert.equal(statusIdFromHref('/jack/status/20?s=1'), '20');
|
|
assert.equal(statusIdFromHref('https://evil.example/jack/status/20'), null);
|
|
assert.equal(statusIdFromHref('/jack/status/abc'), null);
|
|
assert.equal(statusIdFromHref(null), null);
|
|
});
|
|
|
|
// ---------- timeline ----------
|
|
|
|
const item = (id: string, top: number, height = 200): TimelineItem => ({ id, top, bottom: top + height });
|
|
|
|
test('pickAnchor prefers the first post whose top is on screen', () => {
|
|
assert.deepEqual(pickAnchor([item('1', -300), item('2', -50), item('3', 150)]), { id: '3', top: 150 });
|
|
// One tall post covering the whole viewport: it is the anchor.
|
|
assert.deepEqual(pickAnchor([item('1', -900), item('2', -100, 2000)]), { id: '2', top: -100 });
|
|
assert.equal(pickAnchor([]), null);
|
|
assert.equal(pickAnchor([item('1', -500)]), null);
|
|
});
|
|
|
|
test('restoreStep moves the anchor back, waits for it to settle, and jumps once for virtualized posts', () => {
|
|
const obs = { targetTop: 120, scrollY: 0, targetScrollY: 5000, viewport: 800 };
|
|
assert.deepEqual(restoreStep({ fallback: true, jumped: false, stable: 0 }, { ...obs, top: 420 }), { kind: 'scrollBy', dy: 300 });
|
|
assert.deepEqual(restoreStep({ fallback: true, jumped: false, stable: 0 }, { ...obs, top: 121 }), { kind: 'wait' });
|
|
assert.deepEqual(restoreStep({ fallback: true, jumped: false, stable: 2 }, { ...obs, top: 121 }), { kind: 'done' });
|
|
assert.deepEqual(restoreStep({ fallback: true, jumped: false, stable: 0 }, { ...obs, top: null }), { kind: 'scrollTo', y: 5000 });
|
|
assert.deepEqual(restoreStep({ fallback: true, jumped: true, stable: 0 }, { ...obs, top: null }), { kind: 'wait' });
|
|
// Tab switches never jump blindly: only a found post is restored.
|
|
assert.deepEqual(restoreStep({ fallback: false, jumped: false, stable: 0 }, { ...obs, top: null }), { kind: 'wait' });
|
|
// Already near the old offset: nothing to jump.
|
|
assert.deepEqual(restoreStep({ fallback: true, jumped: false, stable: 0 }, { ...obs, top: null, scrollY: 4800 }), { kind: 'wait' });
|
|
});
|
|
|
|
test('compensation only undoes a downward push of the anchor while the page did not scroll', () => {
|
|
const prev = { key: '/home|Following', anchor: { id: '5', top: 100 }, scrollY: 900 };
|
|
const pushed = [item('9', -400), item('5', 700)];
|
|
assert.equal(compensation(prev, { key: prev.key, items: pushed, scrollY: 900 }), 600);
|
|
assert.equal(compensation(prev, { key: prev.key, items: pushed, scrollY: 950 }), 0, 'X or the user scrolled');
|
|
assert.equal(compensation(prev, { key: '/home|For you', items: pushed, scrollY: 900 }), 0, 'another page or tab');
|
|
assert.equal(compensation(prev, { key: prev.key, items: [item('9', 100)], scrollY: 900 }), 0, 'anchor gone');
|
|
assert.equal(compensation(prev, { key: prev.key, items: [item('5', 40)], scrollY: 900 }), 0, 'moved up: not an insertion above');
|
|
assert.equal(compensation(prev, { key: prev.key, items: [item('5', 101)], scrollY: 900 }), 0, 'sub-pixel noise');
|
|
assert.equal(compensation({ ...prev, scrollY: 0 }, { key: prev.key, items: pushed, scrollY: 0 }), 0, 'at the very top X inserts on purpose');
|
|
assert.equal(compensation(null, { key: prev.key, items: pushed, scrollY: 900 }), 0);
|
|
});
|
|
|
|
test('tabToRestore undoes X landing on "For you" only, at most twice per tab bar', () => {
|
|
const labels = ['For you', 'Following', 'Tech list'];
|
|
assert.equal(tabToRestore(labels, 0, 'Following', 0), 1);
|
|
assert.equal(tabToRestore(labels, 0, 'Tech list', 1), 2);
|
|
assert.equal(tabToRestore(labels, 0, 'Following', 2), -1);
|
|
assert.equal(tabToRestore(labels, 1, 'Tech list', 0), -1, 'the user is on another tab; leave it');
|
|
assert.equal(tabToRestore(labels, 0, 'For you', 0), -1);
|
|
assert.equal(tabToRestore(labels, 0, 'Gone list', 0), -1);
|
|
assert.equal(tabToRestore(labels, 0, null, 0), -1);
|
|
});
|
|
|
|
// ---------- video ----------
|
|
|
|
test('isLoopWrap detects a jump from the end to the start only', () => {
|
|
assert.equal(isLoopWrap(29.8, 0.1, 30), true);
|
|
assert.equal(isLoopWrap(15, 0.1, 30), false, 'seek back mid-video');
|
|
assert.equal(isLoopWrap(29.8, 12, 30), false);
|
|
assert.equal(isLoopWrap(0.9, 0.1, 1), false, 'too short to judge');
|
|
assert.equal(isLoopWrap(29.8, 0.1, Number.NaN), false);
|
|
assert.equal(isLoopWrap(29.8, 0.1, Number.POSITIVE_INFINITY), false, 'live stream');
|
|
});
|
|
|
|
test('GIF sources and stored volume parsing', () => {
|
|
assert.equal(isGifSource('https://video.twimg.com/tweet_video/AbC.mp4'), true);
|
|
assert.equal(isGifSource('blob:https://x.com/1234'), false);
|
|
assert.equal(isGifSource('https://video.twimg.com/ext_tw_video/1/pu/vid/avc1/720x1280/a.mp4'), false);
|
|
assert.equal(parseVolume('0.35'), 0.35);
|
|
assert.equal(parseVolume('0'), 0);
|
|
for (const raw of [null, '', 'loud', '1.5', '-0.1', 'NaN']) assert.equal(parseVolume(raw), null, String(raw));
|
|
});
|
|
|
|
// ---------- notification badge ----------
|
|
|
|
test('badge: a count seen on Notifications stays hidden until it changes', () => {
|
|
assert.equal(badgeCount('3'), 3);
|
|
assert.equal(badgeCount('20+'), 20);
|
|
assert.equal(badgeCount(''), null);
|
|
assert.equal(badgeCount('new'), null);
|
|
let state = nextBadge({ stale: null }, false, 3);
|
|
assert.deepEqual(state, { stale: null, hide: false }, 'never visited: shown');
|
|
state = nextBadge(state, true, 3);
|
|
assert.deepEqual(state, { stale: 3, hide: true });
|
|
state = nextBadge(state, false, 3);
|
|
assert.deepEqual(state, { stale: 3, hide: true }, 'left the page, X kept the old count');
|
|
state = nextBadge(state, false, 4);
|
|
assert.deepEqual(state, { stale: null, hide: false }, 'new notifications show again');
|
|
assert.deepEqual(nextBadge({ stale: 3 }, false, null), { stale: null, hide: false });
|
|
});
|
|
|
|
test('stripTitleCount drops only a leading unread prefix', () => {
|
|
assert.equal(stripTitleCount('(3) Home / X'), 'Home / X');
|
|
assert.equal(stripTitleCount('(20+) Notifications / X'), 'Notifications / X');
|
|
assert.equal(stripTitleCount('Home / X'), 'Home / X');
|
|
assert.equal(stripTitleCount('jack (@jack) / X'), 'jack (@jack) / X');
|
|
});
|
|
|
|
// ---------- reply sort ----------
|
|
|
|
const detail = (variables: Record<string, unknown>) => `/i/api/graphql/abc_-1/TweetDetail?variables=${encodeURIComponent(JSON.stringify(variables))}&features=%7B%7D`;
|
|
|
|
test('rewriteReplySort switches Relevance to Recency on TweetDetail reads only', () => {
|
|
const rewritten = rewriteReplySort(detail({ focalTweetId: '20', rankingMode: 'Relevance' }), BASE);
|
|
assert.ok(rewritten?.startsWith('/i/api/graphql/abc_-1/TweetDetail?'), rewritten ?? '');
|
|
const params = new URL(rewritten!, BASE).searchParams;
|
|
assert.deepEqual(JSON.parse(params.get('variables')!), { focalTweetId: '20', rankingMode: 'Recency' });
|
|
assert.equal(params.get('features'), '{}', 'other parameters are kept');
|
|
assert.match(rewriteReplySort(new URL(detail({ rankingMode: 'Relevance' }), BASE), BASE)!, /^https:\/\/x\.com\//);
|
|
for (const input of [
|
|
detail({ focalTweetId: '20', rankingMode: 'Likes' }),
|
|
detail({ focalTweetId: '20', rankingMode: 'Recency' }),
|
|
detail({ focalTweetId: '20' }),
|
|
'/i/api/graphql/abc/TweetDetail?variables=%7Bbad',
|
|
'/i/api/graphql/abc/TweetDetail',
|
|
'/i/api/graphql/abc/HomeTimeline?variables=%7B%22rankingMode%22%3A%22Relevance%22%7D',
|
|
`https://api.example${detail({ rankingMode: 'Relevance' })}`,
|
|
new Request(`https://x.com${detail({ rankingMode: 'Relevance' })}`),
|
|
42,
|
|
]) assert.equal(rewriteReplySort(input, BASE), null, String(input));
|
|
});
|
|
|
|
test('reply sort wrapper passes everything else through and restores fetch on dispose', async () => {
|
|
const calls: unknown[][] = [];
|
|
const inner = ((...args: unknown[]) => { calls.push(args); return Promise.resolve(new Response('ok')); }) as typeof fetch;
|
|
const target = { fetch: inner, location: { href: BASE } };
|
|
const handle = installReplySort(target);
|
|
assert.equal(handle.installed, true);
|
|
const init = { method: 'GET', headers: { a: 'b' } };
|
|
await target.fetch(detail({ rankingMode: 'Relevance' }), init);
|
|
assert.match(String(calls[0]![0]), /Recency/);
|
|
assert.equal(calls[0]![1], init, 'init object is passed through untouched');
|
|
await target.fetch(detail({ rankingMode: 'Relevance' }), { method: 'POST' });
|
|
assert.match(decodeURIComponent(String(calls[1]![0])), /Relevance/, 'non-GET untouched');
|
|
handle.dispose();
|
|
assert.equal(target.fetch, inner);
|
|
});
|
|
|
|
test('reply sort rewrites the XHR TweetDetail reads too and unhooks XHR on dispose', () => {
|
|
const opened: { method: string; url: string }[] = [];
|
|
const sent: unknown[] = [];
|
|
class FakeXHR {
|
|
open(method: string, url: string) { opened.push({ method, url }); }
|
|
send(body?: unknown) { sent.push(body ?? null); }
|
|
}
|
|
const nativeOpen = FakeXHR.prototype.open;
|
|
const nativeSend = FakeXHR.prototype.send;
|
|
const target = { fetch: (async () => new Response('ok')) as typeof fetch, location: { href: BASE }, XMLHttpRequest: FakeXHR as unknown as typeof XMLHttpRequest };
|
|
const handle = installReplySort(target);
|
|
const xhr = new target.XMLHttpRequest();
|
|
xhr.open('GET', detail({ focalTweetId: '20', rankingMode: 'Relevance' }));
|
|
xhr.send();
|
|
assert.equal(sent.length, 1, 'the request is sent exactly once');
|
|
const url = new URL(opened[0]!.url, BASE);
|
|
assert.equal(url.pathname, '/i/api/graphql/abc_-1/TweetDetail');
|
|
assert.deepEqual(JSON.parse(url.searchParams.get('variables')!), { focalTweetId: '20', rankingMode: 'Recency' });
|
|
for (const [method, input] of [
|
|
['GET', detail({ focalTweetId: '20', rankingMode: 'Likes' })],
|
|
['POST', detail({ focalTweetId: '20', rankingMode: 'Relevance' })],
|
|
['GET', '/i/api/graphql/abc/HomeTimeline?variables=%7B%22rankingMode%22%3A%22Relevance%22%7D'],
|
|
['GET', 'https://pbs.twimg.com/media/a.jpg'],
|
|
] as const) {
|
|
const other = new target.XMLHttpRequest();
|
|
other.open(method, input);
|
|
other.send('b');
|
|
assert.equal(opened.at(-1)!.url, input, `${method} ${input} untouched`);
|
|
assert.equal(sent.at(-1), 'b');
|
|
}
|
|
handle.dispose();
|
|
assert.equal(FakeXHR.prototype.open, nativeOpen, 'dispose restores open');
|
|
assert.equal(FakeXHR.prototype.send, nativeSend, 'dispose restores send');
|
|
});
|
|
|
|
// ---------- composer restore ----------
|
|
|
|
test('lost composer map: validated, unexpired, newest first, capped', () => {
|
|
const now = 10_000_000;
|
|
const raw = JSON.stringify({
|
|
'https://x.com/a/status/1': now - 1000,
|
|
'https://x.com/home': now - LOST_TTL_MS - 1,
|
|
'https://evil.example/x': now,
|
|
'https://x.com/b/status/2': 'soon',
|
|
'https://x.com/c/status/3': now + 5000,
|
|
});
|
|
assert.deepEqual(parseLost(raw, now), { 'https://x.com/a/status/1': now - 1000 });
|
|
assert.deepEqual(parseLost('{bad', now), {});
|
|
assert.deepEqual(parseLost('[1]', now), {});
|
|
assert.deepEqual(parseLost(null, now), {});
|
|
const many = Object.fromEntries(Array.from({ length: 30 }, (_, i) => [`https://x.com/u/status/${i}`, now - i]));
|
|
const kept = parseLost(JSON.stringify(many), now);
|
|
assert.equal(Object.keys(kept).length, 20);
|
|
assert.ok('https://x.com/u/status/0' in kept && !('https://x.com/u/status/25' in kept));
|
|
});
|
|
|
|
test('isLoss: text gone without Post and without X\'s Save/Discard sheet', () => {
|
|
const now = 100_000;
|
|
assert.equal(isLoss({ hadText: true, now, postedAt: 0, confirmedAt: 0 }), true);
|
|
assert.equal(isLoss({ hadText: false, now, postedAt: 0, confirmedAt: 0 }), false);
|
|
assert.equal(isLoss({ hadText: true, now, postedAt: now - 1000, confirmedAt: 0 }), false, 'just posted');
|
|
assert.equal(isLoss({ hadText: true, now, postedAt: 0, confirmedAt: now - 500 }), false, 'saved or discarded in X');
|
|
});
|
|
|
|
// ---------- full numbers ----------
|
|
|
|
test('full counts come from the aria-label and replace only abbreviated text', () => {
|
|
assert.equal(fullCountFromLabel('1234 Likes. Like'), 1234);
|
|
assert.equal(fullCountFromLabel('1,234,567 views. View post analytics'), 1234567);
|
|
assert.equal(fullCountFromLabel('12.345 Gefällt mir'), 12345);
|
|
assert.equal(fullCountFromLabel('12 345 J’aime'), 12345);
|
|
assert.equal(fullCountFromLabel('5 Replies. Reply'), 5);
|
|
assert.equal(fullCountFromLabel('Like'), null);
|
|
assert.equal(fullCountFromLabel('Reply'), null);
|
|
assert.equal(fullCountFromLabel(null), null);
|
|
for (const text of ['1.2K', '12K', '3,4 Mio.', '1.5M', '7 B']) assert.equal(isCompactCount(text), true, text);
|
|
for (const text of ['12', '1,234', '1.234', 'Like', '']) assert.equal(isCompactCount(text), false, text);
|
|
assert.equal(formatFullCount(1234567, 'en'), '1,234,567');
|
|
});
|
|
|
|
test('new tweak flags are off by default and every one has a panel row', () => {
|
|
const tweaks = ['disableVideoAutoplay', 'stopVideoLoop', 'sortRepliesRecent', 'fullNumbers', 'hideVerifiedBadges', 'hideSubscribeButtons', 'fullWidthTimeline', 'hideSidebar', 'hideRelevantPeople', 'hideProfileWhoToFollow'] as const;
|
|
for (const key of tweaks) assert.equal(FEATURE_DEFAULTS[key], false, key);
|
|
assert.equal(FEATURE_DEFAULTS.rememberVideoVolume, true);
|
|
const rows = new Set(X_FEATURES.map(feature => feature.id));
|
|
for (const key of [...tweaks, 'rememberVideoVolume'] as const) assert.ok(rows.has(key), key);
|
|
});
|
|
|
|
// ---------- repost / bookmark lanes ----------
|
|
|
|
test('matcher: repost and bookmark mutations', () => {
|
|
assert.deepEqual(matchMutation('/i/api/graphql/ojPdsZsimiJrUGLR1sjUtA/CreateRetweet', post(vars({ tweet_id: '20', dark_request: false })), BASE),
|
|
{ kind: 'retweet', id: '20', key: 'retweet:20', want: true });
|
|
assert.deepEqual(matchMutation('/i/api/graphql/iQtK4dl5hBmXewYZuEOKVw/DeleteRetweet', post(vars({ source_tweet_id: '20', dark_request: false })), BASE),
|
|
{ kind: 'retweet', id: '20', key: 'retweet:20', want: false });
|
|
assert.deepEqual(matchMutation('/i/api/graphql/aoDbu3RHznuiSkQ9aNM67Q/CreateBookmark', post(vars({ tweet_id: '20' })), BASE),
|
|
{ kind: 'bookmark', id: '20', key: 'bookmark:20', want: true });
|
|
assert.deepEqual(matchMutation('https://x.com/i/api/graphql/Wlmlj2-xzyS1GN3a6cj-mQ/DeleteBookmark', post(vars({ tweet_id: '20' })), BASE),
|
|
{ kind: 'bookmark', id: '20', key: 'bookmark:20', want: false });
|
|
// DeleteRetweet only accepts source_tweet_id; the like lane for the same post is separate.
|
|
assert.equal(matchMutation('/i/api/graphql/x/DeleteRetweet', post(vars({ tweet_id: '20' })), BASE), null);
|
|
assert.equal(matchMutation('/i/api/graphql/x/CreateRetweet', { body: vars({ tweet_id: '20' }) }, BASE), null);
|
|
assert.equal(matchMutation('/i/api/graphql/x/CreateBookmark', post(vars({ tweet_id: '2x' })), BASE), null);
|
|
});
|
|
|
|
test('classifier and synthesized bodies for reposts and bookmarks', () => {
|
|
const rt: MutationMatch = { kind: 'retweet', id: '20', key: 'retweet:20', want: true };
|
|
const unrt: MutationMatch = { ...rt, want: false };
|
|
const bm: MutationMatch = { kind: 'bookmark', id: '20', key: 'bookmark:20', want: true };
|
|
const unbm: MutationMatch = { ...bm, want: false };
|
|
const created = '{"data":{"create_retweet":{"retweet_results":{"result":{"rest_id":"99","legacy":{"full_text":"RT"}}}}}}';
|
|
assert.deepEqual(classifyResponse(rt, 200, created), { outcome: 'confirmed', state: true });
|
|
assert.deepEqual(classifyResponse(unrt, 200, '{"data":{"unretweet":{"source_tweet_results":{"result":{"rest_id":"20"}}}}}'), { outcome: 'confirmed', state: false });
|
|
assert.deepEqual(classifyResponse(rt, 200, '{"errors":[{"code":327}],"data":{}}'), { outcome: 'unknown' });
|
|
assert.deepEqual(classifyResponse(rt, 500, created), { outcome: 'unknown' });
|
|
assert.deepEqual(classifyResponse(bm, 200, '{"data":{"tweet_bookmark_put":"Done"}}'), { outcome: 'confirmed', state: true });
|
|
assert.deepEqual(classifyResponse(unbm, 200, '{"data":{"tweet_bookmark_delete":"Done"}}'), { outcome: 'confirmed', state: false });
|
|
assert.deepEqual(classifyResponse(unbm, 200, '{"data":{"tweet_bookmark_put":"Done"}}'), { outcome: 'unknown' });
|
|
assert.equal(synthesizedBody(bm, undefined), '{"data":{"tweet_bookmark_put":"Done"}}');
|
|
assert.equal(synthesizedBody(unbm, undefined), '{"data":{"tweet_bookmark_delete":"Done"}}');
|
|
assert.deepEqual(JSON.parse(synthesizedBody(rt, created)), JSON.parse(created), 'reuses the real repost id');
|
|
assert.deepEqual(JSON.parse(synthesizedBody(unrt, created)), { data: { unretweet: { source_tweet_results: { result: { rest_id: '20', legacy: { full_text: '' } } } } } });
|
|
assert.deepEqual(JSON.parse(synthesizedBody(rt, 'garbage')), { data: { create_retweet: { retweet_results: { result: { rest_id: '20', legacy: { full_text: '' } } } } } });
|
|
});
|
|
|
|
test('gate: repost/unrepost/repost burst sends one request; bookmarks and likes use separate lanes', async () => {
|
|
const sent: string[] = [];
|
|
const pending: (() => void)[] = [];
|
|
const inner = ((input: string) => new Promise<Response>(resolve => {
|
|
sent.push(input.split('/').pop()!);
|
|
const name = input.split('/').pop();
|
|
const body = name === 'CreateRetweet' ? '{"data":{"create_retweet":{"retweet_results":{"result":{"rest_id":"99"}}}}}'
|
|
: name === 'CreateBookmark' ? '{"data":{"tweet_bookmark_put":"Done"}}' : '{"data":{"favorite_tweet":"Done"}}';
|
|
pending.push(() => resolve(new Response(body, { status: 200 })));
|
|
})) as unknown as typeof fetch;
|
|
const target = { fetch: inner, location: { href: BASE } };
|
|
const gate = installMutationGate(target);
|
|
const a = target.fetch('/i/api/graphql/x/CreateRetweet', post(vars({ tweet_id: '20' })));
|
|
const b = target.fetch('/i/api/graphql/x/DeleteRetweet', post(vars({ source_tweet_id: '20' })));
|
|
const c = target.fetch('/i/api/graphql/x/CreateRetweet', post(vars({ tweet_id: '20' })));
|
|
const d = target.fetch('/i/api/graphql/x/CreateBookmark', post(vars({ tweet_id: '20' })));
|
|
const e = target.fetch('/i/api/graphql/x/FavoriteTweet', post(vars({ tweet_id: '20' })));
|
|
await tick();
|
|
assert.deepEqual(sent, ['CreateRetweet', 'CreateBookmark', 'FavoriteTweet'], 'three different lanes, one wire request each');
|
|
for (const resolve of pending) resolve();
|
|
const results = await Promise.all([a, b, c, d, e]);
|
|
assert.ok(results.every(r => r.status === 200));
|
|
assert.ok((await results[1]!.json()).data.unretweet, 'the pair-cancelled unrepost gets a success body of its own shape');
|
|
assert.deepEqual(sent, ['CreateRetweet', 'CreateBookmark', 'FavoriteTweet']);
|
|
gate.dispose();
|
|
});
|