ci: 引入 ruff、pre-commit、前端单测与 GitHub Actions

- CI:后端 ruff + 单测 + dry-run,前端 typecheck/eslint/vitest/build,
  并构建 Docker 镜像做冒烟
- ruff(E4/E7/E9/F/B)全量通过:补全异常链、拆分分号语句
- 修复统计接口 counts 恒为空、已完成体积估算错误;统计与高级检索
  复用应用 Store 连接,不再按环境变量另开且不关闭的连接
- Vitest:API 错误处理、前缀拼接,以及与后端 TaskState 的契约测试
- npm 固定官方源(项目级 .npmrc),lockfile 统一为 registry.npmjs.org;
  Dockerfile 支持 NPM_REGISTRY/PIP_INDEX_URL 构建参数并复制 LICENSE
- pyproject 补充项目元数据、dev 依赖与 ruff 配置;MCP 默认 WARNING 日志

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DerrorsandClaude Opus 5.5 committed 2026-10-19 16:30:31 +08:00
1 parent 864ab2a7c1
commit bdd32a6d21
19 files changed
+953 -383

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install
run: pip install -e ".[all,dev]"
- name: Lint
run: ruff check .
- name: Unit tests
run: python -m unittest discover -s tests -v
- name: Dry-run smoke
run: python -m reelay.main --dry-run
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm run lint:eslint
- run: npm test
- run: npm run build
docker:
runs-on: ubuntu-latest
needs: [backend, frontend]
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v6
with:
context: .
push: false
load: true
tags: reelay:ci
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Smoke test image
run: docker run --rm reelay:ci python -m reelay.main --dry-run
+22
View File
@@ -0,0 +1,22 @@
# pip install -e ".[dev]" && pre-commit install
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.17.1
hooks:
- id: ruff-check
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- id: check-yaml
- id: check-added-large-files
- id: detect-private-key
- repo: local
hooks:
- id: web-lint
name: web typecheck + eslint
entry: bash -c 'cd web && npm run -s typecheck && npm run -s lint:eslint'
language: system
files: ^web/
pass_filenames: false
+10 -3
View File
@@ -1,16 +1,23 @@
# 国内网络可加:--build-arg NPM_REGISTRY=https://registry.npmmirror.com/ \
# --build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
FROM node:24-bookworm-slim AS frontend
ARG NPM_REGISTRY=https://registry.npmjs.org/
WORKDIR /build/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
RUN npm ci --registry="$NPM_REGISTRY"
COPY web/ ./
RUN npm run typecheck && npm run build
FROM python:3.12-slim-bookworm
ARG PIP_INDEX_URL=https://pypi.org/simple
LABEL org.opencontainers.image.source="https://github.com/Derrors/reelay" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.description="Self-hosted Telegram → 115 auto transfer with AI parsing"
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml README.md ./
COPY pyproject.toml README.md LICENSE ./
COPY reelay/ ./reelay/
RUN pip install --no-cache-dir ".[all]" \
RUN pip install --no-cache-dir --index-url "$PIP_INDEX_URL" ".[all]" \
&& useradd --create-home --uid 10001 reelay \
&& mkdir -p /app/data /app/config /app/plugins /app/web/dist \
&& chown -R reelay:reelay /app/data /app/config
+36 -5
View File
@@ -1,14 +1,26 @@
[build-system]
requires = ["setuptools>=61"]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "reelay"
version = "0.1.0"
description = "监听 Telegram 资源 Bot,AI 识别影视资源与 115 链接,审批后转存并整理入库。可扩展内核骨架。"
description = "自托管的 Telegram → 115 网盘自动转存:AI 识别分享链接,审批后转存并按媒体库规范整理入库。"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Derrors" }]
keywords = ["telegram", "115", "media-library", "jellyfin", "emby", "llm", "self-hosted"]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Web Environment",
"Framework :: AsyncIO",
"Operating System :: MacOS",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3.12",
"Topic :: Multimedia :: Video",
]
dependencies = [
"Jinja2>=3.1,<4",
]
@@ -25,7 +37,6 @@ full = [
web = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"httpx2>=0.1",
"PyYAML>=6.0",
]
agent = ["mcp>=1.2"]
@@ -37,9 +48,19 @@ all = [
"PyYAML>=6.0",
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"httpx2>=0.1",
"mcp>=1.2",
]
# 开发与 CI:pip install -e ".[all,dev]"
dev = [
"ruff>=0.15",
"pre-commit>=4",
"httpx2>=2", # starlette TestClient
]
[project.urls]
Homepage = "https://github.com/Derrors/reelay"
Issues = "https://github.com/Derrors/reelay/issues"
Changelog = "https://github.com/Derrors/reelay/blob/main/CHANGELOG.md"
[project.scripts]
reelay = "reelay.main:main"
@@ -48,3 +69,13 @@ reelay-mcp = "reelay.mcp_server:main"
[tool.setuptools.packages.find]
include = ["reelay*"]
[tool.ruff]
line-length = 120
target-version = "py312"
extend-exclude = ["web"]
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "B"]
# FastAPI 的 Query()/Depends() 作为参数默认值是框架惯用法
ignore = ["B008"]
+4 -2
View File
@@ -27,8 +27,10 @@ def _environment() -> ImmutableSandboxedEnvironment:
loader=None, enable_async=False)
safe = {name: env.filters[name] for name in ALLOWED_FILTERS - {"pad2"} if name in env.filters}
safe["pad2"] = lambda value: f"{int(value):02d}"
env.filters.clear(); env.filters.update(safe)
env.tests.clear(); env.globals.clear()
env.filters.clear()
env.filters.update(safe)
env.tests.clear()
env.globals.clear()
return env
+4 -1
View File
@@ -198,7 +198,10 @@ def _server_class():
def build_server(tools: ReelayTools, *, readonly: bool):
"""注册工具;全部声明为 async,SQLite 访问留在事件循环线程,不进线程池。"""
server = _server_class()("reelay", instructions=_INSTRUCTIONS)
# SDK 构造时会按 log_level 配置根日志;默认 WARNING,避免 stderr 被 INFO 刷屏。
level = os.getenv("REELAY_LOG_LEVEL", "WARNING").upper()
level = level if level in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} else "WARNING"
server = _server_class()("reelay", instructions=_INSTRUCTIONS, log_level=level)
async def status() -> dict[str, Any]:
"""Reelay 运行概况:各状态任务数、待处理/执行中/失败/完成数、运行模式、通知投递与 worker/消息源/115 健康状态。
+13 -13
View File
@@ -189,14 +189,14 @@ def create_app(
try:
return {"items": service.list_tasks(state=state, source=source, query=query, limit=limit)}
except Exception as exc:
raise _error(exc)
raise _error(exc) from exc
@app.get("/api/deliveries")
async def deliveries(status: str = "failed", limit: int = Query(default=100, ge=1, le=500)):
try:
return {"items": store.list_deliveries(status=status, limit=limit)}
except ValueError as exc:
raise _error(exc)
raise _error(exc) from exc
@app.post("/api/deliveries/{delivery_id}/retry")
async def retry_delivery(delivery_id: int):
@@ -209,7 +209,7 @@ def create_app(
try:
return service.get_task(task_id)
except Exception as exc:
raise _error(exc)
raise _error(exc) from exc
@app.post("/api/tasks/{task_id}/approve")
async def approve(task_id: str, payload: ActionPayload | None = None):
@@ -220,7 +220,7 @@ def create_app(
)
return {"ok": True}
except Exception as exc:
raise _error(exc)
raise _error(exc) from exc
@app.post("/api/tasks/{task_id}/reject")
async def reject(task_id: str):
@@ -228,7 +228,7 @@ def create_app(
await service.reject(task_id)
return {"ok": True}
except Exception as exc:
raise _error(exc)
raise _error(exc) from exc
@app.post("/api/tasks/{task_id}/retry")
async def retry(task_id: str):
@@ -236,7 +236,7 @@ def create_app(
await service.retry(task_id)
return {"ok": True}
except Exception as exc:
raise _error(exc)
raise _error(exc) from exc
@app.post("/api/tasks/{task_id}/reparse")
async def reparse(task_id: str):
@@ -244,7 +244,7 @@ def create_app(
await service.reparse(task_id)
return {"ok": True}
except Exception as exc:
raise _error(exc)
raise _error(exc) from exc
@app.post("/api/tasks/{task_id}/reorganize")
async def reorganize(task_id: str, payload: ActionPayload):
@@ -252,7 +252,7 @@ def create_app(
await service.requeue_organize(task_id, save_dir_alias=payload.save_dir_alias)
return {"ok": True}
except Exception as exc:
raise _error(exc)
raise _error(exc) from exc
@app.post("/api/tasks/{task_id}/reconcile")
async def reconcile(task_id: str):
@@ -260,21 +260,21 @@ def create_app(
await service.reconcile_transfer(task_id)
return {"ok": True}
except Exception as exc:
raise _error(exc)
raise _error(exc) from exc
@app.patch("/api/tasks/{task_id}/parsed")
async def patch_parsed(task_id: str, payload: ParsePatchPayload):
try:
return service.patch_parsed(task_id, payload.values)
except Exception as exc:
raise _error(exc)
raise _error(exc) from exc
@app.post("/api/organize/preview")
async def organize_preview(payload: OrganizePreviewPayload):
try:
return {"items": service.organize_preview(parsed=payload.parsed, landed=payload.landed)}
except Exception as exc:
raise _error(exc)
raise _error(exc) from exc
@app.get("/api/config")
async def get_config():
@@ -292,7 +292,7 @@ def create_app(
try:
return {"config": await service.save_config(payload.config)}
except Exception as exc:
raise _error(exc)
raise _error(exc) from exc
@app.put("/api/config/text")
async def put_config_text(payload: ConfigTextPayload):
@@ -300,7 +300,7 @@ def create_app(
config = await service.save_config_text(payload.text)
return {"config": config, "text": service.get_config_text()}
except Exception as exc:
raise _error(exc)
raise _error(exc) from exc
frontend = Path(frontend_dir or PROJECT_ROOT / "web" / "dist").resolve()
frontend_assets = frontend / "assets"
+32 -36
View File
@@ -38,7 +38,8 @@ def _parse_size_gb(value: Any) -> float:
m = re.match(r"\s*([0-9.]+)\s*([KMGT]?B|GB|MB|KB|TB)", str(value), re.I)
if not m:
return 0.0
num = float(m.group(1)); unit = m.group(2).upper()
num = float(m.group(1))
unit = m.group(2).upper()
return num * {"B": 1 / 1024 ** 3, "KB": 1 / 1024 ** 2,
"MB": 1 / 1024, "GB": 1, "TB": 1024}.get(unit, 0.0)
@@ -154,7 +155,7 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
registry.validate_config(cfg)
return {'ok': True}
except (ValueError, TypeError, AttributeError) as exc:
raise HTTPException(status_code=422, detail=str(exc))
raise HTTPException(status_code=422, detail=str(exc)) from exc
@app.post('/api/extensions/organizer/preview')
async def organizer_template_preview(request: Request):
@@ -170,7 +171,7 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
"original_name": "sample.S01E02.mkv"}
return {'path': render_path(template, values, label=f'{kind}命名模板')}
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=422, detail=str(exc))
raise HTTPException(status_code=422, detail=str(exc)) from exc
@app.post('/api/extensions/dir-router/preview')
async def dir_router_preview(request: Request):
@@ -183,7 +184,7 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
payload.get('origin_country', []))
return {'path': path}
except (ValueError, TypeError, AttributeError) as exc:
raise HTTPException(status_code=422, detail=str(exc))
raise HTTPException(status_code=422, detail=str(exc)) from exc
@app.patch('/api/config/sections')
async def save_config_sections(request: Request):
@@ -209,7 +210,7 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
try:
saved = await service.save_config(updated)
except (ValueError, KeyError) as exc:
raise HTTPException(status_code=422, detail=str(exc))
raise HTTPException(status_code=422, detail=str(exc)) from exc
return {'config': saved, 'revision': config_revision(saved)}
import asyncio
@@ -258,16 +259,16 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
try:
return await asyncio.to_thread(get_store().cookie_qr_start)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"申请 Cookie 登录二维码失败:{exc}")
raise HTTPException(status_code=502, detail=f"申请 Cookie 登录二维码失败:{exc}") from exc
@app.get("/api/auth115/cookie/qr")
async def auth115_cookie_qr_status(uid: str = Query(...)):
try:
return await asyncio.to_thread(get_store().cookie_qr_status, uid)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc))
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"查询 Cookie 扫码状态失败:{exc}")
raise HTTPException(status_code=502, detail=f"查询 Cookie 扫码状态失败:{exc}") from exc
@app.get("/api/auth115/cookie/qr/image")
async def auth115_cookie_qr_image(uid: str = Query(...)):
@@ -288,9 +289,9 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
try:
png = await asyncio.to_thread(_fetch)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc))
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"获取 Cookie 二维码图片失败:{exc}")
raise HTTPException(status_code=502, detail=f"获取 Cookie 二维码图片失败:{exc}") from exc
return Response(content=png, media_type="image/svg+xml",
headers={"Cache-Control": "no-store"})
@@ -300,7 +301,7 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
try:
return await __import__("asyncio").to_thread(store.qr_start)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"申请授权二维码失败:{exc}")
raise HTTPException(status_code=502, detail=f"申请授权二维码失败:{exc}") from exc
@app.get("/api/auth115/oauth/qr")
async def auth115_qr_status(uid: str = Query(...)):
@@ -308,9 +309,9 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
try:
return await __import__("asyncio").to_thread(store.qr_status, uid)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc))
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"查询扫码状态失败:{exc}")
raise HTTPException(status_code=502, detail=f"查询扫码状态失败:{exc}") from exc
@app.get("/api/auth115/oauth/qr/image")
async def auth115_qr_image(uid: str = Query(...)):
@@ -330,9 +331,9 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
try:
png = await asyncio.to_thread(_fetch)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc))
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"获取二维码图片失败:{exc}")
raise HTTPException(status_code=502, detail=f"获取二维码图片失败:{exc}") from exc
return Response(content=png, media_type="image/png",
headers={"Cache-Control": "no-store"})
@@ -417,21 +418,22 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
@app.get("/api/tasks/advanced")
async def advanced_tasks(q: str | None = None, media_type: str | None = None,
limit: int = Query(default=200, ge=1, le=1000)):
import sqlite3
db = os.getenv("REELAY_DB_PATH", str(project_root / "data" / "reelay.db"))
conn = sqlite3.connect(db); conn.row_factory = sqlite3.Row
conn = store._conn # 与应用同一个 Store;不要按环境变量另开连接
where, args = ["state='done'"], []
if q:
where.append("title LIKE ?"); args.append(f"%{q}%")
where.append("title LIKE ?")
args.append(f"%{q}%")
if media_type:
where.append("json_extract(snapshot,'$.parsed.media_type')=?"); args.append(media_type)
where.append("json_extract(snapshot,'$.parsed.media_type')=?")
args.append(media_type)
rows = conn.execute(
f"SELECT task_id,title,state,snapshot,updated_at FROM tasks "
f"WHERE {' AND '.join(where)} ORDER BY updated_at DESC LIMIT ?",
(*args, limit)).fetchall()
groups: dict[str, dict] = {}
for r in rows:
snap = json.loads(r["snapshot"]); p = snap.get("parsed") or {}
snap = json.loads(r["snapshot"])
p = snap.get("parsed") or {}
key = p.get("title") or "(未识别)"
g = groups.setdefault(key, {"title": key, "media_type": p.get("media_type"),
"year": p.get("year"), "episodes": [], "tasks": 0,
@@ -460,7 +462,7 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
try:
attr = fs.get_attr(int(fid), refresh=True)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=404, detail=f"未找到文件:{exc}")
raise HTTPException(status_code=404, detail=f"未找到文件:{exc}") from exc
return {k: attr.get(k) for k in
("id", "name", "size", "sha1", "parent_id", "is_dir", "pickcode",
"ctime", "mtime")}
@@ -495,13 +497,8 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
# ------------------------------------------------------------------ #
@app.get("/api/stats")
async def stats(days: int = Query(default=30, ge=1, le=365)):
rows = store.dashboard() if hasattr(store, "dashboard") else {}
# 任务按天/状态聚合由 SQL 直接统计
import sqlite3
db = store.db_path if hasattr(store, "db_path") else os.getenv(
"REELAY_DB_PATH", str(project_root / "data" / "reelay.db"))
conn = sqlite3.connect(db)
conn.row_factory = sqlite3.Row
# 任务按天/状态聚合由 SQL 直接统计;复用应用的 Store 连接,不为每个请求新开连接。
conn = store._conn
since = time.time() - days * 86400
daily = conn.execute(
"SELECT substr(datetime(updated_at,'unixepoch','localtime'),1,10) d, state, "
@@ -510,14 +507,13 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
by_title = conn.execute(
"SELECT title, state, count(*) n FROM tasks WHERE title IS NOT NULL "
"GROUP BY title ORDER BY n DESC LIMIT 15").fetchall()
total_bytes = conn.execute(
"SELECT COALESCE(SUM(CAST(json_extract(snapshot,'$.parsed.size') AS REAL)),0) "
"FROM tasks WHERE state='done' AND json_extract(snapshot,'$.parsed.size') IS NOT NULL"
).fetchone()[0]
conn.close()
# 解析出的 size 是 "15.12 GB" 这类文本,按单位换算后再求和。
sizes = conn.execute(
"SELECT json_extract(snapshot,'$.parsed.size') FROM tasks "
"WHERE state='done' AND json_extract(snapshot,'$.parsed.size') IS NOT NULL").fetchall()
return {
"counts": rows.get("counts", {}),
"counts": store.counts_by_state(),
"daily": [dict(r) for r in daily],
"top_titles": [dict(r) for r in by_title],
"estimated_done_gb": round(float(total_bytes or 0) / (1024 ** 3), 2),
"estimated_done_gb": round(sum(_parse_size_gb(row[0]) for row in sizes), 2),
}
+8 -4
View File
@@ -124,12 +124,15 @@ class TestDirectoryReads(unittest.TestCase):
self.assertEqual(result['via'],'oauth')
def test_expired_credentials_and_missing_directory(self):
client=Mock(); credentials=SimpleNamespace(has_oauth=False,cookie_client=lambda:client)
client=Mock()
credentials=SimpleNamespace(has_oauth=False,cookie_client=lambda:client)
client.fs_files.return_value={'state':False,'errno':99,'error':'请重新登录'}
with self.assertRaises(StorageBrowseError) as caught: browse_dirs(credentials,'0')
with self.assertRaises(StorageBrowseError) as caught:
browse_dirs(credentials,'0')
self.assertEqual(caught.exception.code,'credentials_expired')
client.fs_files.return_value={'state':True,'data':[],'path':[{'cid':'0'}]}
with self.assertRaises(StorageBrowseError) as caught: browse_dirs(credentials,'12')
with self.assertRaises(StorageBrowseError) as caught:
browse_dirs(credentials,'12')
self.assertEqual(caught.exception.code,'directory_missing')
def test_invalid_templates_rejected(self):
@@ -138,4 +141,5 @@ class TestDirectoryReads(unittest.TestCase):
'{{ title }}/{{ season|pad2 }}.{{ missing }}', '/{{ title }}.{{ ext }}',
'{missing}/{ext}',
]:
with self.assertRaises(ValueError): normalize_config({'organize':{'naming':{'tv':template}}})
with self.assertRaises(ValueError):
normalize_config({'organize':{'naming':{'tv':template}}})
+14
View File
@@ -594,6 +594,20 @@ class TestWebAuthGuard(unittest.TestCase):
self.assertEqual(body["decision"], "ask") # 未配 LLM:降级结果只能人工确认,而不是被当作“非影视”跳过
self.assertLess(body["confidence"]["score"], 0.5)
def test_stats_counts_and_size_use_app_store(self):
self.client.post("/api/auth/login", json={"password": "pw"})
store = self.app.state.store
for size in ("1.5 GB", "512 MB"):
ctx = message(msg_id=size)
ctx.parsed = ParsedResource(is_media=True, title="片", size=size)
ctx.state = TaskState.DONE
store.save_task(ctx)
body = self.client.get("/api/stats").json()
self.assertEqual(body["counts"], {"done": 2})
self.assertEqual(body["estimated_done_gb"], 2.0)
self.assertEqual(self.client.get("/api/tasks/advanced").status_code, 200)
self.assertEqual(self.client.get("/api/stats").status_code, 200) # 共享连接未被关闭
def test_health_hides_runtime_details_without_login(self):
self.assertEqual(self.client.get("/api/health").json(), {"ok": True, "service": "reelay"})
+3 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import json
import sqlite3
import tempfile
import time
import types
@@ -409,7 +410,7 @@ class RuntimeTest(unittest.IsolatedAsyncioTestCase):
await pipeline.accept(context())
await pipeline.dispatch_deliveries()
self.assertEqual(store.delivery_counts(), {"done": 1, "pending": 1})
for attempt in range(7):
for _ in range(7):
store._conn.execute("UPDATE outbox SET next_attempt=0 WHERE status='pending'")
store._conn.commit()
await pipeline.dispatch_deliveries()
@@ -432,7 +433,7 @@ class RuntimeTest(unittest.IsolatedAsyncioTestCase):
store = self.store()
store._conn.execute("CREATE TRIGGER fail_event BEFORE INSERT ON outbox BEGIN SELECT RAISE(ABORT, 'disk failure'); END")
pipeline = Pipeline(reg, store, {})
with self.assertRaises(Exception):
with self.assertRaises(sqlite3.IntegrityError):
await pipeline.accept(context())
self.assertEqual(store.counts_by_state(), {})
self.assertEqual(store.delivery_counts(), {})
+3 -1
View File
@@ -1 +1,3 @@
registry=https://registry.npmmirror.com/
# 固定官方源,避免本机全局镜像写进 lockfile。国内网络可临时覆盖:
# npm_config_registry=https://registry.npmmirror.com/ npm ci
registry=https://registry.npmjs.org/
+674 -312
View File
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -8,7 +8,8 @@
"build": "vite build",
"typecheck": "tsc -p tsconfig.app.json",
"lint": "concurrently npm:typecheck npm:lint:eslint",
"lint:eslint": "eslint src"
"lint:eslint": "eslint src",
"test": "vitest run"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.14",
@@ -45,6 +46,7 @@
"globals": "^17",
"typescript": "~5.9",
"typescript-eslint": "^8",
"vite": "^8"
"vite": "^8",
"vitest": "^4.1.12"
}
}
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api, apiUrl, ApiError } from '@/api/client';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('apiUrl', () => {
it('拼接 BASE_URL 与接口路径,并去掉多余的前导斜杠', () => {
expect(apiUrl('dashboard')).toBe('/api/dashboard');
expect(apiUrl('/events')).toBe('/api/events');
});
});
describe('request 错误处理', () => {
const respond = (status: number, body: unknown) =>
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(body), { status })));
it('使用后端 detail 作为错误文案并保留状态码', async () => {
respond(409, { detail: '任务不是待审批状态' });
await expect(api.dashboard()).rejects.toMatchObject({ status: 409, message: '任务不是待审批状态' });
});
it('把 FastAPI 校验错误数组拼成可读文案', async () => {
respond(422, { detail: [{ loc: ['body', 'password'], msg: 'Field required' }] });
const error = await api.dashboard().catch((err: unknown) => err);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).message).toBe('body.password:Field required');
});
it('非 JSON 错误响应回退到通用文案', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('<html>bad gateway</html>', { status: 502 })));
await expect(api.dashboard()).rejects.toMatchObject({ status: 502, message: '请求失败(502)' });
});
});
+15
View File
@@ -0,0 +1,15 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { STATE_META } from '@/lib/status';
describe('任务状态文案与后端状态机保持一致', () => {
it('后端 TaskState 的每个取值都有界面文案', () => {
// 直接读取后端契约,后端新增状态而前端遗漏时这里会失败。
const contracts = readFileSync(resolve(import.meta.dirname, '../../../reelay/core/contracts.py'), 'utf8');
const block = contracts.slice(contracts.indexOf('class TaskState'), contracts.indexOf('class ApprovalMode'));
const backendStates = [...block.matchAll(/^\s+[A-Z_]+ = "([a-z_]+)"/gm)].map((match) => match[1]);
expect(backendStates.length).toBeGreaterThan(10);
expect(Object.keys(STATE_META).sort()).toEqual(backendStates.sort());
});
});
-1
View File
@@ -79,4 +79,3 @@ CardFooter.displayName = "CardFooter"
export {
Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle
};
+1 -1
View File
@@ -1,4 +1,4 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./tailwind-theme.css";
@import "./typography.css";
@import "./typography.css";
+7
View File
@@ -0,0 +1,7 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: { alias: { '@': path.resolve(import.meta.dirname, 'src') } },
test: { environment: 'node', include: ['src/**/*.test.ts'] },
});