Skip to main content

aster/blueprint/
acceptance_test_runner.rs

1//! 验收测试运行器
2//!
3//! 用于在代码修改后自动运行相关的验收测试。
4//! 这是验证层的核心组件,集成到 PostToolUse hook 中。
5//!
6//! 特点:
7//! 1. 根据修改的文件找到相关的验收测试
8//! 2. 异步执行,不阻塞对话
9//! 3. 记录测试结果到任务树
10//! 4. 支持多种测试框架
11
12use std::path::{Path, PathBuf};
13use std::process::Stdio;
14use std::sync::Arc;
15use std::time::Instant;
16use tokio::process::Command;
17use tokio::sync::RwLock;
18
19use super::blueprint_manager::BlueprintManager;
20use super::task_tree_manager::TaskTreeManager;
21use super::types::{AcceptanceTest, TaskNode};
22
23// ============================================================================
24// 类型定义
25// ============================================================================
26
27/// 测试运行结果
28#[derive(Debug, Clone)]
29pub struct AcceptanceTestRunResult {
30    /// 测试 ID
31    pub test_id: String,
32    /// 测试名称
33    pub test_name: String,
34    /// 是否通过
35    pub passed: bool,
36    /// 输出内容
37    pub output: String,
38    /// 执行时长(毫秒)
39    pub duration: u64,
40    /// 错误信息
41    pub error_message: Option<String>,
42}
43
44/// 运行器配置
45#[derive(Debug, Clone)]
46pub struct AcceptanceTestRunnerConfig {
47    /// 项目根目录
48    pub project_root: PathBuf,
49    /// 测试超时时间(毫秒)
50    pub test_timeout: u64,
51    /// 是否启用调试日志
52    pub debug: bool,
53    /// 并行运行测试数量
54    pub parallel_count: usize,
55}
56
57impl Default for AcceptanceTestRunnerConfig {
58    fn default() -> Self {
59        Self {
60            project_root: std::env::current_dir().unwrap_or_default(),
61            test_timeout: 60000,
62            debug: false,
63            parallel_count: 1,
64        }
65    }
66}
67
68// ============================================================================
69// 验收测试运行器
70// ============================================================================
71
72/// 验收测试运行器
73pub struct AcceptanceTestRunner {
74    config: AcceptanceTestRunnerConfig,
75    task_tree_manager: Arc<RwLock<TaskTreeManager>>,
76    blueprint_manager: Arc<RwLock<BlueprintManager>>,
77}
78
79impl AcceptanceTestRunner {
80    /// 创建新的运行器
81    pub fn new(
82        config: AcceptanceTestRunnerConfig,
83        task_tree_manager: Arc<RwLock<TaskTreeManager>>,
84        blueprint_manager: Arc<RwLock<BlueprintManager>>,
85    ) -> Self {
86        Self {
87            config,
88            task_tree_manager,
89            blueprint_manager,
90        }
91    }
92
93    /// 运行与修改文件相关的验收测试
94    pub async fn run_tests_for_file(&self, file_path: &str) -> Vec<AcceptanceTestRunResult> {
95        let tree_manager = self.task_tree_manager.read().await;
96
97        // 获取当前任务树
98        let tree = match tree_manager.get_current_task_tree().await {
99            Some(t) => t,
100            None => {
101                self.log("[AcceptanceTestRunner] 没有活跃的任务树");
102                return vec![];
103            }
104        };
105
106        // 找到相关的验收测试
107        let relevant_tests = self.find_relevant_tests(file_path, &tree.root).await;
108        if relevant_tests.is_empty() {
109            self.log(&format!(
110                "[AcceptanceTestRunner] 没有找到与 {} 相关的验收测试",
111                file_path
112            ));
113            return vec![];
114        }
115
116        self.log(&format!(
117            "[AcceptanceTestRunner] 找到 {} 个相关测试",
118            relevant_tests.len()
119        ));
120
121        let mut results = Vec::new();
122
123        // 串行或并行执行测试
124        if self.config.parallel_count > 1 {
125            // 并行执行
126            let batches = self.create_batches(&relevant_tests, self.config.parallel_count);
127            for batch in batches {
128                let mut handles = Vec::new();
129                for test in batch {
130                    let test_clone = test.clone();
131                    let config = self.config.clone();
132                    handles.push(tokio::spawn(async move {
133                        Self::run_single_test_static(&config, &test_clone).await
134                    }));
135                }
136                for handle in handles {
137                    if let Ok(result) = handle.await {
138                        results.push(result);
139                    }
140                }
141            }
142        } else {
143            // 串行执行
144            for test in &relevant_tests {
145                let result = self.run_single_test(test).await;
146                results.push(result);
147            }
148        }
149
150        // 记录测试结果到任务树
151        drop(tree_manager);
152        self.record_results(&tree.id, &results).await;
153
154        // 输出汇总
155        self.print_summary(&results);
156
157        results
158    }
159
160    /// 运行指定的验收测试
161    pub async fn run_acceptance_test(&self, test: &AcceptanceTest) -> AcceptanceTestRunResult {
162        self.run_single_test(test).await
163    }
164
165    /// 运行单个测试
166    async fn run_single_test(&self, test: &AcceptanceTest) -> AcceptanceTestRunResult {
167        Self::run_single_test_static(&self.config, test).await
168    }
169
170    /// 静态方法:运行单个测试(用于并行执行)
171    async fn run_single_test_static(
172        config: &AcceptanceTestRunnerConfig,
173        test: &AcceptanceTest,
174    ) -> AcceptanceTestRunResult {
175        let start_time = Instant::now();
176
177        if config.debug {
178            println!("[AcceptanceTestRunner] 运行测试: {}", test.name);
179        }
180
181        match Self::execute_test_command(config, &test.test_command, Some(&test.test_file_path))
182            .await
183        {
184            Ok(output) => {
185                let duration = start_time.elapsed().as_millis() as u64;
186                let passed = Self::parse_test_success(&output);
187
188                let result = AcceptanceTestRunResult {
189                    test_id: test.id.clone(),
190                    test_name: test.name.clone(),
191                    passed,
192                    output: output.clone(),
193                    duration,
194                    error_message: if passed {
195                        None
196                    } else {
197                        Some(Self::extract_error_message(&output))
198                    },
199                };
200
201                if passed {
202                    println!("✅ 验收测试通过: {} ({}ms)", test.name, duration);
203                } else {
204                    eprintln!("❌ 验收测试失败: {}", test.name);
205                    if let Some(ref err) = result.error_message {
206                        if let Some(first_line) = err.lines().next() {
207                            eprintln!("   错误: {}", first_line);
208                        }
209                    }
210                }
211
212                result
213            }
214            Err(e) => {
215                let duration = start_time.elapsed().as_millis() as u64;
216                eprintln!("❌ 验收测试执行失败: {}", test.name);
217                eprintln!("   {}", e);
218
219                AcceptanceTestRunResult {
220                    test_id: test.id.clone(),
221                    test_name: test.name.clone(),
222                    passed: false,
223                    output: String::new(),
224                    duration,
225                    error_message: Some(e),
226                }
227            }
228        }
229    }
230
231    /// 找到与修改文件相关的验收测试
232    async fn find_relevant_tests(
233        &self,
234        file_path: &str,
235        root_task: &TaskNode,
236    ) -> Vec<AcceptanceTest> {
237        let mut tests = Vec::new();
238        let normalized_path = Path::new(file_path).to_string_lossy().to_lowercase();
239
240        self.traverse_for_tests(root_task, &normalized_path, &mut tests)
241            .await;
242        tests
243    }
244
245    /// 递归遍历任务树查找相关测试
246    async fn traverse_for_tests(
247        &self,
248        task: &TaskNode,
249        normalized_file_path: &str,
250        tests: &mut Vec<AcceptanceTest>,
251    ) {
252        for test in &task.acceptance_tests {
253            if self
254                .is_test_relevant(test, normalized_file_path, task)
255                .await
256            {
257                tests.push(test.clone());
258            }
259        }
260
261        for child in &task.children {
262            Box::pin(self.traverse_for_tests(child, normalized_file_path, tests)).await;
263        }
264    }
265
266    /// 判断测试是否与修改文件相关
267    async fn is_test_relevant(
268        &self,
269        _test: &AcceptanceTest,
270        normalized_file_path: &str,
271        task: &TaskNode,
272    ) -> bool {
273        // 1. 检查任务的代码产出物是否包含该文件
274        for artifact in &task.code_artifacts {
275            if let Some(ref artifact_path) = artifact.file_path {
276                let artifact_normalized = artifact_path.to_lowercase();
277                if normalized_file_path.contains(&artifact_normalized)
278                    || artifact_normalized.contains(normalized_file_path)
279                {
280                    return true;
281                }
282            }
283        }
284
285        // 2. 检查任务所属模块是否包含该文件
286        if let Some(ref module_id) = task.blueprint_module_id {
287            let bp_manager = self.blueprint_manager.read().await;
288            if let Some(blueprint) = bp_manager.get_current_blueprint().await {
289                if let Some(module) = blueprint.modules.iter().find(|m| &m.id == module_id) {
290                    let default_path = format!("src/{}", module.name.to_lowercase());
291                    let module_path = module.root_path.as_deref().unwrap_or(&default_path);
292                    if normalized_file_path.contains(&module_path.to_lowercase()) {
293                        return true;
294                    }
295                }
296            }
297        }
298
299        // 3. 基于文件名匹配(简单启发式)
300        let file_name = Path::new(normalized_file_path)
301            .file_name()
302            .and_then(|n| n.to_str())
303            .unwrap_or("");
304        let task_name_lower = task.name.to_lowercase();
305
306        // 如果文件名包含任务名的一部分,可能相关
307        let file_base_name = file_name
308            .trim_end_matches(".ts")
309            .trim_end_matches(".tsx")
310            .trim_end_matches(".js")
311            .trim_end_matches(".jsx")
312            .trim_end_matches(".rs");
313
314        if task_name_lower.contains(file_base_name)
315            || file_base_name.contains(&task_name_lower.replace(' ', "-"))
316        {
317            return true;
318        }
319
320        false
321    }
322
323    /// 执行测试命令
324    async fn execute_test_command(
325        config: &AcceptanceTestRunnerConfig,
326        command: &str,
327        test_file_path: Option<&str>,
328    ) -> Result<String, String> {
329        // 构建完整命令
330        let full_command = if let Some(path) = test_file_path {
331            if !command.contains(path) {
332                format!("{} {}", command, path)
333            } else {
334                command.to_string()
335            }
336        } else {
337            command.to_string()
338        };
339
340        if config.debug {
341            println!("[AcceptanceTestRunner] 执行命令: {}", full_command);
342        }
343
344        let parts: Vec<&str> = full_command.split_whitespace().collect();
345        if parts.is_empty() {
346            return Err("空命令".to_string());
347        }
348
349        let cmd = parts[0];
350        let args = &parts[1..];
351
352        let output = Command::new(cmd)
353            .args(args)
354            .current_dir(&config.project_root)
355            .stdout(Stdio::piped())
356            .stderr(Stdio::piped())
357            .output()
358            .await
359            .map_err(|e| format!("执行命令失败: {}", e))?;
360
361        let stdout = String::from_utf8_lossy(&output.stdout);
362        let stderr = String::from_utf8_lossy(&output.stderr);
363        let combined = format!("{}{}", stdout, stderr);
364
365        if output.status.success() {
366            Ok(combined)
367        } else {
368            Err(format!(
369                "测试命令退出码: {:?}\n{}",
370                output.status.code(),
371                combined
372            ))
373        }
374    }
375
376    /// 解析测试是否成功
377    fn parse_test_success(output: &str) -> bool {
378        // vitest 成功标识
379        if output.contains("Test Files") && output.contains("passed") {
380            return !output.contains("failed");
381        }
382
383        // jest 成功标识
384        if output.contains("Tests:") && output.contains("passed") {
385            return !output.contains("failed");
386        }
387
388        // mocha 成功标识
389        if output.contains("passing") {
390            return !output.contains("failing");
391        }
392
393        // pytest 成功标识
394        if output.contains("passed") || output.contains("PASSED") {
395            return !output.contains("failed") && !output.contains("FAILED");
396        }
397
398        // cargo test 成功标识
399        if output.contains("test result: ok") {
400            return true;
401        }
402        if output.contains("test result: FAILED") {
403            return false;
404        }
405
406        // 默认:假设成功(因为没有异常退出)
407        true
408    }
409
410    /// 提取错误信息
411    fn extract_error_message(output: &str) -> String {
412        let mut error_lines = Vec::new();
413        let mut in_error = false;
414
415        for line in output.lines() {
416            if line.contains("Error:")
417                || line.contains("FAIL")
418                || line.contains("✖")
419                || line.contains("AssertionError")
420                || line.contains("panicked")
421            {
422                in_error = true;
423            }
424
425            if in_error {
426                error_lines.push(line);
427                if error_lines.len() >= 15 {
428                    break;
429                }
430            }
431        }
432
433        if !error_lines.is_empty() {
434            error_lines.join("\n")
435        } else {
436            output.chars().take(500).collect()
437        }
438    }
439
440    /// 记录测试结果到任务树
441    async fn record_results(&self, _tree_id: &str, results: &[AcceptanceTestRunResult]) {
442        // 注意:TaskTreeManager 目前没有 record_acceptance_test_result 方法
443        // 这里只打印日志,实际记录逻辑需要在 TaskTreeManager 中实现
444        for result in results {
445            if result.passed {
446                tracing::info!("验收测试通过: {} ({}ms)", result.test_name, result.duration);
447            } else {
448                tracing::warn!(
449                    "验收测试失败: {} - {:?}",
450                    result.test_name,
451                    result.error_message
452                );
453            }
454        }
455    }
456
457    /// 从任务树中找到测试对应的任务 ID
458    #[allow(dead_code)]
459    fn find_task_id_for_test(root_task: &TaskNode, test_id: &str) -> Option<String> {
460        for test in &root_task.acceptance_tests {
461            if test.id == test_id {
462                return Some(root_task.id.clone());
463            }
464        }
465
466        for child in &root_task.children {
467            if let Some(found) = Self::find_task_id_for_test(child, test_id) {
468                return Some(found);
469            }
470        }
471
472        None
473    }
474
475    /// 打印汇总
476    fn print_summary(&self, results: &[AcceptanceTestRunResult]) {
477        if results.is_empty() {
478            return;
479        }
480
481        let passed = results.iter().filter(|r| r.passed).count();
482        let failed = results.len() - passed;
483        let total_duration: u64 = results.iter().map(|r| r.duration).sum();
484
485        println!("\n📊 验收测试汇总:");
486        println!(
487            "   通过: {}, 失败: {}, 总耗时: {}ms",
488            passed, failed, total_duration
489        );
490
491        if failed > 0 {
492            println!("\n⚠️ 失败的测试:");
493            for result in results.iter().filter(|r| !r.passed) {
494                println!("   - {}", result.test_name);
495            }
496        }
497    }
498
499    /// 创建批次(用于并行执行)
500    fn create_batches<T: Clone>(&self, items: &[T], batch_size: usize) -> Vec<Vec<T>> {
501        items.chunks(batch_size).map(|c| c.to_vec()).collect()
502    }
503
504    /// 日志输出
505    fn log(&self, message: &str) {
506        if self.config.debug {
507            println!("{}", message);
508        }
509    }
510
511    // --------------------------------------------------------------------------
512    // 配置管理
513    // --------------------------------------------------------------------------
514
515    /// 设置项目根目录
516    pub fn set_project_root(&mut self, project_root: PathBuf) {
517        self.config.project_root = project_root;
518    }
519
520    /// 设置测试超时时间
521    pub fn set_test_timeout(&mut self, timeout: u64) {
522        self.config.test_timeout = timeout;
523    }
524
525    /// 设置调试模式
526    pub fn set_debug(&mut self, debug: bool) {
527        self.config.debug = debug;
528    }
529}
530
531// ============================================================================
532// 工厂函数
533// ============================================================================
534
535/// 创建验收测试运行器实例
536pub fn create_acceptance_test_runner(
537    config: AcceptanceTestRunnerConfig,
538    task_tree_manager: Arc<RwLock<TaskTreeManager>>,
539    blueprint_manager: Arc<RwLock<BlueprintManager>>,
540) -> AcceptanceTestRunner {
541    AcceptanceTestRunner::new(config, task_tree_manager, blueprint_manager)
542}