Skip to main content

quasar_cli/
test.rs

1use {
2    crate::{config::QuasarConfig, error::CliResult, style},
3    std::{
4        process::{Command, Stdio},
5        time::Instant,
6    },
7};
8
9pub fn run(
10    debug: bool,
11    filter: Option<String>,
12    watch: bool,
13    no_build: bool,
14    features: Option<String>,
15) -> CliResult {
16    if watch {
17        return run_watch(debug, filter, no_build, features);
18    }
19    run_once(debug, filter.as_deref(), no_build, features.as_deref())
20}
21
22fn run_once(
23    debug: bool,
24    filter: Option<&str>,
25    no_build: bool,
26    features: Option<&str>,
27) -> CliResult {
28    let config = QuasarConfig::load()?;
29
30    if !no_build {
31        crate::build::run(debug, false, features.map(String::from))?;
32    }
33
34    let sp = style::spinner("Testing...");
35    let start = Instant::now();
36
37    let is_mollusk = config.testing.framework == "mollusk";
38    let result = if config.has_typescript_tests() {
39        run_typescript_tests(filter)
40    } else if config.has_rust_tests() {
41        run_rust_tests(filter)
42    } else {
43        sp.finish_and_clear();
44        println!("  {}", style::warn("no test framework configured"));
45        return Ok(());
46    };
47
48    sp.finish_and_clear();
49
50    let elapsed = start.elapsed();
51
52    match result {
53        Ok(summary) => {
54            println!();
55            for line in &summary.lines {
56                println!("    {line}");
57            }
58            println!();
59            println!(
60                "  {}",
61                style::dim(&format!(
62                    "{} passed ({})",
63                    summary.passed,
64                    style::human_duration(elapsed)
65                ))
66            );
67            Ok(())
68        }
69        Err(summary) => {
70            println!();
71            for line in &summary.lines {
72                println!("    {line}");
73            }
74            println!();
75            eprintln!(
76                "  {} passed, {} failed ({})",
77                summary.passed,
78                summary.failed,
79                style::human_duration(elapsed)
80            );
81            if is_mollusk {
82                eprintln!();
83                eprintln!(
84                    "  {}",
85                    style::dim(
86                        "Tip: enable the \"debug\" feature for more descriptive error messages."
87                    )
88                );
89            }
90            std::process::exit(1);
91        }
92    }
93}
94
95fn run_watch(
96    debug: bool,
97    filter: Option<String>,
98    no_build: bool,
99    features: Option<String>,
100) -> CliResult {
101    if let Err(e) = run_once(debug, filter.as_deref(), no_build, features.as_deref()) {
102        eprintln!("  {}", style::fail(&format!("{e}")));
103    }
104
105    loop {
106        let baseline = crate::build::collect_mtimes(std::path::Path::new("src"));
107        loop {
108            std::thread::sleep(std::time::Duration::from_secs(1));
109            let current = crate::build::collect_mtimes(std::path::Path::new("src"));
110            if current != baseline {
111                if let Err(e) = run_once(debug, filter.as_deref(), no_build, features.as_deref()) {
112                    eprintln!("  {}", style::fail(&format!("{e}")));
113                }
114                break;
115            }
116        }
117    }
118}
119
120struct TestSummary {
121    passed: usize,
122    failed: usize,
123    lines: Vec<String>,
124}
125
126// ---------------------------------------------------------------------------
127// TypeScript (mocha --reporter json)
128// ---------------------------------------------------------------------------
129
130fn run_typescript_tests(filter: Option<&str>) -> Result<TestSummary, TestSummary> {
131    if !std::path::Path::new("node_modules").exists() {
132        let o = Command::new("npm")
133            .args(["install"])
134            .stdout(Stdio::piped())
135            .stderr(Stdio::piped())
136            .output();
137
138        match o {
139            Ok(o) if o.status.success() => {}
140            Ok(o) => {
141                let stderr = String::from_utf8_lossy(&o.stderr);
142                if !stderr.is_empty() {
143                    eprint!("{stderr}");
144                }
145                eprintln!("  {}", style::fail("npm install failed"));
146                std::process::exit(o.status.code().unwrap_or(1));
147            }
148            Err(e) => {
149                eprintln!(
150                    "  {}",
151                    style::fail(&format!("failed to run npm install: {e}"))
152                );
153                std::process::exit(1);
154            }
155        }
156    }
157
158    // Run mocha with JSON reporter to get structured results
159    let mut cmd = Command::new("npx");
160    cmd.args(["mocha", "--require", "tsx", "--delay", "--reporter", "json"]);
161
162    // Find test files matching the glob pattern from package.json
163    // Default to tests/*.test.ts
164    cmd.arg("tests/*.test.ts");
165
166    if let Some(pattern) = filter {
167        cmd.args(["--grep", pattern]);
168    }
169
170    let output = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output();
171
172    let o = match output {
173        Ok(o) => o,
174        Err(e) => {
175            eprintln!("  {}", style::fail(&format!("failed to run mocha: {e}")));
176            std::process::exit(1);
177        }
178    };
179
180    let stdout = String::from_utf8_lossy(&o.stdout);
181
182    // Try to parse JSON output
183    if let Ok(json) = serde_json::from_str::<serde_json::Value>(&stdout) {
184        return parse_mocha_json(&json);
185    }
186
187    // Fallback: couldn't parse JSON, show raw output
188    let stderr = String::from_utf8_lossy(&o.stderr);
189    if !stderr.is_empty() {
190        eprint!("{stderr}");
191    }
192    if !stdout.is_empty() {
193        print!("{stdout}");
194    }
195
196    if o.status.success() {
197        Ok(TestSummary {
198            passed: 0,
199            failed: 0,
200            lines: vec![],
201        })
202    } else {
203        eprintln!("  {}", style::fail("tests failed"));
204        std::process::exit(o.status.code().unwrap_or(1));
205    }
206}
207
208fn parse_mocha_json(json: &serde_json::Value) -> Result<TestSummary, TestSummary> {
209    let mut lines = Vec::new();
210    let mut passed = 0usize;
211    let mut failed = 0usize;
212
213    if let Some(passes) = json.get("passes").and_then(|v| v.as_array()) {
214        for test in passes {
215            let title = test
216                .get("fullTitle")
217                .and_then(|t| t.as_str())
218                .unwrap_or("?");
219            lines.push(style::success(title));
220            passed += 1;
221        }
222    }
223
224    if let Some(failures) = json.get("failures").and_then(|v| v.as_array()) {
225        for test in failures {
226            let title = test
227                .get("fullTitle")
228                .and_then(|t| t.as_str())
229                .unwrap_or("?");
230            lines.push(style::fail(title));
231
232            // Show error message indented
233            if let Some(err) = test.get("err") {
234                if let Some(msg) = err.get("message").and_then(|m| m.as_str()) {
235                    for line in msg.lines().take(10) {
236                        lines.push(format!("    {}", format_failure_line(line)));
237                    }
238                }
239            }
240
241            failed += 1;
242        }
243    }
244
245    let summary = TestSummary {
246        passed,
247        failed,
248        lines,
249    };
250
251    if failed > 0 {
252        Err(summary)
253    } else {
254        Ok(summary)
255    }
256}
257
258// ---------------------------------------------------------------------------
259// Rust (cargo test)
260// ---------------------------------------------------------------------------
261
262fn run_rust_tests(filter: Option<&str>) -> Result<TestSummary, TestSummary> {
263    let mut cmd = Command::new("cargo");
264    cmd.args(["test", "tests::"]);
265    if let Some(pattern) = filter {
266        cmd.arg(pattern);
267    }
268
269    let output = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output();
270
271    let o = match output {
272        Ok(o) => o,
273        Err(e) => {
274            eprintln!(
275                "  {}",
276                style::fail(&format!("failed to run cargo test: {e}"))
277            );
278            std::process::exit(1);
279        }
280    };
281
282    let stdout = String::from_utf8_lossy(&o.stdout);
283    let stderr = String::from_utf8_lossy(&o.stderr);
284
285    // Check for compilation errors (no test results at all)
286    if !o.status.success() && !stdout.contains("test result:") {
287        if !stderr.is_empty() {
288            eprint!("{stderr}");
289        }
290        eprintln!("  {}", style::fail("build failed"));
291        std::process::exit(o.status.code().unwrap_or(1));
292    }
293
294    parse_cargo_test_output(&stdout, &stderr)
295}
296
297/// Format a test failure detail line with special handling for program logs.
298fn format_failure_line(line: &str) -> String {
299    // Program invoke/success/failed traces
300    if line.starts_with("Program ")
301        && (line.contains("invoke [") || line.contains(" success") || line.contains(" failed"))
302    {
303        return style::dim(line);
304    }
305    // Program CU consumption
306    if line.starts_with("Program ") && line.contains("consumed") && line.contains("compute units") {
307        return style::dim(line);
308    }
309    // Program log lines - show them prominently
310    if line.starts_with("Program log:") || line.starts_with("Program data:") {
311        return line.to_string();
312    }
313    // Error type names - highlight in red
314    if line.contains("ProgramError::") || line.contains("InstructionError::") {
315        return style::fail(line);
316    }
317    // Common error patterns from our ProgramError Display
318    if line.starts_with("invalid ")
319        || line.starts_with("insufficient ")
320        || line.starts_with("incorrect ")
321        || line.starts_with("missing ")
322        || line.starts_with("account ")
323        || line.starts_with("arithmetic ")
324        || line.starts_with("compute budget")
325        || line.starts_with("custom program error")
326        || line.starts_with("runtime error")
327        || line.starts_with("borsh ")
328    {
329        return style::fail(line);
330    }
331    // Default - keep as-is
332    line.to_string()
333}
334
335fn parse_cargo_test_output(stdout: &str, stderr: &str) -> Result<TestSummary, TestSummary> {
336    let mut lines = Vec::new();
337    let mut passed = 0usize;
338    let mut failed = 0usize;
339    let mut in_failure_block = false;
340    let mut failure_lines: Vec<String> = Vec::new();
341
342    for line in stdout.lines().chain(stderr.lines()) {
343        let trimmed = line.trim();
344
345        // test foo::bar ... ok
346        if trimmed.starts_with("test ") && trimmed.ends_with("... ok") {
347            let name = trimmed
348                .strip_prefix("test ")
349                .and_then(|s| s.strip_suffix(" ... ok"))
350                .unwrap_or("?");
351            lines.push(style::success(name));
352            passed += 1;
353        }
354        // test foo::bar ... FAILED
355        else if trimmed.starts_with("test ") && trimmed.ends_with("... FAILED") {
356            let name = trimmed
357                .strip_prefix("test ")
358                .and_then(|s| s.strip_suffix(" ... FAILED"))
359                .unwrap_or("?");
360            lines.push(style::fail(name));
361            failed += 1;
362        }
363        // Capture failure details
364        else if trimmed == "failures:" {
365            in_failure_block = true;
366        } else if in_failure_block && trimmed == "failures:" {
367            // Second "failures:" header (list of failed test names) — stop capturing
368            in_failure_block = false;
369        } else if in_failure_block && trimmed.starts_with("---- ") {
370            // New failure detail block
371            if !failure_lines.is_empty() {
372                for fl in &failure_lines {
373                    lines.push(format!("    {fl}"));
374                }
375                failure_lines.clear();
376            }
377        } else if in_failure_block && !trimmed.is_empty() && !trimmed.starts_with("test result:") {
378            failure_lines.push(format_failure_line(trimmed));
379        }
380    }
381
382    // Flush remaining failure lines
383    if !failure_lines.is_empty() {
384        for fl in &failure_lines {
385            lines.push(format!("    {fl}"));
386        }
387    }
388
389    let summary = TestSummary {
390        passed,
391        failed,
392        lines,
393    };
394
395    if failed > 0 {
396        Err(summary)
397    } else {
398        Ok(summary)
399    }
400}