9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
288 lines
12 KiB
JavaScript
288 lines
12 KiB
JavaScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
import './device-follow.js';
|
|
|
|
const { buildDeviceFollowUrl, extractEntries, joinEntryToTweet, shapeRow, parseDeviceFollow, parseLimit } = await import('./device-follow.js').then((m) => m.__test__);
|
|
|
|
function tweet(id, userId, overrides = {}) {
|
|
return {
|
|
id_str: id,
|
|
user_id_str: userId,
|
|
full_text: `text-${id}`,
|
|
favorite_count: 10,
|
|
retweet_count: 2,
|
|
reply_count: 1,
|
|
quote_count: 0,
|
|
created_at: 'Sun May 18 12:34:56 +0000 2026',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function user(id, screenName) {
|
|
return { id_str: id, screen_name: screenName };
|
|
}
|
|
|
|
function entry(tweetId) {
|
|
return {
|
|
entryId: `tweet-${tweetId}`,
|
|
content: { item: { content: { tweet: { id: tweetId } } } },
|
|
};
|
|
}
|
|
|
|
function nonTweetEntry(id = 'cursor-bottom') {
|
|
return {
|
|
entryId: id,
|
|
content: { item: { content: {} } },
|
|
};
|
|
}
|
|
|
|
function payload(tweets, users, entries) {
|
|
return {
|
|
globalObjects: { tweets, users },
|
|
timeline: { id: 'tweet_notifications', instructions: [{ addEntries: { entries } }] },
|
|
};
|
|
}
|
|
|
|
describe('twitter device-follow', () => {
|
|
it('parseLimit accepts 1-200 and rejects everything else without silent clamping', () => {
|
|
expect(parseLimit(undefined)).toBe(20);
|
|
expect(parseLimit(1)).toBe(1);
|
|
expect(parseLimit(200)).toBe(200);
|
|
expect(() => parseLimit(0)).toThrow(ArgumentError);
|
|
expect(() => parseLimit(201)).toThrow(ArgumentError);
|
|
expect(() => parseLimit(1.5)).toThrow(ArgumentError);
|
|
expect(() => parseLimit('abc')).toThrow(ArgumentError);
|
|
});
|
|
|
|
it('builds the device_follow URL with required v1.1 params', () => {
|
|
const url = buildDeviceFollowUrl(25);
|
|
expect(url.startsWith('/i/api/2/notifications/device_follow.json?')).toBe(true);
|
|
const params = new URLSearchParams(url.split('?')[1]);
|
|
expect(params.get('count')).toBe('25');
|
|
expect(params.get('tweet_mode')).toBe('extended');
|
|
expect(params.get('include_ext_views')).toBe('true');
|
|
expect(params.get('include_reply_count')).toBe('1');
|
|
expect(params.get('include_quote_count')).toBe('true');
|
|
});
|
|
|
|
it('extracts entries from the addEntries instruction shape', () => {
|
|
const p = payload({}, {}, [entry('1'), entry('2')]);
|
|
const entries = extractEntries(p.timeline);
|
|
expect(entries).toHaveLength(2);
|
|
expect(entries[0].entryId).toBe('tweet-1');
|
|
});
|
|
|
|
it('extractEntries tolerates missing or empty instructions', () => {
|
|
expect(extractEntries(undefined)).toBeNull();
|
|
expect(extractEntries({})).toBeNull();
|
|
expect(extractEntries({ instructions: [] })).toEqual([]);
|
|
expect(extractEntries({ instructions: [{ addEntries: { entries: [] } }] })).toEqual([]);
|
|
});
|
|
|
|
it('joinEntryToTweet pairs the entry tweet id to globalObjects', () => {
|
|
const tweets = { '1': tweet('1', 'u1') };
|
|
const users = { u1: user('u1', 'alice') };
|
|
const out = joinEntryToTweet(entry('1'), tweets, users);
|
|
expect(out?.tweetId).toBe('1');
|
|
expect(out?.tweet.full_text).toBe('text-1');
|
|
expect(out?.user.screen_name).toBe('alice');
|
|
});
|
|
|
|
it('joinEntryToTweet returns null when tweet id is missing or unmatched', () => {
|
|
expect(joinEntryToTweet({}, {}, {})).toBeNull();
|
|
expect(joinEntryToTweet(entry('1'), {}, {})).toBeNull();
|
|
});
|
|
|
|
it('shapeRow projects the canonical row schema with views=null', () => {
|
|
const row = shapeRow({
|
|
tweetId: '42',
|
|
tweet: tweet('42', 'u1', { favorite_count: 5, retweet_count: 1, reply_count: 7 }),
|
|
user: user('u1', 'bob'),
|
|
});
|
|
expect(row).toEqual({
|
|
id: '42',
|
|
author: 'bob',
|
|
text: 'text-42',
|
|
likes: 5,
|
|
retweets: 1,
|
|
replies: 7,
|
|
views: null,
|
|
created_at: 'Sun May 18 12:34:56 +0000 2026',
|
|
url: 'https://x.com/bob/status/42',
|
|
});
|
|
});
|
|
|
|
it('joinEntryToTweet requires a resolved user screen_name before emitting a row URL', () => {
|
|
expect(joinEntryToTweet(entry('1'), { '1': tweet('1', 'missing') }, {})).toBeNull();
|
|
expect(joinEntryToTweet(entry('1'), { '1': tweet('1', 'u1') }, { u1: { id_str: 'u1' } })).toBeNull();
|
|
});
|
|
|
|
it('shapeRow uses tweet.text when full_text is absent', () => {
|
|
const row = shapeRow({ tweetId: '7', tweet: { text: 'fallback' }, user: user('u1', 'x') });
|
|
expect(row.text).toBe('fallback');
|
|
});
|
|
|
|
it('parseDeviceFollow maps the legacy v1.1 payload to rows', () => {
|
|
const p = payload(
|
|
{ '1': tweet('1', 'u1'), '2': tweet('2', 'u2') },
|
|
{ u1: user('u1', 'alice'), u2: user('u2', 'bob') },
|
|
[entry('1'), entry('2')],
|
|
);
|
|
const rows = parseDeviceFollow(p, new Set());
|
|
expect(rows?.rows.map((r) => r.author)).toEqual(['alice', 'bob']);
|
|
expect(rows?.rows.every((r) => r.views === null)).toBe(true);
|
|
expect(rows?.rows[0].url).toBe('https://x.com/alice/status/1');
|
|
});
|
|
|
|
it('parseDeviceFollow dedupes via the seen set', () => {
|
|
const p = payload(
|
|
{ '1': tweet('1', 'u1') },
|
|
{ u1: user('u1', 'alice') },
|
|
[entry('1'), entry('1')],
|
|
);
|
|
expect(parseDeviceFollow(p, new Set())?.rows).toHaveLength(1);
|
|
});
|
|
|
|
it('parseDeviceFollow returns typed empty metadata for the empty-stream shape', () => {
|
|
const empty = { globalObjects: {}, timeline: { instructions: [{ addEntries: { entries: [] } }] } };
|
|
expect(parseDeviceFollow(empty, new Set())).toEqual({
|
|
rows: [],
|
|
entryCount: 0,
|
|
unmatchedTweetEntries: 0,
|
|
malformedEntries: 0,
|
|
});
|
|
});
|
|
|
|
it('parseDeviceFollow returns null for malformed top-level shape', () => {
|
|
expect(parseDeviceFollow({}, new Set())).toBeNull();
|
|
expect(parseDeviceFollow({ globalObjects: {}, timeline: {} }, new Set())).toBeNull();
|
|
});
|
|
|
|
it('parseDeviceFollow tracks tweet entries that cannot join to required user identity', () => {
|
|
const parsed = parseDeviceFollow(payload({ '1': tweet('1', 'u1') }, {}, [entry('1')]), new Set());
|
|
expect(parsed).toMatchObject({
|
|
rows: [],
|
|
entryCount: 1,
|
|
unmatchedTweetEntries: 1,
|
|
malformedEntries: 0,
|
|
});
|
|
});
|
|
|
|
it('parseDeviceFollow tracks non-empty entries without tweet identity as parser drift', () => {
|
|
const parsed = parseDeviceFollow(payload({}, {}, [nonTweetEntry()]), new Set());
|
|
expect(parsed).toMatchObject({
|
|
rows: [],
|
|
entryCount: 1,
|
|
unmatchedTweetEntries: 0,
|
|
malformedEntries: 1,
|
|
});
|
|
});
|
|
|
|
it('registers with the canonical twitter row columns (minus has_media/media_urls/card)', () => {
|
|
const cmd = getRegistry().get('twitter/device-follow');
|
|
expect(cmd?.columns).toEqual(['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url']);
|
|
expect(cmd?.browser).toBe(true);
|
|
});
|
|
|
|
it('throws AuthRequiredError when ct0 cookie is missing', async () => {
|
|
const cmd = getRegistry().get('twitter/device-follow');
|
|
const page = {
|
|
getCookies: vi.fn().mockResolvedValue([{ name: 'other', value: 'x' }]),
|
|
evaluate: vi.fn(),
|
|
};
|
|
await expect(cmd.func(page, { limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
});
|
|
|
|
it('throws CommandExecutionError on non-2xx response', async () => {
|
|
const cmd = getRegistry().get('twitter/device-follow');
|
|
const page = {
|
|
getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]),
|
|
evaluate: vi.fn().mockResolvedValue({ error: 401 }),
|
|
};
|
|
await expect(cmd.func(page, { limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
});
|
|
|
|
it('throws CommandExecutionError on non-auth non-2xx response', async () => {
|
|
const cmd = getRegistry().get('twitter/device-follow');
|
|
const page = {
|
|
getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]),
|
|
evaluate: vi.fn().mockResolvedValue({ error: 500 }),
|
|
};
|
|
await expect(cmd.func(page, { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
});
|
|
|
|
it('throws CommandExecutionError on browser fetch/json exceptions', async () => {
|
|
const cmd = getRegistry().get('twitter/device-follow');
|
|
const cookies = [{ name: 'ct0', value: 'token' }];
|
|
await expect(cmd.func({
|
|
getCookies: vi.fn().mockResolvedValue(cookies),
|
|
evaluate: vi.fn().mockResolvedValue({ errorKind: 'non_json', detail: 'Unexpected token <' }),
|
|
}, { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
await expect(cmd.func({
|
|
getCookies: vi.fn().mockResolvedValue(cookies),
|
|
evaluate: vi.fn().mockResolvedValue({ errorKind: 'exception', detail: 'network failed' }),
|
|
}, { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
});
|
|
|
|
it('throws EmptyResultError for a valid empty device-follow stream', async () => {
|
|
const cmd = getRegistry().get('twitter/device-follow');
|
|
const page = {
|
|
getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]),
|
|
evaluate: vi.fn().mockResolvedValue(payload({}, {}, [])),
|
|
};
|
|
await expect(cmd.func(page, { limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
});
|
|
|
|
it('throws CommandExecutionError for malformed or unjoinable device-follow payloads', async () => {
|
|
const cmd = getRegistry().get('twitter/device-follow');
|
|
const cookies = [{ name: 'ct0', value: 'token' }];
|
|
await expect(cmd.func({
|
|
getCookies: vi.fn().mockResolvedValue(cookies),
|
|
evaluate: vi.fn().mockResolvedValue({}),
|
|
}, { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
await expect(cmd.func({
|
|
getCookies: vi.fn().mockResolvedValue(cookies),
|
|
evaluate: vi.fn().mockResolvedValue(payload({ '1': tweet('1', 'u1') }, {}, [entry('1')])),
|
|
}, { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
await expect(cmd.func({
|
|
getCookies: vi.fn().mockResolvedValue(cookies),
|
|
evaluate: vi.fn().mockResolvedValue(payload({}, {}, [nonTweetEntry()])),
|
|
}, { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
});
|
|
|
|
it('returns parsed rows when the fetch succeeds', async () => {
|
|
const cmd = getRegistry().get('twitter/device-follow');
|
|
const p = payload(
|
|
{ '1': tweet('1', 'u1'), '2': tweet('2', 'u2') },
|
|
{ u1: user('u1', 'alice'), u2: user('u2', 'bob') },
|
|
[entry('1'), entry('2')],
|
|
);
|
|
const page = {
|
|
getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]),
|
|
evaluate: vi.fn().mockResolvedValue(p),
|
|
};
|
|
const rows = await cmd.func(page, { limit: 5 });
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows[0].author).toBe('alice');
|
|
});
|
|
|
|
it('respects --limit when the upstream returns more than asked', async () => {
|
|
const cmd = getRegistry().get('twitter/device-follow');
|
|
const tweets = {};
|
|
const users = {};
|
|
const entries = [];
|
|
for (let i = 1; i <= 10; i++) {
|
|
tweets[String(i)] = tweet(String(i), `u${i}`);
|
|
users[`u${i}`] = user(`u${i}`, `user${i}`);
|
|
entries.push(entry(String(i)));
|
|
}
|
|
const page = {
|
|
getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]),
|
|
evaluate: vi.fn().mockResolvedValue(payload(tweets, users, entries)),
|
|
};
|
|
const rows = await cmd.func(page, { limit: 3 });
|
|
expect(rows).toHaveLength(3);
|
|
});
|
|
});
|