diff --git a/scripts/lib/vendor/bird-search/lib/cookies.js b/scripts/lib/vendor/bird-search/lib/cookies.js index 49789a1..636fc82 100644 --- a/scripts/lib/vendor/bird-search/lib/cookies.js +++ b/scripts/lib/vendor/bird-search/lib/cookies.js @@ -2,11 +2,13 @@ * Browser cookie extraction for Twitter authentication. * Delegates to @steipete/sweet-cookie for Safari/Chrome/Firefox reads. */ -import { getCookies } from '@steipete/sweet-cookie'; const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0']; const TWITTER_URL = 'https://x.com/'; const TWITTER_ORIGINS = ['https://x.com/', 'https://twitter.com/']; const DEFAULT_COOKIE_TIMEOUT_MS = 30_000; +async function loadSweetCookie() { + return import('@steipete/sweet-cookie'); +} function normalizeValue(value) { if (typeof value !== 'string') { return null; @@ -79,6 +81,7 @@ function pickCookieValue(cookies, name) { async function readTwitterCookiesFromBrowser(options) { const warnings = []; const out = buildEmpty(); + const { getCookies } = options; const { cookies, warnings: providerWarnings } = await getCookies({ url: TWITTER_URL, origins: TWITTER_ORIGINS, @@ -165,8 +168,27 @@ export async function resolveCredentials(options) { return { cookies, warnings }; } const sourcesToTry = resolveSources(options.cookieSource); + let getCookies; + try { + ({ getCookies } = await loadSweetCookie()); + } + catch (error) { + if (error?.code !== 'ERR_MODULE_NOT_FOUND' || + !String(error.message ?? '').includes('@steipete/sweet-cookie')) { + throw error; + } + warnings.push('Browser cookie lookup unavailable because vendored dependency @steipete/sweet-cookie is not installed.'); + if (!cookies.authToken) { + warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox'); + } + if (!cookies.ct0) { + warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox'); + } + return { cookies, warnings }; + } for (const source of sourcesToTry) { const res = await readTwitterCookiesFromBrowser({ + getCookies, source, chromeProfile: options.chromeProfile, firefoxProfile: options.firefoxProfile, diff --git a/tests/test_bird_x.py b/tests/test_bird_x.py index b50c9b6..20ad216 100644 --- a/tests/test_bird_x.py +++ b/tests/test_bird_x.py @@ -1,3 +1,7 @@ +import json +import os +import shutil +import subprocess import sys import unittest from pathlib import Path @@ -7,6 +11,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) from lib.bird_x import parse_bird_response +REPO_ROOT = Path(__file__).resolve().parents[1] +VENDORED_BIRD = REPO_ROOT / "scripts" / "lib" / "vendor" / "bird-search" / "bird-search.mjs" + + class TestBirdXEngagementZero(unittest.TestCase): def test_zero_likes_preserved(self): tweets = [ @@ -22,6 +30,49 @@ class TestBirdXEngagementZero(unittest.TestCase): self.assertEqual(0, items[0]["engagement"]["likes"]) self.assertEqual(5, items[0]["engagement"]["reposts"]) + +@unittest.skipUnless(shutil.which("node"), "node is required for vendored Bird tests") +class TestVendoredBirdRuntime(unittest.TestCase): + def test_check_uses_env_credentials_without_browser_cookie_dependency(self): + env = os.environ.copy() + env["AUTH_TOKEN"] = "dummy-auth" + env["CT0"] = "dummy-ct0" + + result = subprocess.run( + ["node", str(VENDORED_BIRD), "--check"], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(0, result.returncode, result.stderr) + payload = json.loads(result.stdout) + self.assertTrue(payload["authenticated"]) + self.assertEqual("env AUTH_TOKEN", payload["source"]) + + def test_check_with_browser_lookup_disabled_returns_json_warnings(self): + env = os.environ.copy() + env.pop("AUTH_TOKEN", None) + env.pop("CT0", None) + env["BIRD_DISABLE_BROWSER_COOKIES"] = "1" + + result = subprocess.run( + ["node", str(VENDORED_BIRD), "--check"], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(1, result.returncode, result.stderr) + payload = json.loads(result.stdout) + self.assertFalse(payload["authenticated"]) + self.assertTrue(payload["warnings"]) + self.assertIn("Missing auth_token", " ".join(payload["warnings"])) + def test_none_likes_when_missing(self): tweets = [ {