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 ## 0.6.1 — 2026-01-11
### Added
- `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli.
### Fixed ### Fixed
- macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst. - 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. - Terminal hyperlinks now sanitize control characters before emitting OSC 8 sequences (#29) — thanks @mafulafunk.
+1
View File
@@ -16,6 +16,7 @@ Requirements:
Run: Run:
- `pnpm test:live` - `pnpm test:live`
- `pnpm bird following --all --max-pages 2 --json --cookie-source chrome --chrome-profile Default` - `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: Notes:
- Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`). - Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`).
+14 -2
View File
@@ -83,7 +83,18 @@ export function registerListsCommand(program: Command, ctx: CliContext): void {
.option('--cursor <string>', 'Resume pagination from a cursor') .option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON') .option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field') .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 }) => { .action(
async (
listIdOrUrl: string,
cmdOpts: {
count?: string;
json?: boolean;
jsonFull?: boolean;
all?: boolean;
maxPages?: string;
cursor?: string;
},
) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts); const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts); const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
@@ -137,5 +148,6 @@ export function registerListsCommand(program: Command, ctx: CliContext): void {
console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`); console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`);
process.exit(1); process.exit(1);
} }
}); },
);
} }
+3 -1
View File
@@ -458,19 +458,21 @@ export function withLists<TBase extends AbstractConstructor<TwitterClientBase>>(
} }
pagesFetched += 1; pagesFetched += 1;
let added = 0;
for (const tweet of page.tweets) { for (const tweet of page.tweets) {
if (seen.has(tweet.id)) { if (seen.has(tweet.id)) {
continue; continue;
} }
seen.add(tweet.id); seen.add(tweet.id);
tweets.push(tweet); tweets.push(tweet);
added += 1;
if (!unlimited && tweets.length >= limit) { if (!unlimited && tweets.length >= limit) {
break; break;
} }
} }
const pageCursor = page.cursor; const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0) { if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined; nextCursor = undefined;
break; 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);
});
});
});