test,cli: wire pkh test into the CLI

Argument wiring only, per the report ports: the live view carries
phases and the outcome, the per-test report renders after the run
(text or --json) and decides the exit code; runtime errors exit 2.
--list-tests prints the parsed debian/tests/control and exits without
booting a testbed.
This commit is contained in:
2026-09-27 21:04:03 +02:00
parent 299b5c28c3
commit 2a9287d942
2 changed files with 241 additions and 0 deletions
+213
View File
@@ -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 <id>'.\nRead-only: never builds or removes anything ('pkh prune' removes old sessions)."), .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 <id>'.\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 <series> "Target distribution series").required(false))
.arg(arg!(-d --dist <dist> "Target distribution (debian, ubuntu)").required(false))
.arg(arg!(-a --arch <arch> "Target architecture").required(false))
.arg(arg!(-p --pocket <pocket> "Resolve test dependencies from a specific distribution pocket (updates, security, proposed)").required(false))
.arg(arg!(--ppa <ppa> "Add a PPA as test dependency source (can be specified multiple times)").required(false).action(clap::ArgAction::Append))
.arg(arg!(--inject <package> "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 <mode> "Testbed mode: chroot (default, an ephemeral unshare chroot) or local (the current context, no isolation)").required(false))
.arg(arg!(--debs <deb> "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 <secs> "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( .subcommand(
Command::new("lint") Command::new("lint")
.about("Lint the package (lintian wrapper + pkh-native checks)") .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::<String>("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::<String>("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::<String>("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::<String>("series").cloned(),
dist: sub_matches.get_one::<String>("dist").cloned(),
arch: sub_matches.get_one::<String>("arch").cloned(),
pocket: sub_matches.get_one::<String>("pocket").cloned(),
ppa: sub_matches
.get_many::<String>("ppa")
.map(|v| v.cloned().collect())
.unwrap_or_default(),
inject: sub_matches
.get_many::<String>("inject")
.map(|v| v.cloned().collect())
.unwrap_or_default(),
mode,
debs: sub_matches
.get_many::<String>("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::<String>("test_name")
.map(|v| v.cloned().collect())
.unwrap_or_default(),
skip_tests: sub_matches
.get_many::<String>("skip_test")
.map(|v| v.cloned().collect())
.unwrap_or_default(),
setup_commands: sub_matches
.get_many::<String>("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::<u64>("timeout").copied(),
timeout_factor: sub_matches.get_one::<f64>("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)) => { Some(("prune", sub_matches)) => {
let dry_run = sub_matches let dry_run = sub_matches
.get_one::<bool>("dry_run") .get_one::<bool>("dry_run")
+28
View File
@@ -536,6 +536,34 @@ fn create_run_dir(package: &str, series: &str, arch: &str) -> std::io::Result<Pa
Ok(dir) Ok(dir)
} }
/// Render the tests declared in the tree's `debian/tests/control`, one
/// block per test (`pkh test --list-tests`).
pub fn list_tests(tree: &Path) -> Result<String, Box<dyn std::error::Error>> {
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. /// The binary package name of a staged .deb file name.
fn deb_binary_name(path: &Path) -> Option<String> { fn deb_binary_name(path: &Path) -> Option<String> {
let name = path.file_name()?.to_string_lossy(); let name = path.file_name()?.to_string_lossy();