docs: 为全部代码补充中文注释并加入 AGENTS 注释规范
This commit is contained in:
@@ -1,20 +1,31 @@
|
||||
"""pytest 全局配置。
|
||||
|
||||
在测试进程启动时创建独立临时目录,并通过环境变量把应用的数据目录、数据库、
|
||||
存储和后台服务全部指向测试环境,避免污染本地开发数据。
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# 每个测试进程使用独立临时根目录,保证测试之间互不干扰。
|
||||
TEST_ROOT = Path(tempfile.mkdtemp(prefix="wov-api-test-"))
|
||||
os.environ["WOV_DATA_DIR"] = str(TEST_ROOT / "data")
|
||||
os.environ["WOV_DB_PATH"] = str(TEST_ROOT / "data" / "wov.db")
|
||||
os.environ["WOV_STORAGE_DIR"] = str(TEST_ROOT / "storage")
|
||||
# 回收线程轮询调快,便于空闲回收测试尽快完成。
|
||||
os.environ["WOV_REAP_INTERVAL_SECONDS"] = "0.1"
|
||||
# 就绪超时缩短,避免节点启动失败用例等待过久。
|
||||
os.environ["WOV_READY_TIMEOUT_SECONDS"] = "2"
|
||||
# 默认关闭自动种子和后台调度,测试显式控制执行时机。
|
||||
os.environ["WOV_AUTO_SEED"] = "0"
|
||||
os.environ["WOV_SCHEDULER_ENABLED"] = "0"
|
||||
|
||||
|
||||
def _cleanup() -> None:
|
||||
"""进程退出时清理临时测试目录。"""
|
||||
shutil.rmtree(TEST_ROOT, ignore_errors=True)
|
||||
|
||||
|
||||
|
||||
Vendored
+7
@@ -1,3 +1,8 @@
|
||||
"""失败健康检查节点夹具。
|
||||
|
||||
打印就绪端口但 /health 返回 500,用于验证 NodeManager 的启动失败处理。
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import threading
|
||||
|
||||
@@ -12,6 +17,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
# 打印就绪行让 NodeManager 认为进程已启动。
|
||||
print(f"WOV_NODE_READY port={server.server_address[1]}", flush=True)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
# 主线程挂起,保持服务进程存活。
|
||||
threading.Event().wait()
|
||||
|
||||
Vendored
+5
@@ -1,3 +1,8 @@
|
||||
"""错误就绪输出节点夹具。
|
||||
|
||||
打印不带端口的 WOV_NODE_READY 行,用于验证 NodeManager 对格式异常的处理。
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
print("WOV_NODE_READY", flush=True)
|
||||
|
||||
Vendored
+5
@@ -1,3 +1,8 @@
|
||||
"""异常状态码健康检查节点夹具。
|
||||
|
||||
/health 返回 201,用于验证 NodeManager 只接受 200 状态码。
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import threading
|
||||
|
||||
|
||||
Vendored
+7
@@ -1,3 +1,9 @@
|
||||
"""失败调用节点夹具。
|
||||
|
||||
健康检查成功但 /invoke 始终返回 500,支持通过 WOV_FAIL_INVALID_JSON 环境变量
|
||||
切换为非法 JSON,用于验证 NodeManager 的错误响应解析分支。
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
@@ -19,6 +25,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
if os.environ.get("WOV_FAIL_INVALID_JSON") == "1":
|
||||
body = b"not-json"
|
||||
else:
|
||||
# 默认返回协议格式的失败 JSON,验证错误信息透传。
|
||||
body = json.dumps({"status": "failed", "error": "boom"}).encode("utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
|
||||
Vendored
+5
@@ -1,3 +1,8 @@
|
||||
"""stderr 捕获节点夹具。
|
||||
|
||||
启动时向标准错误输出一行警告,用于验证 NodeManager 的 stderr_tail 缓存。
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import sys
|
||||
import threading
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""应用级 API 冒烟测试。
|
||||
|
||||
使用 FastAPI TestClient 验证健康检查、静态页面、节点注册校验以及
|
||||
节点生命周期与手动调用等真实链路。
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -6,12 +12,14 @@ from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
|
||||
WORKSPACE = Path(__file__).resolve().parent.parent.parent
|
||||
# 直接读取 Echo 节点 manifest 作为注册测试的真实数据。
|
||||
ECHO_MANIFEST = json.loads(
|
||||
(WORKSPACE / "wov-node-echo" / "node.manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def test_health() -> None:
|
||||
"""验证静态首页、OpenAPI 文档与健康探针均可访问。"""
|
||||
with TestClient(app) as client:
|
||||
root = client.get("/", follow_redirects=False)
|
||||
assert root.status_code == 200
|
||||
@@ -26,6 +34,7 @@ def test_health() -> None:
|
||||
|
||||
|
||||
def test_register_validation_errors() -> None:
|
||||
"""验证非法节点注册请求会被 Pydantic 或协议校验拒绝。"""
|
||||
with TestClient(app) as client:
|
||||
assert client.post("/api/admin/nodes", json={}).status_code == 422
|
||||
invalid = dict(ECHO_MANIFEST, id="")
|
||||
@@ -37,6 +46,7 @@ def test_register_validation_errors() -> None:
|
||||
|
||||
|
||||
def test_node_lifecycle_and_invoke() -> None:
|
||||
"""验证注册、查询、调用、停止与删除节点的完整生命周期。"""
|
||||
with TestClient(app) as client:
|
||||
registered = client.post("/api/admin/nodes", json=ECHO_MANIFEST)
|
||||
assert registered.status_code == 200
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""用户应用 API 测试。
|
||||
|
||||
覆盖已发布应用的上传建任务、进度查询、产物下载、失败重试以及
|
||||
未发布/无版本工作流的拒绝逻辑。
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -9,6 +15,7 @@ WORKSPACE = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def _create_published_echo_workflow(client) -> str:
|
||||
"""注册 Echo 节点并创建一个已发布的单节点工作流。"""
|
||||
definition = {
|
||||
"name": "echo-flow",
|
||||
"version": 1,
|
||||
@@ -42,6 +49,7 @@ def _create_published_echo_workflow(client) -> str:
|
||||
|
||||
|
||||
def test_upload_run_progress_and_download() -> None:
|
||||
"""验证上传文件建任务、手动执行、查询产物与下载的完整流程。"""
|
||||
with TestClient(app) as client:
|
||||
workflow_id = _create_published_echo_workflow(client)
|
||||
apps = client.get("/api/apps")
|
||||
@@ -101,6 +109,7 @@ def test_upload_run_progress_and_download() -> None:
|
||||
|
||||
|
||||
def test_upload_rejects_unpublished_workflow() -> None:
|
||||
"""验证草稿或不存在的工作流不能被用户发起任务。"""
|
||||
with TestClient(app) as client:
|
||||
client.post(
|
||||
"/api/admin/workflows",
|
||||
@@ -129,6 +138,7 @@ def test_upload_rejects_unpublished_workflow() -> None:
|
||||
|
||||
|
||||
def test_upload_rejects_workflow_without_version() -> None:
|
||||
"""验证已发布但没有任何版本的工作流返回 422。"""
|
||||
with TestClient(app) as client:
|
||||
db = app.state.db
|
||||
db.upsert_workflow(
|
||||
@@ -142,6 +152,7 @@ def test_upload_rejects_workflow_without_version() -> None:
|
||||
|
||||
|
||||
def test_retry_failed_run_requeues_and_reruns() -> None:
|
||||
"""验证失败任务重试会清空旧产物并重新执行成功。"""
|
||||
with TestClient(app) as client:
|
||||
workflow_id = _create_published_echo_workflow(client)
|
||||
uploaded = client.post(
|
||||
@@ -183,6 +194,7 @@ def test_retry_failed_run_requeues_and_reruns() -> None:
|
||||
|
||||
|
||||
def test_retry_rejects_non_failed_and_missing_runs() -> None:
|
||||
"""验证只有 FAILED 状态且存在的任务才能重试。"""
|
||||
with TestClient(app) as client:
|
||||
workflow_id = _create_published_echo_workflow(client)
|
||||
uploaded = client.post(
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""数据库层单元测试。
|
||||
|
||||
直接对 Database 方法调用真实 SQLite 路径,覆盖节点、实例、工作流、
|
||||
版本、任务与产物的增删改查。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.db import Database
|
||||
@@ -5,6 +11,7 @@ from wov_sdk.models import NodeManifest
|
||||
|
||||
|
||||
def manifest() -> NodeManifest:
|
||||
"""构造最小合法节点 manifest 供 CRUD 测试复用。"""
|
||||
return NodeManifest(
|
||||
id="echo",
|
||||
name="Echo",
|
||||
@@ -16,6 +23,7 @@ def manifest() -> NodeManifest:
|
||||
|
||||
|
||||
def test_node_crud(tmp_path) -> None:
|
||||
"""验证节点的注册、覆盖更新与删除。"""
|
||||
db = Database(tmp_path / "wov.db")
|
||||
assert db.get_node("echo") is None
|
||||
assert db.list_nodes() == []
|
||||
@@ -34,6 +42,7 @@ def test_node_crud(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_instance_crud(tmp_path) -> None:
|
||||
"""验证节点实例记录的插入、状态更新与删除。"""
|
||||
db = Database(tmp_path / "wov.db")
|
||||
db.upsert_node(manifest())
|
||||
instance = {
|
||||
@@ -59,6 +68,7 @@ def test_instance_crud(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_workflow_crud(tmp_path) -> None:
|
||||
"""验证工作流概要的插入、发布标记更新与删除。"""
|
||||
db = Database(tmp_path / "wov.db")
|
||||
workflow = {
|
||||
"id": "demo",
|
||||
@@ -79,6 +89,7 @@ def test_workflow_crud(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_workflow_versions(tmp_path) -> None:
|
||||
"""验证工作流版本的写入、最新版本查询与列表。"""
|
||||
db = Database(tmp_path / "wov.db")
|
||||
db.upsert_workflow(
|
||||
{"id": "demo", "name": "Demo", "published": 1, "latest_version": 2}
|
||||
@@ -101,6 +112,7 @@ def test_workflow_versions(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_run_and_artifact_crud(tmp_path) -> None:
|
||||
"""验证任务与产物的创建、查询、更新与删除。"""
|
||||
db = Database(tmp_path / "wov.db")
|
||||
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
|
||||
now = "2026-01-01T00:00:00+00:00"
|
||||
@@ -145,6 +157,7 @@ def test_run_and_artifact_crud(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_reset_run_clears_error_and_artifacts(tmp_path) -> None:
|
||||
"""验证 reset_run 会把失败任务恢复到排队状态并清空旧产物。"""
|
||||
db = Database(tmp_path / "wov.db")
|
||||
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
|
||||
now = "2026-01-01T00:00:00+00:00"
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""NodeManager 单元测试。
|
||||
|
||||
覆盖命令解析、环境变量注入、CUDA 路径处理、进程启动失败分支、实例复用、
|
||||
调用错误处理、回收线程以及停止清理等真实生命周期路径。
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -19,6 +25,7 @@ from app.node_manager import (
|
||||
from wov_sdk.models import InvokeRequest, NodeManifest
|
||||
|
||||
WORKSPACE = Path(__file__).resolve().parent.parent.parent
|
||||
# 从真实节点仓库加载 Echo manifest,测试使用真实协议数据。
|
||||
_BASE_ECHO_MANIFEST = NodeManifest.load(WORKSPACE / "wov-node-echo" / "node.manifest.json")
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||
|
||||
@@ -29,14 +36,17 @@ def db(tmp_path) -> Database:
|
||||
|
||||
|
||||
def _register(db: Database, manifest: NodeManifest) -> None:
|
||||
"""把 manifest 写入测试数据库,模拟注册节点。"""
|
||||
db.upsert_node(manifest)
|
||||
|
||||
|
||||
def echo_manifest() -> NodeManifest:
|
||||
"""返回 Echo 节点 manifest 的副本,避免测试间共享可变对象。"""
|
||||
return NodeManifest.from_dict(_BASE_ECHO_MANIFEST.to_dict())
|
||||
|
||||
|
||||
def test_time_helpers() -> None:
|
||||
"""验证时间工具对正常、空与非法输入的返回。"""
|
||||
now = _now_iso()
|
||||
assert datetime.fromisoformat(now)
|
||||
assert _idle_seconds(now) < 0.1
|
||||
@@ -47,6 +57,7 @@ def test_time_helpers() -> None:
|
||||
|
||||
|
||||
def test_resolve_command_and_env(db: Database) -> None:
|
||||
"""验证 python 命令解析回退与固定命令保持原样。"""
|
||||
manager = NodeManager(db)
|
||||
python_command = NodeManifest(
|
||||
id="x",
|
||||
@@ -71,6 +82,7 @@ def test_resolve_command_and_env(db: Database) -> None:
|
||||
|
||||
|
||||
def test_resolve_command_prefers_node_venv(db: Database, tmp_path, monkeypatch) -> None:
|
||||
"""验证 Windows 与 Linux 虚拟环境解释器优先级。"""
|
||||
monkeypatch.setattr("app.node_manager.WORKSPACE_ROOT", tmp_path)
|
||||
repo = tmp_path / "wov-node-demo"
|
||||
python_exe = repo / ".venv" / "Scripts" / "python.exe"
|
||||
@@ -95,6 +107,7 @@ def test_resolve_command_prefers_node_venv(db: Database, tmp_path, monkeypatch)
|
||||
|
||||
|
||||
def test_cuda_library_dirs_finds_unix_nvidia_libs(tmp_path) -> None:
|
||||
"""验证 Linux site-packages 下的 cublas/cudnn lib 目录被发现。"""
|
||||
cublas = (
|
||||
tmp_path / ".venv" / "lib" / "python3.12" / "site-packages" / "nvidia" / "cublas" / "lib"
|
||||
)
|
||||
@@ -111,6 +124,7 @@ def test_cuda_library_dirs_finds_unix_nvidia_libs(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_cuda_library_dirs_finds_windows_nvidia_bins(tmp_path) -> None:
|
||||
"""验证 Windows Lib/site-packages 下的 bin 目录被发现。"""
|
||||
cublas = tmp_path / ".venv" / "Lib" / "site-packages" / "nvidia" / "cublas" / "bin"
|
||||
cublas.mkdir(parents=True)
|
||||
|
||||
@@ -120,6 +134,7 @@ def test_cuda_library_dirs_finds_windows_nvidia_bins(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_with_cuda_library_path_prepends_on_posix(monkeypatch) -> None:
|
||||
"""验证 Linux 下 CUDA 目录插入 LD_LIBRARY_PATH 开头。"""
|
||||
monkeypatch.setattr(os, "name", "posix")
|
||||
monkeypatch.setattr(
|
||||
"app.node_manager._cuda_library_dirs",
|
||||
@@ -134,6 +149,7 @@ def test_with_cuda_library_path_prepends_on_posix(monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_with_cuda_library_path_uses_path_on_windows(monkeypatch) -> None:
|
||||
"""验证 Windows 下 CUDA 目录插入 PATH 开头。"""
|
||||
monkeypatch.setattr(os, "name", "nt")
|
||||
monkeypatch.setattr(
|
||||
"app.node_manager._cuda_library_dirs",
|
||||
@@ -148,6 +164,7 @@ def test_with_cuda_library_path_uses_path_on_windows(monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_with_cuda_library_path_without_existing(monkeypatch) -> None:
|
||||
"""验证环境变量原本不存在时直接创建。"""
|
||||
monkeypatch.setattr(os, "name", "posix")
|
||||
monkeypatch.setattr(
|
||||
"app.node_manager._cuda_library_dirs",
|
||||
@@ -160,6 +177,7 @@ def test_with_cuda_library_path_without_existing(monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_with_cuda_library_path_deduplicates(monkeypatch) -> None:
|
||||
"""验证已存在的目录不会被重复追加。"""
|
||||
monkeypatch.setattr(os, "name", "posix")
|
||||
monkeypatch.setattr(
|
||||
"app.node_manager._cuda_library_dirs",
|
||||
@@ -176,6 +194,7 @@ def test_with_cuda_library_path_deduplicates(monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_with_cuda_library_path_all_present(monkeypatch) -> None:
|
||||
"""验证所有目录都已存在时环境变量保持不变。"""
|
||||
monkeypatch.setattr(os, "name", "posix")
|
||||
monkeypatch.setattr(
|
||||
"app.node_manager._cuda_library_dirs",
|
||||
@@ -189,6 +208,7 @@ def test_with_cuda_library_path_all_present(monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_with_cuda_library_path_without_libs(monkeypatch) -> None:
|
||||
"""验证没有任何 CUDA 库时环境变量原样返回。"""
|
||||
monkeypatch.setattr("app.node_manager._cuda_library_dirs", lambda repo_dir: [])
|
||||
|
||||
env = _with_cuda_library_path({"LD_LIBRARY_PATH": "/usr/lib/foo"}, Path("/repo"))
|
||||
@@ -197,6 +217,7 @@ def test_with_cuda_library_path_without_libs(monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_node_env_injects_cuda_library_path(db: Database, tmp_path, monkeypatch) -> None:
|
||||
"""验证 _node_env 会注入 SDK、manifest 变量与 CUDA 路径。"""
|
||||
monkeypatch.setattr("app.node_manager.WORKSPACE_ROOT", tmp_path)
|
||||
repo = tmp_path / "wov-node-whisper"
|
||||
cublas = repo / ".venv" / "lib" / "python3.12" / "site-packages" / "nvidia" / "cublas" / "lib"
|
||||
@@ -219,12 +240,14 @@ def test_node_env_injects_cuda_library_path(db: Database, tmp_path, monkeypatch)
|
||||
|
||||
|
||||
def test_acquire_unregistered(db: Database) -> None:
|
||||
"""验证未注册节点无法获取实例。"""
|
||||
manager = NodeManager(db)
|
||||
with pytest.raises(ValueError):
|
||||
manager.acquire("missing")
|
||||
|
||||
|
||||
def test_start_missing_repo(db: Database) -> None:
|
||||
"""验证 repo_dir 不存在时启动失败并记录 error。"""
|
||||
manifest = echo_manifest()
|
||||
manifest.repo_dir = "missing-repo"
|
||||
_register(db, manifest)
|
||||
@@ -235,6 +258,7 @@ def test_start_missing_repo(db: Database) -> None:
|
||||
|
||||
|
||||
def test_start_command_not_found(db: Database) -> None:
|
||||
"""验证命令不存在时启动失败并记录 error。"""
|
||||
manifest = echo_manifest()
|
||||
manifest.command = ["definitely-not-a-real-wov-command"]
|
||||
_register(db, manifest)
|
||||
@@ -245,6 +269,7 @@ def test_start_command_not_found(db: Database) -> None:
|
||||
|
||||
|
||||
def test_start_no_ready_timeout(db: Database, monkeypatch) -> None:
|
||||
"""验证超时未打印就绪行时启动失败。"""
|
||||
monkeypatch.setattr("app.node_manager.NODE_READY_TIMEOUT_SECONDS", 0.2)
|
||||
manifest = echo_manifest()
|
||||
manifest.command = ["python", "-c", "import time; time.sleep(0.5)"]
|
||||
@@ -256,6 +281,7 @@ def test_start_no_ready_timeout(db: Database, monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_start_bad_ready_line(db: Database) -> None:
|
||||
"""验证就绪行缺少端口时启动失败。"""
|
||||
manifest = echo_manifest()
|
||||
manifest.command = ["python", "-u", str(FIXTURES / "bad_ready_node.py")]
|
||||
_register(db, manifest)
|
||||
@@ -266,6 +292,7 @@ def test_start_bad_ready_line(db: Database) -> None:
|
||||
|
||||
|
||||
def test_start_health_check_failure(db: Database) -> None:
|
||||
"""验证 /health 返回错误状态码时启动失败。"""
|
||||
manifest = echo_manifest()
|
||||
manifest.command = ["python", "-u", str(FIXTURES / "bad_node.py")]
|
||||
_register(db, manifest)
|
||||
@@ -276,6 +303,7 @@ def test_start_health_check_failure(db: Database) -> None:
|
||||
|
||||
|
||||
def test_start_health_check_bad_status(db: Database) -> None:
|
||||
"""验证 /health 返回非 200 状态码时启动失败。"""
|
||||
manifest = echo_manifest()
|
||||
manifest.command = ["python", "-u", str(FIXTURES / "bad_status_node.py")]
|
||||
_register(db, manifest)
|
||||
@@ -286,6 +314,7 @@ def test_start_health_check_bad_status(db: Database) -> None:
|
||||
|
||||
|
||||
def test_stderr_is_captured(db: Database) -> None:
|
||||
"""验证节点 stderr 会被缓存到运行时供诊断。"""
|
||||
manifest = echo_manifest()
|
||||
manifest.command = ["python", "-u", str(FIXTURES / "stderr_node.py")]
|
||||
_register(db, manifest)
|
||||
@@ -301,6 +330,7 @@ def test_stderr_is_captured(db: Database) -> None:
|
||||
|
||||
|
||||
def test_acquire_reuses_ready_instance(db: Database) -> None:
|
||||
"""验证释放后的就绪实例会被再次复用。"""
|
||||
_register(db, echo_manifest())
|
||||
manager = NodeManager(db)
|
||||
try:
|
||||
@@ -315,6 +345,7 @@ def test_acquire_reuses_ready_instance(db: Database) -> None:
|
||||
|
||||
|
||||
def test_invoke_success(db: Database, tmp_path) -> None:
|
||||
"""验证真实节点调用返回完成状态与输出。"""
|
||||
_register(db, echo_manifest())
|
||||
manager = NodeManager(db)
|
||||
try:
|
||||
@@ -334,6 +365,7 @@ def test_invoke_success(db: Database, tmp_path) -> None:
|
||||
|
||||
|
||||
def test_invoke_http_error(db: Database, tmp_path) -> None:
|
||||
"""验证节点返回协议 JSON 错误时透传错误信息。"""
|
||||
manifest = echo_manifest()
|
||||
manifest.command = ["python", "-u", str(FIXTURES / "failing_node.py")]
|
||||
_register(db, manifest)
|
||||
@@ -355,6 +387,7 @@ def test_invoke_http_error(db: Database, tmp_path) -> None:
|
||||
|
||||
|
||||
def test_invoke_http_error_invalid_json(db: Database, tmp_path) -> None:
|
||||
"""验证节点返回非法 JSON 时降级为通用错误信息。"""
|
||||
manifest = echo_manifest()
|
||||
manifest.command = ["python", "-u", str(FIXTURES / "failing_node.py")]
|
||||
manifest.env["WOV_FAIL_INVALID_JSON"] = "1"
|
||||
@@ -377,6 +410,7 @@ def test_invoke_http_error_invalid_json(db: Database, tmp_path) -> None:
|
||||
|
||||
|
||||
def test_invoke_network_error(db: Database, tmp_path) -> None:
|
||||
"""验证进程退出导致连接失败时返回 failed。"""
|
||||
_register(db, echo_manifest())
|
||||
manager = NodeManager(db)
|
||||
try:
|
||||
@@ -399,6 +433,7 @@ def test_invoke_network_error(db: Database, tmp_path) -> None:
|
||||
|
||||
|
||||
def test_invoke_uses_configured_timeout(db: Database, monkeypatch) -> None:
|
||||
"""验证调用超时取自配置常量。"""
|
||||
_register(db, echo_manifest())
|
||||
manager = NodeManager(db)
|
||||
captured = {}
|
||||
@@ -444,16 +479,19 @@ def test_invoke_uses_configured_timeout(db: Database, monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_release_unknown(db: Database) -> None:
|
||||
"""验证释放未知实例是无害操作。"""
|
||||
manager = NodeManager(db)
|
||||
manager.release("missing")
|
||||
|
||||
|
||||
def test_stop_unknown_instance(db: Database) -> None:
|
||||
"""验证停止未知实例是无害操作。"""
|
||||
manager = NodeManager(db)
|
||||
manager.stop_instance("missing")
|
||||
|
||||
|
||||
def test_stop_all_for_node(db: Database) -> None:
|
||||
"""验证 stop_all_for_node 会停止该节点全部实例。"""
|
||||
_register(db, echo_manifest())
|
||||
manager = NodeManager(db)
|
||||
runtime, _ = manager.acquire("echo")
|
||||
@@ -463,6 +501,7 @@ def test_stop_all_for_node(db: Database) -> None:
|
||||
|
||||
|
||||
def test_stop_locked_without_process(db: Database) -> None:
|
||||
"""验证无进程句柄的实例也能被标记停止。"""
|
||||
_register(db, echo_manifest())
|
||||
manager = NodeManager(db)
|
||||
runtime = NodeRuntime(
|
||||
@@ -478,6 +517,7 @@ def test_stop_locked_without_process(db: Database) -> None:
|
||||
|
||||
|
||||
def test_stop_process_already_exited() -> None:
|
||||
"""验证进程已退出时停止流程直接返回。"""
|
||||
class FakeProcess:
|
||||
def poll(self):
|
||||
return 0
|
||||
@@ -487,6 +527,7 @@ def test_stop_process_already_exited() -> None:
|
||||
|
||||
|
||||
def test_stop_process_timeout() -> None:
|
||||
"""验证优雅终止超时后强制 kill。"""
|
||||
class FakeProcess:
|
||||
def poll(self):
|
||||
return None
|
||||
@@ -510,6 +551,7 @@ def test_stop_process_timeout() -> None:
|
||||
|
||||
|
||||
def test_reaper_recycles_idle(db: Database, tmp_path) -> None:
|
||||
"""验证空闲超过 TTL 的实例会被回收线程停止。"""
|
||||
manifest = echo_manifest()
|
||||
manifest.idle_ttl_seconds = 0
|
||||
_register(db, manifest)
|
||||
@@ -533,6 +575,7 @@ def test_reaper_recycles_idle(db: Database, tmp_path) -> None:
|
||||
|
||||
|
||||
def test_reaper_keeps_warm(db: Database, tmp_path) -> None:
|
||||
"""验证 keep_warm 实例即使空闲也不会被回收。"""
|
||||
manifest = echo_manifest()
|
||||
manifest.idle_ttl_seconds = 0
|
||||
manifest.keep_warm = True
|
||||
@@ -557,6 +600,7 @@ def test_reaper_keeps_warm(db: Database, tmp_path) -> None:
|
||||
|
||||
|
||||
def test_reaper_keeps_busy(db: Database) -> None:
|
||||
"""验证正在被占用的实例不会被回收。"""
|
||||
manifest = echo_manifest()
|
||||
manifest.idle_ttl_seconds = 0
|
||||
_register(db, manifest)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""调度器单元测试。
|
||||
|
||||
覆盖拓扑排序、任务执行成功/失败分支、输入引用解析、MIME 推断以及
|
||||
后台轮询线程的启动与停止。
|
||||
"""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -15,10 +21,12 @@ from wov_sdk.models import (
|
||||
|
||||
|
||||
def _db(tmp_path) -> Database:
|
||||
"""在临时目录创建独立数据库。"""
|
||||
return Database(tmp_path / "wov.db")
|
||||
|
||||
|
||||
def _echo_definition() -> WorkflowDefinition:
|
||||
"""构造引用 Echo 节点的单步骤工作流定义。"""
|
||||
return WorkflowDefinition(
|
||||
name="echo-flow",
|
||||
version=1,
|
||||
@@ -36,6 +44,7 @@ def _echo_definition() -> WorkflowDefinition:
|
||||
|
||||
|
||||
def test_topological_sort() -> None:
|
||||
"""验证 DAG 排序保持依赖顺序,并拒绝环与未知边。"""
|
||||
definition = WorkflowDefinition(
|
||||
name="dag",
|
||||
version=1,
|
||||
@@ -80,6 +89,7 @@ def test_topological_sort() -> None:
|
||||
|
||||
|
||||
def test_execute_echo_workflow(tmp_path) -> None:
|
||||
"""验证排队任务可被完整执行并登记全部产物。"""
|
||||
db = _db(tmp_path)
|
||||
input_file = tmp_path / "input.txt"
|
||||
input_file.write_text("hello scheduler", encoding="utf-8")
|
||||
@@ -117,6 +127,7 @@ def test_execute_echo_workflow(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_execute_run_missing_workflow(tmp_path, monkeypatch) -> None:
|
||||
"""验证工作流记录缺失时任务被标记为失败。"""
|
||||
db = _db(tmp_path)
|
||||
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
|
||||
now = "2026-01-01T00:00:00+00:00"
|
||||
@@ -142,6 +153,7 @@ def test_execute_run_missing_workflow(tmp_path, monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_execute_run_missing_version(tmp_path) -> None:
|
||||
"""验证版本记录缺失时任务被标记为失败。"""
|
||||
db = _db(tmp_path)
|
||||
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
|
||||
now = "2026-01-01T00:00:00+00:00"
|
||||
@@ -166,6 +178,7 @@ def test_execute_run_missing_version(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_execute_run_missing_node(tmp_path) -> None:
|
||||
"""验证未注册节点被调用时任务失败。"""
|
||||
db = _db(tmp_path)
|
||||
definition = WorkflowDefinition(
|
||||
name="bad",
|
||||
@@ -204,6 +217,7 @@ def test_execute_run_missing_node(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_resolve_ref_and_mime(tmp_path) -> None:
|
||||
"""验证输入引用解析、MIME 推断与文件大小读取。"""
|
||||
db = _db(tmp_path)
|
||||
manager = NodeManager(db)
|
||||
scheduler = WorkflowScheduler(db, manager, tmp_path / "storage")
|
||||
@@ -230,6 +244,7 @@ def test_resolve_ref_and_mime(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_execute_unknown_or_non_queued_run(tmp_path) -> None:
|
||||
"""验证未知任务或非排队任务会被忽略。"""
|
||||
db = _db(tmp_path)
|
||||
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
|
||||
now = "2026-01-01T00:00:00+00:00"
|
||||
@@ -255,6 +270,7 @@ def test_execute_unknown_or_non_queued_run(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_execute_missing_input(tmp_path) -> None:
|
||||
"""验证输入引用无法解析时任务失败。"""
|
||||
db = _db(tmp_path)
|
||||
definition = WorkflowDefinition(
|
||||
name="missing-input",
|
||||
@@ -293,6 +309,7 @@ def test_execute_missing_input(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_execute_node_failed_response(tmp_path) -> None:
|
||||
"""验证节点返回 failed 时任务被标记为失败。"""
|
||||
db = _db(tmp_path)
|
||||
manifest = NodeManifest(
|
||||
id="fail-node",
|
||||
@@ -340,6 +357,7 @@ def test_execute_node_failed_response(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_scheduler_start_stop_loop(tmp_path) -> None:
|
||||
"""验证调度线程可重复启动并正常停止。"""
|
||||
db = _db(tmp_path)
|
||||
manager = NodeManager(db)
|
||||
scheduler = WorkflowScheduler(db, manager, tmp_path / "storage", interval_seconds=0.05)
|
||||
@@ -353,6 +371,7 @@ def test_scheduler_start_stop_loop(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_scheduler_background_executes_queued_run(tmp_path) -> None:
|
||||
"""验证后台线程会自动执行排队中的任务。"""
|
||||
db = _db(tmp_path)
|
||||
input_file = tmp_path / "input.txt"
|
||||
input_file.write_text("background", encoding="utf-8")
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""种子数据测试。
|
||||
|
||||
验证启动种子逻辑会注册节点仓库、创建演示工作流且保持幂等,并验证
|
||||
开启种子与调度器后的应用生命周期。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -10,6 +16,7 @@ WORKSPACE = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def test_seed_nodes_and_demo_workflow(tmp_path) -> None:
|
||||
"""验证 seed_nodes 注册节点、seed_demo_workflow 创建演示流程且幂等。"""
|
||||
db = Database(tmp_path / "wov.db")
|
||||
seed_nodes(db, WORKSPACE)
|
||||
node_ids = [node.id for node in db.list_nodes()]
|
||||
@@ -27,10 +34,12 @@ def test_seed_nodes_and_demo_workflow(tmp_path) -> None:
|
||||
|
||||
empty_workspace = tmp_path / "empty"
|
||||
(empty_workspace / "wov-node-empty").mkdir(parents=True)
|
||||
# 目录存在但没有 manifest 时不应报错。
|
||||
seed_nodes(db, empty_workspace)
|
||||
|
||||
|
||||
def test_lifespan_with_seed_and_scheduler(monkeypatch) -> None:
|
||||
"""验证启用自动种子与调度器后应用正常启动且 demo 应用可见。"""
|
||||
monkeypatch.setenv("WOV_AUTO_SEED", "1")
|
||||
monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "1")
|
||||
with TestClient(app) as client:
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""uvicorn 冒烟测试。
|
||||
|
||||
用真实套接字启动 uvicorn 服务并请求 /health,验证应用能脱离 TestClient
|
||||
在实际 Web 服务环境中正常工作。
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
@@ -8,6 +14,7 @@ from app.main import app
|
||||
|
||||
|
||||
def test_uvicorn_serves_app_over_real_socket() -> None:
|
||||
"""验证 uvicorn 监听真实端口后健康检查可用。"""
|
||||
config = Config(app=app, host="127.0.0.1", port=0, log_level="error")
|
||||
server = Server(config)
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"""工作流管理 API 测试。
|
||||
|
||||
覆盖工作流的创建、查询、校验、发布、版本列表与删除等管理接口。
|
||||
"""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def definition() -> dict:
|
||||
"""构造一个引用 Echo 节点的合法工作流定义。"""
|
||||
return {
|
||||
"name": "echo-flow",
|
||||
"version": 1,
|
||||
@@ -21,6 +27,7 @@ def definition() -> dict:
|
||||
|
||||
|
||||
def test_workflow_crud_and_publish() -> None:
|
||||
"""验证工作流 CRUD、校验、发布与版本列表的完整流程。"""
|
||||
with TestClient(app) as client:
|
||||
created = client.post(
|
||||
"/api/admin/workflows",
|
||||
@@ -64,6 +71,7 @@ def test_workflow_crud_and_publish() -> None:
|
||||
|
||||
|
||||
def test_workflow_slug_without_id() -> None:
|
||||
"""验证未提供 ID 时后端会从名称生成 slug。"""
|
||||
with TestClient(app) as client:
|
||||
created = client.post(
|
||||
"/api/admin/workflows",
|
||||
@@ -77,6 +85,7 @@ def test_workflow_slug_without_id() -> None:
|
||||
|
||||
|
||||
def test_workflow_validation_error() -> None:
|
||||
"""验证重复节点 ID 的 DAG 会被拒绝。"""
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/admin/workflows",
|
||||
@@ -98,6 +107,7 @@ def test_workflow_validation_error() -> None:
|
||||
|
||||
|
||||
def test_publish_workflow_without_version() -> None:
|
||||
"""验证没有版本记录的工作流不能发布。"""
|
||||
with TestClient(app) as client:
|
||||
db = app.state.db
|
||||
db.upsert_workflow(
|
||||
|
||||
Reference in New Issue
Block a user