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
126fn 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 let mut cmd = Command::new("npx");
160 cmd.args(["mocha", "--require", "tsx", "--delay", "--reporter", "json"]);
161
162 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 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&stdout) {
184 return parse_mocha_json(&json);
185 }
186
187 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 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
258fn 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 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
297fn format_failure_line(line: &str) -> String {
299 if line.starts_with("Program ")
301 && (line.contains("invoke [") || line.contains(" success") || line.contains(" failed"))
302 {
303 return style::dim(line);
304 }
305 if line.starts_with("Program ") && line.contains("consumed") && line.contains("compute units") {
307 return style::dim(line);
308 }
309 if line.starts_with("Program log:") || line.starts_with("Program data:") {
311 return line.to_string();
312 }
313 if line.contains("ProgramError::") || line.contains("InstructionError::") {
315 return style::fail(line);
316 }
317 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 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 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 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 else if trimmed == "failures:" {
365 in_failure_block = true;
366 } else if in_failure_block && trimmed == "failures:" {
367 in_failure_block = false;
369 } else if in_failure_block && trimmed.starts_with("---- ") {
370 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 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}