Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42abae9c15 | ||
|
|
a38de2b0a9 | ||
|
|
31a9d55e95 | ||
|
|
b43ab1f6ec | ||
|
|
8ae5b9b5d4 | ||
|
|
1a610c366a | ||
|
|
b68526e872 | ||
|
|
5551d5fa66 | ||
|
|
6a238ed063 | ||
|
|
12c9e4fe88 |
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## 0.5.1 — 2026-01-01
|
||||
|
||||
### Changed
|
||||
- `bird --help` now includes explicit “Shortcuts” and “JSON Output” sections (documents `bird <tweet-id-or-url>` shorthand + `--json`).
|
||||
- Release docs now include explicit npm publish verification steps.
|
||||
|
||||
### Fixed
|
||||
- `pnpm bird --help` now works (dev script runs the CLI entrypoint, not the library entrypoint).
|
||||
- `following`/`followers` now fall back to internal v1.1 REST endpoints when GraphQL returns `404`.
|
||||
|
||||
### Tests
|
||||
- Add root help output regression test.
|
||||
- Add opt-in live CLI test suite (real GraphQL calls; skipped by default; gated via `BIRD_LIVE=1`).
|
||||
|
||||
## 0.5.0 — 2026-01-01
|
||||
|
||||
### Added
|
||||
|
||||
+11
-8
@@ -1,4 +1,4 @@
|
||||
# Releasing bird 0.1.x
|
||||
# Releasing bird
|
||||
|
||||
Target destinations:
|
||||
- npm: `@steipete/bird`
|
||||
@@ -6,7 +6,7 @@ Target destinations:
|
||||
|
||||
## Checklist (npm + GitHub)
|
||||
1) Version bump
|
||||
- Update `package.json` `version` (semver, e.g., `0.1.0`).
|
||||
- Update `package.json` `version` (semver, e.g., `0.5.0`).
|
||||
- Update `CHANGELOG.md` and tag the release section.
|
||||
|
||||
2) Clean build & tests
|
||||
@@ -17,9 +17,12 @@ Target destinations:
|
||||
3) Publish to npm (scoped)
|
||||
- Ensure you are logged in (`npm whoami`).
|
||||
- `npm publish --access public` (from repo root). Package name is `@steipete/bird`.
|
||||
- Verify:
|
||||
- `npm view @steipete/bird version`
|
||||
- `npx -y @steipete/bird@<version> --help`
|
||||
|
||||
4) Git tag & GitHub release
|
||||
- `git tag v0.1.0 && git push origin v0.1.0`
|
||||
- `git tag v<version> && git push origin v<version>`
|
||||
- Create GitHub release from the tag. Include changelog notes and attach optional binary (see below).
|
||||
|
||||
## Optional: attach compiled binary
|
||||
@@ -29,17 +32,17 @@ If you want a single-file binary for Homebrew/GitHub assets:
|
||||
|
||||
## Homebrew tap update (steipete/homebrew-tap)
|
||||
1) Package the binary
|
||||
- From repo root: `tar -czf bird-macos-universal-v0.1.0.tar.gz bird`
|
||||
- Compute SHA: `shasum -a 256 bird-macos-universal-v0.1.0.tar.gz`
|
||||
- From repo root: `tar -czf bird-macos-universal-v<version>.tar.gz bird`
|
||||
- Compute SHA: `shasum -a 256 bird-macos-universal-v<version>.tar.gz`
|
||||
|
||||
2) Update formula in tap repo
|
||||
- File: `homebrew-tap/bird.rb` (create if absent). Model it after `poltergeist.rb`.
|
||||
- Fields to update:
|
||||
- `url "https://github.com/steipete/bird/releases/download/v0.1.0/bird-macos-universal-v0.1.0.tar.gz"`
|
||||
- `url "https://github.com/steipete/bird/releases/download/v<version>/bird-macos-universal-v<version>.tar.gz"`
|
||||
- `sha256 "<calculated_sha>"`
|
||||
- `version "0.1.0"`
|
||||
- `version "<version>"`
|
||||
- Install block: `bin.install "bird"`
|
||||
- `test do`: minimal `assert_match "0.1.0", shell_output("#{bin}/bird --version")`
|
||||
- `test do`: minimal `assert_match "<version>", shell_output("#{bin}/bird --version")`
|
||||
|
||||
3) Push tap changes
|
||||
- `git add bird.rb && git commit -m "bird 0.1.0" && git push`
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Testing
|
||||
|
||||
## Unit tests (default)
|
||||
- `pnpm test`
|
||||
|
||||
## Live tests (hits Twitter/X)
|
||||
|
||||
Runs the CLI against real Twitter/X GraphQL endpoints to verify read-only commands still work.
|
||||
|
||||
Requirements:
|
||||
- Auth cookies in env:
|
||||
- `AUTH_TOKEN` (or `TWITTER_AUTH_TOKEN`)
|
||||
- `CT0` (or `TWITTER_CT0`)
|
||||
- Network access
|
||||
|
||||
Run:
|
||||
- `pnpm test:live`
|
||||
|
||||
Notes:
|
||||
- Live tests are skipped unless `BIRD_LIVE=1` (set by `pnpm test:live`).
|
||||
- Search query is configurable via `BIRD_LIVE_SEARCH_QUERY`.
|
||||
- Command timeout is configurable via `BIRD_LIVE_TIMEOUT_MS` (ms).
|
||||
- Cookie extraction timeout is configurable via `BIRD_LIVE_COOKIE_TIMEOUT_MS` (ms).
|
||||
- Spawned CLI `NODE_ENV` defaults to `production` (override with `BIRD_LIVE_NODE_ENV`).
|
||||
- If you don't tweet, set `BIRD_LIVE_TWEET_ID` to a known tweet ID to use for `read/replies/thread`.
|
||||
- Optional: set `BIRD_LIVE_BOOKMARK_FOLDER_ID` to exercise `bookmarks --folder-id`.
|
||||
- `bird query-ids --fresh` live coverage: set `BIRD_LIVE_QUERY_IDS_FRESH=1`.
|
||||
- The live suite may hit internal X endpoints (v1.1 REST) as fallback; it still uses cookie auth (no developer API key).
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@steipete/bird",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -19,9 +19,10 @@
|
||||
"build:dist": "tsc && node scripts/copy-dist-assets.js",
|
||||
"build:binary": "BIRD_VERSION=$(node -p \"require('./package.json').version\") BIRD_GIT_SHA=$(git rev-parse --short=8 HEAD 2>/dev/null || true) bun build --compile --minify --env=BIRD_* src/cli.ts --outfile bird",
|
||||
"dev": "tsx src/index.ts",
|
||||
"bird": "pnpm run build:dist && node dist/index.js",
|
||||
"bird": "pnpm run build:dist && node dist/cli.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:live": "pnpm run build:dist && BIRD_LIVE=1 vitest run --no-file-parallelism tests/live/live.test.ts",
|
||||
"lint": "pnpm run lint:biome && pnpm run lint:oxlint",
|
||||
"lint:biome": "biome check .",
|
||||
"lint:oxlint": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings src tests scripts",
|
||||
|
||||
+4
-89
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* bird - CLI tool for posting tweets and replies
|
||||
*
|
||||
@@ -9,102 +10,16 @@
|
||||
* bird read <tweet-id-or-url>
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { collectCookieSource, createCliContext } from './cli/shared.js';
|
||||
import { registerBookmarksCommand } from './commands/bookmarks.js';
|
||||
import { registerCheckCommand } from './commands/check.js';
|
||||
import { registerHelpCommand } from './commands/help.js';
|
||||
import { registerPostCommands } from './commands/post.js';
|
||||
import { registerQueryIdsCommand } from './commands/query-ids.js';
|
||||
import { registerReadCommands } from './commands/read.js';
|
||||
import { registerSearchCommands } from './commands/search.js';
|
||||
import { registerUserCommands } from './commands/users.js';
|
||||
import { createProgram, KNOWN_COMMANDS } from './cli/program.js';
|
||||
import { createCliContext } from './cli/shared.js';
|
||||
import { resolveCliInvocation } from './lib/cli-args.js';
|
||||
import { getCliVersion } from './lib/version.js';
|
||||
|
||||
const program: Command = new Command();
|
||||
|
||||
const rawArgs: string[] = process.argv.slice(2);
|
||||
const normalizedArgs: string[] = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
|
||||
|
||||
const ctx = createCliContext(normalizedArgs);
|
||||
|
||||
const collect = (value: string, previous: string[] = []): string[] => {
|
||||
previous.push(value);
|
||||
return previous;
|
||||
};
|
||||
|
||||
const KNOWN_COMMANDS = new Set([
|
||||
'tweet',
|
||||
'reply',
|
||||
'query-ids',
|
||||
'read',
|
||||
'replies',
|
||||
'thread',
|
||||
'search',
|
||||
'mentions',
|
||||
'bookmarks',
|
||||
'following',
|
||||
'followers',
|
||||
'likes',
|
||||
'help',
|
||||
'whoami',
|
||||
'check',
|
||||
]);
|
||||
|
||||
program.addHelpText(
|
||||
'beforeAll',
|
||||
() => `${ctx.colors.banner('bird CLI')} ${ctx.colors.subtitle('— fast X CLI for tweeting, replying, and reading')}`,
|
||||
);
|
||||
|
||||
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
|
||||
|
||||
const formatExample = (command: string, description: string): string =>
|
||||
`${ctx.colors.command(` ${command}`)}\n${ctx.colors.muted(` ${description}`)}`;
|
||||
|
||||
program.addHelpText(
|
||||
'afterAll',
|
||||
() =>
|
||||
`\n${ctx.colors.section('Examples')}\n${[
|
||||
formatExample('bird whoami', 'Show the logged-in account via GraphQL cookies'),
|
||||
formatExample('bird --firefox-profile default-release whoami', 'Use Firefox profile cookies'),
|
||||
formatExample('bird tweet "hello from bird"', 'Send a tweet'),
|
||||
formatExample('bird replies https://x.com/user/status/1234567890123456789', 'Check replies to a tweet'),
|
||||
].join('\n\n')}`,
|
||||
);
|
||||
|
||||
program
|
||||
.option('--auth-token <token>', 'Twitter auth_token cookie')
|
||||
.option('--ct0 <token>', 'Twitter ct0 cookie')
|
||||
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction', ctx.config.chromeProfile)
|
||||
.option('--firefox-profile <name>', 'Firefox profile name for cookie extraction', ctx.config.firefoxProfile)
|
||||
.option('--cookie-timeout <ms>', 'Cookie extraction timeout in milliseconds (keychain/OS helpers)')
|
||||
.option(
|
||||
'--cookie-source <source>',
|
||||
'Cookie source for browser cookie extraction (repeatable)',
|
||||
collectCookieSource,
|
||||
[],
|
||||
)
|
||||
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, [])
|
||||
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, [])
|
||||
.option('--timeout <ms>', 'Request timeout in milliseconds')
|
||||
.option('--quote-depth <depth>', 'Max quoted tweet depth (default: 1; 0 disables)')
|
||||
.option('--plain', 'Plain output (stable, no emoji, no color)')
|
||||
.option('--no-emoji', 'Disable emoji output')
|
||||
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)');
|
||||
|
||||
program.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
ctx.applyOutputFromCommand(actionCommand);
|
||||
});
|
||||
|
||||
registerHelpCommand(program, ctx);
|
||||
registerQueryIdsCommand(program, ctx);
|
||||
registerPostCommands(program, ctx);
|
||||
registerReadCommands(program, ctx);
|
||||
registerSearchCommands(program, ctx);
|
||||
registerBookmarksCommand(program, ctx);
|
||||
registerUserCommands(program, ctx);
|
||||
registerCheckCommand(program, ctx);
|
||||
const program = createProgram(ctx);
|
||||
|
||||
const { argv, showHelp } = resolveCliInvocation(normalizedArgs, KNOWN_COMMANDS);
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Command } from 'commander';
|
||||
import { registerBookmarksCommand } from '../commands/bookmarks.js';
|
||||
import { registerCheckCommand } from '../commands/check.js';
|
||||
import { registerHelpCommand } from '../commands/help.js';
|
||||
import { registerPostCommands } from '../commands/post.js';
|
||||
import { registerQueryIdsCommand } from '../commands/query-ids.js';
|
||||
import { registerReadCommands } from '../commands/read.js';
|
||||
import { registerSearchCommands } from '../commands/search.js';
|
||||
import { registerUserCommands } from '../commands/users.js';
|
||||
import { getCliVersion } from '../lib/version.js';
|
||||
import { type CliContext, collectCookieSource } from './shared.js';
|
||||
|
||||
export const KNOWN_COMMANDS = new Set([
|
||||
'tweet',
|
||||
'reply',
|
||||
'query-ids',
|
||||
'read',
|
||||
'replies',
|
||||
'thread',
|
||||
'search',
|
||||
'mentions',
|
||||
'bookmarks',
|
||||
'following',
|
||||
'followers',
|
||||
'likes',
|
||||
'help',
|
||||
'whoami',
|
||||
'check',
|
||||
]);
|
||||
|
||||
export function createProgram(ctx: CliContext): Command {
|
||||
const program: Command = new Command();
|
||||
|
||||
program.configureHelp({
|
||||
showGlobalOptions: true,
|
||||
styleTitle: (t) => ctx.colors.section(t),
|
||||
styleUsage: (t) => ctx.colors.description(t),
|
||||
styleCommandText: (t) => ctx.colors.command(t),
|
||||
styleCommandDescription: (t) => ctx.colors.muted(t),
|
||||
styleOptionTerm: (t) => ctx.colors.option(t),
|
||||
styleOptionText: (t) => ctx.colors.option(t),
|
||||
styleOptionDescription: (t) => ctx.colors.muted(t),
|
||||
styleArgumentTerm: (t) => ctx.colors.argument(t),
|
||||
styleArgumentText: (t) => ctx.colors.argument(t),
|
||||
styleArgumentDescription: (t) => ctx.colors.muted(t),
|
||||
styleSubcommandTerm: (t) => ctx.colors.command(t),
|
||||
styleSubcommandText: (t) => ctx.colors.command(t),
|
||||
styleSubcommandDescription: (t) => ctx.colors.muted(t),
|
||||
styleDescriptionText: (t) => ctx.colors.muted(t),
|
||||
});
|
||||
|
||||
const collect = (value: string, previous: string[] = []): string[] => {
|
||||
previous.push(value);
|
||||
return previous;
|
||||
};
|
||||
|
||||
program.addHelpText(
|
||||
'beforeAll',
|
||||
() =>
|
||||
`${ctx.colors.banner('bird')} ${ctx.colors.muted(getCliVersion())} ${ctx.colors.subtitle(
|
||||
'— fast X CLI for tweeting, replying, and reading',
|
||||
)}`,
|
||||
);
|
||||
|
||||
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
|
||||
|
||||
const formatExample = (command: string, description: string): string =>
|
||||
`${ctx.colors.command(` ${command}`)}\n${ctx.colors.muted(` ${description}`)}`;
|
||||
|
||||
program.addHelpText(
|
||||
'afterAll',
|
||||
() =>
|
||||
`\n${ctx.colors.section('Examples')}\n${[
|
||||
formatExample('bird whoami', 'Show the logged-in account via GraphQL cookies'),
|
||||
formatExample('bird --firefox-profile default-release whoami', 'Use Firefox profile cookies'),
|
||||
formatExample('bird tweet "hello from bird"', 'Send a tweet'),
|
||||
formatExample(
|
||||
'bird 1234567890123456789 --json',
|
||||
'Read a tweet (ID or URL shorthand for `read`) and print JSON',
|
||||
),
|
||||
].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[
|
||||
formatExample('bird <tweet-id-or-url> [--json]', 'Shorthand for `bird read <tweet-id-or-url>`'),
|
||||
].join('\n\n')}\n\n${ctx.colors.section('JSON Output')}\n${ctx.colors.muted(
|
||||
` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, query-ids`,
|
||||
)}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird <command> --help')} to see per-command flags.)`)}`,
|
||||
);
|
||||
|
||||
program.addHelpText(
|
||||
'afterAll',
|
||||
() =>
|
||||
`\n\n${ctx.colors.section('Config')}\n${ctx.colors.muted(
|
||||
` Reads ${ctx.colors.argument('~/.config/bird/config.json5')} and ${ctx.colors.argument('./.birdrc.json5')} (JSON5)`,
|
||||
)}\n${ctx.colors.muted(
|
||||
` Supports: chromeProfile, firefoxProfile, cookieSource, cookieTimeoutMs, timeoutMs, quoteDepth`,
|
||||
)}\n\n${ctx.colors.section('Env')}\n${ctx.colors.muted(
|
||||
` ${ctx.colors.option('NO_COLOR')}, ${ctx.colors.option('BIRD_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_COOKIE_TIMEOUT_MS')}, ${ctx.colors.option('BIRD_QUOTE_DEPTH')}`,
|
||||
)}`,
|
||||
);
|
||||
|
||||
program
|
||||
.option('--auth-token <token>', 'Twitter auth_token cookie')
|
||||
.option('--ct0 <token>', 'Twitter ct0 cookie')
|
||||
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction', ctx.config.chromeProfile)
|
||||
.option('--firefox-profile <name>', 'Firefox profile name for cookie extraction', ctx.config.firefoxProfile)
|
||||
.option('--cookie-timeout <ms>', 'Cookie extraction timeout in milliseconds (keychain/OS helpers)')
|
||||
.option('--cookie-source <source>', 'Cookie source for browser cookie extraction (repeatable)', collectCookieSource)
|
||||
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect)
|
||||
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect)
|
||||
.option('--timeout <ms>', 'Request timeout in milliseconds')
|
||||
.option('--quote-depth <depth>', 'Max quoted tweet depth (default: 1; 0 disables)')
|
||||
.option('--plain', 'Plain output (stable, no emoji, no color)')
|
||||
.option('--no-emoji', 'Disable emoji output')
|
||||
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)');
|
||||
|
||||
program.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
ctx.applyOutputFromCommand(actionCommand);
|
||||
});
|
||||
|
||||
registerHelpCommand(program, ctx);
|
||||
registerQueryIdsCommand(program, ctx);
|
||||
registerPostCommands(program, ctx);
|
||||
registerReadCommands(program, ctx);
|
||||
registerSearchCommands(program, ctx);
|
||||
registerBookmarksCommand(program, ctx);
|
||||
registerUserCommands(program, ctx);
|
||||
registerCheckCommand(program, ctx);
|
||||
|
||||
return program;
|
||||
}
|
||||
+51
-2
@@ -190,8 +190,57 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn
|
||||
accent: wrap((t) => kleur.green(t)),
|
||||
};
|
||||
|
||||
const p = (kind: Parameters<typeof statusPrefix>[0]): string => statusPrefix(kind, output);
|
||||
const l = (kind: Parameters<typeof labelPrefix>[0]): string => labelPrefix(kind, output);
|
||||
const p = (kind: Parameters<typeof statusPrefix>[0]): string => {
|
||||
const prefix = statusPrefix(kind, output);
|
||||
if (output.plain || !output.color) {
|
||||
return prefix;
|
||||
}
|
||||
if (kind === 'ok') {
|
||||
return kleur.green(prefix);
|
||||
}
|
||||
if (kind === 'warn') {
|
||||
return kleur.yellow(prefix);
|
||||
}
|
||||
if (kind === 'err') {
|
||||
return kleur.red(prefix);
|
||||
}
|
||||
if (kind === 'info') {
|
||||
return kleur.cyan(prefix);
|
||||
}
|
||||
return kleur.gray(prefix);
|
||||
};
|
||||
|
||||
const l = (kind: Parameters<typeof labelPrefix>[0]): string => {
|
||||
const prefix = labelPrefix(kind, output);
|
||||
if (output.plain || !output.color) {
|
||||
return prefix;
|
||||
}
|
||||
if (kind === 'url') {
|
||||
return kleur.cyan(prefix);
|
||||
}
|
||||
if (kind === 'date') {
|
||||
return kleur.magenta(prefix);
|
||||
}
|
||||
if (kind === 'source') {
|
||||
return kleur.gray(prefix);
|
||||
}
|
||||
if (kind === 'engine') {
|
||||
return kleur.blue(prefix);
|
||||
}
|
||||
if (kind === 'credentials') {
|
||||
return kleur.yellow(prefix);
|
||||
}
|
||||
if (kind === 'user') {
|
||||
return kleur.cyan(prefix);
|
||||
}
|
||||
if (kind === 'userId') {
|
||||
return kleur.magenta(prefix);
|
||||
}
|
||||
if (kind === 'email') {
|
||||
return kleur.green(prefix);
|
||||
}
|
||||
return kleur.gray(prefix);
|
||||
};
|
||||
|
||||
const config = loadConfig((message) => {
|
||||
console.error(colors.muted(`${p('warn')}${message}`));
|
||||
|
||||
@@ -34,6 +34,152 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||
return Array.from(new Set([primary, 'kuFUYP9eV1FPoEy4N-pi7w']));
|
||||
}
|
||||
|
||||
private async getFollowersViaRest(userId: string, count: number): Promise<FollowingResult> {
|
||||
const params = new URLSearchParams({
|
||||
user_id: userId,
|
||||
count: String(count),
|
||||
skip_status: 'true',
|
||||
include_user_entities: 'false',
|
||||
});
|
||||
|
||||
const urls = [
|
||||
`https://x.com/i/api/1.1/followers/list.json?${params.toString()}`,
|
||||
`https://api.twitter.com/1.1/followers/list.json?${params.toString()}`,
|
||||
];
|
||||
|
||||
let lastError: string | undefined;
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
users?: Array<{
|
||||
id_str?: string;
|
||||
id?: string | number;
|
||||
screen_name?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
followers_count?: number;
|
||||
friends_count?: number;
|
||||
verified?: boolean;
|
||||
profile_image_url_https?: string;
|
||||
created_at?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const users = (data.users ?? [])
|
||||
.map((u) => {
|
||||
const id = typeof u.id_str === 'string' ? u.id_str : typeof u.id === 'number' ? String(u.id) : null;
|
||||
const username = typeof u.screen_name === 'string' ? u.screen_name : null;
|
||||
if (!id || !username) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
username,
|
||||
name: typeof u.name === 'string' && u.name.length > 0 ? u.name : username,
|
||||
description: typeof u.description === 'string' ? u.description : undefined,
|
||||
followersCount: typeof u.followers_count === 'number' ? u.followers_count : undefined,
|
||||
followingCount: typeof u.friends_count === 'number' ? u.friends_count : undefined,
|
||||
isBlueVerified: typeof u.verified === 'boolean' ? u.verified : undefined,
|
||||
profileImageUrl: typeof u.profile_image_url_https === 'string' ? u.profile_image_url_https : undefined,
|
||||
createdAt: typeof u.created_at === 'string' ? u.created_at : undefined,
|
||||
};
|
||||
})
|
||||
.filter((u) => u !== null);
|
||||
|
||||
return { success: true, users };
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: lastError ?? 'Unknown error fetching followers' };
|
||||
}
|
||||
|
||||
private async getFollowingViaRest(userId: string, count: number): Promise<FollowingResult> {
|
||||
const params = new URLSearchParams({
|
||||
user_id: userId,
|
||||
count: String(count),
|
||||
skip_status: 'true',
|
||||
include_user_entities: 'false',
|
||||
});
|
||||
|
||||
const urls = [
|
||||
`https://x.com/i/api/1.1/friends/list.json?${params.toString()}`,
|
||||
`https://api.twitter.com/1.1/friends/list.json?${params.toString()}`,
|
||||
];
|
||||
|
||||
let lastError: string | undefined;
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
users?: Array<{
|
||||
id_str?: string;
|
||||
id?: string | number;
|
||||
screen_name?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
followers_count?: number;
|
||||
friends_count?: number;
|
||||
verified?: boolean;
|
||||
profile_image_url_https?: string;
|
||||
created_at?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const users = (data.users ?? [])
|
||||
.map((u) => {
|
||||
const id = typeof u.id_str === 'string' ? u.id_str : typeof u.id === 'number' ? String(u.id) : null;
|
||||
const username = typeof u.screen_name === 'string' ? u.screen_name : null;
|
||||
if (!id || !username) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
username,
|
||||
name: typeof u.name === 'string' && u.name.length > 0 ? u.name : username,
|
||||
description: typeof u.description === 'string' ? u.description : undefined,
|
||||
followersCount: typeof u.followers_count === 'number' ? u.followers_count : undefined,
|
||||
followingCount: typeof u.friends_count === 'number' ? u.friends_count : undefined,
|
||||
isBlueVerified: typeof u.verified === 'boolean' ? u.verified : undefined,
|
||||
profileImageUrl: typeof u.profile_image_url_https === 'string' ? u.profile_image_url_https : undefined,
|
||||
createdAt: typeof u.created_at === 'string' ? u.created_at : undefined,
|
||||
};
|
||||
})
|
||||
.filter((u) => u !== null);
|
||||
|
||||
return { success: true, users };
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: lastError ?? 'Unknown error fetching following' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the account associated with the current cookies
|
||||
*/
|
||||
@@ -244,6 +390,14 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||
if (secondAttempt.success) {
|
||||
return { success: true, users: secondAttempt.users };
|
||||
}
|
||||
|
||||
// GraphQL Following can also return 404 (queryId churn / endpoint flakiness).
|
||||
// Fallback to the internal v1.1 REST endpoint used by the web client (cookie-auth; no dev API key).
|
||||
const restAttempt = await this.getFollowingViaRest(userId, count);
|
||||
if (restAttempt.success) {
|
||||
return restAttempt;
|
||||
}
|
||||
|
||||
return { success: false, error: secondAttempt.error };
|
||||
}
|
||||
|
||||
@@ -334,6 +488,14 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||
if (secondAttempt.success) {
|
||||
return { success: true, users: secondAttempt.users };
|
||||
}
|
||||
|
||||
// GraphQL Followers regularly returns 404 (queryId churn / endpoint flakiness).
|
||||
// Fallback to the internal v1.1 REST endpoint used by the web client (cookie-auth; no dev API key).
|
||||
const restAttempt = await this.getFollowersViaRest(userId, count);
|
||||
if (restAttempt.success) {
|
||||
return restAttempt;
|
||||
}
|
||||
|
||||
return { success: false, error: secondAttempt.error };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createProgram } from '../src/cli/program.js';
|
||||
import { createCliContext } from '../src/cli/shared.js';
|
||||
|
||||
describe('root help output', () => {
|
||||
it('mentions shorthand + json and lists user commands', () => {
|
||||
const ctx = createCliContext([]);
|
||||
const program = createProgram(ctx);
|
||||
let help = '';
|
||||
program.configureOutput({
|
||||
writeOut: (s) => {
|
||||
help += s;
|
||||
},
|
||||
writeErr: () => {},
|
||||
});
|
||||
program.outputHelp();
|
||||
|
||||
expect(help).toContain('following');
|
||||
expect(help).toContain('followers');
|
||||
expect(help).toContain('likes');
|
||||
|
||||
expect(help).toContain('bird <tweet-id-or-url>');
|
||||
expect(help).toContain('--json');
|
||||
});
|
||||
|
||||
it('shows global options in subcommand help', () => {
|
||||
const ctx = createCliContext([]);
|
||||
const program = createProgram(ctx);
|
||||
const cmd = program.commands.find((c) => c.name() === 'read');
|
||||
if (!cmd) {
|
||||
throw new Error('Expected "read" command to be registered');
|
||||
}
|
||||
|
||||
let help = '';
|
||||
const output = {
|
||||
writeOut: (s) => {
|
||||
help += s;
|
||||
},
|
||||
writeErr: () => {},
|
||||
};
|
||||
program.configureOutput(output);
|
||||
cmd.configureOutput(output);
|
||||
|
||||
cmd.outputHelp();
|
||||
|
||||
expect(help).toContain('--auth-token');
|
||||
expect(help).toContain('--ct0');
|
||||
expect(help).toContain('--timeout');
|
||||
expect(help).toContain('--quote-depth');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
type RunResult = { exitCode: number; stdout: string; stderr: string; signal: NodeJS.Signals | null };
|
||||
|
||||
const LIVE = process.env.BIRD_LIVE === '1';
|
||||
|
||||
const authToken = (process.env.AUTH_TOKEN ?? process.env.TWITTER_AUTH_TOKEN ?? '').trim();
|
||||
const ct0 = (process.env.CT0 ?? process.env.TWITTER_CT0 ?? '').trim();
|
||||
|
||||
const CLI_PATH = path.resolve(process.cwd(), 'dist', 'cli.js');
|
||||
|
||||
const WHOAMI_HANDLE_REGEX = /^user:\s*@([A-Za-z0-9_]+)/m;
|
||||
const WHOAMI_USER_ID_REGEX = /^user_id:\s*([0-9]+)/m;
|
||||
const TWEET_ID_REGEX = /^\d+$/;
|
||||
const LIVE_NODE_ENV = (process.env.BIRD_LIVE_NODE_ENV ?? 'production').trim() || 'production';
|
||||
|
||||
function runBird(args: string[], options: { timeoutMs?: number } = {}): Promise<RunResult> {
|
||||
if (!LIVE) {
|
||||
throw new Error('runBird() called without BIRD_LIVE=1');
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [CLI_PATH, ...args], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
AUTH_TOKEN: authToken,
|
||||
CT0: ct0,
|
||||
NODE_ENV: LIVE_NODE_ENV,
|
||||
},
|
||||
});
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
|
||||
|
||||
const killTimer =
|
||||
typeof options.timeoutMs === 'number' && options.timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
}, options.timeoutMs)
|
||||
: null;
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (killTimer) {
|
||||
clearTimeout(killTimer);
|
||||
}
|
||||
resolve({
|
||||
exitCode: code ?? (signal ? 1 : 0),
|
||||
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
|
||||
stderr: Buffer.concat(stderrChunks).toString('utf8'),
|
||||
signal: signal ?? null,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson<T>(stdout: string): T {
|
||||
try {
|
||||
return JSON.parse(stdout) as T;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON output: ${error instanceof Error ? error.message : String(error)}\n${stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
const d = LIVE ? describe : describe.skip;
|
||||
|
||||
d('live CLI (Twitter/X)', () => {
|
||||
const timeoutArg = (process.env.BIRD_LIVE_TIMEOUT_MS ?? '20000').trim();
|
||||
const cookieTimeoutArg = (process.env.BIRD_LIVE_COOKIE_TIMEOUT_MS ?? '30000').trim();
|
||||
const baseArgs = ['--plain', '--timeout', timeoutArg, '--quote-depth', '0'];
|
||||
|
||||
let whoamiStdout = '';
|
||||
let handle = '';
|
||||
let userId = '';
|
||||
let tweetId = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!existsSync(CLI_PATH)) {
|
||||
throw new Error(`Missing ${CLI_PATH}. Run: pnpm run build:dist`);
|
||||
}
|
||||
|
||||
if (!authToken || !ct0) {
|
||||
const check = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'check'], { timeoutMs: 45_000 });
|
||||
if (check.exitCode !== 0) {
|
||||
throw new Error(
|
||||
'Missing live credentials.\n' +
|
||||
'- Option A: set AUTH_TOKEN + CT0 (or TWITTER_AUTH_TOKEN/TWITTER_CT0)\n' +
|
||||
'- Option B: login to x.com in Safari/Chrome/Firefox for cookie extraction\n\n' +
|
||||
`bird check output:\n${check.stdout}\n${check.stderr}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const who = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'whoami'], { timeoutMs: 45_000 });
|
||||
if (who.exitCode !== 0) {
|
||||
throw new Error(`whoami failed (exit ${who.exitCode}, signal ${who.signal ?? 'none'}):\n${who.stderr}`);
|
||||
}
|
||||
whoamiStdout = who.stdout;
|
||||
|
||||
const handleMatch = WHOAMI_HANDLE_REGEX.exec(who.stdout);
|
||||
if (!handleMatch?.[1]) {
|
||||
throw new Error(`Could not parse whoami handle:\n${who.stdout}`);
|
||||
}
|
||||
handle = handleMatch[1];
|
||||
|
||||
const userIdMatch = WHOAMI_USER_ID_REGEX.exec(who.stdout);
|
||||
if (!userIdMatch?.[1]) {
|
||||
throw new Error(`Could not parse whoami user_id:\n${who.stdout}`);
|
||||
}
|
||||
userId = userIdMatch[1];
|
||||
|
||||
const forcedTweetId = (process.env.BIRD_LIVE_TWEET_ID ?? '').trim();
|
||||
if (forcedTweetId) {
|
||||
if (!TWEET_ID_REGEX.test(forcedTweetId)) {
|
||||
throw new Error(`Invalid BIRD_LIVE_TWEET_ID (expected digits): "${forcedTweetId}"`);
|
||||
}
|
||||
tweetId = forcedTweetId;
|
||||
return;
|
||||
}
|
||||
|
||||
const searchQuery = (
|
||||
process.env.BIRD_LIVE_SEARCH_QUERY ?? `from:${handle} -filter:replies -filter:retweets`
|
||||
).trim();
|
||||
const search = await runBird(
|
||||
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'search', searchQuery, '-n', '25', '--json'],
|
||||
{
|
||||
timeoutMs: 45_000,
|
||||
},
|
||||
);
|
||||
if (search.exitCode !== 0) {
|
||||
throw new Error(`search failed (exit ${search.exitCode}, signal ${search.signal ?? 'none'}):\n${search.stderr}`);
|
||||
}
|
||||
const tweets = parseJson<Array<{ id?: string }>>(search.stdout);
|
||||
const first = String(tweets[0]?.id ?? '');
|
||||
if (!TWEET_ID_REGEX.test(first)) {
|
||||
throw new Error(
|
||||
`Search returned no usable tweets. Query: "${searchQuery}". ` +
|
||||
`Override with BIRD_LIVE_SEARCH_QUERY.\n${search.stdout}`,
|
||||
);
|
||||
}
|
||||
tweetId = first;
|
||||
});
|
||||
|
||||
it('requires env credentials + built dist CLI', async () => {
|
||||
expect(existsSync(CLI_PATH)).toBe(true);
|
||||
expect(whoamiStdout.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('whoami works', async () => {
|
||||
expect(whoamiStdout).toContain('user: @');
|
||||
expect(whoamiStdout).toContain('user_id:');
|
||||
expect(whoamiStdout).toContain('engine:');
|
||||
expect(whoamiStdout).toContain('credentials:');
|
||||
});
|
||||
|
||||
it('read returns tweet JSON', async () => {
|
||||
const read = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'read', tweetId, '--json'], {
|
||||
timeoutMs: 45_000,
|
||||
});
|
||||
expect(read.exitCode).toBe(0);
|
||||
const readTweet = parseJson<{ id?: string; author?: { username?: string } }>(read.stdout);
|
||||
expect(readTweet.id).toBe(tweetId);
|
||||
expect(readTweet.author?.username?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('tweet-id shorthand returns tweet JSON', async () => {
|
||||
const shorthand = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, tweetId, '--json'], {
|
||||
timeoutMs: 45_000,
|
||||
});
|
||||
expect(shorthand.exitCode).toBe(0);
|
||||
const shorthandTweet = parseJson<{ id?: string }>(shorthand.stdout);
|
||||
expect(shorthandTweet.id).toBe(tweetId);
|
||||
});
|
||||
|
||||
it('replies returns JSON array', async () => {
|
||||
const replies = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'replies', tweetId, '--json'], {
|
||||
timeoutMs: 45_000,
|
||||
});
|
||||
expect(replies.exitCode).toBe(0);
|
||||
const replyTweets = parseJson<Array<{ id?: string }>>(replies.stdout);
|
||||
expect(Array.isArray(replyTweets)).toBe(true);
|
||||
});
|
||||
|
||||
it('thread returns JSON array', async () => {
|
||||
const thread = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'thread', tweetId, '--json'], {
|
||||
timeoutMs: 45_000,
|
||||
});
|
||||
expect(thread.exitCode).toBe(0);
|
||||
const threadTweets = parseJson<Array<{ id?: string }>>(thread.stdout);
|
||||
expect(Array.isArray(threadTweets)).toBe(true);
|
||||
expect(threadTweets.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('mentions returns JSON array', async () => {
|
||||
const mentions = await runBird(
|
||||
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'mentions', '-n', '10', '--json'],
|
||||
{
|
||||
timeoutMs: 45_000,
|
||||
},
|
||||
);
|
||||
expect(mentions.exitCode).toBe(0);
|
||||
const mentionTweets = parseJson<Array<{ id?: string }>>(mentions.stdout);
|
||||
expect(Array.isArray(mentionTweets)).toBe(true);
|
||||
});
|
||||
|
||||
it('bookmarks returns JSON array', async () => {
|
||||
const bookmarks = await runBird(
|
||||
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'bookmarks', '-n', '10', '--json'],
|
||||
{
|
||||
timeoutMs: 45_000,
|
||||
},
|
||||
);
|
||||
expect(bookmarks.exitCode).toBe(0);
|
||||
const bookmarkTweets = parseJson<Array<{ id?: string }>>(bookmarks.stdout);
|
||||
expect(Array.isArray(bookmarkTweets)).toBe(true);
|
||||
});
|
||||
|
||||
it('bookmarks --folder-id works (opt-in)', async () => {
|
||||
const folderId = (process.env.BIRD_LIVE_BOOKMARK_FOLDER_ID ?? '').trim();
|
||||
if (!folderId) {
|
||||
return;
|
||||
}
|
||||
const bookmarks = await runBird(
|
||||
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'bookmarks', '--folder-id', folderId, '-n', '10', '--json'],
|
||||
{ timeoutMs: 45_000 },
|
||||
);
|
||||
expect(bookmarks.exitCode).toBe(0);
|
||||
const bookmarkTweets = parseJson<Array<{ id?: string }>>(bookmarks.stdout);
|
||||
expect(Array.isArray(bookmarkTweets)).toBe(true);
|
||||
});
|
||||
|
||||
it('likes returns JSON array', async () => {
|
||||
const likes = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'likes', '-n', '10', '--json'], {
|
||||
timeoutMs: 45_000,
|
||||
});
|
||||
expect(likes.exitCode).toBe(0);
|
||||
const likeTweets = parseJson<Array<{ id?: string }>>(likes.stdout);
|
||||
expect(Array.isArray(likeTweets)).toBe(true);
|
||||
});
|
||||
|
||||
it('following returns JSON array', async () => {
|
||||
const following = await runBird(
|
||||
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'following', '--user', userId, '-n', '10', '--json'],
|
||||
{
|
||||
timeoutMs: 45_000,
|
||||
},
|
||||
);
|
||||
expect(following.exitCode).toBe(0);
|
||||
const followingUsers = parseJson<Array<{ id?: string; username?: string }>>(following.stdout);
|
||||
expect(Array.isArray(followingUsers)).toBe(true);
|
||||
});
|
||||
|
||||
it('followers returns JSON array', async () => {
|
||||
const followers = await runBird(
|
||||
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'followers', '--user', userId, '-n', '10', '--json'],
|
||||
{
|
||||
timeoutMs: 45_000,
|
||||
},
|
||||
);
|
||||
expect(followers.exitCode).toBe(0);
|
||||
const followerUsers = parseJson<Array<{ id?: string; username?: string }>>(followers.stdout);
|
||||
expect(Array.isArray(followerUsers)).toBe(true);
|
||||
});
|
||||
|
||||
it('query-ids returns JSON', async () => {
|
||||
const queryIds = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'query-ids', '--json'], {
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
expect(queryIds.exitCode).toBe(0);
|
||||
const snapshot = parseJson<{ cached?: boolean; ids?: Record<string, string> }>(queryIds.stdout);
|
||||
expect(typeof snapshot.cached).toBe('boolean');
|
||||
});
|
||||
|
||||
it('query-ids --fresh works (opt-in)', async () => {
|
||||
if (process.env.BIRD_LIVE_QUERY_IDS_FRESH !== '1') {
|
||||
return;
|
||||
}
|
||||
const queryIds = await runBird([...baseArgs, 'query-ids', '--fresh', '--json'], { timeoutMs: 5 * 60_000 });
|
||||
expect(queryIds.exitCode).toBe(0);
|
||||
const snapshot = parseJson<{ cached?: boolean; ids?: Record<string, string> }>(queryIds.stdout);
|
||||
expect(snapshot.cached).toBe(true);
|
||||
expect(snapshot.ids && Object.keys(snapshot.ids).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -200,6 +200,86 @@ describe('TwitterClient following/followers', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('falls back to REST following list after repeated 404s', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' })
|
||||
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'still nope' })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
users: [
|
||||
{
|
||||
id_str: '1',
|
||||
screen_name: 'alpha',
|
||||
name: 'Alpha',
|
||||
description: 'bio-1',
|
||||
followers_count: 10,
|
||||
friends_count: 5,
|
||||
verified: true,
|
||||
profile_image_url_https: 'https://example.com/1.jpg',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise<string[]> };
|
||||
clientPrivate.getFollowingQueryIds = async () => ['test'];
|
||||
|
||||
const result = await client.getFollowing('123', 1);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.users?.[0].username).toBe('alpha');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
|
||||
const urls = mockFetch.mock.calls.map((call) => String(call[0]));
|
||||
expect(urls[0]).toContain('/Following?');
|
||||
expect(urls[1]).toContain('/Following?');
|
||||
expect(urls[2]).toContain('/friends/list.json?');
|
||||
});
|
||||
|
||||
it('falls back to REST followers list after repeated 404s', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' })
|
||||
.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'still nope' })
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
users: [
|
||||
{
|
||||
id_str: '1',
|
||||
screen_name: 'alpha',
|
||||
name: 'Alpha',
|
||||
description: 'bio-1',
|
||||
followers_count: 10,
|
||||
friends_count: 5,
|
||||
verified: true,
|
||||
profile_image_url_https: 'https://example.com/1.jpg',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> };
|
||||
clientPrivate.getFollowersQueryIds = async () => ['test'];
|
||||
|
||||
const result = await client.getFollowers('123', 1);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.users?.[0].username).toBe('alpha');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
|
||||
const urls = mockFetch.mock.calls.map((call) => String(call[0]));
|
||||
expect(urls[0]).toContain('/Followers?');
|
||||
expect(urls[1]).toContain('/Followers?');
|
||||
expect(urls[2]).toContain('/followers/list.json?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TwitterClient likes', () => {
|
||||
|
||||
Reference in New Issue
Block a user