174 lines
10 KiB
TypeScript
174 lines
10 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import { extractUsers, installUserInfo, LruMap, parseCreatedAt } from '../src/inject/userInfo.ts';
|
|
import { createPluginApi } from '../src/inject/pluginApi.ts';
|
|
|
|
const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
// Tap delivery (clone().text().then(...)) runs off the fetch promise itself, so awaiting the fetch
|
|
// call is not enough; give it a tick (same pattern as tests/hubs.test.ts).
|
|
const tick = () => new Promise(resolve => setTimeout(resolve, 15));
|
|
|
|
/** Shaped like an X GraphQL `UserResults` node (fixture-tested only; see userInfo.ts). */
|
|
const userNode = (overrides: Record<string, unknown> = {}) => ({
|
|
__typename: 'User',
|
|
rest_id: '111',
|
|
is_blue_verified: true,
|
|
verified_type: 'None',
|
|
legacy: { screen_name: 'someone', following: null },
|
|
relationship_perspectives: { following: true },
|
|
...overrides,
|
|
});
|
|
|
|
test('extractUsers reads blue check, org verification and following from a GraphQL user node', () => {
|
|
const [user] = extractUsers({ data: { user: { result: userNode() } } });
|
|
assert.deepEqual(user, { xUserId: '111', handle: 'someone', blue: true, verifiedType: 'None', following: true, followedBy: null, createdAt: null });
|
|
});
|
|
|
|
test('extractUsers reports Business/Government verification and leaves absent fields null (never false)', () => {
|
|
const [business] = extractUsers({ u: userNode({ rest_id: '2', verified_type: 'Business', is_blue_verified: false, relationship_perspectives: undefined, legacy: { screen_name: 'biz' } }) });
|
|
assert.deepEqual(business, { xUserId: '2', handle: 'biz', blue: false, verifiedType: 'Business', following: null, followedBy: null, createdAt: null });
|
|
|
|
const [unseen] = extractUsers({ u: userNode({ rest_id: '3', is_blue_verified: undefined, verified_type: undefined, legacy: { screen_name: 'plain' }, relationship_perspectives: undefined }) });
|
|
assert.deepEqual(unseen, { xUserId: '3', handle: 'plain', blue: null, verifiedType: null, following: null, followedBy: null, createdAt: null });
|
|
});
|
|
|
|
test('extractUsers ignores nodes without a valid rest_id/screen_name pair and dedupes by rest_id', () => {
|
|
const noHandle = userNode({ legacy: { following: true } });
|
|
delete (noHandle as Record<string, unknown>).legacy;
|
|
assert.deepEqual(extractUsers({ a: { legacy: { screen_name: 'nope' } }, b: noHandle }), []);
|
|
const dup = extractUsers({ a: userNode(), b: { nested: userNode({ is_blue_verified: false }) } });
|
|
assert.equal(dup.length, 1);
|
|
});
|
|
|
|
test('installUserInfo caches per handle (case-insensitively) from tapped responses', async () => {
|
|
const target = { fetch: (async () => json({ data: { user: { result: userNode() } } })) as unknown as typeof fetch };
|
|
const users = installUserInfo(target);
|
|
assert.equal(users.get('someone'), null, 'nothing observed yet');
|
|
await target.fetch('https://x.com/i/api/graphql/abc/UserByScreenName?x=1');
|
|
await tick();
|
|
assert.deepEqual(users.get('SOMEONE'), { xUserId: '111', handle: 'someone', blue: true, verifiedType: 'None', following: true, followedBy: null, createdAt: null });
|
|
users.dispose();
|
|
});
|
|
|
|
test('installUserInfo merges a later response over the earlier one without erasing known fields', async () => {
|
|
let body: unknown = { data: { user: { result: userNode() } } };
|
|
const target = { fetch: (async () => json(body)) as unknown as typeof fetch };
|
|
const users = installUserInfo(target);
|
|
users.get('someone'); // first use installs the tap
|
|
await target.fetch('https://x.com/i/api/graphql/abc/HomeTimeline');
|
|
await tick();
|
|
assert.equal(users.get('someone')?.following, true);
|
|
// A later timeline entry that omits relationship_perspectives must not turn a known `following:
|
|
// true` back into unknown.
|
|
body = { data: { user: { result: userNode({ relationship_perspectives: undefined, legacy: { screen_name: 'someone' } }) } } };
|
|
await target.fetch('https://x.com/i/api/graphql/abc/HomeTimeline');
|
|
await tick();
|
|
assert.equal(users.get('someone')?.following, true, 'known value is not erased by a response missing the field');
|
|
users.dispose();
|
|
});
|
|
|
|
test('extractUsers reads X current user objects (core/verification, no legacy) as seen live on 2026-09-28', () => {
|
|
const live = {
|
|
__typename: 'User', rest_id: '444', is_blue_verified: true,
|
|
core: { created_at: 'Mon Jan 01 00:00:00 +0000 2024', name: 'Someone', screen_name: 'live_shape' },
|
|
verification: { verified: false },
|
|
relationship_perspectives: { following: false, followed_by: false, blocking: false, muting: false },
|
|
};
|
|
const [user] = extractUsers({ data: { home: { instructions: [{ entries: [{ content: { user_results: { result: live } } }] }] } } });
|
|
assert.deepEqual(user, { xUserId: '444', handle: 'live_shape', blue: true, verifiedType: 'None', following: false, followedBy: false, createdAt: Date.UTC(2024, 0, 1) });
|
|
const [org] = extractUsers({ u: { ...live, rest_id: '445', core: { screen_name: 'org_shape' }, verification: { verified: true, verified_type: 'Business' } } });
|
|
assert.equal(org?.verifiedType, 'Business');
|
|
});
|
|
|
|
test('parseCreatedAt reads the two shapes X sends and nothing else (absent or odd = unknown)', () => {
|
|
const now = Date.UTC(2026, 8, 30);
|
|
assert.equal(parseCreatedAt('Mon Jan 01 00:00:00 +0000 2024', now), Date.UTC(2024, 0, 1));
|
|
assert.equal(parseCreatedAt('Tue Mar 21 20:50:14 +0000 2006', now), Date.UTC(2006, 2, 21, 20, 50, 14));
|
|
assert.equal(parseCreatedAt('Sat Jun 06 01:30:00 +0530 2015', now), Date.UTC(2015, 5, 5, 20, 0, 0), 'an offset is applied');
|
|
assert.equal(parseCreatedAt('2019-05-04T03:02:01.000Z', now), Date.UTC(2019, 4, 4, 3, 2, 1));
|
|
for (const odd of [undefined, null, 1704067200000, '', 'March 2015', 'Joined March 2015', '2015', 'Mon Foo 01 00:00:00 +0000 2024', '2019-05-04',
|
|
'Mon Jan 01 00:00:00 +0000 1999', 'Mon Jan 01 00:00:00 +0000 2031']) {
|
|
assert.equal(parseCreatedAt(odd, now), null, String(odd));
|
|
}
|
|
});
|
|
|
|
test('extractUsers reads followed_by and created_at where X sent them, and a later response never erases them', async () => {
|
|
const legacy = userNode({ legacy: { screen_name: 'someone', created_at: 'Sat Jun 06 01:30:00 +0000 2015', followed_by: true } });
|
|
const [old] = extractUsers({ u: legacy });
|
|
assert.deepEqual([old!.followedBy, old!.createdAt], [true, Date.UTC(2015, 5, 6, 1, 30)]);
|
|
// The perspective wins over the legacy field, and "not followed by" is a value, not a missing one.
|
|
const [both] = extractUsers({ u: userNode({ relationship_perspectives: { following: true, followed_by: false }, legacy: { screen_name: 'someone', followed_by: true } }) });
|
|
assert.equal(both!.followedBy, false);
|
|
let body: unknown = { u: legacy };
|
|
const target = { fetch: (async () => json(body)) as unknown as typeof fetch };
|
|
const users = installUserInfo(target);
|
|
await target.fetch('https://x.com/i/api/graphql/abc/UserByScreenName');
|
|
await tick();
|
|
body = { u: userNode() };
|
|
await target.fetch('https://x.com/i/api/graphql/abc/HomeTimeline');
|
|
await tick();
|
|
assert.deepEqual([users.get('someone')?.followedBy, users.get('someone')?.createdAt], [true, Date.UTC(2015, 5, 6, 1, 30)]);
|
|
users.dispose();
|
|
});
|
|
|
|
test('installUserInfo taps at install, so the first timeline (loaded before any plugin asks) is seen', async () => {
|
|
const original = (async () => json({ data: { user: { result: userNode() } } })) as unknown as typeof fetch;
|
|
const target = { fetch: original };
|
|
const users = installUserInfo(target);
|
|
assert.notEqual(target.fetch, original, 'fetch is wrapped at install');
|
|
await target.fetch('https://x.com/i/api/graphql/abc/UserByScreenName');
|
|
await tick();
|
|
assert.equal(users.get('someone')?.blue, true, 'a response before any plugin asked is known');
|
|
const seen: string[][] = [];
|
|
const off = users.onUpdate(handles => seen.push(handles));
|
|
await target.fetch('https://x.com/i/api/graphql/abc/UserByScreenName');
|
|
await tick();
|
|
assert.deepEqual(seen, [['someone']], 'listeners get the lowercase handles a response added');
|
|
off();
|
|
await target.fetch('https://x.com/i/api/graphql/abc/UserByScreenName');
|
|
await tick();
|
|
assert.equal(seen.length, 1, 'a disposed listener is not called again');
|
|
users.dispose();
|
|
assert.equal(target.fetch, original, 'dispose restores fetch');
|
|
});
|
|
|
|
test('LruMap evicts the least recently used entry; get and set refresh recency', () => {
|
|
const lru = new LruMap<string, number>(2);
|
|
lru.set('a', 1);
|
|
lru.set('b', 2);
|
|
assert.equal(lru.get('a'), 1, 'reading a makes it the newest');
|
|
lru.set('c', 3);
|
|
assert.deepEqual([...lru.map.keys()], ['a', 'c'], 'b (least recently used) is evicted, not a (oldest inserted)');
|
|
lru.set('a', 10);
|
|
lru.set('d', 4);
|
|
assert.deepEqual([...lru.map.keys()], ['a', 'd'], 'updating a refreshed it; c was evicted');
|
|
assert.equal(lru.get('missing'), undefined);
|
|
});
|
|
|
|
test('installUserInfo cache keeps recently read users when full', async () => {
|
|
let body: unknown = { data: { user: { result: userNode() } } };
|
|
const target = { fetch: (async () => json(body)) as unknown as typeof fetch };
|
|
const users = installUserInfo(target, 2);
|
|
users.get('someone');
|
|
await target.fetch('https://x.com/i/api/graphql/abc/HomeTimeline');
|
|
body = { u: userNode({ rest_id: '2', legacy: { screen_name: 'second' } }) };
|
|
await target.fetch('https://x.com/i/api/graphql/abc/HomeTimeline');
|
|
await tick();
|
|
assert.ok(users.get('someone'), 'read: someone becomes most recently used');
|
|
body = { u: userNode({ rest_id: '3', legacy: { screen_name: 'third' } }) };
|
|
await target.fetch('https://x.com/i/api/graphql/abc/HomeTimeline');
|
|
await tick();
|
|
assert.ok(users.get('someone') && users.get('third'), 'recently read and newest are kept');
|
|
assert.equal(users.get('second'), null, 'the least recently used user is evicted');
|
|
users.dispose();
|
|
});
|
|
|
|
test('plugin api and api.users are frozen', () => {
|
|
const win = { document: { querySelector: () => null }, location: { href: 'https://x.com/' } } as unknown as Window & typeof globalThis;
|
|
const api = createPluginApi(win);
|
|
assert.ok(Object.isFrozen(api) && Object.isFrozen(api.users));
|
|
assert.throws(() => { (api as unknown as Record<string, unknown>).tweet = null; }, TypeError);
|
|
assert.throws(() => { (api.users as unknown as Record<string, unknown>).get = null; }, TypeError);
|
|
assert.equal(api.users.get('x'), null);
|
|
assert.equal(typeof api.users.onUpdate(() => {}), 'function');
|
|
});
|