feat: 编辑器实时探测并显示已关联虚拟环境
fix: 按系统 ANSI 代码页解码子进程输出,修复中文日志乱码
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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) {
|
||||
let mut builder = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/python/venv")
|
||||
.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("probe 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("工作目录")
|
||||
);
|
||||
}
|
||||
@@ -719,3 +719,105 @@ async fn scheduler_restores_enabled_tasks_after_restart() {
|
||||
second_state.shutdown();
|
||||
second_pool.close().await;
|
||||
}
|
||||
|
||||
async fn run_command_task(app: &TestApp, id: &str, args: Vec<&str>) -> i64 {
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
let task = script_task(id, &working_dir, "cmd.exe", "/C", args, "0 0 2 * * *", 10);
|
||||
let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await;
|
||||
assert_eq!(create_response.status(), StatusCode::CREATED);
|
||||
let run_response = send_json(
|
||||
&app.router,
|
||||
Method::POST,
|
||||
&format!("/api/v1/tasks/{id}/run"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
|
||||
response_json(run_response).await["id"]
|
||||
.as_i64()
|
||||
.expect("run id should be an integer")
|
||||
}
|
||||
|
||||
async fn read_log_chunk(app: &TestApp, run_id: i64, offset: u64) -> Value {
|
||||
let response = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
&format!("/api/v1/runs/{run_id}/logs?offset={offset}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
response_json(response).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gbk_encoded_output_is_decoded_to_utf8() {
|
||||
let app = test_app().await;
|
||||
// 固定的 GBK 字节只在中日韩等使用该代码页的系统上才有意义。
|
||||
let encoding = task_pilot_backend::runs::executor::OutputEncoding::from_env();
|
||||
if encoding.name() != "GBK" {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload = app._temp_dir.path().join("gbk.txt");
|
||||
// "中文" 的 GBK 编码
|
||||
std::fs::write(&payload, [0xD6, 0xD0, 0xCE, 0xC4]).expect("GBK fixture should be written");
|
||||
let run_id = run_command_task(&app, "gbk_task", vec!["type", "gbk.txt"]).await;
|
||||
|
||||
let completed = wait_for_run(&app.router, run_id).await;
|
||||
assert_eq!(completed["status"], "succeeded");
|
||||
let log = read_log_chunk(&app, run_id, 0).await;
|
||||
let content = log["content"].as_str().expect("log content should be text");
|
||||
|
||||
assert!(
|
||||
content.contains("中文"),
|
||||
"unexpected log content: {content}"
|
||||
);
|
||||
assert!(!content.contains('\u{fffd}'));
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn long_utf8_log_is_reassembled_across_chunk_boundaries() {
|
||||
let app = test_app().await;
|
||||
let payload = app._temp_dir.path().join("long.txt");
|
||||
let body = format!("{}\n尾巴\n", "中".repeat(30_000));
|
||||
std::fs::write(&payload, body.as_bytes()).expect("UTF-8 fixture should be written");
|
||||
let run_id = run_command_task(&app, "long_log_task", vec!["type", "long.txt"]).await;
|
||||
|
||||
let completed = wait_for_run(&app.router, run_id).await;
|
||||
assert_eq!(completed["status"], "succeeded");
|
||||
|
||||
let first = read_log_chunk(&app, run_id, 0).await;
|
||||
assert_eq!(
|
||||
first["complete"], false,
|
||||
"a terminal run must not report completion before the whole log is read"
|
||||
);
|
||||
let mut content = first["content"].as_str().unwrap_or_default().to_owned();
|
||||
let mut offset = first["next_offset"]
|
||||
.as_u64()
|
||||
.expect("next_offset should be an integer");
|
||||
|
||||
for _ in 0..20 {
|
||||
let chunk = read_log_chunk(&app, run_id, offset).await;
|
||||
content.push_str(chunk["content"].as_str().unwrap_or_default());
|
||||
offset = chunk["next_offset"]
|
||||
.as_u64()
|
||||
.expect("next_offset should be an integer");
|
||||
if chunk["complete"].as_bool().unwrap_or(false) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
!content.contains('\u{fffd}'),
|
||||
"log lost a character boundary"
|
||||
);
|
||||
assert!(content.contains("尾巴"));
|
||||
assert_eq!(
|
||||
content.len(),
|
||||
usize::try_from(offset).expect("offset fits usize")
|
||||
);
|
||||
app.state.shutdown();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user