diff --git a/src/main.rs b/src/main.rs index 6d8fb54..cca1631 100644 --- a/src/main.rs +++ b/src/main.rs @@ -381,6 +381,82 @@ fn main() { .long_about("List the resumable build sessions recorded from this tree, one per target (series/arch), with the id to pass to 'pkh deb --resume '.\nRead-only: never builds or removes anything ('pkh prune' removes old sessions)."), ) ) + .subcommand( + Command::new("test") + .about("Run the package's DEP-8 as-installed tests (autopkgtests)") + .arg(arg!([path] "Source tree to test (default: the current directory)").required(false)) + .arg(arg!(-s --series "Target distribution series").required(false)) + .arg(arg!(-d --dist "Target distribution (debian, ubuntu)").required(false)) + .arg(arg!(-a --arch "Target architecture").required(false)) + .arg(arg!(-p --pocket "Resolve test dependencies from a specific distribution pocket (updates, security, proposed)").required(false)) + .arg(arg!(--ppa "Add a PPA as test dependency source (can be specified multiple times)").required(false).action(clap::ArgAction::Append)) + .arg(arg!(--inject "Inject a package into the testbed before the tests (.deb path or package name, can be specified multiple times)").required(false).action(clap::ArgAction::Append)) + .arg(arg!(--mode "Testbed mode: chroot (default, an ephemeral unshare chroot) or local (the current context, no isolation)").required(false)) + .arg(arg!(--debs "Explicit binary package to test, overriding the pkh deb output reuse (can be specified multiple times)").required(false).action(clap::ArgAction::Append)) + .arg( + clap::Arg::new("no_build") + .long("no-build") + .action(clap::ArgAction::SetTrue) + .help("Never build: fail when no fresh 'pkh deb' output exists next to the tree"), + ) + .arg(arg!(--build "Ignore fresh pkh deb output and rebuild the binaries first").required(false)) + .arg( + clap::Arg::new("test_name") + .long("test-name") + .value_name("NAME") + .action(clap::ArgAction::Append) + .help("Run only this test (can be specified multiple times)"), + ) + .arg( + clap::Arg::new("skip_test") + .long("skip-test") + .value_name("NAME") + .action(clap::ArgAction::Append) + .help("Skip this test, reporting it as skipped (can be specified multiple times)"), + ) + .arg( + clap::Arg::new("setup_commands") + .long("setup-commands") + .value_name("CMD") + .action(clap::ArgAction::Append) + .help("Shell command run in the testbed before the tests (can be specified multiple times)"), + ) + .arg( + clap::Arg::new("apt_upgrade") + .long("apt-upgrade") + .action(clap::ArgAction::SetTrue) + .help("Update, upgrade and autopurge the testbed before the tests"), + ) + .arg(arg!(--shell "Drop into an interactive testbed shell after the run").required(false)) + .arg( + clap::Arg::new("shell_fail") + .long("shell-fail") + .action(clap::ArgAction::SetTrue) + .help("Drop into an interactive testbed shell when a test failed"), + ) + .arg(arg!(--timeout "Per-test timeout in seconds (default: 10000)").required(false).value_parser(clap::value_parser!(u64))) + .arg( + clap::Arg::new("timeout_factor") + .long("timeout-factor") + .value_name("FACTOR") + .value_parser(clap::value_parser!(f64)) + .help("Scale the default timeouts (an explicit --timeout stands as given)"), + ) + .arg( + clap::Arg::new("fail_on") + .long("fail-on") + .value_name("TRIGGERS") + .help("Comma-separated extra failure triggers: skip, no-tests (default: none)"), + ) + .arg( + clap::Arg::new("list_tests") + .long("list-tests") + .action(clap::ArgAction::SetTrue) + .help("Print the tests declared in debian/tests/control and exit"), + ) + .arg(arg!(--json "Emit the report as JSON").required(false)) + .arg(arg!(--verbose "Show raw test output instead of the summarized live view").required(false)), + ) .subcommand( Command::new("lint") .about("Lint the package (lintian wrapper + pkh-native checks)") @@ -902,6 +978,143 @@ fn main() { } } } + Some(("test", sub_matches)) => { + let path = sub_matches + .get_one::("path") + .map(std::path::PathBuf::from); + + // Read-only listing of the declared tests; never boots a + // testbed. + if sub_matches.get_flag("list_tests") { + let tree = path.unwrap_or_else(current_dir_or_exit); + match pkh::test::list_tests(&tree) { + Ok(listing) => print!("{listing}"), + Err(e) => { + error!("{e}"); + std::process::exit(2); + } + } + return; + } + + // Ctrl+C must release the ephemeral chroot instead of dying + // on the default disposition (the deb pattern). + interrupt::install(); + + let mode = match sub_matches.get_one::("mode").map(String::as_str) { + None => None, + Some(value) => match pkh::test::TestMode::parse(value) { + Some(mode) => Some(mode), + None => { + error!("Invalid --mode value '{value}': expected chroot or local"); + std::process::exit(1); + } + }, + }; + let (fail_on_skip, fail_on_no_tests) = + match sub_matches.get_one::("fail_on").map(String::as_str) { + None => (false, false), + Some(list) => { + let mut skip = false; + let mut no_tests = false; + for trigger in list.split(',').map(str::trim).filter(|t| !t.is_empty()) { + match trigger { + "skip" => skip = true, + "no-tests" => no_tests = true, + other => { + error!( + "Unknown --fail-on trigger '{other}': \ + expected skip or no-tests" + ); + std::process::exit(1); + } + } + } + (skip, no_tests) + } + }; + + // Live build view, unless --verbose (DebUi additionally + // disables itself when stdout is not a terminal). + let quiet = pkh::report::Quiet; + let live = if sub_matches.get_flag("verbose") { + None + } else { + Some(pkh::ui::deb::DebUi::new(&multi)) + }; + let view: &dyn pkh::report::BuildView = live + .as_ref() + .map(|v| v as &dyn pkh::report::BuildView) + .unwrap_or(&quiet); + + let options = pkh::test::TestOptions { + tree: path, + series: sub_matches.get_one::("series").cloned(), + dist: sub_matches.get_one::("dist").cloned(), + arch: sub_matches.get_one::("arch").cloned(), + pocket: sub_matches.get_one::("pocket").cloned(), + ppa: sub_matches + .get_many::("ppa") + .map(|v| v.cloned().collect()) + .unwrap_or_default(), + inject: sub_matches + .get_many::("inject") + .map(|v| v.cloned().collect()) + .unwrap_or_default(), + mode, + debs: sub_matches + .get_many::("debs") + .map(|v| v.map(std::path::PathBuf::from).collect()) + .unwrap_or_default(), + no_build: sub_matches.get_flag("no_build"), + build: sub_matches.get_flag("build"), + test_names: sub_matches + .get_many::("test_name") + .map(|v| v.cloned().collect()) + .unwrap_or_default(), + skip_tests: sub_matches + .get_many::("skip_test") + .map(|v| v.cloned().collect()) + .unwrap_or_default(), + setup_commands: sub_matches + .get_many::("setup_commands") + .map(|v| v.cloned().collect()) + .unwrap_or_default(), + apt_upgrade: sub_matches.get_flag("apt_upgrade"), + shell: sub_matches.get_flag("shell"), + shell_fail: sub_matches.get_flag("shell_fail"), + timeout: sub_matches.get_one::("timeout").copied(), + timeout_factor: sub_matches.get_one::("timeout_factor").copied(), + fail_on_skip, + fail_on_no_tests, + json: sub_matches.get_flag("json"), + verbose: sub_matches.get_flag("verbose"), + view, + }; + let json = options.json; + + match rt.block_on(pkh::test::run(options)) { + Ok(report) => { + // The per-test report is the deliverable; the live + // view carries phases and the outcome banner only. + if json { + println!("{}", report.render_json()); + } else { + print!("{}", report.render_text()); + } + std::process::exit(report.exit_code(fail_on_skip, fail_on_no_tests)); + } + Err(e) => { + // On Ctrl+C the interrupt watchdog owns the shutdown + // (see `pkh deb`): park here instead of racing it. + if pkh::interrupt::interrupted() { + interrupt::wait_for_shutdown(); + } + error!("{e}"); + std::process::exit(2); + } + } + } Some(("prune", sub_matches)) => { let dry_run = sub_matches .get_one::("dry_run") diff --git a/src/test/mod.rs b/src/test/mod.rs index 0d0c58c..fa69db0 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -536,6 +536,34 @@ fn create_run_dir(package: &str, series: &str, arch: &str) -> std::io::Result Result> { + let tests_control = TestsControl::parse(&tree.join("debian/tests/control")) + .map_err(|e| format!("cannot parse debian/tests/control: {e}"))?; + let mut out = String::new(); + for test in &tests_control.tests { + out.push_str(&test.name); + out.push('\n'); + if let Some(command) = &test.command { + out.push_str(&format!(" Test-Command: {command}\n")); + } + if !test.restrictions.is_empty() { + out.push_str(&format!( + " Restrictions: {}\n", + test.restrictions.join(" ") + )); + } + if let Some(depends) = &test.depends { + out.push_str(&format!(" Depends: {depends}\n")); + } + if let Some(directory) = &test.tests_directory { + out.push_str(&format!(" Tests-Directory: {directory}\n")); + } + } + Ok(out) +} + /// The binary package name of a staged .deb file name. fn deb_binary_name(path: &Path) -> Option { let name = path.file_name()?.to_string_lossy();