use crate::ported::vm_helper::ShellExecutor;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
pub const ZTEST_BUILTIN_NAMES: &[&str] = &[
"run_tests",
"zassert_contains",
"zassert_dies",
"zassert_eq",
"zassert_err",
"zassert_false",
"zassert_ge",
"zassert_gt",
"zassert_le",
"zassert_lt",
"zassert_match",
"zassert_ne",
"zassert_near",
"zassert_ok",
"zassert_true",
"ztest_run",
"ztest_skip",
];
fn ztest_pass(exec: &ShellExecutor, msg: &str) {
exec.ztest_pass_count.fetch_add(1, Ordering::Relaxed);
if !exec.ztest_suppress_stdout {
eprintln!(" \x1b[32m\u{2713}\x1b[0m {}", msg);
}
}
fn ztest_fail(exec: &ShellExecutor, msg: &str, detail: &str) {
exec.ztest_fail_count.fetch_add(1, Ordering::Relaxed);
if !exec.ztest_suppress_stdout {
eprintln!(" \x1b[31m\u{2717}\x1b[0m {} \u{2014} {}", msg, detail);
}
}
fn ztest_skip_count(exec: &ShellExecutor, msg: &str) {
exec.ztest_skip_count.fetch_add(1, Ordering::Relaxed);
if !exec.ztest_suppress_stdout {
eprintln!(" \x1b[33m\u{21B7}\x1b[0m skipped: {}", msg);
}
}
fn assert_label(args: &[String], required: usize, default: &str) -> String {
if args.len() > required {
args[required].clone()
} else {
default.to_string()
}
}
fn shell_truthy(s: &str) -> bool {
!s.is_empty() && s != "0"
}
fn parse_num(s: &str) -> f64 {
s.trim().parse::<f64>().unwrap_or(0.0)
}
impl ShellExecutor {
pub(crate) fn builtin_zassert_eq(&self, args: &[String]) -> i32 {
let a = args.first().map(String::as_str).unwrap_or("");
let b = args.get(1).map(String::as_str).unwrap_or("");
let msg = assert_label(args, 2, "zassert_eq");
if a == b {
ztest_pass(self, &msg);
0
} else {
ztest_fail(self, &msg, &format!("got '{}', expected '{}'", a, b));
1
}
}
pub(crate) fn builtin_zassert_ne(&self, args: &[String]) -> i32 {
let a = args.first().map(String::as_str).unwrap_or("");
let b = args.get(1).map(String::as_str).unwrap_or("");
let msg = assert_label(args, 2, "zassert_ne");
if a != b {
ztest_pass(self, &msg);
0
} else {
ztest_fail(self, &msg, &format!("both equal '{}'", a));
1
}
}
pub(crate) fn builtin_zassert_ok(&self, args: &[String]) -> i32 {
let v = args.first().map(String::as_str).unwrap_or("");
let msg = assert_label(args, 1, "zassert_ok");
if shell_truthy(v) {
ztest_pass(self, &msg);
0
} else {
ztest_fail(self, &msg, &format!("got falsy: '{}'", v));
1
}
}
pub(crate) fn builtin_zassert_err(&self, args: &[String]) -> i32 {
let v = args.first().map(String::as_str).unwrap_or("");
let msg = assert_label(args, 1, "zassert_err");
if !shell_truthy(v) {
ztest_pass(self, &msg);
0
} else {
ztest_fail(self, &msg, &format!("expected falsy, got '{}'", v));
1
}
}
pub(crate) fn builtin_zassert_true(&self, args: &[String]) -> i32 {
self.builtin_zassert_ok(args)
}
pub(crate) fn builtin_zassert_false(&self, args: &[String]) -> i32 {
self.builtin_zassert_err(args)
}
pub(crate) fn builtin_zassert_gt(&self, args: &[String]) -> i32 {
let a = parse_num(args.first().map(String::as_str).unwrap_or(""));
let b = parse_num(args.get(1).map(String::as_str).unwrap_or(""));
let msg = assert_label(args, 2, "zassert_gt");
if a > b {
ztest_pass(self, &msg);
0
} else {
ztest_fail(self, &msg, &format!("{} not > {}", a, b));
1
}
}
pub(crate) fn builtin_zassert_lt(&self, args: &[String]) -> i32 {
let a = parse_num(args.first().map(String::as_str).unwrap_or(""));
let b = parse_num(args.get(1).map(String::as_str).unwrap_or(""));
let msg = assert_label(args, 2, "zassert_lt");
if a < b {
ztest_pass(self, &msg);
0
} else {
ztest_fail(self, &msg, &format!("{} not < {}", a, b));
1
}
}
pub(crate) fn builtin_zassert_ge(&self, args: &[String]) -> i32 {
let a = parse_num(args.first().map(String::as_str).unwrap_or(""));
let b = parse_num(args.get(1).map(String::as_str).unwrap_or(""));
let msg = assert_label(args, 2, "zassert_ge");
if a >= b {
ztest_pass(self, &msg);
0
} else {
ztest_fail(self, &msg, &format!("{} not >= {}", a, b));
1
}
}
pub(crate) fn builtin_zassert_le(&self, args: &[String]) -> i32 {
let a = parse_num(args.first().map(String::as_str).unwrap_or(""));
let b = parse_num(args.get(1).map(String::as_str).unwrap_or(""));
let msg = assert_label(args, 2, "zassert_le");
if a <= b {
ztest_pass(self, &msg);
0
} else {
ztest_fail(self, &msg, &format!("{} not <= {}", a, b));
1
}
}
pub(crate) fn builtin_zassert_match(&self, args: &[String]) -> i32 {
let pattern = args.first().map(String::as_str).unwrap_or("");
let string = args.get(1).map(String::as_str).unwrap_or("");
let msg = assert_label(args, 2, "zassert_match");
match regex::Regex::new(pattern) {
Ok(re) => {
if re.is_match(string) {
ztest_pass(self, &msg);
0
} else {
ztest_fail(self, &msg, &format!("'{}' !~ /{}/", string, pattern));
1
}
}
Err(e) => {
ztest_fail(self, &msg, &format!("bad regex: {}", e));
1
}
}
}
pub(crate) fn builtin_zassert_contains(&self, args: &[String]) -> i32 {
let haystack = args.first().map(String::as_str).unwrap_or("");
let needle = args.get(1).map(String::as_str).unwrap_or("");
let msg = assert_label(args, 2, "zassert_contains");
if haystack.contains(needle) {
ztest_pass(self, &msg);
0
} else {
ztest_fail(
self,
&msg,
&format!("'{}' does not contain '{}'", haystack, needle),
);
1
}
}
pub(crate) fn builtin_zassert_near(&self, args: &[String]) -> i32 {
let a = parse_num(args.first().map(String::as_str).unwrap_or(""));
let b = parse_num(args.get(1).map(String::as_str).unwrap_or(""));
let eps = args
.get(2)
.map(|s| s.trim().parse::<f64>().unwrap_or(1e-9))
.unwrap_or(1e-9);
let msg = assert_label(args, 3, "zassert_near");
if (a - b).abs() <= eps {
ztest_pass(self, &msg);
0
} else {
ztest_fail(self, &msg, &format!("{} not near {} (eps={})", a, b, eps));
1
}
}
pub(crate) fn builtin_zassert_dies(&self, args: &[String]) -> i32 {
let cmd = args.first().map(String::as_str).unwrap_or("");
let msg = assert_label(args, 1, "zassert_dies");
if cmd.is_empty() {
ztest_fail(self, &msg, "first arg must be a shell command string");
return 1;
}
let mut scratch = ShellExecutor::new();
scratch.zsh_compat = self.zsh_compat;
scratch.bash_compat = self.bash_compat;
scratch.posix_mode = self.posix_mode;
match scratch.execute_script(cmd) {
Ok(0) => {
ztest_fail(self, &msg, "expected die but command succeeded");
1
}
Ok(_) | Err(_) => {
ztest_pass(self, &msg);
0
}
}
}
pub(crate) fn builtin_ztest_run(&self, _args: &[String]) -> i32 {
let pass = self.ztest_pass_count.load(Ordering::Relaxed);
let fail = self.ztest_fail_count.load(Ordering::Relaxed);
let skip = self.ztest_skip_count.load(Ordering::Relaxed);
let total = pass + fail + skip;
if !self.ztest_suppress_stdout {
eprintln!();
if fail == 0 {
if skip == 0 {
eprintln!("\x1b[32m \u{2713} All {} tests passed\x1b[0m", total);
} else {
eprintln!(
"\x1b[32m \u{2713} {} passed\x1b[0m, \x1b[33m{} skipped\x1b[0m (of {})",
pass, skip, total
);
}
} else {
let skip_tail = if skip > 0 {
format!(" (\x1b[33m{} skipped\x1b[0m)", skip)
} else {
String::new()
};
eprintln!(
"\x1b[31m \u{2717} {} of {} tests failed\x1b[0m{}",
fail, total, skip_tail
);
}
}
self.ztest_pass_total.fetch_add(pass, Ordering::Relaxed);
self.ztest_fail_total.fetch_add(fail, Ordering::Relaxed);
self.ztest_skip_total.fetch_add(skip, Ordering::Relaxed);
self.ztest_pass_count.store(0, Ordering::Relaxed);
self.ztest_fail_count.store(0, Ordering::Relaxed);
self.ztest_skip_count.store(0, Ordering::Relaxed);
if fail > 0 {
self.ztest_run_failed.store(true, Ordering::Relaxed);
}
if fail == 0 {
0
} else {
1
}
}
pub(crate) fn builtin_ztest_skip(&self, args: &[String]) -> i32 {
let msg = args.first().cloned().unwrap_or_else(|| "skipped".into());
ztest_skip_count(self, &msg);
0
}
}
pub fn try_dispatch_known(name: &str) -> bool {
matches!(
name,
"zassert_eq"
| "zassert_ne"
| "zassert_ok"
| "zassert_err"
| "zassert_true"
| "zassert_false"
| "zassert_gt"
| "zassert_lt"
| "zassert_ge"
| "zassert_le"
| "zassert_match"
| "zassert_contains"
| "zassert_near"
| "zassert_dies"
| "ztest_run"
| "run_tests"
| "ztest_skip"
)
}
pub fn try_dispatch(exec: &ShellExecutor, name: &str, args: &[String]) -> Option<i32> {
let s = match name {
"zassert_eq" => exec.builtin_zassert_eq(args),
"zassert_ne" => exec.builtin_zassert_ne(args),
"zassert_ok" => exec.builtin_zassert_ok(args),
"zassert_err" => exec.builtin_zassert_err(args),
"zassert_true" => exec.builtin_zassert_true(args),
"zassert_false" => exec.builtin_zassert_false(args),
"zassert_gt" => exec.builtin_zassert_gt(args),
"zassert_lt" => exec.builtin_zassert_lt(args),
"zassert_ge" => exec.builtin_zassert_ge(args),
"zassert_le" => exec.builtin_zassert_le(args),
"zassert_match" => exec.builtin_zassert_match(args),
"zassert_contains" => exec.builtin_zassert_contains(args),
"zassert_near" => exec.builtin_zassert_near(args),
"zassert_dies" => exec.builtin_zassert_dies(args),
"ztest_run" | "run_tests" => exec.builtin_ztest_run(args),
"ztest_skip" => exec.builtin_ztest_skip(args),
_ => return None,
};
Some(s)
}
#[derive(serde::Serialize, serde::Deserialize)]
struct WorkerRequest {
path: String,
#[serde(default)]
chdir: Option<String>,
}
#[derive(serde::Serialize, serde::Deserialize, Clone)]
struct WorkerResponse {
name: String,
passes: usize,
fails: usize,
failed: bool,
detail: Option<String>,
#[serde(default)]
stderr: String,
}
fn run_one_inproc(script_abs: &Path) -> (usize, usize, bool, Option<String>) {
let file_str = script_abs.to_string_lossy().to_string();
let source = match std::fs::read_to_string(script_abs) {
Ok(s) => s,
Err(e) => return (0, 0, true, Some(format!("read failed: {}", e))),
};
let mut exec = ShellExecutor::new();
exec.scriptname = Some(file_str.clone());
exec.scriptfilename = Some(file_str.clone());
exec.ztest_suppress_stdout = false;
let exec_result = exec.execute_script(&source);
let passes = exec.ztest_pass_total.load(Ordering::Relaxed)
+ exec.ztest_pass_count.load(Ordering::Relaxed);
let fails = exec.ztest_fail_total.load(Ordering::Relaxed)
+ exec.ztest_fail_count.load(Ordering::Relaxed);
let test_marked_failed = exec.ztest_run_failed.load(Ordering::Relaxed);
match exec_result {
Ok(rc) => {
let f = test_marked_failed || fails > 0 || rc != 0;
let detail = if f {
if fails > 0 {
Some(format!("{} assertion(s) failed", fails))
} else if rc != 0 {
Some(format!("exited with code {}", rc))
} else {
None
}
} else {
None
};
(passes, fails, f, detail)
}
Err(e) => (passes, fails, true, Some(e)),
}
}
pub fn run_ztest_worker_loop() -> i32 {
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let reader = BufReader::new(stdin.lock());
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => break, };
if line.is_empty() {
continue;
}
let req: WorkerRequest = match serde_json::from_str(&line) {
Ok(r) => r,
Err(e) => {
let resp = WorkerResponse {
name: line.chars().take(60).collect(),
passes: 0,
fails: 0,
failed: true,
detail: Some(format!("worker: malformed request: {}", e)),
stderr: String::new(),
};
let mut out = stdout.lock();
let _ = writeln!(out, "{}", serde_json::to_string(&resp).unwrap());
let _ = out.flush();
continue;
}
};
let pid = unsafe { libc::fork() };
if pid < 0 {
let err = std::io::Error::last_os_error();
let resp = WorkerResponse {
name: req.path.clone(),
passes: 0,
fails: 0,
failed: true,
detail: Some(format!("worker: fork failed: {}", err)),
stderr: String::new(),
};
let mut out = stdout.lock();
let _ = writeln!(out, "{}", serde_json::to_string(&resp).unwrap());
let _ = out.flush();
continue;
}
if pid == 0 {
let (saved_stdout, stderr_capture_fd, mut stderr_path): (
libc::c_int,
libc::c_int,
[u8; 32],
) = unsafe {
let saved = libc::dup(1);
if saved >= 0 {
let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
if devnull >= 0 {
libc::dup2(devnull, 1);
libc::close(devnull);
}
}
let mut tmpl: [u8; 32] = *b"/tmp/zshrs-ztest-XXXXXX\0\0\0\0\0\0\0\0\0";
let cap_fd = libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char);
if cap_fd >= 0 {
libc::unlink(tmpl.as_ptr() as *const libc::c_char);
libc::dup2(cap_fd, 2);
}
(saved, cap_fd, tmpl)
};
let _ = &mut stderr_path;
if let Some(cd) = req.chdir.as_deref() {
let _ = std::env::set_current_dir(cd);
}
let script_abs = PathBuf::from(&req.path);
let (passes, fails, file_failed, detail) = run_one_inproc(&script_abs);
let name = script_abs
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| req.path.clone());
let captured_stderr: String = unsafe {
if stderr_capture_fd >= 0 {
libc::lseek(stderr_capture_fd, 0, libc::SEEK_SET);
let mut buf = Vec::with_capacity(4096);
let mut chunk = [0u8; 8192];
loop {
let n = libc::read(
stderr_capture_fd,
chunk.as_mut_ptr() as *mut libc::c_void,
chunk.len(),
);
if n <= 0 {
break;
}
buf.extend_from_slice(&chunk[..n as usize]);
}
libc::close(stderr_capture_fd);
String::from_utf8_lossy(&buf).into_owned()
} else {
String::new()
}
};
let resp = WorkerResponse {
name,
passes,
fails,
failed: file_failed,
detail,
stderr: captured_stderr,
};
let line = format!(
"{}\n",
serde_json::to_string(&resp).unwrap_or_else(|_| String::new())
);
unsafe {
if saved_stdout >= 0 {
let _ = libc::write(
saved_stdout,
line.as_ptr() as *const libc::c_void,
line.len(),
);
libc::close(saved_stdout);
}
}
unsafe { libc::_exit(0) };
}
let mut status: libc::c_int = 0;
unsafe { libc::waitpid(pid, &mut status, 0) };
}
0
}
pub fn run_ztests_pool(targets: &[String], j_threads: Option<&str>, quiet: bool) -> i32 {
let targets: Vec<String> = if targets.is_empty() {
if Path::new("t").is_dir() {
vec!["t".to_string()]
} else if Path::new("tests").is_dir() {
vec!["tests".to_string()]
} else {
eprintln!("zshrs --ztest: no t/ or tests/ directory found");
return 1;
}
} else {
targets
.iter()
.filter(|t| Path::new(t.as_str()).exists())
.cloned()
.collect()
};
if targets.is_empty() {
eprintln!("zshrs --ztest: no valid paths found");
return 1;
}
let mut test_files: Vec<String> = Vec::new();
for target in &targets {
let target_path = Path::new(target);
if target_path.is_dir() {
if let Ok(entries) = std::fs::read_dir(target_path) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path().to_string_lossy().to_string();
let name = entry.file_name().to_string_lossy().to_string();
if (name.starts_with("test_") || name.starts_with("t_"))
&& (name.ends_with(".zsh")
|| name.ends_with(".sh")
|| name.ends_with(".zshrs"))
{
test_files.push(path);
}
}
}
} else {
test_files.push(target.clone());
}
}
test_files.sort();
test_files.dedup();
if test_files.is_empty() {
eprintln!("zshrs --ztest: no test files found");
return 1;
}
let total = test_files.len();
let n_workers = j_threads
.and_then(|s| s.parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or_else(|| {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
});
let exe: PathBuf = std::env::current_exe()
.ok()
.filter(|p| p.exists())
.or_else(|| {
std::env::args()
.next()
.and_then(|a| std::fs::canonicalize(&a).ok())
})
.unwrap_or_else(|| {
PathBuf::from(std::env::args().next().unwrap_or_else(|| "zshrs".into()))
});
if !quiet {
eprintln!(
"\x1b[36mRunning {} test file{} ({} workers)\x1b[0m\n",
total,
if total == 1 { "" } else { "s" },
n_workers
);
}
let jobs: Vec<(String, String, String)> = test_files
.iter()
.map(|f| {
let abs = std::fs::canonicalize(f)
.unwrap_or_else(|_| PathBuf::from(f))
.to_string_lossy()
.to_string();
let chdir = Path::new(&abs)
.parent()
.and_then(|p| p.parent())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string());
let name = Path::new(&abs)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| abs.clone());
(name, abs, chdir)
})
.collect();
let (job_tx, job_rx) = crossbeam_channel::unbounded::<(String, String, String)>();
for j in jobs {
job_tx.send(j).expect("send job");
}
drop(job_tx);
let total_pass = Arc::new(AtomicUsize::new(0));
let total_fail = Arc::new(AtomicUsize::new(0));
let failed_count = Arc::new(AtomicUsize::new(0));
let failure_details: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let print_lock: Arc<Mutex<()>> = Arc::new(Mutex::new(()));
let mut handles = Vec::with_capacity(n_workers);
for _ in 0..n_workers {
let job_rx = job_rx.clone();
let total_pass = Arc::clone(&total_pass);
let total_fail = Arc::clone(&total_fail);
let failed_count = Arc::clone(&failed_count);
let failure_details = Arc::clone(&failure_details);
let print_lock = Arc::clone(&print_lock);
let exe = exe.clone();
let h = thread::Builder::new()
.stack_size(16 * 1024 * 1024)
.spawn(move || {
let mut child = match process::Command::new(&exe)
.arg("--ztest-worker")
.stdin(process::Stdio::piped())
.stdout(process::Stdio::piped())
.stderr(process::Stdio::inherit())
.spawn()
{
Ok(c) => c,
Err(e) => {
eprintln!("zshrs --ztest: failed to spawn worker: {}", e);
return;
}
};
let mut stdin = child.stdin.take().expect("worker stdin");
let stdout = child.stdout.take().expect("worker stdout");
let mut reader = BufReader::new(stdout);
let mut line_buf = String::new();
while let Ok((_short_name, abs, chdir)) = job_rx.recv() {
let req = WorkerRequest {
path: abs.clone(),
chdir: Some(chdir),
};
let req_json = serde_json::to_string(&req).expect("serialize");
if writeln!(stdin, "{}", req_json).is_err() {
break;
}
if stdin.flush().is_err() {
break;
}
line_buf.clear();
if reader.read_line(&mut line_buf).is_err() || line_buf.is_empty() {
break;
}
let resp: WorkerResponse = match serde_json::from_str(line_buf.trim()) {
Ok(r) => r,
Err(e) => WorkerResponse {
name: abs.clone(),
passes: 0,
fails: 0,
failed: true,
detail: Some(format!("malformed worker response: {}", e)),
stderr: String::new(),
},
};
total_pass.fetch_add(resp.passes, Ordering::Relaxed);
total_fail.fetch_add(resp.fails, Ordering::Relaxed);
if resp.failed {
failed_count.fetch_add(1, Ordering::Relaxed);
if let Some(d) = &resp.detail {
failure_details
.lock()
.unwrap()
.push((resp.name.clone(), d.clone()));
}
}
if !quiet {
let _g = print_lock.lock().unwrap();
eprintln!("\x1b[1m\u{2500}\u{2500} {} \u{2500}\u{2500}\x1b[0m", resp.name);
if !resp.stderr.is_empty() {
eprint!("{}", resp.stderr);
}
eprintln!(
" {} passed, {} failed{}",
resp.passes,
resp.fails,
if resp.failed { " (file FAILED)" } else { "" }
);
eprintln!();
}
}
drop(stdin); let _ = child.wait();
})
.expect("spawn worker thread");
handles.push(h);
}
for h in handles {
let _ = h.join();
}
let failed = failed_count.load(Ordering::Relaxed);
let total_pass = total_pass.load(Ordering::Relaxed);
let total_fail = total_fail.load(Ordering::Relaxed);
let failure_details = Arc::try_unwrap(failure_details)
.expect("workers dropped failure_details Arc")
.into_inner()
.unwrap();
let grand_total = total_pass + total_fail;
if !quiet {
eprintln!("\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}");
}
if failed == 0 {
if !quiet {
eprintln!(
"\x1b[32m\u{2713} All {} test file{} passed ({} assertions)\x1b[0m",
total,
if total == 1 { "" } else { "s" },
grand_total
);
}
0
} else {
if !quiet {
let bar = "\u{2550}".repeat(64);
eprintln!();
eprintln!("\x1b[1;31m{}\x1b[0m", bar);
eprintln!("\x1b[1;31m FAILURES SUMMARY\x1b[0m");
eprintln!("\x1b[1;31m{}\x1b[0m", bar);
for (file_name, details) in &failure_details {
eprintln!();
eprintln!("\x1b[1;33m\u{2500}\u{2500} {} \u{2500}\u{2500}\x1b[0m", file_name);
for line in details.lines().take(20) {
eprintln!(" {}", line);
}
if details.lines().count() > 20 {
eprintln!(" ... ({} more lines)", details.lines().count() - 20);
}
}
eprintln!();
eprintln!("\x1b[1;31m{}\x1b[0m", bar);
eprintln!(
"\x1b[31m\u{2717} {} of {} test file{} failed ({} passed, {} failed)\x1b[0m",
failed,
total,
if total == 1 { "" } else { "s" },
total_pass,
total_fail
);
}
1
}
}