use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fmt::Write as _;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use anyhow::{bail, Context, Result};
use crate::{match_commands, CommandNode, CommandTest, RootSpec, RunContext};
#[derive(Debug)]
struct CollectedTest<'a> {
chain: Vec<String>,
name: String,
spec: &'a CommandTest,
}
impl CollectedTest<'_> {
fn command_path(&self) -> String {
if self.chain.is_empty() {
"(root)".to_string()
} else {
self.chain.join(" ")
}
}
fn label(&self) -> String {
format!("{} {}", self.command_path(), self.name)
}
}
pub fn count_tests(node: &CommandNode) -> usize {
node.tests.len() + node.commands.values().map(count_tests).sum::<usize>()
}
fn collect_from_node<'a>(
chain: &[String],
node: &'a CommandNode,
out: &mut Vec<CollectedTest<'a>>,
) {
for (name, spec) in &node.tests {
out.push(CollectedTest {
chain: chain.to_vec(),
name: name.clone(),
spec,
});
}
for (child_name, child) in &node.commands {
let mut next = chain.to_vec();
next.push(child_name.clone());
collect_from_node(&next, child, out);
}
}
fn collect_from_map<'a>(
prefix: &[String],
map: &'a BTreeMap<String, CommandNode>,
out: &mut Vec<CollectedTest<'a>>,
) {
for (name, node) in map {
let mut chain = prefix.to_vec();
chain.push(name.clone());
collect_from_node(&chain, node, out);
}
}
fn print_test_help() {
print!(
"\
test — run Given/When/Then shell tests
jan test
jan test <command path...>
With no path, every test in the preferred tree runs. With a path, tests on that
command and all nested descendants run.
`when:` is extra argv for the command the test is declared on (do not repeat the
script path). Omit `when` to invoke that command with no extra args. Tests never
write the audit log.
Examples:
jan test
jan test scripts files
jan test scripts files csv-summary
"
);
}
pub fn dispatch_test(args: &[OsString], spec: &RootSpec, ctx: &RunContext<'_>) -> Result<i32> {
let mut path: Vec<OsString> = Vec::new();
for a in args {
let s = a.to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_test_help();
return Ok(0);
}
other if other.starts_with('-') && other != "-" => {
bail!("unknown test flag `{other}`");
}
_ => path.push(a.clone()),
}
}
if path.is_empty() {
return run_tests(spec, &[], None, ctx);
}
let m = match_commands(spec, &path);
if !m.trailing.is_empty() {
let t = m.trailing[0].to_string_lossy();
if m.chain.is_empty() {
bail!("unknown top-level command `{t}`");
}
bail!("unknown subcommand `{t}` under `{}`", m.chain.join(" "));
}
let node = match m.node {
Some(n) => n,
None => {
let key = path[0].to_string_lossy();
bail!("unknown top-level command `{key}`");
}
};
run_tests(spec, &m.chain, Some(node), ctx)
}
pub fn run_tests(
spec: &RootSpec,
chain: &[String],
node: Option<&CommandNode>,
ctx: &RunContext<'_>,
) -> Result<i32> {
let mut tests = Vec::new();
match node {
Some(n) => collect_from_node(chain, n, &mut tests),
None => collect_from_map(&[], &spec.commands, &mut tests),
}
if tests.is_empty() {
let where_ = if chain.is_empty() {
"this tree".to_string()
} else {
format!("`{}`", chain.join(" "))
};
eprintln!("no tests defined under {where_}");
return Ok(0);
}
let jan_bin = std::env::current_exe().context("resolve jan executable for tests")?;
let mut passed = 0usize;
let mut failed = 0usize;
for (i, t) in tests.iter().enumerate() {
match run_one(t, &jan_bin, ctx) {
Ok(()) => {
eprintln!("ok {} {}", i + 1, t.label());
passed += 1;
}
Err(e) => {
eprintln!("not ok {} {}", i + 1, t.label());
eprintln!("{e:#}");
failed += 1;
}
}
}
eprintln!("# {passed} passed, {failed} failed, {} total", tests.len());
if failed > 0 {
Ok(1)
} else {
Ok(0)
}
}
fn run_one(t: &CollectedTest<'_>, jan_bin: &Path, ctx: &RunContext<'_>) -> Result<()> {
let tmp = tempfile::tempdir().context("create jan test temp dir")?;
write_harness(t, jan_bin, tmp.path(), ctx)?;
let output = Command::new("sh")
.arg(tmp.path().join("test.sh"))
.current_dir(tmp.path())
.output()
.context("spawn `sh` for command test")?;
if output.status.success() {
return Ok(());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let code = output.status.code().unwrap_or(255);
let mut msg = format!("test `{}` failed (sh exit {code})", t.label());
if !stdout.trim().is_empty() {
msg.push_str("\n--- stdout ---\n");
msg.push_str(stdout.trim_end());
}
if !stderr.trim().is_empty() {
msg.push_str("\n--- stderr ---\n");
msg.push_str(stderr.trim_end());
}
bail!("{msg}");
}
fn write_harness(
t: &CollectedTest<'_>,
jan_bin: &Path,
tmp: &Path,
ctx: &RunContext<'_>,
) -> Result<()> {
fs::write(tmp.join(".jan-given.sh"), t.spec.given.trim_start())?;
fs::write(tmp.join(".jan-then.sh"), t.spec.then.trim_start())?;
let cmd = tmp.join("cmd");
let mut wrap = String::from("#!/bin/sh\n");
write!(
wrap,
"exec {} --no-log",
sh_single_quote(&jan_bin.to_string_lossy())
)
.unwrap();
for part in &t.chain {
wrap.push(' ');
wrap.push_str(&sh_single_quote(part));
}
wrap.push_str(" \"$@\"\n");
fs::write(&cmd, wrap)?;
let mut perms = fs::metadata(&cmd)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&cmd, perms)?;
let jan_shim = tmp.join("jan");
let mut shim = String::from("#!/bin/sh\n");
writeln!(
shim,
"exec {} --no-log \"$@\"",
sh_single_quote(&jan_bin.to_string_lossy())
)
.unwrap();
fs::write(&jan_shim, shim)?;
let mut shim_perms = fs::metadata(&jan_shim)?.permissions();
shim_perms.set_mode(0o755);
fs::set_permissions(&jan_shim, shim_perms)?;
let mut when_run = String::from("#!/bin/sh\n");
when_run.push_str("exec ");
when_run.push_str(&sh_single_quote(&cmd.to_string_lossy()));
let when = t.spec.when.trim();
if !when.is_empty() {
when_run.push(' ');
when_run.push_str(when);
}
when_run.push('\n');
fs::write(tmp.join(".jan-when-run.sh"), when_run)?;
let mut buf = String::new();
buf.push_str("#!/bin/sh\n");
buf.push_str("# jan Given/When/Then harness — do not run by hand\n");
buf.push_str("set -u\n");
writeln!(
buf,
"export JAN_BIN={}",
sh_single_quote(&jan_bin.to_string_lossy())
)
.unwrap();
writeln!(
buf,
"export JAN_TEST_TMP={}",
sh_single_quote(&tmp.to_string_lossy())
)
.unwrap();
writeln!(
buf,
"export JAN_CWD={}",
sh_single_quote(&ctx.cwd.to_string_lossy())
)
.unwrap();
writeln!(buf, "export JAN_CMD={}", sh_single_quote(&t.command_path())).unwrap();
buf.push_str("export JAN_NO_LOG=1\n");
buf.push_str("cd \"$JAN_TEST_TMP\" || exit 1\n");
buf.push_str("PATH=\"$JAN_TEST_TMP:$(dirname \"$JAN_BIN\"):$PATH\"\n");
buf.push_str("export PATH\n\n");
buf.push_str("# --- given ---\n");
buf.push_str("if [ -s \"$JAN_TEST_TMP/.jan-given.sh\" ]; then\n");
buf.push_str(" set -a\n");
buf.push_str(" set -e\n");
buf.push_str(" . \"$JAN_TEST_TMP/.jan-given.sh\"\n");
buf.push_str(" set +e\n");
buf.push_str(" set +a\n");
buf.push_str("fi\n\n");
buf.push_str("# --- when ---\n");
buf.push_str("JAN_STDOUT_FILE=\"$JAN_TEST_TMP/.jan-stdout\"\n");
buf.push_str("JAN_STDERR_FILE=\"$JAN_TEST_TMP/.jan-stderr\"\n");
buf.push_str("set +e\n");
buf.push_str(
"sh \"$JAN_TEST_TMP/.jan-when-run.sh\" >\"$JAN_STDOUT_FILE\" 2>\"$JAN_STDERR_FILE\"\n",
);
buf.push_str("JAN_STATUS=$?\n");
buf.push_str("JAN_STDOUT=$(cat \"$JAN_STDOUT_FILE\"; printf x)\n");
buf.push_str("JAN_STDOUT=${JAN_STDOUT%x}\n");
buf.push_str("JAN_STDERR=$(cat \"$JAN_STDERR_FILE\"; printf x)\n");
buf.push_str("JAN_STDERR=${JAN_STDERR%x}\n");
buf.push_str("export JAN_STATUS JAN_STDOUT JAN_STDERR JAN_STDOUT_FILE JAN_STDERR_FILE\n\n");
buf.push_str("# --- then ---\n");
buf.push_str("set -e\n");
buf.push_str(". \"$JAN_TEST_TMP/.jan-then.sh\"\n");
fs::write(tmp.join("test.sh"), buf)?;
Ok(())
}
fn sh_single_quote(s: &str) -> String {
let mut out = String::from("'");
for c in s.chars() {
if c == '\'' {
out.push_str("'\\''");
} else {
out.push(c);
}
}
out.push('\'');
out
}
#[cfg(test)]
mod tests {
use super::sh_single_quote;
#[test]
fn quotes_apostrophes() {
assert_eq!(sh_single_quote("a'b"), "'a'\\''b'");
assert_eq!(sh_single_quote("plain"), "'plain'");
}
}