test: Phase 3 添加 smoke test、CI 与 run-daily 去重锁
离线 mock 测试 generate 产出,GitHub Actions 跑 pytest,30 分钟内重复调度自动 skip。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
20
.github/workflows/test.yml
vendored
Normal file
20
.github/workflows/test.yml
vendored
Normal file
@@ -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
|
||||||
3
pytest.ini
Normal file
3
pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
pythonpath = .
|
||||||
4
requirements-dev.txt
Normal file
4
requirements-dev.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
python-dotenv>=1.0.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
certifi>=2024.0.0
|
||||||
|
pytest>=8.0
|
||||||
@@ -3,16 +3,21 @@
|
|||||||
# .\run-daily.ps1
|
# .\run-daily.ps1
|
||||||
# .\run-daily.ps1 -SkipPush
|
# .\run-daily.ps1 -SkipPush
|
||||||
# .\run-daily.ps1 -SkipGenerate
|
# .\run-daily.ps1 -SkipGenerate
|
||||||
|
# .\run-daily.ps1 -Force # bypass duplicate-run lock
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[switch]$SkipPush,
|
[switch]$SkipPush,
|
||||||
[switch]$SkipGenerate
|
[switch]$SkipGenerate,
|
||||||
|
[switch]$Force
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
Set-Location $Root
|
Set-Location $Root
|
||||||
|
|
||||||
|
$LockFile = Join-Path $Root ".cache\run-daily.lock"
|
||||||
|
$LockMaxMinutes = 30
|
||||||
|
|
||||||
function Import-DotEnvFile {
|
function Import-DotEnvFile {
|
||||||
param([string]$Path)
|
param([string]$Path)
|
||||||
if (-not (Test-Path $Path)) { return }
|
if (-not (Test-Path $Path)) { return }
|
||||||
@@ -29,6 +34,33 @@ function Import-DotEnvFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-RunDailyLock {
|
||||||
|
if (-not (Test-Path $LockFile)) { return $false }
|
||||||
|
$age = (Get-Date) - (Get-Item $LockFile).LastWriteTime
|
||||||
|
return $age.TotalMinutes -lt $LockMaxMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function Clear-RunDailyLock {
|
||||||
|
if (Test-Path $LockFile) {
|
||||||
|
Remove-Item $LockFile -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Force -and (Test-RunDailyLock)) {
|
||||||
|
Write-Host "Skip: run-daily already ran within ${LockMaxMinutes} minutes (lock: $LockFile). Use -Force to override."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-RunDailyLock
|
||||||
|
try {
|
||||||
Import-DotEnvFile (Join-Path $Root ".env")
|
Import-DotEnvFile (Join-Path $Root ".env")
|
||||||
Import-DotEnvFile (Join-Path $Root ".env.local")
|
Import-DotEnvFile (Join-Path $Root ".env.local")
|
||||||
|
|
||||||
@@ -56,3 +88,7 @@ if (-not $SkipPush) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Done: $date"
|
Write-Host "Done: $date"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Clear-RunDailyLock
|
||||||
|
}
|
||||||
|
|||||||
50
tests/conftest.py
Normal file
50
tests/conftest.py
Normal file
@@ -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")
|
||||||
12
tests/fixtures/sample-rss.xml
vendored
Normal file
12
tests/fixtures/sample-rss.xml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Sample AI Feed</title>
|
||||||
|
<item>
|
||||||
|
<title>Sample AI headline for smoke tests</title>
|
||||||
|
<link>https://example.com/ai-news/1</link>
|
||||||
|
<pubDate>Wed, 02 Jul 2026 08:00:00 GMT</pubDate>
|
||||||
|
<description>A minimal RSS item used by offline tests.</description>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
32
tests/fixtures/skills-feed.json
vendored
Normal file
32
tests/fixtures/skills-feed.json
vendored
Normal file
@@ -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": []
|
||||||
|
}
|
||||||
66
tests/test_smoke.py
Normal file
66
tests/test_smoke.py
Normal file
@@ -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 "<item>" in xml
|
||||||
Reference in New Issue
Block a user