- 工作目录探测新增 pyproject.toml / requirements*.txt 扫描、uv 可用性与 can_create - 新增 POST /api/v1/python/venv/create:已有虚拟环境优先复用;pyproject 走 uv sync,requirements 走 uv venv + uv pip install,并使用默认 PyPI 镜像 - 编辑器在缺少虚拟环境且具备依赖文件时显示「用 uv 创建环境」;没有 uv 或没有依赖文件时明确报错 - 解释器字段改为可选,留空由服务端默认 python.exe 并使用虚拟环境;Python 任务缺少虚拟环境/依赖文件时运行直接失败,不再回退系统解释器 - 补后端探测/创建测试、前端入口测试与 README
269 lines
7.7 KiB
Rust
269 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_project_without_dependency_files() {
|
|
let (app, temp_dir) = test_app().await;
|
|
let token = fetch_token(&app).await;
|
|
let project = temp_dir.path().join("empty-project");
|
|
fs::create_dir_all(&project).expect("project directory should be created");
|
|
|
|
let (status, body) = post_create(
|
|
&app,
|
|
Some(&token),
|
|
json!({
|
|
"working_dir": project.to_str().expect("project 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("pyproject.toml")
|
|
);
|
|
}
|
|
|
|
#[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");
|
|
}
|