diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..45e9e4f
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,20 @@
+name: test
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ pytest:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ - name: Install dependencies
+ run: pip install -r requirements-dev.txt
+ - name: Run tests
+ run: pytest -q
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..4584de7
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+testpaths = tests
+pythonpath = .
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..e08bb14
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,4 @@
+python-dotenv>=1.0.0
+httpx>=0.27.0
+certifi>=2024.0.0
+pytest>=8.0
diff --git a/run-daily.ps1 b/run-daily.ps1
index aab641e..c02d7df 100644
--- a/run-daily.ps1
+++ b/run-daily.ps1
@@ -3,16 +3,21 @@
# .\run-daily.ps1
# .\run-daily.ps1 -SkipPush
# .\run-daily.ps1 -SkipGenerate
+# .\run-daily.ps1 -Force # bypass duplicate-run lock
param(
[switch]$SkipPush,
- [switch]$SkipGenerate
+ [switch]$SkipGenerate,
+ [switch]$Force
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $Root
+$LockFile = Join-Path $Root ".cache\run-daily.lock"
+$LockMaxMinutes = 30
+
function Import-DotEnvFile {
param([string]$Path)
if (-not (Test-Path $Path)) { return }
@@ -29,30 +34,61 @@ function Import-DotEnvFile {
}
}
-Import-DotEnvFile (Join-Path $Root ".env")
-Import-DotEnvFile (Join-Path $Root ".env.local")
+function Test-RunDailyLock {
+ if (-not (Test-Path $LockFile)) { return $false }
+ $age = (Get-Date) - (Get-Item $LockFile).LastWriteTime
+ return $age.TotalMinutes -lt $LockMaxMinutes
+}
-$python = "python"
-$date = Get-Date -Format "yyyy-MM-dd"
-$reportWecom = Join-Path $Root "output\$date.wecom.md"
+function Set-RunDailyLock {
+ $dir = Split-Path $LockFile -Parent
+ if (-not (Test-Path $dir)) {
+ New-Item -ItemType Directory -Path $dir -Force | Out-Null
+ }
+ Set-Content -Path $LockFile -Value (Get-Date -Format "o") -Encoding UTF8
+}
-if (-not $SkipGenerate) {
- Write-Host "Generating daily report: $date"
- & $python -m daily generate
- if ($LASTEXITCODE -ne 0) {
- throw "daily generate failed with exit code $LASTEXITCODE"
+function Clear-RunDailyLock {
+ if (Test-Path $LockFile) {
+ Remove-Item $LockFile -Force -ErrorAction SilentlyContinue
}
}
-if (-not $SkipPush) {
- if (-not (Test-Path $reportWecom)) {
- throw "Report not found: $reportWecom"
- }
- Write-Host "Pushing to WeCom webhook..."
- & $python -m daily push $reportWecom
- if ($LASTEXITCODE -ne 0) {
- throw "daily push failed with exit code $LASTEXITCODE"
- }
+if (-not $Force -and (Test-RunDailyLock)) {
+ Write-Host "Skip: run-daily already ran within ${LockMaxMinutes} minutes (lock: $LockFile). Use -Force to override."
+ exit 0
}
-Write-Host "Done: $date"
+Set-RunDailyLock
+try {
+ Import-DotEnvFile (Join-Path $Root ".env")
+ Import-DotEnvFile (Join-Path $Root ".env.local")
+
+ $python = "python"
+ $date = Get-Date -Format "yyyy-MM-dd"
+ $reportWecom = Join-Path $Root "output\$date.wecom.md"
+
+ if (-not $SkipGenerate) {
+ Write-Host "Generating daily report: $date"
+ & $python -m daily generate
+ if ($LASTEXITCODE -ne 0) {
+ throw "daily generate failed with exit code $LASTEXITCODE"
+ }
+ }
+
+ if (-not $SkipPush) {
+ if (-not (Test-Path $reportWecom)) {
+ throw "Report not found: $reportWecom"
+ }
+ Write-Host "Pushing to WeCom webhook..."
+ & $python -m daily push $reportWecom
+ if ($LASTEXITCODE -ne 0) {
+ throw "daily push failed with exit code $LASTEXITCODE"
+ }
+ }
+
+ Write-Host "Done: $date"
+}
+finally {
+ Clear-RunDailyLock
+}
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..76fc899
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+import pytest
+
+FIXTURES = Path(__file__).parent / "fixtures"
+
+
+@pytest.fixture
+def skills_feed() -> dict:
+ return json.loads((FIXTURES / "skills-feed.json").read_text(encoding="utf-8"))
+
+
+@pytest.fixture
+def fixed_cst():
+ return datetime(2026, 7, 3, 9, 30, tzinfo=timezone(timedelta(hours=8)))
+
+
+@pytest.fixture
+def isolated_output(tmp_path, monkeypatch):
+ import daily.config as config
+ import daily.report_data as report_data
+
+ out = tmp_path / "output"
+ cache = tmp_path / "cache"
+ logs = tmp_path / "logs"
+ out.mkdir()
+ cache.mkdir()
+ logs.mkdir()
+ monkeypatch.setattr(config, "OUTPUT_DIR", out)
+ monkeypatch.setattr(report_data, "OUTPUT_DIR", out)
+ monkeypatch.setattr(config, "CACHE_DIR", cache)
+ monkeypatch.setattr(config, "LOG_DIR", logs)
+ monkeypatch.setattr(config, "SNAPSHOT_FILE", cache / "last-report.json")
+ return out
+
+
+@pytest.fixture
+def offline_generate_env(monkeypatch):
+ monkeypatch.setenv("DAILY_AI_NEWS", "0")
+ monkeypatch.setenv("DAILY_CN_AI_NEWS", "0")
+ monkeypatch.setenv("DAILY_ZH_DESC", "0")
+ monkeypatch.setenv("SKILLS_BOARD_SOURCE", "feed")
+ monkeypatch.setenv("DAILY_REPORT_MODE", "classic")
+ monkeypatch.setenv("DAILY_CURSOR_EDITOR", "0")
+ monkeypatch.setenv("DAILY_TRENDING_LIMIT", "10")
+ monkeypatch.setenv("DAILY_HOT_LIMIT", "10")
diff --git a/tests/fixtures/sample-rss.xml b/tests/fixtures/sample-rss.xml
new file mode 100644
index 0000000..43d4e9c
--- /dev/null
+++ b/tests/fixtures/sample-rss.xml
@@ -0,0 +1,12 @@
+
+
+
+ Sample AI Feed
+ -
+ Sample AI headline for smoke tests
+ https://example.com/ai-news/1
+ Wed, 02 Jul 2026 08:00:00 GMT
+ A minimal RSS item used by offline tests.
+
+
+
diff --git a/tests/fixtures/skills-feed.json b/tests/fixtures/skills-feed.json
new file mode 100644
index 0000000..e54c19c
--- /dev/null
+++ b/tests/fixtures/skills-feed.json
@@ -0,0 +1,32 @@
+{
+ "updatedAt": "2026-07-02T12:00:00Z",
+ "topTrending": [
+ {
+ "id": "vercel-labs/skills/find-skills",
+ "title": "find-skills",
+ "source": "vercel-labs/skills",
+ "installs": 18000,
+ "link": "https://www.skills.sh/vercel-labs/skills/find-skills",
+ "description": "Help users discover and install Agent Skills."
+ },
+ {
+ "id": "halt-catch-fire/skills/remotion-render",
+ "title": "remotion-render",
+ "source": "halt-catch-fire/skills",
+ "installs": 21549,
+ "link": "https://www.skills.sh/halt-catch-fire/skills/remotion-render",
+ "description": "Render videos from React/Remotion component code."
+ }
+ ],
+ "topHot": [
+ {
+ "id": "vercel-labs/skills/find-skills",
+ "title": "find-skills",
+ "source": "vercel-labs/skills",
+ "installs": 243,
+ "link": "https://www.skills.sh/vercel-labs/skills/find-skills",
+ "description": "Help users discover and install Agent Skills."
+ }
+ ],
+ "topAllTime": []
+}
diff --git a/tests/test_smoke.py b/tests/test_smoke.py
new file mode 100644
index 0000000..bf81ba8
--- /dev/null
+++ b/tests/test_smoke.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+
+FIXTURES = Path(__file__).parent / "fixtures"
+
+
+def _sample_github_repo() -> dict:
+ return {
+ "repo": "owner/sample",
+ "url": "https://github.com/owner/sample",
+ "description": "Sample repository for smoke tests.",
+ "language": "Python",
+ "stars_today_fmt": "120",
+ "total_stars_fmt": "1.2K",
+ "created_at": "2026-06-01",
+ }
+
+
+def test_format_installs():
+ from shared.skills_data import format_installs
+
+ assert format_installs(999) == "999"
+ assert format_installs(18000) == "18.0K"
+ assert format_installs(2_500_000) == "2.5M"
+
+
+def test_generate_report_offline(
+ skills_feed,
+ fixed_cst,
+ isolated_output,
+ offline_generate_env,
+ monkeypatch,
+):
+ import daily.generate as generate
+
+ monkeypatch.setattr(generate, "load_feed", lambda force=False: skills_feed)
+ monkeypatch.setattr(generate, "fetch_github_trending", lambda n: [_sample_github_repo()])
+ monkeypatch.setattr(generate, "fetch_emerging_repos", lambda n, exclude=None: [])
+ monkeypatch.setattr(generate, "fetch_topic_hot_repos", lambda n, exclude=None: ("llm", []))
+ monkeypatch.setattr(generate, "_now_cst", lambda: fixed_cst)
+
+ _, wecom_md, out_md, out_wecom = generate.generate_report()
+
+ assert out_md.exists()
+ assert out_wecom.exists()
+ assert "早报" in out_md.read_text(encoding="utf-8")
+ assert wecom_md.strip()
+ assert "📰" in wecom_md
+
+ data_path = isolated_output / "2026-07-03.data.json"
+ assert data_path.exists()
+ payload = json.loads(data_path.read_text(encoding="utf-8"))
+ assert payload["meta"]["report_mode"] == "classic"
+ assert payload["data"]["date"] == "2026-07-03"
+ assert len(payload["data"]["skills_trending"]) >= 1
+ assert len(payload["data"]["skills_hot"]) >= 1
+
+
+def test_rss_fixture_is_well_formed():
+ xml = (FIXTURES / "sample-rss.xml").read_text(encoding="utf-8")
+ assert "Sample AI headline" in xml
+ assert "- " in xml