diff --git a/biome.json b/biome.json index 5139e91..e2ada81 100644 --- a/biome.json +++ b/biome.json @@ -11,10 +11,28 @@ "rules": { "recommended": true, "complexity": { - "noForEach": "off" + "noForEach": "error" + }, + "correctness": { + "useImportExtensions": { + "level": "error", + "options": { + "forceJsExtensions": true + } + } + }, + "performance": { + "useTopLevelRegex": "error" }, "style": { - "noNonNullAssertion": "off" + "noNonNullAssertion": "error", + "useBlockStatements": "error" + }, + "suspicious": { + "noExplicitAny": "error" + }, + "nursery": { + "useRegexpExec": "error" } } }, diff --git a/src/index.ts b/src/index.ts index 931064a..7c4a9e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,16 +32,19 @@ import { runtimeQueryIds } from './lib/runtime-query-ids.js'; import { type TweetData, TwitterClient } from './lib/twitter-client.js'; import { getCliVersion } from './lib/version.js'; -const program = new Command(); +const program: Command = new Command(); -const rawArgs = process.argv.slice(2); -const normalizedArgs = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs; -const isTty = process.stdout.isTTY; +const rawArgs: string[] = process.argv.slice(2); +const normalizedArgs: string[] = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs; +const isTty: boolean = process.stdout.isTTY; let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, process.env, isTty); kleur.enabled = output.color; -const wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text); -const collect = (value: string, previous: string[] = []) => { +const wrap = + (styler: (text: string) => string): ((text: string) => string) => + (text: string): string => + isTty ? styler(text) : text; +const collect = (value: string, previous: string[] = []): string[] => { previous.push(value); return previous; }; @@ -50,16 +53,22 @@ const COOKIE_SOURCES: CookieSource[] = ['safari', 'chrome', 'firefox']; function parseCookieSource(value: string): CookieSource { const normalized = value.trim().toLowerCase(); - if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') return normalized; + if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') { + return normalized; + } throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`); } function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined { - if (typeof input === 'string') return [parseCookieSource(input)]; + if (typeof input === 'string') { + return [parseCookieSource(input)]; + } if (Array.isArray(input)) { const result: CookieSource[] = []; for (const entry of input) { - if (typeof entry !== 'string') continue; + if (typeof entry !== 'string') { + continue; + } result.push(parseCookieSource(entry)); } return result.length > 0 ? result : undefined; @@ -67,15 +76,15 @@ function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined { return undefined; } -const collectCookieSource = (value: string, previous: CookieSource[] = []) => { +const collectCookieSource = (value: string, previous: CookieSource[] = []): CookieSource[] => { previous.push(parseCookieSource(value)); return previous; }; -const p = (kind: Parameters[0]) => statusPrefix(kind, output); -const l = (kind: Parameters[0]) => labelPrefix(kind, output); +const p = (kind: Parameters[0]): string => statusPrefix(kind, output); +const l = (kind: Parameters[0]): string => labelPrefix(kind, output); -function applyOutputFromCommand(command: Command) { +function applyOutputFromCommand(command: Command): void { const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean }; output = resolveOutputConfigFromCommander(opts, process.env, isTty); kleur.enabled = output.color; @@ -102,7 +111,9 @@ type BirdConfig = { }; function readConfigFile(path: string): Partial { - if (!existsSync(path)) return {}; + if (!existsSync(path)) { + return {}; + } try { const raw = readFileSync(path, 'utf8'); const parsed = JSON5.parse(raw) as Partial; @@ -151,7 +162,7 @@ program.addHelpText( program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion()); -const formatExample = (command: string, description: string) => +const formatExample = (command: string, description: string): string => `${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`; program.addHelpText( @@ -192,7 +203,7 @@ type CredentialsOptions = { cookieSource?: CookieSource[]; }; -function resolveCredentialsFromOptions(opts: CredentialsOptions) { +function resolveCredentialsFromOptions(opts: CredentialsOptions): ReturnType { const cookieSource = opts.cookieSource?.length ? opts.cookieSource : (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES); @@ -213,9 +224,13 @@ type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer }; function resolveTimeoutMs(...values: Array): number | undefined { for (const value of values) { - if (value === undefined || value === null || value === '') continue; + if (value === undefined || value === null || value === '') { + continue; + } const parsed = typeof value === 'number' ? value : Number(value); - if (Number.isFinite(parsed) && parsed > 0) return parsed; + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } } return undefined; } @@ -226,17 +241,31 @@ function resolveTimeoutFromOptions(options: { timeout?: string | number }): numb function detectMime(path: string): string | null { const ext = path.toLowerCase(); - if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg'; - if (ext.endsWith('.png')) return 'image/png'; - if (ext.endsWith('.webp')) return 'image/webp'; - if (ext.endsWith('.gif')) return 'image/gif'; - if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) return 'video/mp4'; - if (ext.endsWith('.mov')) return 'video/quicktime'; + if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) { + return 'image/jpeg'; + } + if (ext.endsWith('.png')) { + return 'image/png'; + } + if (ext.endsWith('.webp')) { + return 'image/webp'; + } + if (ext.endsWith('.gif')) { + return 'image/gif'; + } + if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) { + return 'video/mp4'; + } + if (ext.endsWith('.mov')) { + return 'video/quicktime'; + } return null; } function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] { - if (opts.media.length === 0) return []; + if (opts.media.length === 0) { + return []; + } const specs: MediaSpec[] = []; for (const [index, path] of opts.media.entries()) { const mime = detectMime(path); @@ -248,9 +277,15 @@ function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] { } const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length; - if (videoCount > 1) throw new Error('Only one video can be attached'); - if (videoCount === 1 && specs.length > 1) throw new Error('Video cannot be combined with other media'); - if (specs.length > 4) throw new Error('Maximum 4 media attachments'); + if (videoCount > 1) { + throw new Error('Only one video can be attached'); + } + if (videoCount === 1 && specs.length > 1) { + throw new Error('Video cannot be combined with other media'); + } + if (specs.length > 4) { + throw new Error('Maximum 4 media attachments'); + } return specs; } diff --git a/src/lib/cli-args.ts b/src/lib/cli-args.ts index 1b7c8ef..6e1c2c4 100644 --- a/src/lib/cli-args.ts +++ b/src/lib/cli-args.ts @@ -8,7 +8,9 @@ const TWEET_ID_REGEX = /^\d{8,}$/; export function looksLikeTweetInput(value: string): boolean { const trimmed = value.trim(); - if (!trimmed) return false; + if (!trimmed) { + return false; + } return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed); } diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts index 4c0556a..9df0825 100644 --- a/src/lib/cookies.ts +++ b/src/lib/cookies.ts @@ -21,10 +21,12 @@ export type CookieSource = 'safari' | 'chrome' | 'firefox'; const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'] as const; const TWITTER_URL = 'https://x.com/'; -const TWITTER_ORIGINS = ['https://x.com/', 'https://twitter.com/']; +const TWITTER_ORIGINS: string[] = ['https://x.com/', 'https://twitter.com/']; function normalizeValue(value: unknown): string | null { - if (typeof value !== 'string') return null; + if (typeof value !== 'string') { + return null; + } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } @@ -38,24 +40,36 @@ function buildEmpty(): TwitterCookies { } function readEnvCookie(cookies: TwitterCookies, keys: readonly string[], field: 'authToken' | 'ct0'): void { - if (cookies[field]) return; + if (cookies[field]) { + return; + } for (const key of keys) { const value = normalizeValue(process.env[key]); - if (!value) continue; + if (!value) { + continue; + } cookies[field] = value; - if (!cookies.source) cookies.source = `env ${key}`; + if (!cookies.source) { + cookies.source = `env ${key}`; + } break; } } function resolveSources(cookieSource?: CookieSource | CookieSource[]): CookieSource[] { - if (Array.isArray(cookieSource)) return cookieSource; - if (cookieSource) return [cookieSource]; + if (Array.isArray(cookieSource)) { + return cookieSource; + } + if (cookieSource) { + return [cookieSource]; + } return ['safari', 'chrome', 'firefox']; } function labelForSource(source: CookieSource, profile?: string): string { - if (source === 'safari') return 'Safari'; + if (source === 'safari') { + return 'Safari'; + } if (source === 'chrome') { return profile ? `Chrome profile "${profile}"` : 'Chrome default profile'; } @@ -67,13 +81,19 @@ function pickCookieValue( name: (typeof TWITTER_COOKIE_NAMES)[number], ): string | null { const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string'); - if (matches.length === 0) return null; + if (matches.length === 0) { + return null; + } const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com')); - if (preferred?.value) return preferred.value; + if (preferred?.value) { + return preferred.value; + } const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com')); - if (twitter?.value) return twitter.value; + if (twitter?.value) { + return twitter.value; + } return matches[0]?.value ?? null; } @@ -99,8 +119,12 @@ async function readTwitterCookiesFromBrowser(options: { const authToken = pickCookieValue(cookies, 'auth_token'); const ct0 = pickCookieValue(cookies, 'ct0'); - if (authToken) out.authToken = authToken; - if (ct0) out.ct0 = ct0; + if (authToken) { + out.authToken = authToken; + } + if (ct0) { + out.ct0 = ct0; + } if (out.authToken && out.ct0) { out.cookieHeader = cookieHeader(out.authToken, out.ct0); @@ -156,7 +180,9 @@ export async function resolveCredentials(options: { } if (options.ct0) { cookies.ct0 = options.ct0; - if (!cookies.source) cookies.source = 'CLI argument'; + if (!cookies.source) { + cookies.source = 'CLI argument'; + } } readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken'); diff --git a/src/lib/extract-tweet-id.ts b/src/lib/extract-tweet-id.ts index f8df6f6..ae46b93 100644 --- a/src/lib/extract-tweet-id.ts +++ b/src/lib/extract-tweet-id.ts @@ -1,9 +1,11 @@ /** * Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID. */ +const TWEET_URL_REGEX = /(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/; + export function extractTweetId(input: string): string { // If it's a URL, extract the tweet ID - const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/); + const urlMatch = TWEET_URL_REGEX.exec(input); if (urlMatch) { return urlMatch[1]; } diff --git a/src/lib/normalize-handle.ts b/src/lib/normalize-handle.ts index 2d6572e..127dc0d 100644 --- a/src/lib/normalize-handle.ts +++ b/src/lib/normalize-handle.ts @@ -1,13 +1,21 @@ +const HANDLE_REGEX = /^[A-Za-z0-9_]{1,15}$/; + export function normalizeHandle(input?: string | null): string | null { const raw = (input ?? '').trim(); - if (!raw) return null; + if (!raw) { + return null; + } const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw; const handle = withoutAt.trim(); - if (!handle) return null; + if (!handle) { + return null; + } // X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries. - if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null; + if (!HANDLE_REGEX.test(handle)) { + return null; + } return handle; } @@ -16,7 +24,9 @@ export function mentionsQueryFromUserOption(userOption?: string | null): { query: string | null; error: string | null; } { - if (typeof userOption === 'undefined') return { query: null, error: null }; + if (typeof userOption === 'undefined') { + return { query: null, error: null }; + } const handle = normalizeHandle(userOption); if (!handle) { diff --git a/src/lib/output.ts b/src/lib/output.ts index 61aeece..8ed1bff 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -53,14 +53,22 @@ export function resolveOutputConfigFromCommander( } export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string { - if (cfg.plain) return `${STATUS[kind].plain} `; - if (cfg.emoji) return `${STATUS[kind].emoji} `; + if (cfg.plain) { + return `${STATUS[kind].plain} `; + } + if (cfg.emoji) { + return `${STATUS[kind].emoji} `; + } return `${STATUS[kind].text} `; } export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string { - if (cfg.plain) return `${LABELS[kind].plain} `; - if (cfg.emoji) return `${LABELS[kind].emoji} `; + if (cfg.plain) { + return `${LABELS[kind].plain} `; + } + if (cfg.emoji) { + return `${LABELS[kind].emoji} `; + } return `${LABELS[kind].text} `; } @@ -72,8 +80,12 @@ export function formatStatsLine( const retweetCount = stats.retweetCount ?? 0; const replyCount = stats.replyCount ?? 0; - if (cfg.plain) return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`; - if (!cfg.emoji) return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`; + if (cfg.plain) { + return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`; + } + if (!cfg.emoji) { + return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`; + } return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`; } diff --git a/src/lib/runtime-query-ids.ts b/src/lib/runtime-query-ids.ts index 9dfbf90..bd335b9 100644 --- a/src/lib/runtime-query-ids.ts +++ b/src/lib/runtime-query-ids.ts @@ -3,9 +3,9 @@ import { homedir } from 'node:os'; import path from 'node:path'; const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json'; -const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; +const DEFAULT_TTL_MS: number = 24 * 60 * 60 * 1000; -const DISCOVERY_PAGES = [ +const DISCOVERY_PAGES: string[] = [ 'https://x.com/?lang=en', 'https://x.com/explore', 'https://x.com/notifications', @@ -13,6 +13,7 @@ const DISCOVERY_PAGES = [ ]; const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g; +const QUERY_ID_REGEX = /^[a-zA-Z0-9_-]+$/; const OPERATION_PATTERNS = [ { @@ -67,6 +68,15 @@ export type RuntimeQueryIdsOptions = { fetchImpl?: typeof fetch; }; +export type RuntimeQueryIdStore = { + cachePath: string; + ttlMs: number; + getSnapshotInfo: () => Promise; + getQueryId: (operationName: string) => Promise; + refresh: (operationNames: string[], opts?: { force?: boolean }) => Promise; + clearMemory: () => void; +}; + async function fetchText(fetchImpl: typeof fetch, url: string): Promise { const response = await fetchImpl(url, { headers: HEADERS }); if (!response.ok) { @@ -85,7 +95,9 @@ function resolveDefaultCachePath(): string { } function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null { - if (!raw || typeof raw !== 'object') return null; + if (!raw || typeof raw !== 'object') { + return null; + } const record = raw as Record; const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null; const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null; @@ -93,10 +105,14 @@ function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null { const discovery = record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record) : null; - if (!fetchedAt || !ttlMs || !ids || !discovery) return null; + if (!fetchedAt || !ttlMs || !ids || !discovery) { + return null; + } const pages = Array.isArray(discovery.pages) ? discovery.pages : null; const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null; - if (!pages || !bundles) return null; + if (!pages || !bundles) { + return null; + } const normalizedIds: Record = {}; for (const [key, value] of Object.entries(ids)) { @@ -122,7 +138,9 @@ async function readSnapshotFromDisk(cachePath: string): Promise { - if (discovered.size === targets.size) return; + if (discovered.size === targets.size) { + return; + } const label = url.split('/').at(-1) ?? url; try { const js = await fetchText(fetchImpl, url); @@ -196,13 +228,15 @@ async function fetchAndExtract( } }), ); - if (discovered.size === targets.size) break; + if (discovered.size === targets.size) { + break; + } } return discovered; } -export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) { +export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}): RuntimeQueryIdStore { const fetchImpl = options.fetchImpl ?? fetch; const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath(); @@ -212,7 +246,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) let refreshInFlight: Promise | null = null; const loadSnapshot = async (): Promise => { - if (memorySnapshot) return memorySnapshot; + if (memorySnapshot) { + return memorySnapshot; + } if (!loadOnce) { loadOnce = (async () => { const fromDisk = await readSnapshotFromDisk(cachePath); @@ -225,7 +261,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) const getSnapshotInfo = async (): Promise => { const snapshot = await loadSnapshot(); - if (!snapshot) return null; + if (!snapshot) { + return null; + } const fetchedAtMs = new Date(snapshot.fetchedAt).getTime(); const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY; const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs; @@ -235,7 +273,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) const getQueryId = async (operationName: string): Promise => { const info = await getSnapshotInfo(); - if (!info) return null; + if (!info) { + return null; + } return info.snapshot.ids[operationName] ?? null; }; @@ -243,7 +283,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) operationNames: string[], opts: { force?: boolean } = {}, ): Promise => { - if (refreshInFlight) return refreshInFlight; + if (refreshInFlight) { + return refreshInFlight; + } refreshInFlight = (async () => { const current = await getSnapshotInfo(); diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index e5f5379..2ef1946 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -4,6 +4,7 @@ import { randomBytes, randomUUID } from 'node:crypto'; import type { TwitterCookies } from './cookies.js'; +// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension. import queryIds from './query-ids.json' with { type: 'json' }; import { runtimeQueryIds } from './runtime-query-ids.js'; @@ -12,6 +13,9 @@ const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql'; const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json'; const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json'; const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json'; +const SETTINGS_SCREEN_NAME_REGEX = /"screen_name":"([^"]+)"/; +const SETTINGS_USER_ID_REGEX = /"user_id"\s*:\s*"(\d+)"/; +const SETTINGS_NAME_REGEX = /"name":"([^"\\]*(?:\\.[^"\\]*)*)"/; // Query IDs rotate frequently; the values in query-ids.json are refreshed by // scripts/update-query-ids.ts. The fallback values keep the client usable if @@ -339,7 +343,9 @@ export class TwitterClient { | undefined, tweetId: string, ) { - if (!instructions) return undefined; + if (!instructions) { + return undefined; + } for (const instruction of instructions) { for (const entry of instruction.entries || []) { @@ -401,10 +407,14 @@ export class TwitterClient { private mediaCategoryForMime(mimeType: string): string | null { if (mimeType.startsWith('image/')) { - if (mimeType === 'image/gif') return 'tweet_gif'; + if (mimeType === 'image/gif') { + return 'tweet_gif'; + } return 'tweet_image'; } - if (mimeType.startsWith('video/')) return 'tweet_video'; + if (mimeType.startsWith('video/')) { + return 'tweet_video'; + } return null; } @@ -414,7 +424,9 @@ export class TwitterClient { async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise { const category = this.mediaCategoryForMime(input.mimeType); - if (!category) return { success: false, error: `Unsupported media type: ${input.mimeType}` }; + if (!category) { + return { success: false, error: `Unsupported media type: ${input.mimeType}` }; + } try { const initParams = new URLSearchParams({ @@ -442,7 +454,9 @@ export class TwitterClient { : initBody.media_id !== undefined ? String(initBody.media_id) : undefined; - if (!mediaId) return { success: false, error: 'Media upload INIT did not return media_id' }; + if (!mediaId) { + return { success: false, error: 'Media upload INIT did not return media_id' }; + } const chunkSize = 5 * 1024 * 1024; let segmentIndex = 0; @@ -516,11 +530,15 @@ export class TwitterClient { error?: { message?: string; name?: string }; }; }; - if (!statusBody.processing_info) break; + if (!statusBody.processing_info) { + break; + } info.state = statusBody.processing_info.state; info.check_after_secs = statusBody.processing_info.check_after_secs; info.error = statusBody.processing_info.error; - if (info.state === 'succeeded') break; + if (info.state === 'succeeded') { + break; + } attempts += 1; } } @@ -547,15 +565,21 @@ export class TwitterClient { for (const value of values) { if (typeof value === 'string') { const trimmed = value.trim(); - if (trimmed) return trimmed; + if (trimmed) { + return trimmed; + } } } return undefined; } private collectTextFields(value: unknown, keys: Set, output: string[]): void { - if (!value) return; - if (typeof value === 'string') return; + if (!value) { + return; + } + if (typeof value === 'string') { + return; + } if (Array.isArray(value)) { for (const item of value) { @@ -569,7 +593,9 @@ export class TwitterClient { if (keys.has(key)) { if (typeof nested === 'string') { const trimmed = nested.trim(); - if (trimmed) output.push(trimmed); + if (trimmed) { + output.push(trimmed); + } continue; } } @@ -582,7 +608,9 @@ export class TwitterClient { const seen = new Set(); const result: string[] = []; for (const value of values) { - if (seen.has(value)) continue; + if (seen.has(value)) { + continue; + } seen.add(value); result.push(value); } @@ -591,7 +619,9 @@ export class TwitterClient { private extractArticleText(result: GraphqlTweetResult | undefined): string | undefined { const article = result?.article; - if (!article) return undefined; + if (!article) { + return undefined; + } const articleResult = article.article_results?.result ?? article; if (process.env.BIRD_DEBUG_ARTICLE === '1') { @@ -656,7 +686,9 @@ export class TwitterClient { private extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined { const note = result?.note_tweet?.note_tweet_results?.result; - if (!note) return undefined; + if (!note) { + return undefined; + } return this.firstText( note.text, @@ -681,10 +713,14 @@ export class TwitterClient { const username = userLegacy?.screen_name ?? userCore?.screen_name; const name = userLegacy?.name ?? userCore?.name ?? username; const userId = userResult?.rest_id; - if (!result?.rest_id || !username) return undefined; + if (!result?.rest_id || !username) { + return undefined; + } const text = this.extractTweetText(result); - if (!text) return undefined; + if (!text) { + return undefined; + } return { id: result.rest_id, @@ -742,7 +778,9 @@ export class TwitterClient { }): GraphqlTweetResult[] { const results: GraphqlTweetResult[] = []; const pushResult = (result?: GraphqlTweetResult) => { - if (result?.rest_id) results.push(result); + if (result?.rest_id) { + results.push(result); + } }; const content = entry.content; @@ -809,7 +847,9 @@ export class TwitterClient { const results = this.collectTweetResultsFromEntry(entry); for (const result of results) { const mapped = this.mapTweetResult(result); - if (!mapped || seen.has(mapped.id)) continue; + if (!mapped || seen.has(mapped.id)) { + continue; + } seen.add(mapped.id); tweets.push(mapped); } @@ -990,7 +1030,9 @@ export class TwitterClient { for (const instruction of instructions) { for (const entry of instruction.entries ?? []) { const result = entry.content?.itemContent?.tweet_results?.result; - if (result?.rest_id !== tweetId) continue; + if (result?.rest_id !== tweetId) { + continue; + } const articleResult = result.article?.article_results?.result; const title = this.firstText(articleResult?.title, result.article?.title); const plainText = this.firstText(articleResult?.plain_text, result.article?.plain_text); @@ -1352,12 +1394,16 @@ export class TwitterClient { if (data.errors && data.errors.length > 0) { const fallback = await this.tryStatusUpdateFallback(data.errors, variables); - if (fallback) return fallback; + if (fallback) { + return fallback; + } return { success: false, error: this.formatErrors(data.errors) }; } const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id; - if (tweetId) return { success: true, tweetId }; + if (tweetId) { + return { success: true, tweetId }; + } return { success: false, error: 'Tweet created but no ID returned' }; } @@ -1375,7 +1421,9 @@ export class TwitterClient { if (data.errors && data.errors.length > 0) { const fallback = await this.tryStatusUpdateFallback(data.errors, variables); - if (fallback) return fallback; + if (fallback) { + return fallback; + } return { success: false, error: this.formatErrors(data.errors), @@ -1414,7 +1462,9 @@ export class TwitterClient { mediaIds?: string[]; } | null { const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null; - if (!text) return null; + if (!text) { + return null; + } const reply = variables.reply; const inReplyToTweetId = @@ -1486,7 +1536,9 @@ export class TwitterClient { const tweetId = typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined; - if (tweetId) return { success: true, tweetId }; + if (tweetId) { + return { success: true, tweetId }; + } return { success: false, error: 'Tweet created but no ID returned' }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; @@ -1497,12 +1549,18 @@ export class TwitterClient { errors: Array<{ message: string; code?: number }>, variables: Record, ): Promise { - if (!errors.some((error) => error.code === 226)) return null; + if (!errors.some((error) => error.code === 226)) { + return null; + } const input = this.statusUpdateInputFromCreateTweetVariables(variables); - if (!input) return null; + if (!input) { + return null; + } const fallback = await this.postStatusUpdate(input); - if (fallback.success) return fallback; + if (fallback.success) { + return fallback; + } return { success: false, @@ -1511,8 +1569,12 @@ export class TwitterClient { } private async ensureClientUserId(): Promise { - if (process.env.NODE_ENV === 'test') return; - if (this.clientUserId) return; + if (process.env.NODE_ENV === 'test') { + return; + } + if (this.clientUserId) { + return; + } const result = await this.getCurrentUser(); if (result.success && result.user?.id) { this.clientUserId = result.user.id; @@ -1622,12 +1684,16 @@ export class TwitterClient { }; const firstAttempt = await tryOnce(); - if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets }; + if (firstAttempt.success) { + return { success: true, tweets: firstAttempt.tweets }; + } if (firstAttempt.had404) { await this.refreshQueryIds(); const secondAttempt = await tryOnce(); - if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets }; + if (secondAttempt.success) { + return { success: true, tweets: secondAttempt.tweets }; + } return { success: false, error: secondAttempt.error }; } @@ -1729,9 +1795,9 @@ export class TwitterClient { } const html = await response.text(); - const usernameMatch = html.match(/"screen_name":"([^"]+)"/); - const idMatch = html.match(/"user_id"\s*:\s*"(\d+)"/); - const nameMatch = html.match(/"name":"([^"\\]*(?:\\.[^"\\]*)*)"/); + const usernameMatch = SETTINGS_SCREEN_NAME_REGEX.exec(html); + const idMatch = SETTINGS_USER_ID_REGEX.exec(html); + const nameMatch = SETTINGS_NAME_REGEX.exec(html); const username = usernameMatch?.[1]; const userId = idMatch?.[1]; @@ -1765,7 +1831,9 @@ export class TwitterClient { */ async getReplies(tweetId: string): Promise { const response = await this.fetchTweetDetail(tweetId); - if (!response.success) return response; + if (!response.success) { + return response; + } const instructions = response.data.threaded_conversation_with_injections_v2?.instructions; const tweets = this.parseTweetsFromInstructions(instructions); @@ -1779,7 +1847,9 @@ export class TwitterClient { */ async getThread(tweetId: string): Promise { const response = await this.fetchTweetDetail(tweetId); - if (!response.success) return response; + if (!response.success) { + return response; + } const instructions = response.data.threaded_conversation_with_injections_v2?.instructions; const tweets = this.parseTweetsFromInstructions(instructions); @@ -1899,12 +1969,16 @@ export class TwitterClient { }; const firstAttempt = await tryOnce(); - if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets }; + if (firstAttempt.success) { + return { success: true, tweets: firstAttempt.tweets }; + } if (firstAttempt.had404) { await this.refreshQueryIds(); const secondAttempt = await tryOnce(); - if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets }; + if (secondAttempt.success) { + return { success: true, tweets: secondAttempt.tweets }; + } return { success: false, error: secondAttempt.error }; } diff --git a/src/lib/version.ts b/src/lib/version.ts index 5a1fb0b..685fb46 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -5,6 +5,9 @@ import { fileURLToPath } from 'node:url'; declare const __dirname: string | undefined; export const FALLBACK_VERSION = 'unknown'; +const REF_PREFIX_REGEX = /^ref:\s*/i; +const LINE_SPLIT_REGEX = /\r?\n/; +const GITDIR_REGEX = /gitdir:\s*(.+)\s*$/i; function readPackageVersionFromJsonFile(candidate: string): string | null { try { @@ -37,7 +40,9 @@ function resolveStartDir(importMetaUrl?: string): string { } } - if (typeof __dirname === 'string' && __dirname.length > 0) return __dirname; + if (typeof __dirname === 'string' && __dirname.length > 0) { + return __dirname; + } return process.cwd(); } @@ -47,27 +52,37 @@ export function resolvePackageVersion(importMetaUrl?: string): string { typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string' ? process.env.BIRD_VERSION.trim() : ''; - if (injected.length > 0) return injected; + if (injected.length > 0) { + return injected; + } let dir = resolveStartDir(importMetaUrl); for (let i = 0; i < 10; i += 1) { const version = readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ?? readVersionFromTextFile(path.join(dir, 'VERSION')); - if (version) return version; + if (version) { + return version; + } const parent = path.dirname(dir); - if (parent === dir) break; + if (parent === dir) { + break; + } dir = parent; } return FALLBACK_VERSION; } -function truncateSha(sha: string, length = 8): string { +function truncateSha(sha: string, length: number = 8): string { const trimmed = sha.trim(); - if (!trimmed) return ''; - if (trimmed.length <= length) return trimmed; + if (!trimmed) { + return ''; + } + if (trimmed.length <= length) { + return trimmed; + } return trimmed.slice(0, length); } @@ -80,14 +95,18 @@ function resolveGitShaFromGitDir(gitDir: string): string | null { return null; } - if (!head) return null; + if (!head) { + return null; + } if (!head.startsWith('ref:')) { const sha = truncateSha(head); return sha.length > 0 ? sha : null; } - const ref = head.replace(/^ref:\s*/i, '').trim(); - if (!ref) return null; + const ref = head.replace(REF_PREFIX_REGEX, '').trim(); + if (!ref) { + return null; + } const refPath = path.join(gitDir, ref); try { @@ -100,9 +119,11 @@ function resolveGitShaFromGitDir(gitDir: string): string | null { const packedRefsPath = path.join(gitDir, 'packed-refs'); try { const packed = fs.readFileSync(packedRefsPath, 'utf8'); - const lines = packed.split(/\r?\n/); + const lines = packed.split(LINE_SPLIT_REGEX); for (const line of lines) { - if (!line || line.startsWith('#') || line.startsWith('^')) continue; + if (!line || line.startsWith('#') || line.startsWith('^')) { + continue; + } const [shaRaw, refName] = line.split(' '); if (refName?.trim() === ref) { const sha = truncateSha(shaRaw ?? ''); @@ -121,7 +142,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null { typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string' ? process.env.BIRD_GIT_SHA.trim() : ''; - if (injected.length > 0) return truncateSha(injected); + if (injected.length > 0) { + return truncateSha(injected); + } let dir = resolveStartDir(importMetaUrl); for (let i = 0; i < 10; i += 1) { @@ -130,15 +153,19 @@ export function resolveGitSha(importMetaUrl?: string): string | null { const stat = fs.statSync(dotGit); if (stat.isDirectory()) { const sha = resolveGitShaFromGitDir(dotGit); - if (sha) return sha; + if (sha) { + return sha; + } } else if (stat.isFile()) { const txt = fs.readFileSync(dotGit, 'utf8'); - const match = txt.match(/gitdir:\s*(.+)\s*$/i); + const match = GITDIR_REGEX.exec(txt); const gitDir = match?.[1]?.trim(); if (gitDir) { const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir); const sha = resolveGitShaFromGitDir(resolved); - if (sha) return sha; + if (sha) { + return sha; + } } } } catch { @@ -146,7 +173,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null { } const parent = path.dirname(dir); - if (parent === dir) break; + if (parent === dir) { + break; + } dir = parent; } diff --git a/tests/cookies.test.ts b/tests/cookies.test.ts index 23d6ce8..849732b 100644 --- a/tests/cookies.test.ts +++ b/tests/cookies.test.ts @@ -191,6 +191,57 @@ describe('cookies', () => { expect(result.cookies.ct0).toBe('test_ct0'); expect(result.cookies.source).toContain('Chrome'); }); + + it('uses default browser order when cookieSource is omitted', async () => { + sweet.results.set('safari', { cookies: [], warnings: [] }); + sweet.results.set('chrome', { cookies: [], warnings: [] }); + sweet.results.set('firefox', { + cookies: [ + { name: 'auth_token', value: 'firefox_auth', domain: 'x.com' }, + { name: 'ct0', value: 'firefox_ct0', domain: 'x.com' }, + ], + warnings: [], + }); + + const { resolveCredentials } = await import('../src/lib/cookies.js'); + const result = await resolveCredentials({}); + + expect(result.cookies.authToken).toBe('firefox_auth'); + expect(result.cookies.ct0).toBe('firefox_ct0'); + expect(result.cookies.source).toContain('Firefox'); + }); + + it('prefers twitter.com cookies when x.com is missing', async () => { + sweet.results.set('chrome', { + cookies: [ + { name: 'auth_token', value: 'twitter_auth', domain: 'twitter.com' }, + { name: 'ct0', value: 'twitter_ct0', domain: 'twitter.com' }, + ], + warnings: [], + }); + + const { resolveCredentials } = await import('../src/lib/cookies.js'); + const result = await resolveCredentials({ cookieSource: 'chrome' }); + + expect(result.cookies.authToken).toBe('twitter_auth'); + expect(result.cookies.ct0).toBe('twitter_ct0'); + }); + + it('falls back to the first cookie when no domain matches', async () => { + sweet.results.set('firefox', { + cookies: [ + { name: 'auth_token', value: 'first_auth', domain: 'example.com' }, + { name: 'ct0', value: 'first_ct0', domain: 'example.com' }, + ], + warnings: [], + }); + + const { resolveCredentials } = await import('../src/lib/cookies.js'); + const result = await resolveCredentials({ cookieSource: 'firefox' }); + + expect(result.cookies.authToken).toBe('first_auth'); + expect(result.cookies.ct0).toBe('first_ct0'); + }); }); describe('extractCookiesFromSafari', () => { diff --git a/tests/normalize-handle.test.ts b/tests/normalize-handle.test.ts index 89de4a6..522fd14 100644 --- a/tests/normalize-handle.test.ts +++ b/tests/normalize-handle.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js'; +const INVALID_HANDLE_REGEX = /Invalid --user handle/; + describe('normalizeHandle', () => { it('accepts bare handle', () => { expect(normalizeHandle('steipete')).toBe('steipete'); @@ -36,6 +38,6 @@ describe('mentionsQueryFromUserOption', () => { it('returns error for invalid handle', () => { const result = mentionsQueryFromUserOption('@stei-pete'); expect(result.query).toBeNull(); - expect(result.error).toMatch(/Invalid --user handle/); + expect(result.error).toMatch(INVALID_HANDLE_REGEX); }); }); diff --git a/tests/runtime-query-ids.test.ts b/tests/runtime-query-ids.test.ts index 0cc72aa..f6624cd 100644 --- a/tests/runtime-query-ids.test.ts +++ b/tests/runtime-query-ids.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { mkdir, readFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -8,6 +8,7 @@ import { createRuntimeQueryIdStore } from '../src/lib/runtime-query-ids.js'; describe('runtime-query-ids', () => { afterEach(() => { vi.unstubAllGlobals(); + delete process.env.BIRD_QUERY_IDS_CACHE; }); it('refreshes IDs by scanning discovered bundles and persists cache', async () => { @@ -60,4 +61,83 @@ describe('runtime-query-ids', () => { const parsed = JSON.parse(raw) as { ids?: Record }; expect(parsed.ids?.SearchTimeline).toBe('EEE'); }); + + it('uses env cache path override and returns fresh snapshot without refreshing', async () => { + const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`); + await mkdir(cacheDir, { recursive: true }); + const cachePath = path.join(cacheDir, 'query-ids-cache.json'); + process.env.BIRD_QUERY_IDS_CACHE = cachePath; + + const snapshot = { + fetchedAt: new Date().toISOString(), + ttlMs: 60_000, + ids: { CreateTweet: 'AAA' }, + discovery: { pages: ['https://x.com/'], bundles: ['main.js'] }, + }; + await writeFile(cachePath, JSON.stringify(snapshot), 'utf8'); + + const fetchMock = vi.fn(); + const store = createRuntimeQueryIdStore({ fetchImpl: fetchMock as unknown as typeof fetch }); + const info = await store.refresh(['CreateTweet']); + + expect(store.cachePath).toBe(cachePath); + expect(info?.snapshot.ids.CreateTweet).toBe('AAA'); + expect(fetchMock).not.toHaveBeenCalled(); + + store.clearMemory(); + }); + + it('returns current snapshot when refresh finds no matching operations', async () => { + const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`); + await mkdir(cacheDir, { recursive: true }); + const cachePath = path.join(cacheDir, 'query-ids.json'); + + const snapshot = { + fetchedAt: new Date(0).toISOString(), + ttlMs: 1, + ids: { CreateTweet: 'AAA' }, + discovery: { pages: ['https://x.com/'], bundles: ['main.test.js'] }, + }; + await writeFile(cachePath, JSON.stringify(snapshot), 'utf8'); + + const html = ``; + const js = 'console.log("no ops here")'; + + const fetchMock = vi.fn(async (url: string | URL) => { + const asString = String(url); + if (asString.startsWith('https://x.com/')) { + return new Response(html, { status: 200 }); + } + if (asString.startsWith('https://abs.twimg.com/')) { + return new Response(js, { status: 200 }); + } + return new Response('not found', { status: 404 }); + }); + + const store = createRuntimeQueryIdStore({ + cachePath, + ttlMs: 1, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + const info = await store.refresh(['CreateTweet']); + expect(info?.snapshot.ids.CreateTweet).toBe('AAA'); + }); + + it('throws when no bundles can be discovered', async () => { + const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`); + await mkdir(cacheDir, { recursive: true }); + const cachePath = path.join(cacheDir, 'query-ids.json'); + + const fetchMock = vi.fn(async () => new Response('nope', { status: 500 })); + + const store = createRuntimeQueryIdStore({ + cachePath, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + await expect(store.refresh(['CreateTweet'], { force: true })).rejects.toThrow( + 'No client bundles discovered; x.com layout may have changed.', + ); + }); }); diff --git a/tests/twitter-client-coverage.api.test.ts b/tests/twitter-client-coverage.api.test.ts new file mode 100644 index 0000000..7083dc6 --- /dev/null +++ b/tests/twitter-client-coverage.api.test.ts @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { TwitterClient } from '../src/lib/twitter-client.js'; + +const validCookies = { + authToken: 'test_auth_token', + ct0: 'test_ct0_token', + cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token', + source: 'test', +}; + +type ResponseLike = { + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}; + +type TwitterClientApiPrivate = TwitterClient & { + getBookmarksQueryIds: () => Promise; +}; + +const makeResponse = (overrides: Partial = {}): ResponseLike => ({ + ok: true, + status: 200, + json: async (): Promise => ({}), + text: async (): Promise => '', + ...overrides, +}); + +describe('TwitterClient API coverage', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe('createTweet fallback paths', () => { + it('returns an error when retry response is not ok', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' })) + .mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' })) + .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'boom' })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.tweet('hi'); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 500'); + }); + + it('returns API errors when retry response contains errors', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' })) + .mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' })) + .mockResolvedValueOnce( + makeResponse({ + json: async () => ({ errors: [{ message: 'rate limited', code: 1 }] }), + }), + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.tweet('hi'); + + expect(result.success).toBe(false); + expect(result.error).toContain('rate limited'); + }); + + it('returns an error when retry response has no tweet id', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' })) + .mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' })) + .mockResolvedValueOnce( + makeResponse({ + json: async () => ({ data: { create_tweet: { tweet_results: { result: {} } } } }), + }), + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.tweet('hi'); + + expect(result.success).toBe(false); + expect(result.error).toContain('no ID'); + }); + }); + + describe('search error paths', () => { + it('returns an error for non-ok responses', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.search('test'); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 500'); + }); + + it('returns errors from payloads', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + makeResponse({ + json: async () => ({ errors: [{ message: 'bad' }] }), + }), + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.search('test'); + + expect(result.success).toBe(false); + expect(result.error).toBe('bad'); + }); + + it('returns an error when fetching throws', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('boom')); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.search('test'); + + expect(result.success).toBe(false); + expect(result.error).toContain('boom'); + }); + + it('returns the second attempt error after 404s', async () => { + const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.search('test'); + + expect(result.success).toBe(false); + expect(result.error).toBe('HTTP 404'); + }); + }); + + describe('bookmarks error paths', () => { + it('returns an error for non-ok responses', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getBookmarks(1); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 500'); + }); + + it('returns API errors from payloads', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + makeResponse({ + json: async () => ({ errors: [{ message: 'bad' }] }), + }), + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getBookmarks(1); + + expect(result.success).toBe(false); + expect(result.error).toBe('bad'); + }); + + it('returns an error when fetching throws', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('boom')); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getBookmarks(1); + + expect(result.success).toBe(false); + expect(result.error).toContain('boom'); + }); + + it('returns unknown error when no query ids are available', async () => { + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientApiPrivate; + clientPrivate.getBookmarksQueryIds = async () => []; + + const result = await client.getBookmarks(1); + + expect(result.success).toBe(false); + expect(result.error).toBe('Unknown error fetching bookmarks'); + }); + + it('returns the second attempt error after 404s', async () => { + const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getBookmarks(1); + + expect(result.success).toBe(false); + expect(result.error).toBe('HTTP 404'); + }); + }); +}); diff --git a/tests/twitter-client-coverage.core.test.ts b/tests/twitter-client-coverage.core.test.ts new file mode 100644 index 0000000..ed94c81 --- /dev/null +++ b/tests/twitter-client-coverage.core.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { runtimeQueryIds } from '../src/lib/runtime-query-ids.js'; +import { TwitterClient } from '../src/lib/twitter-client.js'; + +const validCookies = { + authToken: 'test_auth_token', + ct0: 'test_ct0_token', + cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token', + source: 'test', +}; + +type ResponseLike = { + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}; + +type TwitterClientPrivate = TwitterClient & { + refreshQueryIds: () => Promise; + fetchWithTimeout: (url: string, init: RequestInit) => Promise; + ensureClientUserId: () => Promise; + getBaseHeaders: () => Record; + fetchUserArticlePlainText: (userId: string, tweetId: string) => Promise<{ title?: string; plainText?: string }>; + fetchTweetDetail: (tweetId: string) => Promise<{ success: true } | { success: false; error: string }>; + postStatusUpdate: (input: { text: string }) => Promise<{ success: boolean; error?: string }>; +}; + +const makeResponse = (overrides: Partial = {}): ResponseLike => ({ + ok: true, + status: 200, + json: async (): Promise => ({}), + text: async (): Promise => '', + ...overrides, +}); + +describe('TwitterClient core coverage', () => { + const originalFetch = global.fetch; + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + global.fetch = originalFetch; + process.env.NODE_ENV = originalNodeEnv; + delete process.env.BIRD_DEBUG_ARTICLE; + vi.restoreAllMocks(); + }); + + it('refreshQueryIds calls runtime refresh outside test env', async () => { + const client = new TwitterClient({ cookies: validCookies }); + const refreshSpy = vi.spyOn(runtimeQueryIds, 'refresh').mockResolvedValue(null); + process.env.NODE_ENV = 'production'; + + const clientPrivate = client as unknown as TwitterClientPrivate; + await clientPrivate.refreshQueryIds(); + + expect(refreshSpy).toHaveBeenCalled(); + }); + + it('fetchWithTimeout uses an abort signal when timeout is set', async () => { + const mockFetch = vi.fn(async (_url: string, init?: RequestInit) => { + expect(init?.signal).toBeInstanceOf(AbortSignal); + return makeResponse(); + }); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies, timeoutMs: 5 }); + const clientPrivate = client as unknown as TwitterClientPrivate; + await clientPrivate.fetchWithTimeout('https://example.com', { method: 'GET' }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('returns a not found error when tweet data is missing', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + makeResponse({ + json: async () => ({ + data: { + threaded_conversation_with_injections_v2: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'other', + legacy: { full_text: 'nope', created_at: '2024-01-01T00:00:00Z' }, + core: { user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } } }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }), + }), + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getTweet('123'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Tweet not found in response'); + }); + + it('adds client user id header after ensureClientUserId', async () => { + const client = new TwitterClient({ cookies: validCookies }); + process.env.NODE_ENV = 'production'; + + vi.spyOn(client, 'getCurrentUser').mockResolvedValue({ + success: true, + user: { id: '42', username: 'tester', name: 'Tester' }, + }); + + const clientPrivate = client as unknown as TwitterClientPrivate; + await clientPrivate.ensureClientUserId(); + const headers = clientPrivate.getBaseHeaders(); + + expect(headers['x-twitter-client-user-id']).toBe('42'); + }); + + describe('fetchUserArticlePlainText failures', () => { + it('returns empty object when response is not ok', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce(makeResponse({ ok: false, status: 500 })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet'); + + expect(result).toEqual({}); + }); + + it('returns empty object when fetching throws', async () => { + const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom')); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet'); + + expect(result).toEqual({}); + }); + + it('returns empty object when no matching tweet is found', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + makeResponse({ + json: async () => ({ + data: { + user: { result: { timeline: { timeline: { instructions: [] } } } }, + }, + }), + }), + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet'); + + expect(result).toEqual({}); + }); + }); + + describe('fetchTweetDetail error handling', () => { + it('returns API errors from response payloads', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + makeResponse({ + json: async () => ({ errors: [{ message: 'bad news' }] }), + }), + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + const result = await clientPrivate.fetchTweetDetail('1'); + + expect(result.success).toBe(false); + expect(result.error).toBe('bad news'); + }); + + it('parses POST responses when GET returns 404', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' })) + .mockResolvedValueOnce( + makeResponse({ + json: async () => ({ + data: { + tweetResult: { + result: { + rest_id: '1', + legacy: { + full_text: 'hi', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + }, + core: { user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } } }, + }, + }, + }, + }), + }), + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + const result = await clientPrivate.fetchTweetDetail('1'); + + expect(result.success).toBe(true); + }); + + it('returns an error when fetch throws', async () => { + const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom')); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + const result = await clientPrivate.fetchTweetDetail('1'); + + expect(result.success).toBe(false); + expect(result.error).toContain('boom'); + }); + }); + + describe('postStatusUpdate error handling', () => { + it('returns an error when postStatusUpdate throws', async () => { + const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom')); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + const result = await clientPrivate.postStatusUpdate({ text: 'hello' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('boom'); + }); + }); +}); diff --git a/tests/twitter-client-coverage.upload.test.ts b/tests/twitter-client-coverage.upload.test.ts new file mode 100644 index 0000000..00c2555 --- /dev/null +++ b/tests/twitter-client-coverage.upload.test.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { TwitterClient } from '../src/lib/twitter-client.js'; + +const validCookies = { + authToken: 'test_auth_token', + ct0: 'test_ct0_token', + cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token', + source: 'test', +}; + +type ResponseLike = { + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}; + +type TwitterClientUploadPrivate = TwitterClient & { + sleep: (ms: number) => Promise; + extractArticleText: (result: unknown) => string | undefined; +}; + +const makeResponse = (overrides: Partial = {}): ResponseLike => ({ + ok: true, + status: 200, + json: async (): Promise => ({}), + text: async (): Promise => '', + ...overrides, +}); + +describe('TwitterClient upload coverage', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + delete process.env.BIRD_DEBUG_ARTICLE; + vi.restoreAllMocks(); + }); + + describe('uploadMedia error paths', () => { + it('rejects unsupported media types', async () => { + global.fetch = vi.fn() as unknown as typeof fetch; + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.uploadMedia({ data: new Uint8Array([1, 2, 3]), mimeType: 'application/pdf' }); + + expect(result.success).toBe(false); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('returns an error when INIT fails', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 500'); + }); + + it('returns an error when APPEND fails', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) })) + .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 500'); + }); + + it('returns an error when FINALIZE fails', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) })) + .mockResolvedValueOnce(makeResponse()) + .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 500'); + }); + + it('returns an error when media processing fails', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) })) + .mockResolvedValueOnce(makeResponse()) + .mockResolvedValueOnce( + makeResponse({ + json: async () => ({ processing_info: { state: 'failed', error: { message: 'processing failed' } } }), + }), + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('processing failed'); + }); + + it('returns an error when STATUS fails', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) })) + .mockResolvedValueOnce(makeResponse()) + .mockResolvedValueOnce( + makeResponse({ + json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }), + }), + ) + .mockResolvedValueOnce(makeResponse({ ok: false, status: 503, text: async () => 'down' })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientUploadPrivate; + clientPrivate.sleep = vi.fn().mockResolvedValue(undefined); + + const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 503'); + }); + + it('retries STATUS and continues when processing is pending', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) })) + .mockResolvedValueOnce(makeResponse()) + .mockResolvedValueOnce( + makeResponse({ + json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }), + }), + ) + .mockResolvedValueOnce( + makeResponse({ + json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }), + }), + ) + .mockResolvedValueOnce(makeResponse({ json: async () => ({ processing_info: { state: 'succeeded' } }) })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientUploadPrivate; + clientPrivate.sleep = vi.fn().mockResolvedValue(undefined); + + const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' }); + + expect(result.success).toBe(true); + }); + + it('returns an error when metadata upload fails', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) })) + .mockResolvedValueOnce(makeResponse()) + .mockResolvedValueOnce(makeResponse({ json: async () => ({}) })) + .mockResolvedValueOnce(makeResponse({ ok: false, status: 400, text: async () => 'bad' })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png', alt: 'alt' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 400'); + }); + + it('returns an error when upload throws', async () => { + const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom')); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' }); + + expect(result.success).toBe(false); + expect(result.error).toContain('boom'); + }); + }); + + describe('article extraction edge cases', () => { + it('logs article payloads when debug flag is set', () => { + const client = new TwitterClient({ cookies: validCookies }); + process.env.BIRD_DEBUG_ARTICLE = '1'; + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const clientPrivate = client as unknown as TwitterClientUploadPrivate; + const result = clientPrivate.extractArticleText({ + rest_id: '1', + article: { + title: 'Title', + plain_text: 'Body', + article_results: { result: { title: 'Title', plain_text: 'Body' } }, + }, + }); + + expect(result).toBe('Title\n\nBody'); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('drops duplicate body when it matches the title', () => { + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientUploadPrivate; + const result = clientPrivate.extractArticleText({ + rest_id: '1', + article: { + title: 'Same', + plain_text: 'Same', + article_results: { result: { title: 'Same', plain_text: 'Same' } }, + }, + }); + + expect(result).toBe('Same'); + }); + }); +}); diff --git a/tests/version.test.ts b/tests/version.test.ts index 75d6439..9bb2422 100644 --- a/tests/version.test.ts +++ b/tests/version.test.ts @@ -3,7 +3,13 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; -import { FALLBACK_VERSION, formatVersionLine, resolveGitSha, resolvePackageVersion } from '../src/lib/version.js'; +import { + FALLBACK_VERSION, + formatVersionLine, + getCliVersion, + resolveGitSha, + resolvePackageVersion, +} from '../src/lib/version.js'; function withTempDir(fn: (dir: string) => T): T { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bird-version-test-')); @@ -53,6 +59,17 @@ describe('getCliVersion', () => { }); }); + it('falls back to VERSION file when package.json has no version', () => { + withTempDir((dir) => { + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({})); + fs.writeFileSync(path.join(dir, 'VERSION'), '5.5.5\n'); + const entry = path.join(dir, 'entry.ts'); + fs.writeFileSync(entry, '// noop'); + + expect(resolvePackageVersion(pathToFileURL(entry).href)).toBe('5.5.5'); + }); + }); + it('falls back to unknown when no version files exist', () => { withTempDir((dir) => { const entry = path.join(dir, 'a', 'b', 'c', 'entry.ts'); @@ -63,6 +80,31 @@ describe('getCliVersion', () => { }); }); + it('returns null when git HEAD is missing', () => { + withTempDir((dir) => { + const gitDir = path.join(dir, '.git'); + fs.mkdirSync(gitDir, { recursive: true }); + + const entry = path.join(dir, 'entry.ts'); + fs.writeFileSync(entry, '// noop'); + + expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull(); + }); + }); + + it('returns null when git ref cannot be resolved', () => { + withTempDir((dir) => { + const gitDir = path.join(dir, '.git'); + fs.mkdirSync(gitDir, { recursive: true }); + fs.writeFileSync(path.join(gitDir, 'HEAD'), 'ref: refs/heads/main\n'); + + const entry = path.join(dir, 'entry.ts'); + fs.writeFileSync(entry, '// noop'); + + expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull(); + }); + }); + it('resolves git sha from .git directory HEAD', () => { withTempDir((dir) => { const gitDir = path.join(dir, '.git'); @@ -113,6 +155,16 @@ describe('getCliVersion', () => { }); }); + it('returns null when no git directory exists', () => { + withTempDir((dir) => { + const entry = path.join(dir, 'nested', 'entry.ts'); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(entry, '// noop'); + + expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull(); + }); + }); + it('formats version line with version + sha when both available', () => { withTempDir((dir) => { fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '3.3.3' })); @@ -126,4 +178,10 @@ describe('getCliVersion', () => { expect(formatVersionLine(pathToFileURL(entry).href)).toBe('3.3.3 (dddddddd)'); }); }); + + it('returns a CLI version string', () => { + const value = getCliVersion(); + expect(typeof value).toBe('string'); + expect(value.length).toBeGreaterThan(0); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index a2db739..ab6c83c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,10 +11,10 @@ export default defineConfig({ include: ['src/**/*.ts'], exclude: ['src/index.ts'], thresholds: { - statements: 70, - branches: 70, - functions: 70, - lines: 70, + statements: 90, + branches: 80, + functions: 90, + lines: 90, }, }, },