修复LLM原文日志并行测试污染
Project CI / Repository checks (push) Successful in 1m34s
Project CI / Native shell tests (push) Successful in 2m12s
Project CI / Backend tests (push) Failing after 1m54s
Project CI / Frontend tests (push) Successful in 43s

将原文日志目录收敛为LlmConfig实例配置
迁移platform-llm与api-server测试到独立日志目录
补充Rust异步测试环境变量隔离规范
This commit is contained in:
2026-07-23 15:38:45 +08:00
parent 44748b7846
commit 24b10f0660
4 changed files with 48 additions and 32 deletions
@@ -63,6 +63,14 @@
- 验证:运行触达文件的定向 `vitest`,必要时追加 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联:`docs/technical/【前端测试】React组件测试准则-2026-06-26.md``src/components/image-editor/useCanvasGenerationDialogs.test.tsx``src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx`
## Rust 并行测试不要在 await 跨度内修改进程环境变量
- 现象:单独运行的异步测试稳定通过,默认并行运行整个 crate 时却看到临时目录多出其它测试的文件、文件对被拆散,或目录清理与并发写入互相竞争;Gitea Backend CI 可能表现为日志数量断言偶发增加。
- 原因:`std::env::set_var` / `remove_var` 修改整个测试进程,不属于当前 async task。测试在 `await` 前设置目录、结束后恢复时,同一 test binary 的其它用例会在中间窗口读取该值;只锁修改环境变量的测试也无效,除非所有间接读取方都参与同一把锁。
- 处理:文件、队列、缓存等副作用目录进入实例配置,在构造时一次性解析环境默认值,并允许测试显式注入唯一临时目录。不要靠 `--test-threads=1`、固定 sleep 或只过滤自己的文件名掩盖错误路由;纯环境解析测试只有在全部相关读写都封闭于同一 `OnceLock<Mutex<()>>` 时才使用全局锁。
- 验证:先精确运行目标用例,再以默认并行度重复运行完整 crate;失败类测试同时执行时,各实例目录只能包含自己的输入 / 输出日志,测试结束后临时目录必须清理。
- 关联:`server-rs/crates/platform-llm/src/lib.rs``server-rs/crates/api-server/src/creation_agent_llm_turn.rs``server-rs/crates/api-server/src/custom_world_foundation_draft.rs`
## 带 objectKey 的画布图片测试要等待换签后可见
- 现象:测试点击“添加素材”后,图层状态已经写入,但立即用 `getByAltText('画布图片:...')` 偶发或稳定找不到图片;前一张图可能通过,紧接着添加的第二张失败。
@@ -359,9 +359,6 @@ mod tests {
.expect("system time should be after epoch")
.as_nanos()
));
unsafe {
std::env::set_var("LLM_RAW_LOG_DIR", &log_dir);
}
let success_json = serde_json::json!({
"replyText": "好,我们先把玩具王国定住。",
"progressPercent": 12,
@@ -405,7 +402,8 @@ mod tests {
0,
1,
)
.expect("LLM config should build");
.expect("LLM config should build")
.with_raw_log_dir(log_dir.clone());
let llm_client = platform_llm::LlmClient::new(config).expect("LLM client should build");
let mut visible_replies = Vec::new();
@@ -437,9 +435,6 @@ mod tests {
assert!(requests[0].contains("\"web_search\""));
assert!(!requests[1].contains("\"tools\""));
unsafe {
std::env::remove_var("LLM_RAW_LOG_DIR");
}
if log_dir.exists() {
fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed");
}
@@ -2587,9 +2587,6 @@ mod tests {
.expect("system time should be after epoch")
.as_nanos()
));
unsafe {
std::env::set_var("LLM_RAW_LOG_DIR", &log_dir);
}
let request_capture = Arc::new(Mutex::new(Vec::new()));
let server_url = spawn_mock_server_with_statuses(
request_capture.clone(),
@@ -2604,7 +2601,7 @@ mod tests {
},
],
);
let llm_client = build_test_llm_client(server_url);
let llm_client = build_test_llm_client_with_raw_log_dir(server_url, log_dir.clone());
let parsed = request_foundation_json_stage(
&llm_client,
@@ -2628,9 +2625,6 @@ mod tests {
assert!(requests[0].contains("\"web_search\""));
assert!(!requests[1].contains("\"tools\""));
unsafe {
std::env::remove_var("LLM_RAW_LOG_DIR");
}
if log_dir.exists() {
std::fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed");
}
@@ -3256,7 +3250,19 @@ mod tests {
}
fn build_test_llm_client(base_url: String) -> LlmClient {
let config = LlmConfig::new(
LlmClient::new(build_test_llm_config(base_url)).expect("llm client should build")
}
fn build_test_llm_client_with_raw_log_dir(
base_url: String,
raw_log_dir: std::path::PathBuf,
) -> LlmClient {
let config = build_test_llm_config(base_url).with_raw_log_dir(raw_log_dir);
LlmClient::new(config).expect("llm client should build")
}
fn build_test_llm_config(base_url: String) -> LlmConfig {
LlmConfig::new(
LlmProvider::Ark,
base_url,
"test-key".to_string(),
@@ -3265,9 +3271,7 @@ mod tests {
0,
1,
)
.expect("llm config should build");
LlmClient::new(config).expect("llm client should build")
.expect("llm config should build")
}
fn spawn_mock_server(
+23 -14
View File
@@ -40,6 +40,7 @@ pub struct LlmConfig {
base_url: String,
api_key: String,
model: String,
raw_log_dir: PathBuf,
request_timeout_ms: u64,
max_retries: u32,
retry_backoff_ms: u64,
@@ -426,6 +427,9 @@ impl LlmConfig {
let base_url = normalize_non_empty(base_url, "LLM base_url 不能为空")?;
let api_key = normalize_non_empty(api_key, "LLM api_key 不能为空")?;
let model = normalize_non_empty(model, "LLM model 不能为空")?;
let raw_log_dir = env::var("LLM_RAW_LOG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_LLM_RAW_LOG_DIR));
if request_timeout_ms == 0 {
return Err(LlmError::InvalidConfig(
@@ -438,6 +442,7 @@ impl LlmConfig {
base_url,
api_key,
model,
raw_log_dir,
request_timeout_ms,
max_retries,
retry_backoff_ms,
@@ -450,6 +455,11 @@ impl LlmConfig {
self
}
pub fn with_raw_log_dir(mut self, raw_log_dir: impl Into<PathBuf>) -> Self {
self.raw_log_dir = raw_log_dir.into();
self
}
pub fn ark_default(api_key: String, model: String) -> Result<Self, LlmError> {
Self::new(
LlmProvider::Ark,
@@ -478,6 +488,10 @@ impl LlmConfig {
&self.model
}
fn raw_log_dir(&self) -> &PathBuf {
&self.raw_log_dir
}
pub fn request_timeout_ms(&self) -> u64 {
self.request_timeout_ms
}
@@ -1368,9 +1382,7 @@ fn write_llm_raw_failure(
failure_stage: &str,
raw_output: &str,
) -> Result<(), String> {
let log_dir = env::var("LLM_RAW_LOG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_LLM_RAW_LOG_DIR));
let log_dir = config.raw_log_dir();
fs::create_dir_all(&log_dir).map_err(|error| format!("创建日志目录失败:{error}"))?;
let prefix = build_llm_raw_log_prefix(failure_stage);
@@ -2354,9 +2366,6 @@ mod tests {
"platform-llm-raw-log-test-{}",
build_llm_raw_log_prefix("parse_error")
));
unsafe {
std::env::set_var("LLM_RAW_LOG_DIR", &log_dir);
}
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
@@ -2365,7 +2374,8 @@ mod tests {
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let config = build_test_config(server_url, 0).with_raw_log_dir(log_dir.clone());
let client = LlmClient::new(config).expect("client should be created");
let error = client
.request_single_message_text("系统原文", "用户原文")
.await
@@ -2398,14 +2408,15 @@ mod tests {
assert!(!input_text.contains("test-key"));
assert_eq!(output_text, "不是合法 JSON");
unsafe {
std::env::remove_var("LLM_RAW_LOG_DIR");
}
fs::remove_dir_all(log_dir).expect("log dir should be removed");
}
fn build_test_client(base_url: String, max_retries: u32) -> LlmClient {
let config = LlmConfig::new(
LlmClient::new(build_test_config(base_url, max_retries)).expect("client should be created")
}
fn build_test_config(base_url: String, max_retries: u32) -> LlmConfig {
LlmConfig::new(
LlmProvider::Ark,
base_url,
"test-key".to_string(),
@@ -2414,9 +2425,7 @@ mod tests {
max_retries,
1,
)
.expect("config should be valid");
LlmClient::new(config).expect("client should be created")
.expect("config should be valid")
}
fn spawn_mock_server(responses: Vec<MockResponse>) -> String {