Files
CNWei 1914d38451 feat: 本地 uv 与托管 Python 环境管理
- 新增 uv 管理:只认程序目录副本或指定路径,支持从科大镜像下载并按 sha256 校验

- uv 环境变量(UV_*)可配置,默认相对程序目录(python / uv-cache),同时注入 uv 调用与任务进程

- 新增托管虚拟环境与 Python 版本管理:用 uv 下载解释器、预创建环境、查看已装包

- 新建任务保存时自动创建 .venv,运行期只校验、不再自动装依赖;镜像只在装包时使用

- 前端改为顶部品牌栏 + 三栏工作台,uv / 镜像源 / 虚拟环境各自独立弹窗

- 发布包构建后去掉工作区继承的低完整性标签

- 修复调度集成测试并发时误取 skipped 运行的问题
2026-11-07 16:57:27 +08:00

268 lines
7.7 KiB
Rust

use std::{fs, path::Path};
use axum::{
Router,
body::Body,
http::{Request, StatusCode, header},
};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use tempfile::TempDir;
use tower::ServiceExt;
async fn test_app() -> (Router, TempDir) {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let pool = task_pilot_backend::database::connect("sqlite::memory:")
.await
.expect("test database should initialize");
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
.await
.expect("test app should start");
(app, temp_dir)
}
async fn fetch_token(app: &Router) -> String {
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/session")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
let body = response
.into_body()
.collect()
.await
.expect("session body should be readable")
.to_bytes();
let json: Value = serde_json::from_slice(&body).expect("session should be JSON");
json["token"]
.as_str()
.expect("session token should be a string")
.to_owned()
}
async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
post_json(app, "/api/v1/python/venv", token, payload).await
}
async fn post_create(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) {
post_json(app, "/api/v1/python/venv/create", token, payload).await
}
async fn post_json(
app: &Router,
uri: &str,
token: Option<&str>,
payload: Value,
) -> (StatusCode, Value) {
let mut builder = Request::builder()
.method("POST")
.uri(uri)
.header(header::CONTENT_TYPE, "application/json");
if let Some(token) = token {
builder = builder.header("x-taskpilot-token", token);
}
let response = app
.clone()
.oneshot(
builder
.body(Body::from(payload.to_string()))
.expect("request should build"),
)
.await
.expect("router should respond");
let status = response.status();
let body = response
.into_body()
.collect()
.await
.expect("response body should be readable")
.to_bytes();
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
}
fn create_fake_venv(project: &Path) {
let venv = project.join(".venv");
fs::create_dir_all(venv.join("Scripts")).expect("venv directory should be created");
fs::write(venv.join("pyvenv.cfg"), "home = C:\\Python\n")
.expect("pyvenv.cfg should be written");
fs::write(venv.join("Scripts").join("python.exe"), "")
.expect("python.exe placeholder should be written");
}
#[tokio::test]
async fn venv_probe_requires_management_token() {
let (app, _temp_dir) = test_app().await;
let (status, body) = post_probe(
&app,
None,
json!({ "working_dir": ".", "interpreter": "python.exe" }),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}
#[tokio::test]
async fn venv_probe_reports_linked_environment() {
let (app, temp_dir) = test_app().await;
let project = temp_dir.path().join("project");
fs::create_dir_all(&project).expect("project directory should be created");
create_fake_venv(&project);
let token = fetch_token(&app).await;
let (status, body) = post_probe(
&app,
Some(&token),
json!({
"working_dir": project.to_str().expect("project path should be UTF-8"),
"interpreter": "python.exe",
"venv": null
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "linked");
assert_eq!(body["interpreter_kind"], "python");
assert_eq!(body["source"], "working_directory");
let expected_python = Path::new(".venv")
.join("Scripts")
.join("python.exe")
.to_string_lossy()
.into_owned();
assert_eq!(body["display_path"], expected_python);
assert_eq!(
body["venv_path"],
project.join(".venv").to_string_lossy().into_owned()
);
}
#[tokio::test]
async fn venv_probe_skips_non_python_interpreters() {
let (app, temp_dir) = test_app().await;
let token = fetch_token(&app).await;
let (status, body) = post_probe(
&app,
Some(&token),
json!({
"working_dir": temp_dir.path().to_str().expect("temp path should be UTF-8"),
"interpreter": "node.exe"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "not_applicable");
assert_eq!(body["interpreter_kind"], "node");
assert_eq!(body["display_path"], "node.exe");
}
#[tokio::test]
async fn venv_probe_reports_missing_working_directory() {
let (app, temp_dir) = test_app().await;
let token = fetch_token(&app).await;
let absent = temp_dir.path().join("absent");
let (status, body) = post_probe(
&app,
Some(&token),
json!({
"working_dir": absent.to_str().expect("absent path should be UTF-8"),
"interpreter": "python.exe"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "incomplete");
assert!(
body["message"]
.as_str()
.expect("message should be a string")
.contains("工作目录")
);
}
#[tokio::test]
async fn venv_probe_reports_project_dependency_files() {
let (app, temp_dir) = test_app().await;
let token = fetch_token(&app).await;
let project = temp_dir.path().join("deps-project");
fs::create_dir_all(&project).expect("project directory should be created");
fs::write(
project.join("pyproject.toml"),
"[project]\nname = \"demo\"\n",
)
.expect("pyproject.toml should be written");
fs::write(project.join("requirements.txt"), "").expect("requirements.txt should be written");
let (status, body) = post_probe(
&app,
Some(&token),
json!({
"working_dir": project.to_str().expect("project path should be UTF-8"),
"interpreter": "python.exe"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "missing");
assert_eq!(body["has_pyproject"], true);
assert_eq!(body["has_requirements"], true);
assert_eq!(body["requirements_files"], json!(["requirements.txt"]));
assert_eq!(body["can_create"], body["uv_available"]);
}
#[tokio::test]
async fn venv_create_rejects_missing_working_directory() {
let (app, temp_dir) = test_app().await;
let token = fetch_token(&app).await;
let absent = temp_dir.path().join("absent-project");
let (status, body) = post_create(
&app,
Some(&token),
json!({
"working_dir": absent.to_str().expect("absent path should be UTF-8")
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "VENV_CREATE_FAILED");
assert!(
body["message"]
.as_str()
.expect("message should be a string")
.contains("工作目录")
);
}
#[tokio::test]
async fn venv_create_requires_management_token() {
let (app, temp_dir) = test_app().await;
let (status, body) = post_create(
&app,
None,
json!({
"working_dir": temp_dir.path().to_str().expect("temp path should be UTF-8")
}),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}