fix: list timeline pagination (#30) (thanks @zheli)

This commit is contained in:
Peter Steinberger
2026-01-11 11:27:46 +00:00
parent ebcd1618f9
commit 6cf177f92f
11 changed files with 1751 additions and 1506 deletions
+3
View File
@@ -2,6 +2,9 @@
## 0.6.1 — 2026-01-11
### Added
- `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli.
### Fixed
- macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst.
- Terminal hyperlinks now sanitize control characters before emitting OSC 8 sequences (#29) — thanks @mafulafunk.
+1
View File
@@ -16,6 +16,7 @@ Requirements:
Run:
- `pnpm test:live`
- `pnpm bird following --all --max-pages 2 --json --cookie-source chrome --chrome-profile Default`
- `pnpm bird list-timeline <list-id> --all --max-pages 2 --json --cookie-source chrome --chrome-profile Default`
Notes:
- Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`).
+65 -53
View File
@@ -83,59 +83,71 @@ export function registerListsCommand(program: Command, ctx: CliContext): void {
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (listIdOrUrl: string, cmdOpts: { count?: string; json?: boolean; jsonFull?: boolean; all?: boolean; maxPages?: string; cursor?: string }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
.action(
async (
listIdOrUrl: string,
cmdOpts: {
count?: string;
json?: boolean;
jsonFull?: boolean;
all?: boolean;
maxPages?: string;
cursor?: string;
},
) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const listId = extractListId(listIdOrUrl);
if (!listId) {
console.error(`${ctx.p('err')}Invalid list ID or URL. Expected numeric ID or https://x.com/i/lists/<id>.`);
process.exit(2);
}
const usePagination = cmdOpts.all || cmdOpts.cursor || maxPages !== undefined;
if (!usePagination && (!Number.isFinite(count) || count <= 0)) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor };
const result = usePagination
? await client.getAllListTimeline(listId, paginationOptions)
: await client.getListTimeline(listId, count, timelineOptions);
if (result.success && result.tweets) {
const isJson = cmdOpts.json || cmdOpts.jsonFull;
if (isJson && usePagination) {
console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2));
} else {
ctx.printTweets(result.tweets, { json: isJson, emptyMessage: 'No tweets found in this list.' });
const listId = extractListId(listIdOrUrl);
if (!listId) {
console.error(`${ctx.p('err')}Invalid list ID or URL. Expected numeric ID or https://x.com/i/lists/<id>.`);
process.exit(2);
}
} else {
console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`);
process.exit(1);
}
});
const usePagination = cmdOpts.all || cmdOpts.cursor || maxPages !== undefined;
if (!usePagination && (!Number.isFinite(count) || count <= 0)) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
process.exit(1);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor };
const result = usePagination
? await client.getAllListTimeline(listId, paginationOptions)
: await client.getListTimeline(listId, count, timelineOptions);
if (result.success && result.tweets) {
const isJson = cmdOpts.json || cmdOpts.jsonFull;
if (isJson && usePagination) {
console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2));
} else {
ctx.printTweets(result.tweets, { json: isJson, emptyMessage: 'No tweets found in this list.' });
}
} else {
console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`);
process.exit(1);
}
},
);
}
+3 -1
View File
@@ -458,19 +458,21 @@ export function withLists<TBase extends AbstractConstructor<TwitterClientBase>>(
}
pagesFetched += 1;
let added = 0;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
added += 1;
if (!unlimited && tweets.length >= limit) {
break;
}
}
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0) {
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break;
}
@@ -0,0 +1,137 @@
// ABOUTME: Tests for TwitterClient list methods.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient lists memberships', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('getListMemberships', () => {
it('fetches list memberships and parses list results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '5555',
name: 'Member List',
member_count: 100,
user_results: {
result: {
rest_id: '99999',
legacy: { screen_name: 'otheruser', name: 'Other User' },
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListMembershipsQueryIds = async () => ['test'];
const result = await client.getListMemberships(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].id).toBe('5555');
expect(result.lists?.[0].name).toBe('Member List');
expect(result.lists?.[0].owner?.username).toBe('otheruser');
});
it('retries on 404 error after refreshing query IDs', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not Found',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '6666',
name: 'Retry Membership List',
mode: 'Public',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListMembershipsQueryIds = async () => ['test'];
clientPrivate.refreshQueryIds = async () => {};
const result = await client.getListMemberships(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].id).toBe('6666');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
});
@@ -0,0 +1,292 @@
// ABOUTME: Tests for TwitterClient list methods.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient lists owned errors', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('getOwnedLists error paths', () => {
it('returns error on API failure', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Internal Server Error',
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns error when getCurrentUser fails', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: false,
error: 'Unauthorized',
});
const result = await client.getOwnedLists(100);
expect(result.success).toBe(false);
// When getCurrentUser fails with an error, that error is used; otherwise fallback message is used
expect(result.error).toContain('Unauthorized');
});
it('handles API errors in response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'Rate limit exceeded' }],
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(false);
expect(result.error).toContain('Rate limit exceeded');
});
it('returns missing feature flag errors from the API', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'missing required feature flag: responsive_web_graphql_exclude_directive_enabled' }],
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(1);
expect(result.success).toBe(false);
expect(result.error).toContain('missing required feature flag');
});
it('retries on 404 error after refreshing query IDs', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not Found',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '333',
name: 'Retry List',
mode: 'Public',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
clientPrivate.refreshQueryIds = async () => {};
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].id).toBe('333');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('skips list entries with missing id_str or name', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '111',
name: 'Valid List',
},
},
},
},
{
content: {
itemContent: {
list: {
// Missing id_str
name: 'Invalid List 1',
},
},
},
},
{
content: {
itemContent: {
list: {
id_str: '222',
// Missing name
},
},
},
},
{
content: {
itemContent: {
list: {
id_str: '333',
name: 'Another Valid List',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toHaveLength(2);
expect(result.lists?.[0].id).toBe('111');
expect(result.lists?.[1].id).toBe('333');
});
it('handles list with missing owner gracefully', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '444',
name: 'List Without Owner',
// No user_results
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toHaveLength(1);
expect(result.lists?.[0].owner).toBeUndefined();
});
});
});
+308
View File
@@ -0,0 +1,308 @@
// ABOUTME: Tests for TwitterClient list methods.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient lists owned', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('getOwnedLists', () => {
it('fetches owned lists and parses list results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '1234567890',
name: 'My Test List',
description: 'A test list for testing',
member_count: 10,
subscriber_count: 5,
mode: 'Public',
created_at: '2024-01-01T00:00:00Z',
user_results: {
result: {
rest_id: '12345',
legacy: { screen_name: 'testuser', name: 'Test User' },
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toHaveLength(1);
expect(result.lists?.[0].id).toBe('1234567890');
expect(result.lists?.[0].name).toBe('My Test List');
expect(result.lists?.[0].description).toBe('A test list for testing');
expect(result.lists?.[0].memberCount).toBe(10);
expect(result.lists?.[0].subscriberCount).toBe(5);
expect(result.lists?.[0].isPrivate).toBe(false);
expect(result.lists?.[0].owner?.username).toBe('testuser');
});
it('includes required feature flags in list requests', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
await client.getOwnedLists(1);
const [url] = mockFetch.mock.calls[0];
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.responsive_web_graphql_exclude_directive_enabled).toBe(true);
expect(parsedFeatures.blue_business_profile_image_shape_enabled).toBe(false);
expect(parsedFeatures.responsive_web_text_conversations_enabled).toBe(false);
expect(parsedFeatures.tweetypie_unmention_optimization_enabled).toBe(true);
expect(parsedFeatures.vibe_api_enabled).toBe(false);
expect(parsedFeatures.interactive_text_enabled).toBe(false);
});
it('handles private lists correctly', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '9999',
name: 'Secret List',
mode: 'Private',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].isPrivate).toBe(true);
});
it('handles lowercase private mode', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '8888',
name: 'Lowercase Private',
mode: 'private',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].isPrivate).toBe(true);
});
it('handles missing mode', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '9999',
name: 'No Mode',
mode: null,
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].isPrivate).toBe(false);
});
it('returns empty array when no lists exist', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toEqual([]);
});
});
});
@@ -0,0 +1,463 @@
// ABOUTME: Tests for paginated list timeline fetches.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient lists pagination (continued)', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('resumes from cursor', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet-resumed',
legacy: {
full_text: 'Resumed tweet',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet-resumed',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890', { cursor: 'resume-cursor' });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.tweets?.[0].id).toBe('tweet-resumed');
// Verify cursor was passed in the request
const callUrl = (mockFetch.mock.calls[0] as [string])[0];
expect(callUrl).toContain('resume-cursor');
});
it('deduplicates tweets across pages', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'duplicate-tweet',
legacy: {
full_text: 'Duplicate',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'duplicate-tweet',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'duplicate-tweet',
legacy: {
full_text: 'Duplicate',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'duplicate-tweet',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'unique-tweet',
legacy: {
full_text: 'Unique',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'unique-tweet',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User 2' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890');
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(2);
expect(result.tweets?.[0].id).toBe('duplicate-tweet');
expect(result.tweets?.[1].id).toBe('unique-tweet');
});
it('stops when a page only returns duplicates', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'dup',
legacy: {
full_text: 'Duplicate',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'dup',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'dup',
legacy: {
full_text: 'Duplicate',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'dup',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-3',
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890');
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('stops when no cursor is returned', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: {
full_text: 'Page 1',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet2',
legacy: {
full_text: 'Last page',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User 2' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890');
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(2);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,265 @@
// ABOUTME: Tests for paginated list timeline fetches.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient lists pagination', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('getAllListTimeline', () => {
it('fetches multiple pages and returns all tweets', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: {
full_text: 'First tweet',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet2',
legacy: {
full_text: 'Second tweet',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User 2' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890');
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(2);
expect(result.tweets?.[0].id).toBe('tweet1');
expect(result.tweets?.[1].id).toBe('tweet2');
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(2);
// Verify cursor was passed in second request
const secondCallUrl = (mockFetch.mock.calls[1] as [string])[0];
expect(secondCallUrl).toContain('cursor-page-2');
});
it('stops at maxPages limit and returns nextCursor', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: {
full_text: 'Page 1',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet2',
legacy: {
full_text: 'Page 2',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User 2' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-3',
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890', { maxPages: 2 });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(2);
expect(result.nextCursor).toBe('cursor-page-3');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
});
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
// ABOUTME: Tests for TwitterClient list methods.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient lists timeline', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('getListTimeline', () => {
it('fetches list timeline and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '111',
legacy: {
full_text: 'Tweet from list',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '111',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'listmember', name: 'List Member' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('111');
expect(result.tweets?.[0].text).toBe('Tweet from list');
expect(result.tweets?.[0].author.username).toBe('listmember');
});
it('returns empty array when list has no tweets', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(true);
expect(result.tweets).toEqual([]);
});
it('returns error on API failure', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => 'Forbidden',
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 403');
});
it('handles API errors in response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'List not found' }],
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('nonexistent', 20);
expect(result.success).toBe(false);
expect(result.error).toContain('List not found');
});
it('retries on 404 error after refreshing query IDs', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not Found',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '222',
legacy: {
full_text: 'Retry success',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '222',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User Two' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
clientPrivate.refreshQueryIds = async () => {};
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('222');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
});