use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result};
use clap::Parser;
use crate::ui;
#[derive(Debug, Parser)]
pub struct TestArgs {
#[arg(last = true)]
pub cargo_args: Vec<String>,
}
pub fn run(args: TestArgs) -> Result<()> {
ui::header(
"portaki test",
"Forward to cargo test — the module's own tests, on the host target.",
);
let mut cmd = Command::new("cargo");
cmd.arg("test");
for arg in &args.cargo_args {
cmd.arg(arg);
}
let status = cmd.status().context("cargo test")?;
ui::blank();
if status.success() {
ui::success("tests passed");
ui::blank();
Ok(())
} else {
anyhow::bail!("cargo test failed");
}
}
const CONFORMANCE_MODULE: &str = "portaki_conformance::";
#[derive(Debug, PartialEq, Eq)]
pub enum Refusal {
Failed,
NoConformance,
}
impl Refusal {
pub fn message(&self) -> String {
match self {
Refusal::Failed => "the module's tests fail — portaki publish does not push a module \
whose tests do not pass, and there is no flag to make it. Fix them, then publish \
again; portaki test runs the same thing"
.to_string(),
Refusal::NoConformance => format!(
"the module's tests pass, but the conformance battery did not run — portaki \
publish requires it. Add tests/conformance.rs containing \
`portaki_test_utils::conformance!();` (portaki-test-utils {} or later)",
env!("CARGO_PKG_VERSION")
),
}
}
}
pub fn publish_test_command(module_root: &Path) -> Command {
let mut cmd = Command::new("cargo");
cmd.arg("test")
.arg("--no-fail-fast")
.current_dir(module_root)
.env_remove("CARGO_BUILD_TARGET");
cmd
}
pub fn conformance_ran(stdout: &str) -> bool {
stdout.lines().any(|line| {
line.trim_start()
.strip_prefix("test ")
.is_some_and(|rest| rest.starts_with(CONFORMANCE_MODULE))
})
}
pub fn verdict(success: bool, stdout: &str) -> Result<(), Refusal> {
if !success {
Err(Refusal::Failed)
} else if !conformance_ran(stdout) {
Err(Refusal::NoConformance)
} else {
Ok(())
}
}
pub fn gate_publish(module_root: &Path) -> Result<()> {
let step = ui::step("running the module's tests (conformance battery included)");
let output = publish_test_command(module_root)
.output()
.context("run cargo test")?;
let stdout = String::from_utf8_lossy(&output.stdout);
match verdict(output.status.success(), &stdout) {
Ok(()) => {
if ui::verbose() {
ui::emit_captured(&output.stdout);
}
step.done("tests passed, conformance battery included");
Ok(())
}
Err(refusal) => {
step.abandon();
ui::emit_captured(&output.stdout);
if refusal == Refusal::Failed {
ui::emit_captured(&output.stderr);
}
anyhow::bail!(refusal.message())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn the_battery_is_recognised_in_libtest_output() {
let stdout = "\nrunning 5 tests\ntest portaki_conformance::manifest ... ok\n\
test portaki_conformance::i18n ... ok\n";
assert!(conformance_ran(stdout));
}
#[test]
fn a_lookalike_is_not_the_battery() {
let stdout = "test tests::portaki_conformance_is_documented ... ok\n\
test my_portaki_conformance::manifest ... ok\n\
portaki_conformance::manifest\n";
assert!(!conformance_ran(stdout));
}
#[test]
fn failing_tests_refuse_whatever_ran() {
let stdout = "test portaki_conformance::manifest ... FAILED\n";
assert_eq!(verdict(false, stdout), Err(Refusal::Failed));
}
#[test]
fn passing_tests_without_the_battery_refuse() {
assert_eq!(
verdict(true, "test settings::round_trip ... ok\n"),
Err(Refusal::NoConformance)
);
}
#[test]
fn passing_tests_with_the_battery_publish() {
assert_eq!(
verdict(true, "test portaki_conformance::surfaces ... ok\n"),
Ok(())
);
}
#[test]
fn the_refusals_say_what_to_do() {
let failed = Refusal::Failed.message();
assert!(failed.contains("tests fail"), "{failed}");
assert!(failed.contains("portaki test"), "{failed}");
let missing = Refusal::NoConformance.message();
assert!(missing.contains("tests/conformance.rs"), "{missing}");
assert!(
missing.contains("portaki_test_utils::conformance!();"),
"{missing}"
);
assert!(missing.contains(env!("CARGO_PKG_VERSION")), "{missing}");
}
#[test]
fn the_tests_run_in_the_module_on_the_host() {
let root = Path::new("/modules/weather");
let cmd = publish_test_command(root);
assert_eq!(cmd.get_program(), "cargo");
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(args, ["test", "--no-fail-fast"]);
assert_eq!(cmd.get_current_dir(), Some(root));
assert!(cmd
.get_envs()
.any(|(key, value)| key == "CARGO_BUILD_TARGET" && value.is_none()));
}
fn crate_with_tests(name: &str, tests: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[workspace]\n"
),
)
.unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/lib.rs"), "").unwrap();
fs::create_dir_all(dir.path().join("tests")).unwrap();
fs::write(dir.path().join("tests/module.rs"), tests).unwrap();
dir
}
#[test]
fn a_failing_test_refuses_the_publication() {
let module = crate_with_tests(
"failing-module",
"mod portaki_conformance { #[test] fn manifest() {} }\n\
#[test] fn settings_round_trip() { assert_eq!(1, 2, \"settings lost\"); }\n",
);
let refusal = gate_publish(module.path()).unwrap_err().to_string();
assert!(refusal.contains("tests fail"), "{refusal}");
}
#[test]
fn a_module_without_the_battery_is_refused() {
let module = crate_with_tests(
"unconformed-module",
"#[test] fn settings_round_trip() {}\n",
);
let refusal = gate_publish(module.path()).unwrap_err().to_string();
assert!(
refusal.contains("conformance battery did not run"),
"{refusal}"
);
}
#[test]
fn a_module_whose_tests_and_battery_pass_goes_through() {
let module = crate_with_tests(
"conformed-module",
"mod portaki_conformance { #[test] fn manifest() {} #[test] fn surfaces() {} }\n\
#[test] fn settings_round_trip() {}\n",
);
gate_publish(module.path()).unwrap();
}
}