返回 last30days-skill
test_bird_x.py
根目录 / tests / test_bird_x.py
1 import json
2 import os
3 import shutil
4 import subprocess
5 import textwrap
6 import unittest
7 from pathlib import Path
8 from unittest import mock
9
10 from lib.bird_x import parse_bird_response
11
12 REPO_ROOT = Path(__file__).resolve().parents[1]
13 VENDORED_BIRD = REPO_ROOT / "skills" / "last30days" / "scripts" / "lib" / "vendor" / "bird-search" / "bird-search.mjs"
14
15
16 class TestBirdXEngagementZero(unittest.TestCase):
17 def test_zero_likes_preserved(self):
18 tweets = [
19 {
20 "id": "1",
21 "text": "test",
22 "permanent_url": "https://x.com/u/status/1",
23 "likeCount": 0,
24 "retweetCount": 5,
25 }
26 ]
27 items = parse_bird_response(tweets, "test query")
28 self.assertEqual(0, items[0]["engagement"]["likes"])
29 self.assertEqual(5, items[0]["engagement"]["reposts"])
30
31 @unittest.skipUnless(shutil.which("node"), "node is required for vendored Bird tests")
32 class TestVendoredBirdRuntime(unittest.TestCase):
33 def test_check_uses_env_credentials_without_browser_cookie_dependency(self):
34 env = os.environ.copy()
35 env["AUTH_TOKEN"] = "dummy-auth"
36 env["CT0"] = "dummy-ct0"
37
38 result = subprocess.run(
39 ["node", str(VENDORED_BIRD), "--check"],
40 cwd=REPO_ROOT,
41 env=env,
42 capture_output=True,
43 text=True,
44 check=False,
45 )
46
47 self.assertEqual(0, result.returncode, result.stderr)
48 payload = json.loads(result.stdout)
49 self.assertTrue(payload["authenticated"])
50 self.assertEqual("env AUTH_TOKEN", payload["source"])
51
52 def test_check_with_browser_lookup_disabled_returns_json_warnings(self):
53 env = os.environ.copy()
54 env.pop("AUTH_TOKEN", None)
55 env.pop("CT0", None)
56 env["BIRD_DISABLE_BROWSER_COOKIES"] = "1"
57
58 result = subprocess.run(
59 ["node", str(VENDORED_BIRD), "--check"],
60 cwd=REPO_ROOT,
61 env=env,
62 capture_output=True,
63 text=True,
64 check=False,
65 )
66
67 self.assertEqual(1, result.returncode, result.stderr)
68 payload = json.loads(result.stdout)
69 self.assertFalse(payload["authenticated"])
70 self.assertTrue(payload["warnings"])
71 self.assertIn("Missing auth_token", " ".join(payload["warnings"]))
72
73 def test_browser_cookie_helpers_lazy_load_sweet_cookie(self):
74 sweet_cookie_dir = (
75 REPO_ROOT
76 / "skills"
77 / "last30days"
78 / "scripts"
79 / "lib"
80 / "vendor"
81 / "bird-search"
82 / "lib"
83 / "node_modules"
84 / "@steipete"
85 / "sweet-cookie"
86 )
87 if sweet_cookie_dir.exists():
88 self.skipTest("vendored sweet-cookie test stub already exists")
89
90 sweet_cookie_dir.mkdir(parents=True)
91 (sweet_cookie_dir / "package.json").write_text(
92 json.dumps(
93 {
94 "name": "@steipete/sweet-cookie",
95 "type": "module",
96 "exports": "./index.js",
97 }
98 ),
99 encoding="utf-8",
100 )
101 (sweet_cookie_dir / "index.js").write_text(
102 textwrap.dedent(
103 """
104 export async function getCookies(options) {
105 const browser = options.browsers?.[0] ?? "unknown";
106 return {
107 cookies: [
108 { name: "auth_token", value: `${browser}-auth`, domain: "x.com" },
109 { name: "ct0", value: `${browser}-ct0`, domain: "x.com" },
110 ],
111 warnings: [],
112 };
113 }
114 """
115 ),
116 encoding="utf-8",
117 )
118
119 try:
120 result = subprocess.run(
121 [
122 "node",
123 "--input-type=module",
124 "-e",
125 textwrap.dedent(
126 """
127 import {
128 extractCookiesFromSafari,
129 extractCookiesFromChrome,
130 extractCookiesFromFirefox,
131 } from "./skills/last30days/scripts/lib/vendor/bird-search/lib/cookies.js";
132
133 const payload = await Promise.all([
134 extractCookiesFromSafari(),
135 extractCookiesFromChrome("Profile 1"),
136 extractCookiesFromFirefox("default-release"),
137 ]);
138 process.stdout.write(JSON.stringify(payload));
139 """
140 ),
141 ],
142 cwd=REPO_ROOT,
143 capture_output=True,
144 text=True,
145 check=False,
146 )
147
148 self.assertEqual(0, result.returncode, result.stderr)
149 payload = json.loads(result.stdout)
150 self.assertEqual("Safari", payload[0]["cookies"]["source"])
151 self.assertEqual('Chrome profile "Profile 1"', payload[1]["cookies"]["source"])
152 self.assertEqual(
153 'Firefox profile "default-release"', payload[2]["cookies"]["source"]
154 )
155 self.assertEqual("safari-auth", payload[0]["cookies"]["authToken"])
156 self.assertEqual("chrome-auth", payload[1]["cookies"]["authToken"])
157 self.assertEqual("firefox-auth", payload[2]["cookies"]["authToken"])
158 finally:
159 shutil.rmtree(sweet_cookie_dir, ignore_errors=True)
160 for path in [sweet_cookie_dir.parent, sweet_cookie_dir.parent.parent]:
161 try:
162 path.rmdir()
163 except OSError:
164 pass
165
166 def test_none_likes_when_missing(self):
167 tweets = [
168 {
169 "id": "1",
170 "text": "test tweet with no engagement fields",
171 "permanent_url": "https://x.com/u/status/1",
172 # no likeCount, like_count, or favorite_count
173 }
174 ]
175 items = parse_bird_response(tweets, "test query")
176 self.assertIsNone(items[0]["engagement"])
177
178 def test_fallback_to_second_key(self):
179 tweets = [
180 {
181 "id": "1",
182 "text": "test",
183 "permanent_url": "https://x.com/u/status/1",
184 "like_count": 7,
185 }
186 ]
187 items = parse_bird_response(tweets, "test query")
188 self.assertEqual(7, items[0]["engagement"]["likes"])
189
190 def test_zero_does_not_fall_through(self):
191 """likeCount=0 should not fall through to like_count=10."""
192 tweets = [
193 {
194 "id": "1",
195 "text": "test",
196 "permanent_url": "https://x.com/u/status/1",
197 "likeCount": 0,
198 "like_count": 10,
199 }
200 ]
201 items = parse_bird_response(tweets, "test query")
202 self.assertEqual(0, items[0]["engagement"]["likes"])
203
204 def test_engagement_none_when_all_fields_missing(self):
205 """All-None engagement dict should become None, not propagate."""
206 tweets = [
207 {
208 "id": "1",
209 "text": "test",
210 "permanent_url": "https://x.com/u/status/1",
211 }
212 ]
213 items = parse_bird_response(tweets, "test query")
214 self.assertIsNone(items[0]["engagement"])
215
216 def test_engagement_preserved_when_any_field_present(self):
217 """Engagement dict kept when at least one metric exists."""
218 tweets = [
219 {
220 "id": "1",
221 "text": "test",
222 "permanent_url": "https://x.com/u/status/1",
223 "likeCount": 5,
224 }
225 ]
226 items = parse_bird_response(tweets, "test query")
227 self.assertIsNotNone(items[0]["engagement"])
228 self.assertEqual(5, items[0]["engagement"]["likes"])
229
230
231 class TestRunBirdSearchJsonDecodeRetry(unittest.TestCase):
232 """When bird-search returns non-JSON stdout, retry the subprocess.
233
234 Twitter's edge sometimes serves an HTML anti-bot interstitial in place of
235 JSON. Before this fix, that response made json.loads raise JSONDecodeError
236 and the function returned {"items": []} with no diagnostic — silent-empty
237 against an orchestrator that can't distinguish "Twitter blocked us" from
238 "no tweets matched the query."
239 """
240
241 def _make_result(self, stdout: str, stderr: str = "", returncode: int = 0):
242 from lib.subproc import SubprocResult
243 return SubprocResult(returncode=returncode, stdout=stdout, stderr=stderr)
244
245 def test_retries_subprocess_on_html_interstitial_then_succeeds(self):
246 """First subprocess attempt returns HTML; second returns JSON → success."""
247 from unittest import mock
248 from lib import bird_x
249
250 html_interstitial = "<!DOCTYPE html><html><body>Rate limited</body></html>"
251 json_success = '[{"id": "1", "text": "tweet"}]'
252
253 results = [
254 (self._make_result(stdout=html_interstitial), None),
255 (self._make_result(stdout=json_success), None),
256 ]
257
258 with mock.patch.object(bird_x, "_invoke_bird_subprocess", side_effect=results), \
259 mock.patch.object(bird_x.time, "sleep") as mock_sleep:
260 response = bird_x._run_bird_search("test", count=10, timeout=30)
261
262 self.assertNotIn("error", response)
263 self.assertEqual(response["items"], [{"id": "1", "text": "tweet"}])
264 # Should have slept between the failed first attempt and the retry.
265 mock_sleep.assert_called_once_with(bird_x.JSON_DECODE_RETRY_DELAY)
266
267 def test_returns_error_after_all_retries_exhausted(self):
268 """All attempts return HTML → error dict with diagnostic + items=[]."""
269 from unittest import mock
270 from lib import bird_x
271
272 html_interstitial = "<!DOCTYPE html><html>blocked</html>"
273 results = [
274 (self._make_result(stdout=html_interstitial), None),
275 (self._make_result(stdout=html_interstitial), None),
276 ]
277
278 with mock.patch.object(bird_x, "_invoke_bird_subprocess", side_effect=results), \
279 mock.patch.object(bird_x.time, "sleep"):
280 response = bird_x._run_bird_search("test", count=10, timeout=30)
281
282 self.assertIn("error", response)
283 self.assertIn("Invalid JSON response", response["error"])
284 # Diagnostic message names the anti-bot interstitial so it's
285 # distinguishable from a genuine no-results case in logs.
286 self.assertIn("anti-bot interstitial", response["error"].lower())
287 self.assertEqual(response["items"], [])
288
289 def test_terminal_subprocess_error_is_not_retried(self):
290 """Subprocess timeout / spawn failure → terminal error, no retry."""
291 from unittest import mock
292 from lib import bird_x
293
294 timeout_error = {"error": "Search timed out after 30s", "items": []}
295 results = [(None, timeout_error)]
296
297 with mock.patch.object(bird_x, "_invoke_bird_subprocess", side_effect=results), \
298 mock.patch.object(bird_x.time, "sleep") as mock_sleep:
299 response = bird_x._run_bird_search("test", count=10, timeout=30)
300
301 self.assertEqual(response, timeout_error)
302 mock_sleep.assert_not_called()
303
304 if __name__ == "__main__":
305 unittest.main()
306
307
308 class TestXFromAndAboutLanes(unittest.TestCase):
309 """U7/U8: FROM lane drops the topic-AND; ABOUT lane queries @handle and
310 excludes the handle's own tweets."""
311
312 def _result(self, body_items):
313 import json as _j
314 class _R:
315 returncode = 0
316 stderr = ""
317 r = _R()
318 r.stdout = _j.dumps({"items": body_items})
319 return r
320
321 def test_from_lane_drops_topic_and(self):
322 from unittest import mock
323 from lib import bird_x
324 captured = []
325
326 def fake_run(cmd, timeout=None, env=None):
327 captured.append(cmd[2]) # the query string arg
328 return self._result([])
329
330 with mock.patch.object(bird_x.subproc, "run_with_timeout", side_effect=fake_run):
331 bird_x.search_handles(["xuezhao"], "lan xuezhao", "2026-05-19", count_per=1)
332 self.assertEqual(captured[0], "from:xuezhao since:2026-05-19")
333 self.assertNotIn("lan xuezhao", captured[0])
334
335 def test_mention_lane_queries_at_handle(self):
336 from unittest import mock
337 from lib import bird_x
338 captured = []
339
340 def fake_run(cmd, timeout=None, env=None):
341 captured.append(cmd[2])
342 return self._result([])
343
344 with mock.patch.object(bird_x.subproc, "run_with_timeout", side_effect=fake_run):
345 bird_x.search_mentions(["xuezhao"], "2026-05-19", count_per=1)
346 self.assertEqual(captured[0], "@xuezhao since:2026-05-19")
347
348 def test_mention_lane_excludes_own_tweets(self):
349 from unittest import mock
350 from lib import bird_x
351 parsed = [
352 {"url": "https://x.com/xuezhao/status/1", "title": "own tweet"},
353 {"url": "https://twitter.com/xuezhao/status/3", "title": "own legacy-domain tweet"},
354 {"url": "https://x.com/fan99/status/2", "title": "mention of them"},
355 ]
356 with mock.patch.object(bird_x.subproc, "run_with_timeout",
357 return_value=self._result([{"id": "x"}])), \
358 mock.patch.object(bird_x, "parse_bird_response", return_value=parsed):
359 out = bird_x.search_mentions(["xuezhao"], "2026-05-19", count_per=5)
360 urls = [it["url"] for it in out]
361 self.assertNotIn("https://x.com/xuezhao/status/1", urls) # own (x.com) excluded
362 self.assertNotIn("https://twitter.com/xuezhao/status/3", urls) # own (twitter.com) excluded
363 self.assertIn("https://x.com/fan99/status/2", urls) # mention kept
364
365 def test_mention_lane_empty_when_no_mentions(self):
366 from unittest import mock
367 from lib import bird_x
368 with mock.patch.object(bird_x.subproc, "run_with_timeout",
369 return_value=self._result([])), \
370 mock.patch.object(bird_x, "parse_bird_response", return_value=[]):
371 out = bird_x.search_mentions(["xuezhao"], "2026-05-19")
372 self.assertEqual(out, [])
373 class TestProbeAndDiagnoseHonesty(unittest.TestCase):
374 """U5: --diagnose probe + true auth lane; X is not reported green when dead."""
375
376 def setUp(self):
377 from lib import bird_x
378 bird_x._probe_cache = "unset"
379 bird_x._credentials = {"AUTH_TOKEN": "t", "CT0": "c"} # injected creds present
380
381 def tearDown(self):
382 from lib import bird_x
383 bird_x._probe_cache = "unset"
384 bird_x._credentials = {}
385
386 def test_probe_true_when_response_ok(self):
387 from unittest import mock
388 from lib import bird_x
389 with mock.patch.object(bird_x, "_run_bird_search", return_value={"items": [{"id": "1"}]}):
390 self.assertTrue(bird_x.probe_works())
391
392 def test_probe_false_on_auth_error(self):
393 from unittest import mock
394 from lib import bird_x
395 with mock.patch.object(bird_x, "_run_bird_search",
396 return_value={"error": "Missing auth_token", "items": []}):
397 self.assertIs(bird_x.probe_works(), False)
398
399 def test_probe_none_on_timeout_inconclusive(self):
400 from unittest import mock
401 from lib import bird_x
402 with mock.patch.object(bird_x, "_run_bird_search",
403 return_value={"error": "Search timed out after 8s", "items": []}):
404 self.assertIsNone(bird_x.probe_works())
405
406 def test_probe_false_when_no_credentials(self):
407 from unittest import mock
408 from lib import bird_x
409 bird_x._credentials = {}
410 with mock.patch.dict(os.environ, {}, clear=True):
411 self.assertIs(bird_x.probe_works(), False)
412
413 def test_probe_cached_per_process(self):
414 from unittest import mock
415 from lib import bird_x
416 with mock.patch.object(bird_x, "_run_bird_search",
417 return_value={"items": [{"id": "1"}]}) as m:
418 bird_x.probe_works()
419 bird_x.probe_works()
420 self.assertEqual(m.call_count, 1) # cached, not re-run
421
422 def test_get_x_source_status_reports_true_lane(self):
423 from unittest import mock
424 from lib import env, bird_x
425 cfg = {"AUTH_TOKEN": "t", "CT0": "c", "_AUTH_TOKEN_SOURCE": "browser"}
426 with mock.patch.object(bird_x, "get_bird_status",
427 return_value={"installed": True, "authenticated": True,
428 "username": "env AUTH_TOKEN", "can_install": True}):
429 status = env.get_x_source_status(cfg, probe=False)
430 self.assertEqual(status["bird_username"], "browser AUTH_TOKEN")
431
432 def test_diagnose_probe_downgrades_when_dead(self):
433 from unittest import mock
434 from lib import env, bird_x
435 cfg = {"AUTH_TOKEN": "t", "CT0": "c", "_AUTH_TOKEN_SOURCE": "browser"}
436 with mock.patch.object(bird_x, "get_bird_status",
437 return_value={"installed": True, "authenticated": True,
438 "username": "env AUTH_TOKEN", "can_install": True}), \
439 mock.patch.object(bird_x, "probe_works", return_value=False):
440 status = env.get_x_source_status(cfg, probe=True)
441 self.assertFalse(status["bird_authenticated"])
442 self.assertIn("no working X auth", status["bird_username"])
443
444
445 class TestHandleSearchLogsOnSuccess(unittest.TestCase):
446 """U6: handle searches log query + count on success, not only on failure."""
447
448 def test_search_handles_logs_on_success(self):
449 from unittest import mock
450 from lib import bird_x
451
452 class _R:
453 returncode = 0
454 stdout = '{"items": [{"id": "1"}]}'
455 stderr = ""
456
457 logged = []
458 with mock.patch.object(bird_x.subproc, "run_with_timeout", return_value=_R()), \
459 mock.patch.object(bird_x, "_log", side_effect=lambda m: logged.append(m)):
460 bird_x.search_handles(["mvanhorn"], "matt van horn", "2026-05-19", count_per=1)
461 self.assertTrue(any("Searching:" in m for m in logged),
462 f"expected a Searching: log on success, got {logged}")
463
464
465 class TestStrongestTokenRetryAnchored(unittest.TestCase):
466 """The last-chance retry must keep an entity anchor, not collapse to a bare
467 generic token (e.g. 'compound') that floods the X pool with off-topic noise.
468 """
469
470 def test_last_chance_retry_keeps_entity_anchor(self):
471 from unittest import mock
472 from lib import bird_x
473
474 queries = []
475
476 def fake_run(query, count, timeout):
477 queries.append(query)
478 return {"items": []} # always 0 → forces every retry tier
479
480 # extract_compound_terms may run; let it. Force all bird calls empty.
481 with mock.patch.object(bird_x, "_run_bird_search", side_effect=fake_run):
482 bird_x.search_x("trevin chow ai agents compound", "2026-05-19", "2026-06-18")
483
484 self.assertTrue(queries, "expected at least one bird query")
485 last = queries[-1]
486 # The final (last-chance) query keeps the entity anchor ...
487 self.assertIn("trevin", last)
488 # ... and is NOT a bare generic token query.
489 self.assertFalse(last.startswith("compound "), f"bare generic retry: {last!r}")
490 self.assertNotEqual(last, "compound since:2026-05-19")
491
492 def test_retry_with_single_distinctive_token_no_crash(self):
493 from unittest import mock
494 from lib import bird_x
495
496 queries = []
497
498 def fake_run(query, count, timeout):
499 queries.append(query)
500 return {"items": []}
501
502 with mock.patch.object(bird_x, "_run_bird_search", side_effect=fake_run):
503 # 'trending tools' is all low-signal except nothing distinctive ->
504 # whatever survives, the retry must not crash and stays anchored.
505 bird_x.search_x("agentcookie", "2026-05-19", "2026-06-18")
506
507 self.assertTrue(queries)
508 self.assertIn("agentcookie", queries[-1])
509
510
511 class TestBirdRetryQueryCorrectness(unittest.TestCase):
512 def test_quoted_topic_only_generates_balanced_retry_queries(self):
513 from lib import bird_x
514
515 queries = []
516
517 def fake_run(query, count, timeout):
518 queries.append(query)
519 if len(queries) == 1:
520 return {"items": []}
521 return {"error": "Bird search failed", "items": []}
522
523 with mock.patch.object(
524 bird_x,
525 "_extract_core_subject",
526 return_value='immobilienmakler(berlin "mixed-use',
527 ), mock.patch(
528 "lib.query.extract_compound_terms",
529 return_value=['"immobilienmakler berlin"'],
530 ), mock.patch.object(bird_x, "_run_bird_search", side_effect=fake_run):
531 response = bird_x.search_x(
532 '"Immobilienmakler Berlin" competitors',
533 "2026-07-12",
534 "2026-07-19",
535 )
536
537 self.assertGreaterEqual(len(queries), 2)
538 self.assertEqual(
539 "immobilienmakler berlin mixed-use since:2026-07-12",
540 queries[0],
541 )
542 for query in queries:
543 self.assertEqual(0, query.count('"') % 2, query)
544 self.assertEqual(query.count("("), query.count(")"), query)
545 self.assertNotIn("error", response)
546 self.assertEqual([], response["items"])
547
548 def test_every_failed_attempt_still_reports_backend_failure(self):
549 from lib import bird_x
550
551 with mock.patch.object(
552 bird_x, "_extract_core_subject", return_value="immobilienmakler berlin market"
553 ), mock.patch.object(
554 bird_x,
555 "_run_bird_search",
556 return_value={"error": "Bird search failed", "items": []},
557 ):
558 response = bird_x.search_x(
559 "Immobilienmakler Berlin market", "2026-07-12", "2026-07-19"
560 )
561
562 self.assertEqual("Bird search failed", response["error"])
563
564
565 class LeadingMentionsTests(unittest.TestCase):
566 """U5: leading @mentions parsed from post text identify reply targets."""
567
568 def test_single_leading_mention(self):
569 from lib import bird_x
570 self.assertEqual(["alpha"], bird_x._leading_mentions("@alpha thanks so much!"))
571
572 def test_multiple_leading_mentions(self):
573 from lib import bird_x
574 self.assertEqual(["alpha", "beta"], bird_x._leading_mentions("@alpha @beta hi"))
575
576 def test_in_body_mention_not_collected(self):
577 from lib import bird_x
578 self.assertEqual([], bird_x._leading_mentions("hello @gamma nice work"))
579
580 def test_punctuation_stripped(self):
581 from lib import bird_x
582 self.assertEqual(["alpha"], bird_x._leading_mentions("@alpha, nice"))
583
584 def test_empty_text(self):
585 from lib import bird_x
586 self.assertEqual([], bird_x._leading_mentions(""))
587 self.assertEqual([], bird_x._leading_mentions(None))
588
589
590 if __name__ == "__main__":
591 unittest.main()
592
592 lines PYTHON