diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 4c93884..e6f86df 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -10,7 +10,7 @@ permissions: contents: read jobs: - plugin-contract: + tests: runs-on: ubuntu-latest steps: - name: Checkout @@ -22,5 +22,5 @@ jobs: - name: Set up Python run: uv python install 3.12 - - name: Run plugin contract tests - run: uv run pytest tests/test_plugin_contract.py tests/test_version_consistency.py + - name: Run test suite + run: uv run pytest diff --git a/skills/last30days/scripts/store.py b/skills/last30days/scripts/store.py index 6cf77e6..7173517 100644 --- a/skills/last30days/scripts/store.py +++ b/skills/last30days/scripts/store.py @@ -673,27 +673,31 @@ def findings_from_report( limit: Optional[int] = None, ) -> List[Dict[str, Any]]: """Convert report into persisted findings. - + Uses ranked candidates (post-rerank) when available for quality scores and explanations. Supplements with raw items from items_by_source for HN/PM that didn't rank highly - but are valuable for watchlist persistence. + but are valuable for watchlist persistence. When ranked_candidates is empty + (degraded path — rerank failed or was skipped), falls back to supplementing + all sources from items_by_source so findings aren't silently dropped. """ findings = [] seen_urls = set() - - # Phase 1: Process ranked candidates (high-quality data with explanations and corroboration) + for candidate in report.ranked_candidates: - finding = finding_from_candidate(candidate) - findings.append(finding) + findings.append(finding_from_candidate(candidate)) seen_urls.add(candidate.url) - - # Phase 2: Add HN/PM items not already captured in ranked candidates - for source_name in ["hackernews", "polymarket"]: + + supplement_sources = ( + list(report.items_by_source) + if not report.ranked_candidates + else ["hackernews", "polymarket"] + ) + for source_name in supplement_sources: if source_name not in report.items_by_source: continue for item in report.items_by_source[source_name]: if item.url in seen_urls: - continue # Already captured with rich data + continue findings.append({ "source": source_name, "source_url": item.url, @@ -705,8 +709,7 @@ def findings_from_report( "relevance_score": item.local_relevance or 0.5, }) seen_urls.add(item.url) - - # Apply global limit after collecting all findings (fix: was per-source, now global) + return findings[:limit] if limit is not None else findings diff --git a/tests/test_footer_nudge_suppression.py b/tests/test_footer_nudge_suppression.py index 592bfbe..3881a31 100644 --- a/tests/test_footer_nudge_suppression.py +++ b/tests/test_footer_nudge_suppression.py @@ -6,6 +6,7 @@ from __future__ import annotations import os import subprocess import sys +import tempfile import unittest from pathlib import Path @@ -27,13 +28,31 @@ class FooterNudgeSuppressionTests(unittest.TestCase): "--emit=md", *argv, ] - env = {**os.environ, "LAST30DAYS_SKIP_PREFLIGHT": "1"} + env = { + **os.environ, + "LAST30DAYS_SKIP_PREFLIGHT": "1", + # Skip ~/.config/last30days/.env so a contributor's saved + # BRAVE/EXA/SERPER/PARALLEL key doesn't make grounding "available" + # and suppress the promo we're checking for. + "LAST30DAYS_CONFIG_DIR": "", + # Pin X as available so _missing_sources_for_promo selects "web" + # (otherwise the "x" promo wins and the BRAVE_API_KEY string never + # appears). + "XAI_API_KEY": "test-stub", + } # Strip any grounded-web keys the host might have so the promo path - # triggers deterministically in mock + no-backend. + # triggers deterministically in mock + no-backend. Also strip X cookie + # credentials so XAI_API_KEY is the unambiguous X backend. for key in ("BRAVE_API_KEY", "EXA_API_KEY", "SERPER_API_KEY", - "PARALLEL_API_KEY", "OPENROUTER_API_KEY"): + "PARALLEL_API_KEY", "OPENROUTER_API_KEY", + "AUTH_TOKEN", "CT0", "LAST30DAYS_X_BACKEND"): env.pop(key, None) - return subprocess.run(cmd, capture_output=True, text=True, env=env) + # Run from a tmpdir so _find_project_env() can't walk up into any + # .claude/last30days.env above the repo on the contributor's machine. + with tempfile.TemporaryDirectory() as tmp: + return subprocess.run( + cmd, capture_output=True, text=True, env=env, cwd=tmp, + ) def test_bare_run_emits_web_promo(self): result = self._run(topic="OpenAI") diff --git a/tests/test_setup_openclaw.py b/tests/test_setup_openclaw.py index 250cc1d..f39eb01 100644 --- a/tests/test_setup_openclaw.py +++ b/tests/test_setup_openclaw.py @@ -200,8 +200,10 @@ class TestPollDeviceAuth: @patch("lib.setup_wizard.urlopen") def test_timeout_returns_none(self, mock_urlopen, mock_time): """Returns None when timeout is exceeded.""" - # Simulate time passing beyond deadline - mock_time.time = MagicMock(side_effect=[0, 301]) + # poll_device_auth calls time.time() for deadline init, last_reminder init, + # then once per while-loop iteration. Three values are enough for one check + # that exceeds the deadline. + mock_time.time = MagicMock(side_effect=[0, 0, 301]) mock_time.sleep = MagicMock() result = setup_wizard.poll_device_auth("dc-123", interval=5, timeout=300) @@ -211,7 +213,9 @@ class TestPollDeviceAuth: @patch("lib.setup_wizard.urlopen") def test_expired_token_returns_none(self, mock_urlopen, mock_time): """Returns None on expired_token error.""" - mock_time.time = MagicMock(side_effect=[0, 0]) + # Loop terminates via urlopen response, not the clock — pin time to 0 + # so the deadline check stays a non-event regardless of call count. + mock_time.time = MagicMock(return_value=0) mock_time.sleep = MagicMock() expired_resp = MagicMock() @@ -230,7 +234,7 @@ class TestPollDeviceAuth: """HTTP 400 during polling continues (authorization pending).""" from urllib.error import HTTPError - mock_time.time = MagicMock(side_effect=[0, 0, 0]) + mock_time.time = MagicMock(return_value=0) mock_time.sleep = MagicMock() success_resp = MagicMock() diff --git a/tests/test_store.py b/tests/test_store.py index 66a0f93..aaacfa5 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -3,7 +3,7 @@ import json import sqlite3 import tempfile -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path import pytest @@ -503,16 +503,16 @@ def test_get_new_findings_filters_by_date(temp_db, sample_report): findings = store.findings_from_report(sample_report) store.store_findings(run_id, topic["id"], findings) - # Get findings since tomorrow (should be empty) - tomorrow = (datetime.now() + timedelta(days=1)).strftime("%Y-%m-%d") + # Use UTC because store writes first_seen via SQLite's datetime('now') (UTC). + # Local-time math here would flake near midnight UTC. + tomorrow = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%d") new_findings = store.get_new_findings(topic["id"], since=tomorrow) - + assert len(new_findings) == 0 - - # Get findings since yesterday (should have all) - yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") + + yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d") new_findings = store.get_new_findings(topic["id"], since=yesterday) - + assert len(new_findings) == 4