From 8ae5b9b5d499230bc8651f5574ed7bb48cc59fa8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 16:53:24 +0100 Subject: [PATCH] test(live): add live CLI suite --- CHANGELOG.md | 4 + docs/testing.md | 27 ++++ package.json | 1 + tests/live/live.test.ts | 290 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 322 insertions(+) create mode 100644 docs/testing.md create mode 100644 tests/live/live.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e44ec6..362a12e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,12 @@ - Subcommand help now lists global options (credentials, output controls, timeouts, etc.). - Help output now documents config files and env vars. +### Fixed +- `followers` now falls back to the legacy API when GraphQL returns 404. + ### Tests - Add regression test for global options in subcommand help. +- Add opt-in live CLI test suite (real GraphQL calls; skipped by default). ## 0.5.0 — 2026-01-01 diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..b421e33 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,27 @@ +# 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`. diff --git a/package.json b/package.json index 0179337..8174847 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "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", diff --git a/tests/live/live.test.ts b/tests/live/live.test.ts new file mode 100644 index 0000000..004eae3 --- /dev/null +++ b/tests/live/live.test.ts @@ -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 { + 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(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>(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>(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>(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>(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>(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>(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>(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>(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>(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 }>(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 }>(queryIds.stdout); + expect(snapshot.cached).toBe(true); + expect(snapshot.ids && Object.keys(snapshot.ids).length).toBeGreaterThan(0); + }); +});