diff --git a/CHANGELOG.md b/CHANGELOG.md index 05c54ca..296c4c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,31 @@ # Changelog -## 0.4.1 — Unreleased +## 0.4.2 — Unreleased + +### Added +- `likes` command to list your liked tweets (thanks @swairshah). + +### Changed +- Query ID updater now tracks the Likes GraphQL operation. + +## 0.4.1 — 2025-12-31 + +### Added +- `bookmarks` command to list your bookmarked tweets. +- `bookmarks --folder-id` to fetch bookmark folders (thanks @tylerseymour). ### Added - Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml). ### Changed - Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`). +- Query ID updater now tracks the Bookmarks GraphQL operation. +- Lint rules stricter (block statements, no-negation-else, useConst/useTemplate, top-level regex, import extension enforcement). +- `pnpm lint` now runs both Biome and oxlint (type-aware). + +### Tests +- Coverage thresholds raised to 90% statements/lines/functions (80% branches). +- Added targeted Twitter client coverage suites. ## 0.4.0 — 2025-12-26 diff --git a/README.md b/README.md index 12879fd..a61ad9c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,13 @@ bird search "from:steipete" -n 5 bird mentions -n 5 bird mentions --user @steipete -n 5 +# Bookmarks +bird bookmarks -n 5 +bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/ + +# Likes +bird likes -n 5 + # Refresh GraphQL query IDs cache (no rebuild) bird query-ids --fresh ``` @@ -64,6 +71,8 @@ bird query-ids --fresh - `bird thread [--json]` — show the full conversation thread. - `bird search "" [-n count] [--json]` — search for tweets matching a query. - `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user). +- `bird bookmarks [-n count] [--folder-id id] [--json]` — list your bookmarked tweets (or a specific bookmark folder). +- `bird likes [-n count] [--json]` — list your liked tweets. - `bird whoami` — print which Twitter account your cookies belong to. - `bird check` — show which credentials are available and where they were sourced from. @@ -120,7 +129,7 @@ Environment shortcuts: ## Output -- `--json` prints raw tweet objects for read/replies/thread/search/mentions. +- `--json` prints raw tweet objects for read/replies/thread/search/mentions/bookmarks. - `read` returns full text for Notes and Articles when present. - Use `--plain` for stable, script-friendly output (no emoji, no color). diff --git a/biome.json b/biome.json index 5139e91..8b33966 100644 --- a/biome.json +++ b/biome.json @@ -11,10 +11,31 @@ "rules": { "recommended": true, "complexity": { - "noForEach": "off" + "noForEach": "error" + }, + "correctness": { + "useImportExtensions": { + "level": "error", + "options": { + "forceJsExtensions": true + } + } + }, + "performance": { + "useTopLevelRegex": "error" }, "style": { - "noNonNullAssertion": "off" + "noNegationElse": "error", + "noNonNullAssertion": "error", + "useBlockStatements": "error", + "useConst": "error", + "useTemplate": "error" + }, + "suspicious": { + "noExplicitAny": "error" + }, + "nursery": { + "useRegexpExec": "error" } } }, diff --git a/package.json b/package.json index e05dff0..b5498f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@steipete/bird", - "version": "0.4.0", + "version": "0.4.1", "description": "CLI tool for tweeting and replying via Twitter/X GraphQL API", "type": "module", "main": "dist/index.js", @@ -21,14 +21,18 @@ "bird": "pnpm run build:dist && node dist/index.js", "test": "vitest run", "test:watch": "vitest", - "lint": "biome check .", - "lint:fix": "biome check --write .", + "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", + "lint:fix": "pnpm run lint:biome:fix && pnpm run lint:oxlint:fix", + "lint:biome:fix": "biome check --write .", + "lint:oxlint:fix": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings --fix src tests scripts", "format": "biome format --write .", "binary": "pnpm run build:binary", "graphql:update": "tsx scripts/update-query-ids.ts" }, "dependencies": { - "@steipete/sweet-cookie": "github:steipete/sweet-cookie#path:packages/core", + "@steipete/sweet-cookie": "0.1.0", "commander": "^14.0.2", "json5": "^2.2.3", "kleur": "^4.1.5" @@ -37,11 +41,13 @@ "@biomejs/biome": "^2.3.10", "@types/node": "^25.0.3", "@vitest/coverage-v8": "4.0.16", + "oxlint": "^1.36.0", + "oxlint-tsgolint": "^0.10.0", "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.16" }, "engines": { - "node": ">=20" + "node": ">=22" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb08df8..65c9fbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@steipete/sweet-cookie': - specifier: github:steipete/sweet-cookie#path:packages/core - version: https://codeload.github.com/steipete/sweet-cookie/tar.gz/62b5e7815c792674d3414aa8ae1face5992c40ec#path:packages/core + specifier: 0.1.0 + version: 0.1.0 commander: specifier: ^14.0.2 version: 14.0.2 @@ -30,6 +30,12 @@ importers: '@vitest/coverage-v8': specifier: 4.0.16 version: 4.0.16(vitest@4.0.16(@types/node@25.0.3)(tsx@4.21.0)) + oxlint: + specifier: ^1.36.0 + version: 1.36.0(oxlint-tsgolint@0.10.0) + oxlint-tsgolint: + specifier: ^0.10.0 + version: 0.10.0 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -282,6 +288,76 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@oxlint-tsgolint/darwin-arm64@0.10.0': + resolution: {integrity: sha512-mhBF/pjey0UdLL1ocU46Fqta+uJuRfqrLfDpcViRg17BtDiUNd8JY9iN2FOoS2HGSCAgCUjZ0AZkwkHwFs/VTw==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@0.10.0': + resolution: {integrity: sha512-roLi34mw/i1z+NS7luboix55SXyhVv38dNUTcRDkk+0lNPzI9ngrM+1y1N2oBSUmz5o9OZGnfJJ7BSGCw/fFEQ==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@0.10.0': + resolution: {integrity: sha512-HL9NThPH1V2F6l9XhwNmhQZUknN4m4yQYEvQFFGfZTYN6cvEEBIiqfF4KvBUg8c0xadMbQlW+Ug7/ybA9Nn+CA==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@0.10.0': + resolution: {integrity: sha512-Tw8QNq8ab+4+qE5krvJyMA66v6XE3GoiISRD5WmJ7YOxUnu//jSw/bBm7OYf/TNEZyeV0BTR7zXzhT5R+VFWlQ==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@0.10.0': + resolution: {integrity: sha512-LTogmTRwpwQqVaH1Ama8Wd5/VVZWBSF8v5qTbeT628+1F5Kt1V5eHBvyFh4oN18UCZlgqrh7DqkDhsieXUaC8Q==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@0.10.0': + resolution: {integrity: sha512-ygqxx8EmNWy9/wCQS5uXq9k/o2EyYNwNxY1ZHNzlmZC/kV06Aemx5OBDafefawBNqH7xTZPfccUrjdiy+QlTrw==} + cpu: [x64] + os: [win32] + + '@oxlint/darwin-arm64@1.36.0': + resolution: {integrity: sha512-MJkj82GH+nhvWKJhSIM6KlZ8tyGKdogSQXtNdpIyP02r/tTayFJQaAEWayG2Jhsn93kske+nimg5MYFhwO/rlg==} + cpu: [arm64] + os: [darwin] + + '@oxlint/darwin-x64@1.36.0': + resolution: {integrity: sha512-VvEhfkqj/99dCTqOcfkyFXOSbx4lIy5u2m2GHbK4WCMDySokOcMTNRHGw8fH/WgQ5cDrDMSTYIGQTmnBGi9tiQ==} + cpu: [x64] + os: [darwin] + + '@oxlint/linux-arm64-gnu@1.36.0': + resolution: {integrity: sha512-EMx92X5q+hHc3olTuj/kgkx9+yP0p/AVs4yvHbUfzZhBekXNpUWxWvg4hIKmQWn+Ee2j4o80/0ACGO0hDYJ9mg==} + cpu: [arm64] + os: [linux] + + '@oxlint/linux-arm64-musl@1.36.0': + resolution: {integrity: sha512-7YCxtrPIctVYLqWrWkk8pahdCxch6PtsaucfMLC7TOlDt4nODhnQd4yzEscKqJ8Gjrw1bF4g+Ngob1gB+Qr9Fw==} + cpu: [arm64] + os: [linux] + + '@oxlint/linux-x64-gnu@1.36.0': + resolution: {integrity: sha512-lnaJVlx5r3NWmoOMesfQXJSf78jHTn8Z+sdAf795Kgteo72+qGC1Uax2SToCJVN2J8PNG3oRV5bLriiCNR2i6Q==} + cpu: [x64] + os: [linux] + + '@oxlint/linux-x64-musl@1.36.0': + resolution: {integrity: sha512-AhuEU2Qdl66lSfTGu/Htirq8r/8q2YnZoG3yEXLMQWnPMn7efy8spD/N1NA7kH0Hll+cdfwgQkQqC2G4MS2lPQ==} + cpu: [x64] + os: [linux] + + '@oxlint/win32-arm64@1.36.0': + resolution: {integrity: sha512-GlWCBjUJY2QgvBFuNRkiRJu7K/djLmM0UQKfZV8IN+UXbP/JbjZHWKRdd4LXlQmzoz7M5Hd6p+ElCej8/90FCg==} + cpu: [arm64] + os: [win32] + + '@oxlint/win32-x64@1.36.0': + resolution: {integrity: sha512-J+Vc00Utcf8p77lZPruQgb0QnQXuKnFogN88kCnOqs2a83I+vTBB8ILr0+L9sTwVRvIDMSC0pLdeQH4svWGFZg==} + cpu: [x64] + os: [win32] + '@rollup/rollup-android-arm-eabi@4.53.5': resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} cpu: [arm] @@ -395,15 +471,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@steipete/sweet-cookie@https://codeload.github.com/steipete/sweet-cookie/tar.gz/62b5e7815c792674d3414aa8ae1face5992c40ec#path:packages/core': - resolution: {path: packages/core, tarball: https://codeload.github.com/steipete/sweet-cookie/tar.gz/62b5e7815c792674d3414aa8ae1face5992c40ec} - version: 0.0.0 + '@steipete/sweet-cookie@0.1.0': + resolution: {integrity: sha512-cF8hS6rTohAFI1NAm2/HM9omBlw6V1DpwF6q5e9FB+l274HjtyirCgCc6QK2Rtuh7uDoqWHshFQZPsceyJuVgw==} engines: {node: '>=22'} - peerDependencies: - chrome-cookies-secure: ^3.0.0 - peerDependenciesMeta: - chrome-cookies-secure: - optional: true '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -567,6 +637,20 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + oxlint-tsgolint@0.10.0: + resolution: {integrity: sha512-LDDSIu5J/4D4gFUuQQIEQpAC6maNEbMg4nC8JL/+Pe0cUDR86dtVZ09E2x5MwCh8f9yfktoaxt5x6UIVyzrajg==} + hasBin: true + + oxlint@1.36.0: + resolution: {integrity: sha512-IicUdXfXgI8OKrDPnoSjvBfeEF8PkKtm+CoLlg4LYe4ypc8U+T4r7730XYshdBGZdelg+JRw8GtCb2w/KaaZvw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.10.0' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -857,6 +941,48 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@oxlint-tsgolint/darwin-arm64@0.10.0': + optional: true + + '@oxlint-tsgolint/darwin-x64@0.10.0': + optional: true + + '@oxlint-tsgolint/linux-arm64@0.10.0': + optional: true + + '@oxlint-tsgolint/linux-x64@0.10.0': + optional: true + + '@oxlint-tsgolint/win32-arm64@0.10.0': + optional: true + + '@oxlint-tsgolint/win32-x64@0.10.0': + optional: true + + '@oxlint/darwin-arm64@1.36.0': + optional: true + + '@oxlint/darwin-x64@1.36.0': + optional: true + + '@oxlint/linux-arm64-gnu@1.36.0': + optional: true + + '@oxlint/linux-arm64-musl@1.36.0': + optional: true + + '@oxlint/linux-x64-gnu@1.36.0': + optional: true + + '@oxlint/linux-x64-musl@1.36.0': + optional: true + + '@oxlint/win32-arm64@1.36.0': + optional: true + + '@oxlint/win32-x64@1.36.0': + optional: true + '@rollup/rollup-android-arm-eabi@4.53.5': optional: true @@ -925,7 +1051,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@steipete/sweet-cookie@https://codeload.github.com/steipete/sweet-cookie/tar.gz/62b5e7815c792674d3414aa8ae1face5992c40ec#path:packages/core': {} + '@steipete/sweet-cookie@0.1.0': {} '@types/chai@5.2.3': dependencies: @@ -1111,6 +1237,27 @@ snapshots: obug@2.1.1: {} + oxlint-tsgolint@0.10.0: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 0.10.0 + '@oxlint-tsgolint/darwin-x64': 0.10.0 + '@oxlint-tsgolint/linux-arm64': 0.10.0 + '@oxlint-tsgolint/linux-x64': 0.10.0 + '@oxlint-tsgolint/win32-arm64': 0.10.0 + '@oxlint-tsgolint/win32-x64': 0.10.0 + + oxlint@1.36.0(oxlint-tsgolint@0.10.0): + optionalDependencies: + '@oxlint/darwin-arm64': 1.36.0 + '@oxlint/darwin-x64': 1.36.0 + '@oxlint/linux-arm64-gnu': 1.36.0 + '@oxlint/linux-arm64-musl': 1.36.0 + '@oxlint/linux-x64-gnu': 1.36.0 + '@oxlint/linux-x64-musl': 1.36.0 + '@oxlint/win32-arm64': 1.36.0 + '@oxlint/win32-x64': 1.36.0 + oxlint-tsgolint: 0.10.0 + pathe@2.0.3: {} picocolors@1.1.1: {} diff --git a/scripts/update-query-ids.ts b/scripts/update-query-ids.ts index 91b062f..f809d26 100644 --- a/scripts/update-query-ids.ts +++ b/scripts/update-query-ids.ts @@ -13,6 +13,8 @@ const TARGET_OPERATIONS = [ 'FavoriteTweet', 'TweetDetail', 'SearchTimeline', + 'Bookmarks', + 'Likes', ] as const; type OperationName = (typeof TARGET_OPERATIONS)[number]; diff --git a/src/index.ts b/src/index.ts index 86fc042..c969b0f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import JSON5 from 'json5'; import kleur from 'kleur'; import { resolveCliInvocation } from './lib/cli-args.js'; import { type CookieSource, resolveCredentials } from './lib/cookies.js'; +import { extractBookmarkFolderId } from './lib/extract-bookmark-folder-id.js'; import { extractTweetId } from './lib/extract-tweet-id.js'; import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js'; import { @@ -32,16 +33,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 +54,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 +77,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; @@ -103,7 +113,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; @@ -140,6 +152,7 @@ const KNOWN_COMMANDS = new Set([ 'search', 'mentions', 'bookmarks', + 'likes', 'help', 'whoami', 'check', @@ -152,7 +165,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( @@ -194,7 +207,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); @@ -215,9 +228,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; } @@ -241,17 +258,31 @@ function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number }) 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); @@ -263,9 +294,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; } @@ -327,6 +364,7 @@ program 'SearchTimeline', 'UserArticlesTweets', 'Bookmarks', + 'Likes', ]; if (cmdOpts.fresh) { @@ -675,13 +713,13 @@ program if (!query) { const who = await client.getCurrentUser(); const handle = normalizeHandle(who.user?.username); - if (!handle) { + if (handle) { + query = `@${handle}`; + } else { console.error( `${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user .`, ); process.exit(1); - } else { - query = `@${handle}`; } } @@ -700,6 +738,48 @@ program .command('bookmarks') .description('Get your bookmarked tweets') .option('-n, --count ', 'Number of bookmarks to fetch', '20') + .option('--folder-id ', 'Bookmark folder (collection) id') + .option('--json', 'Output as JSON') + .action(async (cmdOpts: { count?: string; json?: boolean; folderId?: string }) => { + const opts = program.opts(); + const timeoutMs = resolveTimeoutFromOptions(opts); + const count = Number.parseInt(cmdOpts.count || '20', 10); + + const { cookies, warnings } = await resolveCredentialsFromOptions(opts); + + for (const warning of warnings) { + console.error(`${p('warn')}${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error(`${p('err')}Missing required credentials`); + process.exit(1); + } + + const client = new TwitterClient({ cookies, timeoutMs }); + const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null; + if (cmdOpts.folderId && !folderId) { + console.error(`${p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/.`); + process.exit(1); + } + const result = folderId + ? await client.getBookmarkFolderTimeline(folderId, count) + : await client.getBookmarks(count); + + if (result.success && result.tweets) { + const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.'; + printTweets(result.tweets, { json: cmdOpts.json, emptyMessage }); + } else { + console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`); + process.exit(1); + } + }); + +// Likes command - get user's liked tweets +program + .command('likes') + .description('Get your liked tweets') + .option('-n, --count ', 'Number of likes to fetch', '20') .option('--json', 'Output as JSON') .action(async (cmdOpts: { count?: string; json?: boolean }) => { const opts = program.opts(); @@ -719,12 +799,12 @@ program } const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); - const result = await client.getBookmarks(count); + const result = await client.getLikes(count); if (result.success && result.tweets) { - printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No bookmarks found.' }); + printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No liked tweets found.' }); } else { - console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`); + console.error(`${p('err')}Failed to fetch likes: ${result.error}`); process.exit(1); } }); 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-bookmark-folder-id.ts b/src/lib/extract-bookmark-folder-id.ts new file mode 100644 index 0000000..0e47c88 --- /dev/null +++ b/src/lib/extract-bookmark-folder-id.ts @@ -0,0 +1,20 @@ +/** + * Extract bookmark folder (collection) ID from an X/Twitter bookmarks URL. + */ +const BOOKMARK_FOLDER_URL_REGEX = /(?:twitter\.com|x\.com)\/i\/bookmarks\/(\d+)/i; +const BOOKMARK_FOLDER_ID_REGEX = /^\d{5,}$/; + +export function extractBookmarkFolderId(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) { + return null; + } + const urlMatch = BOOKMARK_FOLDER_URL_REGEX.exec(trimmed); + if (urlMatch) { + return urlMatch[1]; + } + if (BOOKMARK_FOLDER_ID_REGEX.test(trimmed)) { + return trimmed; + } + return null; +} 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/query-ids.json b/src/lib/query-ids.json index d981057..119b852 100644 --- a/src/lib/query-ids.json +++ b/src/lib/query-ids.json @@ -3,5 +3,6 @@ "CreateRetweet": "LFho5rIi4xcKO90p9jwG7A", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "TweetDetail": "97JF30KziU00483E_8elBA", - "SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ" + "SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ", + "Likes": "JR2gceKucIKcVNB_9JkhsA" } diff --git a/src/lib/runtime-query-ids.ts b/src/lib/runtime-query-ids.ts index 9dfbf90..4016d2b 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)) { @@ -120,9 +136,7 @@ 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 +224,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 +242,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 +257,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 +269,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 +279,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 96c20e9..6ef1878 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 @@ -24,6 +28,8 @@ const FALLBACK_QUERY_IDS = { SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw', + Likes: 'JR2gceKucIKcVNB_9JkhsA', + BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ', } as const; type OperationName = keyof typeof FALLBACK_QUERY_IDS; @@ -356,7 +362,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 || []) { @@ -418,10 +426,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; } @@ -431,7 +443,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({ @@ -459,7 +473,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; @@ -515,7 +531,10 @@ export class TwitterClient { const delaySecs = Number.isFinite(info.check_after_secs) ? Math.max(1, info.check_after_secs as number) : 2; await this.sleep(delaySecs * 1000); - const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({ command: 'STATUS', media_id: mediaId })}`; + const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({ + command: 'STATUS', + media_id: mediaId, + }).toString()}`; const statusResp = await this.fetchWithTimeout(statusUrl, { method: 'GET', headers: this.getUploadHeaders(), @@ -533,11 +552,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; } } @@ -564,15 +587,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) { @@ -586,7 +615,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; } } @@ -599,7 +630,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); } @@ -608,7 +641,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') { @@ -673,7 +708,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, @@ -704,10 +741,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; + } let quotedTweet: TweetData | undefined; if (quoteDepth > 0) { @@ -774,7 +815,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; @@ -841,7 +884,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); } @@ -988,7 +1033,7 @@ export class TwitterClient { }); const queryId = await this.getQueryId('UserArticlesTweets'); - const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params}`; + const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params.toString()}`; try { const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() }); @@ -1022,7 +1067,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); @@ -1131,7 +1178,7 @@ export class TwitterClient { const queryIds = await this.getTweetDetailQueryIds(); for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params}`; + const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params.toString()}`; const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders(), @@ -1384,12 +1431,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' }; } @@ -1407,7 +1458,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), @@ -1446,7 +1499,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 = @@ -1518,7 +1573,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) }; @@ -1529,12 +1586,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, @@ -1543,8 +1606,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; @@ -1574,7 +1641,7 @@ export class TwitterClient { const queryIds = await this.getSearchTimelineQueryIds(); for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`; + const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params.toString()}`; try { const response = await this.fetchWithTimeout(url, { @@ -1654,12 +1721,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 }; } @@ -1761,9 +1832,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]; @@ -1797,7 +1868,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); @@ -1811,7 +1884,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); @@ -1829,10 +1904,10 @@ export class TwitterClient { return { success: true, tweets: thread }; } - private buildBookmarksFeatures(): Record { + // Shared timeline features for likes/bookmarks-style timelines. + private buildTimelineFeatures(): Record { return { ...this.buildSearchFeatures(), - graphql_timeline_v2_bookmark_timeline: true, blue_business_profile_image_shape_enabled: true, responsive_web_text_conversations_enabled: false, tweetypie_unmention_optimization_enabled: true, @@ -1844,11 +1919,27 @@ export class TwitterClient { }; } + private buildBookmarksFeatures(): Record { + return { + ...this.buildTimelineFeatures(), + graphql_timeline_v2_bookmark_timeline: true, + }; + } + + private buildLikesFeatures(): Record { + return this.buildTimelineFeatures(); + } + private async getBookmarksQueryIds(): Promise { const primary = await this.getQueryId('Bookmarks'); return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw'])); } + private async getBookmarkFolderQueryIds(): Promise { + const primary = await this.getQueryId('BookmarkFolderTimeline'); + return Array.from(new Set([primary, 'KJIQpsvxrTfRIlbaRIySHQ'])); + } + /** * Get the authenticated user's bookmarks */ @@ -1874,7 +1965,7 @@ export class TwitterClient { const queryIds = await this.getBookmarksQueryIds(); for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params}`; + const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`; try { const response = await this.fetchWithTimeout(url, { @@ -1931,12 +2022,235 @@ 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 }; + } + + return { success: false, error: firstAttempt.error }; + } + private async getLikesQueryIds(): Promise { + const primary = await this.getQueryId('Likes'); + return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA'])); + } + + /** + * Get the authenticated user's liked tweets + */ + async getLikes(count = 20): Promise { + const userResult = await this.getCurrentUser(); + if (!userResult.success || !userResult.user) { + return { success: false, error: userResult.error ?? 'Could not determine current user' }; + } + + const variables = { + userId: userResult.user.id, + count, + includePromotedContent: false, + withClientEventToken: false, + withBirdwatchNotes: false, + withVoice: true, + }; + + const features = this.buildLikesFeatures(); + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getLikesQueryIds(); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/Likes?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + user?: { + result?: { + timeline?: { + timeline?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + }>; + }>; + }; + }; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; + const tweets = this.parseTweetsFromInstructions(instructions); + + return { success: true as const, tweets, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error fetching likes', had404 }; + }; + + const firstAttempt = await tryOnce(); + 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 }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; + } + + /** + * Get the authenticated user's bookmark folder timeline + */ + async getBookmarkFolderTimeline(folderId: string, count = 20): Promise { + const variablesWithCount = { + bookmark_collection_id: folderId, + includePromotedContent: true, + count, + }; + + const variablesWithoutCount = { + bookmark_collection_id: folderId, + includePromotedContent: true, + }; + + const features = this.buildBookmarksFeatures(); + + const tryOnce = async (variables: Record) => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getBookmarkFolderQueryIds(); + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + bookmark_collection_timeline?: { + timeline?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + }>; + }>; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions; + const tweets = this.parseTweetsFromInstructions(instructions); + + return { success: true as const, tweets, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', had404 }; + }; + + let firstAttempt = await tryOnce(variablesWithCount); + if (!firstAttempt.success && firstAttempt.error?.includes('Variable "$count"')) { + firstAttempt = await tryOnce(variablesWithoutCount); + } + if (firstAttempt.success) { + return { success: true, tweets: firstAttempt.tweets }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + let secondAttempt = await tryOnce(variablesWithCount); + if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) { + secondAttempt = await tryOnce(variablesWithoutCount); + } + 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/cli.test.ts b/tests/cli.test.ts index 9c62bc4..a3f5a9d 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { extractBookmarkFolderId } from '../src/lib/extract-bookmark-folder-id.js'; import { extractTweetId } from '../src/lib/extract-tweet-id.js'; describe('CLI utilities', () => { @@ -29,4 +30,30 @@ describe('CLI utilities', () => { expect(extractTweetId(url)).toBe('1234567890123456789'); }); }); + + describe('extractBookmarkFolderId', () => { + it('should extract ID from x.com bookmarks URL', () => { + const url = 'https://x.com/i/bookmarks/1976792203235119344'; + expect(extractBookmarkFolderId(url)).toBe('1976792203235119344'); + }); + + it('should extract ID from twitter.com bookmarks URL', () => { + const url = 'https://twitter.com/i/bookmarks/1976792203235119344'; + expect(extractBookmarkFolderId(url)).toBe('1976792203235119344'); + }); + + it('should accept a numeric ID as-is', () => { + const id = '1976792203235119344'; + expect(extractBookmarkFolderId(id)).toBe('1976792203235119344'); + }); + + it('should return null for invalid values', () => { + expect(extractBookmarkFolderId('not-an-id')).toBeNull(); + }); + + it('should return null for folder_id query URLs', () => { + const url = 'https://x.com/i/bookmarks?folder_id=1976792203235119344'; + expect(extractBookmarkFolderId(url)).toBeNull(); + }); + }); }); diff --git a/tests/cookies.test.ts b/tests/cookies.test.ts index 23d6ce8..c7c7f6c 100644 --- a/tests/cookies.test.ts +++ b/tests/cookies.test.ts @@ -18,8 +18,6 @@ vi.mock('@steipete/sweet-cookie', () => ({ }), })); -const itIfDarwin = process.platform === 'darwin' ? it : it.skip; - describe('cookies', () => { const originalEnv = process.env; @@ -63,7 +61,10 @@ describe('cookies', () => { expect(result.cookies.source).toContain('Firefox'); }); - itIfDarwin('honors cookieSource=safari', async () => { + it('honors cookieSource=safari', async () => { + if (process.platform !== 'darwin') { + return; + } sweet.results.set('safari', { cookies: [ { name: 'auth_token', value: 'safari_auth', domain: 'x.com' }, @@ -191,10 +192,64 @@ 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', () => { - itIfDarwin('returns cookies from Safari', async () => { + it('returns cookies from Safari', async () => { + if (process.platform !== 'darwin') { + return; + } sweet.results.set('safari', { cookies: [ { name: 'auth_token', value: 'safari_auth', domain: 'x.com' }, @@ -211,7 +266,10 @@ describe('cookies', () => { expect(result.cookies.source).toBe('Safari'); }); - itIfDarwin('prefers Safari over Chrome when both are available', async () => { + it('prefers Safari over Chrome when both are available', async () => { + if (process.platform !== 'darwin') { + return; + } sweet.results.set('safari', { cookies: [ { name: 'auth_token', value: 'safari_auth', domain: 'x.com' }, 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..591aa1b --- /dev/null +++ b/tests/twitter-client-coverage.api.test.ts @@ -0,0 +1,360 @@ +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; + getLikesQueryIds: () => Promise; + getCurrentUser: () => Promise<{ + success: boolean; + user?: { id: string; username: string; name: string }; + error?: string; + }>; + getBookmarkFolderQueryIds: () => 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'); + }); + }); + describe('likes error paths', () => { + const stubCurrentUser = async () => ({ + success: true, + user: { id: '123', username: 'tester', name: 'Tester' }, + }); + + 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 clientPrivate = client as unknown as TwitterClientApiPrivate; + clientPrivate.getCurrentUser = stubCurrentUser; + clientPrivate.getLikesQueryIds = async () => ['test']; + + const result = await client.getLikes(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 clientPrivate = client as unknown as TwitterClientApiPrivate; + clientPrivate.getCurrentUser = stubCurrentUser; + clientPrivate.getLikesQueryIds = async () => ['test']; + + const result = await client.getLikes(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 clientPrivate = client as unknown as TwitterClientApiPrivate; + clientPrivate.getCurrentUser = stubCurrentUser; + clientPrivate.getLikesQueryIds = async () => ['test']; + + const result = await client.getLikes(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.getCurrentUser = stubCurrentUser; + clientPrivate.getLikesQueryIds = async () => []; + + const result = await client.getLikes(1); + + expect(result.success).toBe(false); + expect(result.error).toBe('Unknown error fetching likes'); + }); + + 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 clientPrivate = client as unknown as TwitterClientApiPrivate; + clientPrivate.getCurrentUser = stubCurrentUser; + clientPrivate.getLikesQueryIds = async () => ['test']; + + const result = await client.getLikes(1); + + expect(result.success).toBe(false); + expect(result.error).toBe('HTTP 404'); + }); + }); + describe('bookmark folder 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.getBookmarkFolderTimeline('123', 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.getBookmarkFolderTimeline('123', 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.getBookmarkFolderTimeline('123', 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.getBookmarkFolderQueryIds = async () => []; + + const result = await client.getBookmarkFolderTimeline('123', 1); + + expect(result.success).toBe(false); + expect(result.error).toBe('Unknown error fetching bookmark folder'); + }); + + 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.getBookmarkFolderTimeline('123', 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/twitter-client.test.ts b/tests/twitter-client.test.ts index 645f665..5b86f1f 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -9,6 +9,14 @@ describe('TwitterClient', () => { cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token', source: 'test', }; + type TwitterClientPrivate = TwitterClient & { + getCurrentUser: () => Promise<{ + success: boolean; + user?: { id: string; username: string; name: string }; + error?: string; + }>; + getLikesQueryIds: () => Promise; + }; afterEach(() => { global.fetch = originalFetch; @@ -1161,6 +1169,320 @@ describe('TwitterClient', () => { }); }); + describe('bookmarks', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + it('fetches bookmarks and parses tweet results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + bookmark_timeline_v2: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '1', + legacy: { + full_text: 'saved', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: '1', + }, + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'root', name: 'Root' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getBookmarks(2); + + expect(result.success).toBe(true); + expect(result.tweets?.[0].id).toBe('1'); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('GET'); + expect(String(url)).toContain('/Bookmarks?'); + const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string); + expect(parsedVars.count).toBe(2); + const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string); + expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true); + }); + }); + + describe('likes', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + it('fetches likes and parses tweet results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '2', + legacy: { + full_text: 'liked', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: '2', + }, + core: { + user_results: { + result: { + rest_id: 'u2', + legacy: { screen_name: 'root', name: 'Root' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getCurrentUser = async () => ({ + success: true, + user: { id: '42', username: 'tester', name: 'Tester' }, + }); + clientPrivate.getLikesQueryIds = async () => ['test']; + + const result = await client.getLikes(2); + + expect(result.success).toBe(true); + expect(result.tweets?.[0].id).toBe('2'); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('GET'); + expect(String(url)).toContain('/Likes?'); + const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string); + expect(parsedVars.userId).toBe('42'); + expect(parsedVars.count).toBe(2); + const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string); + expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBeUndefined(); + }); + + it('returns an error when current user is unavailable', async () => { + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getCurrentUser = async () => ({ success: false, error: 'no user' }); + + const result = await client.getLikes(1); + + expect(result.success).toBe(false); + expect(result.error).toBe('no user'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + describe('bookmark folders', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + it('fetches bookmark folder timeline and parses tweet results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + bookmark_collection_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '9', + legacy: { + full_text: 'saved in folder', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: '9', + }, + core: { + user_results: { + result: { + rest_id: 'u9', + legacy: { screen_name: 'folder', name: 'Folder' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getBookmarkFolderTimeline('123', 2); + + expect(result.success).toBe(true); + expect(result.tweets?.[0].id).toBe('9'); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('GET'); + expect(String(url)).toContain('/BookmarkFolderTimeline?'); + const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string); + expect(parsedVars.bookmark_collection_id).toBe('123'); + expect(parsedVars.count).toBe(2); + const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string); + expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true); + }); + + it('retries without count when API rejects the count variable', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + errors: [{ message: 'Variable "$count" is not defined by operation' }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + bookmark_collection_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '9', + legacy: { + full_text: 'saved in folder', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: '9', + }, + core: { + user_results: { + result: { + rest_id: 'u9', + legacy: { screen_name: 'folder', name: 'Folder' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as TwitterClient & { getBookmarkFolderQueryIds: () => Promise }; + clientPrivate.getBookmarkFolderQueryIds = async () => ['test']; + + const result = await client.getBookmarkFolderTimeline('123', 2); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const firstVars = JSON.parse( + new URL(mockFetch.mock.calls[0][0] as string).searchParams.get('variables') as string, + ); + const secondVars = JSON.parse( + new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string, + ); + + expect(firstVars.count).toBe(2); + expect(secondVars.count).toBeUndefined(); + }); + }); describe('conversation helpers', () => { let mockFetch: ReturnType; 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/tsconfig.oxlint.json b/tsconfig.oxlint.json new file mode 100644 index 0000000..48ac963 --- /dev/null +++ b/tsconfig.oxlint.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules", "dist", "coverage"] +} 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, }, }, },