test: add pkh test, the native DEP-8 runner for local and chroot
The DEP-8 run-time contract is one page, so pkh implements it natively instead of wrapping autopkgtest (see plans/pkh-test.md): the testbed is a pkh context — chroot mode reuses the deb ephemeral chroots and their cached mmdebstrap tarballs, local mode runs on the current context — and the binaries under test are the freshness-guarded pkh deb output next to the tree, rebuilt through the deb flow when stale or missing. Per test: restrictions gate on the testbed (isolation-machine, needs-reboot and container isolation skip with a pointer at --mode vm), the stanza Depends are expanded (@, @builddeps@, @recommends@) and installed, then the script or Test-Command runs in the staged tree with the AUTOPKGTEST_* (and legacy ADT_*) environment, a per-test timeout and the stderr/exit-77 verdict rules; flaky tests retry once. stdout, stderr and $AUTOPKGTEST_ARTIFACTS are collected per test under /var/tmp/pkh/tests/ (pruned separately). --shell/--shell-fail drop into the testbed for debugging. pin_pocket and install_injected_packages become crate-internal helpers shared with the deb flow, which they mirror.
This commit is contained in:
+628
@@ -0,0 +1,628 @@
|
||||
//! Run a source package's DEP-8 as-installed tests (`pkh test`).
|
||||
//!
|
||||
//! The runner is native for the chroot and local testbeds: the testbed
|
||||
//! is a pkh context (an ephemeral unshare chroot bootstrapped exactly
|
||||
//! like a `pkh deb` build chroot, or the current context directly), and
|
||||
//! the DEP-8 per-test contract lives in [`runner`]. `--mode vm` execs
|
||||
//! the installed autopkgtest with its qemu runner instead — the one
|
||||
//! backend pkh does not reimplement (serial-console protocol, image
|
||||
//! management, reboot support).
|
||||
//!
|
||||
//! Binaries under test are the fresh `pkh deb` output next to the tree
|
||||
//! (freshness-guarded like the lint wrapper); when they are missing or
|
||||
//! stale, the deb flow builds them in-process first.
|
||||
|
||||
pub(crate) mod bed;
|
||||
pub mod expand;
|
||||
pub mod options;
|
||||
pub mod outcome;
|
||||
pub(crate) mod runner;
|
||||
|
||||
pub use options::{TestMode, TestOptions};
|
||||
pub use outcome::TestRunReport;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::context::{LineSink, Stream};
|
||||
use crate::debian::control::{ControlInfo, parse_paragraphs};
|
||||
use crate::debian::tests_control::TestsControl;
|
||||
use crate::logfmt::GenericClassifier;
|
||||
use crate::report::{BuildTarget, BuildView, TargetKind};
|
||||
use crate::test::bed::Bed;
|
||||
use crate::test::outcome::Verdict;
|
||||
|
||||
/// Where the built binaries under test came from.
|
||||
struct Binaries {
|
||||
/// Host-side paths of the .deb files.
|
||||
paths: Vec<PathBuf>,
|
||||
/// Provenance note for the report.
|
||||
note: String,
|
||||
}
|
||||
|
||||
/// Run the DEP-8 tests of a source package tree.
|
||||
///
|
||||
/// Errors are runtime errors (no declared tests, unusable testbed,
|
||||
/// failed in-process build); test failures are a report, not an error.
|
||||
pub async fn run(opts: TestOptions<'_>) -> Result<TestRunReport, Box<dyn std::error::Error>> {
|
||||
let view = opts.view;
|
||||
let tree = opts
|
||||
.tree
|
||||
.clone()
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
let tree = tree
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("cannot access the source tree '{}': {e}", tree.display()))?;
|
||||
|
||||
// Package identity and target, resolved like a deb build.
|
||||
let entry = crate::debian::parse_changelog_entry(&tree.join("debian/changelog"))
|
||||
.map_err(|e| format!("cannot read the package identity: {e}"))?;
|
||||
let package = entry.source.clone();
|
||||
let version = entry.version.full();
|
||||
let dist = opts
|
||||
.dist
|
||||
.clone()
|
||||
.unwrap_or_else(crate::build::env::current_vendor);
|
||||
let series = match opts.series.clone() {
|
||||
Some(series) => series,
|
||||
None if crate::distro_info::is_unreleased(&entry.distribution) => {
|
||||
let resolved = crate::distro_info::effective_series(&entry.distribution, &dist).await?;
|
||||
log::info!(
|
||||
"Changelog is UNRELEASED, testing against series {}",
|
||||
resolved
|
||||
);
|
||||
resolved
|
||||
}
|
||||
None => entry.distribution.clone(),
|
||||
};
|
||||
let arch = opts.arch.clone().unwrap_or_else(crate::get_current_arch);
|
||||
|
||||
// Pre-check: the package must declare tests, before any testbed is
|
||||
// booted.
|
||||
let control_path = tree.join("debian/tests/control");
|
||||
if !control_path.exists() {
|
||||
return Err(
|
||||
"the package declares no autopkgtests (no debian/tests/control). \
|
||||
Add debian/tests/control and 'Testsuite: autopkgtest' to debian/control \
|
||||
(pkh lint flags this too), or point pkh test at another tree."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let tests_control = TestsControl::parse(&control_path)
|
||||
.map_err(|e| format!("cannot parse debian/tests/control: {e}"))?;
|
||||
if tests_control.tests.is_empty() {
|
||||
return Err("debian/tests/control declares no tests".into());
|
||||
}
|
||||
|
||||
// Binaries under test: fresh pkh deb output, an in-process build, or
|
||||
// an explicit --debs list.
|
||||
let binaries = resolve_binaries(&opts, &tree, &package, &version, &arch, view).await?;
|
||||
|
||||
view.target(BuildTarget {
|
||||
package: &package,
|
||||
version: &version,
|
||||
target: &format!("{series}/{arch}"),
|
||||
display: format!(
|
||||
"Testing {} ({}) on {}/{} ({})",
|
||||
package,
|
||||
version,
|
||||
series,
|
||||
arch,
|
||||
opts.mode.unwrap_or_default().label()
|
||||
),
|
||||
kind: TargetKind::Test,
|
||||
tee_log: true,
|
||||
});
|
||||
|
||||
let mode = opts.mode.unwrap_or_default();
|
||||
if mode == TestMode::Local {
|
||||
log::warn!(
|
||||
"Tests run directly on the current context: they install packages \
|
||||
and can rewrite it. Use the default chroot mode for isolation."
|
||||
);
|
||||
}
|
||||
let run_dir = create_run_dir(&package, &series, &arch)?;
|
||||
let sink = view.sink().or_else(|| {
|
||||
// Verbose runs without a live view still stream test output live.
|
||||
opts.verbose
|
||||
.then(|| Arc::new(TerminalLines) as Arc<dyn LineSink>)
|
||||
});
|
||||
|
||||
let base_ctx = crate::context::current();
|
||||
let bed = Bed::prepare(mode, &series, &arch, base_ctx, view).await?;
|
||||
let ctx = bed.ctx();
|
||||
|
||||
let mut env = HashMap::new();
|
||||
env.insert("LANG".to_string(), "C".to_string());
|
||||
env.insert("DEBIAN_FRONTEND".to_string(), "noninteractive".to_string());
|
||||
if matches!(bed, Bed::Chroot(_)) {
|
||||
env.insert("HOME".to_string(), "/root".to_string());
|
||||
env.insert("USER".to_string(), "root".to_string());
|
||||
env.insert("LOGNAME".to_string(), "root".to_string());
|
||||
}
|
||||
|
||||
configure_testbed(&ctx, &env, &series, &arch, &opts, view).await?;
|
||||
|
||||
// Stage the tree and the binaries into the testbed. The copy also
|
||||
// protects the host tree: tests may write into it (rw-build-tree).
|
||||
view.message("Staging the tree and binaries");
|
||||
let staging_root = bed.scratch_root()?;
|
||||
let staged_tree = ctx.ensure_available(&tree, &staging_root)?;
|
||||
let staged_tree = staged_tree.to_string_lossy().into_owned();
|
||||
let mut staged_debs = Vec::new();
|
||||
for deb in &binaries.paths {
|
||||
staged_debs.push(ctx.ensure_available(deb, &staging_root)?);
|
||||
}
|
||||
|
||||
// Install the exact binaries under test before the tests run.
|
||||
if !staged_debs.is_empty() {
|
||||
view.phase(
|
||||
"Installing built binaries",
|
||||
Box::new(GenericClassifier::new()),
|
||||
);
|
||||
let mut cmd = ctx.command("apt-get");
|
||||
cmd.envs(env.clone())
|
||||
.arg("-y")
|
||||
.arg("--allow-downgrades")
|
||||
.arg("install");
|
||||
for deb in &staged_debs {
|
||||
cmd.arg(format!(
|
||||
"./{}",
|
||||
deb.to_string_lossy().trim_start_matches('/')
|
||||
));
|
||||
}
|
||||
let status = cmd.status().map_err(|e| e.to_string())?;
|
||||
if !status.success() {
|
||||
return Err("installing the built binaries into the testbed failed".into());
|
||||
}
|
||||
}
|
||||
|
||||
// What @ expands to: the names of the binaries actually under test,
|
||||
// from their .deb file names (<binary>_<version>_<arch>.deb).
|
||||
let control_info = ControlInfo::parse(&tree.join("debian/control"))
|
||||
.map_err(|e| format!("cannot parse debian/control: {e}"))?;
|
||||
let binary_names: Vec<String> = staged_debs
|
||||
.iter()
|
||||
.filter_map(|deb| deb_binary_name(deb))
|
||||
.collect();
|
||||
let run_ctx = runner::RunContext {
|
||||
ctx,
|
||||
tree: staged_tree.clone(),
|
||||
staging_root,
|
||||
arch: arch.clone(),
|
||||
host_arch: crate::get_current_arch(),
|
||||
supports_root: bed.supports_root(),
|
||||
timeout_secs: opts.timeout_secs(),
|
||||
sink,
|
||||
view,
|
||||
control: control_info,
|
||||
binaries: binary_names,
|
||||
env: env.clone(),
|
||||
artifacts_dir: run_dir.clone(),
|
||||
};
|
||||
let outcomes = runner::run_tests(
|
||||
&run_ctx,
|
||||
&tests_control.tests,
|
||||
&opts.test_names,
|
||||
&opts.skip_tests,
|
||||
);
|
||||
|
||||
// Debugging escape hatches.
|
||||
let has_failures = outcomes
|
||||
.iter()
|
||||
.any(|outcome| outcome.verdict == Verdict::Fail);
|
||||
if opts.shell || (opts.shell_fail && has_failures) {
|
||||
drop_shell(&bed, &staged_tree, &env, view)?;
|
||||
}
|
||||
|
||||
let report = TestRunReport {
|
||||
package,
|
||||
version,
|
||||
series,
|
||||
arch,
|
||||
mode: mode.label().to_string(),
|
||||
binaries: binaries.paths,
|
||||
binaries_note: binaries.note,
|
||||
outcomes,
|
||||
artifacts_dir: run_dir.clone(),
|
||||
raw_exit_code: None,
|
||||
};
|
||||
if report.has_failures() {
|
||||
view.finish_failure();
|
||||
} else {
|
||||
view.finish_success(&[run_dir]);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Resolve the binaries under test: `--debs` overrides everything;
|
||||
/// otherwise the fresh `pkh deb` output next to the tree (freshness-
|
||||
/// guarded, the lint wrapper's artifact strategy); otherwise the deb
|
||||
/// flow builds them in-process. `--no-build` refuses instead.
|
||||
async fn resolve_binaries(
|
||||
opts: &TestOptions<'_>,
|
||||
tree: &Path,
|
||||
package: &str,
|
||||
version: &str,
|
||||
arch: &str,
|
||||
view: &dyn BuildView,
|
||||
) -> Result<Binaries, Box<dyn std::error::Error>> {
|
||||
if !opts.debs.is_empty() {
|
||||
for deb in &opts.debs {
|
||||
if !deb.exists() {
|
||||
return Err(format!("--deb file '{}' does not exist", deb.display()).into());
|
||||
}
|
||||
}
|
||||
return Ok(Binaries {
|
||||
paths: opts.debs.clone(),
|
||||
note: "explicitly passed with --debs".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if !opts.build
|
||||
&& let Some(paths) = fresh_deb_output(tree, package, version, arch)
|
||||
{
|
||||
log::info!("Testing the fresh pkh deb output next to the tree");
|
||||
return Ok(Binaries {
|
||||
paths,
|
||||
note: "fresh pkh deb output".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if opts.no_build {
|
||||
return Err(
|
||||
"no fresh pkh deb output next to the tree and --no-build given: \
|
||||
run 'pkh deb' first, drop --no-build, or pass --debs"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
view.message("No fresh pkh deb output: building the binaries first");
|
||||
let artifacts = crate::deb::build_binary_package(crate::deb::DebBuildOptions {
|
||||
series: opts.series.clone(),
|
||||
arch: opts.arch.clone(),
|
||||
pocket: opts.pocket.clone(),
|
||||
ppa: opts.ppa.clone(),
|
||||
cwd: Some(tree.to_path_buf()),
|
||||
view,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let debs: Vec<PathBuf> = artifacts
|
||||
.into_iter()
|
||||
.filter(|path| path.extension().is_some_and(|ext| ext == "deb"))
|
||||
.collect();
|
||||
if debs.is_empty() {
|
||||
return Err("the build produced no binary packages to test".into());
|
||||
}
|
||||
Ok(Binaries {
|
||||
paths: debs,
|
||||
note: "built by pkh deb just now".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The fresh `pkh deb` output next to the tree, when it matches the
|
||||
/// current changelog entry and no tree content is newer than it: the
|
||||
/// `<source>_<version>_<arch>.changes` file gates, its checksum lists
|
||||
/// name the debs. Stale or missing output tests nothing.
|
||||
fn fresh_deb_output(tree: &Path, package: &str, version: &str, arch: &str) -> Option<Vec<PathBuf>> {
|
||||
let entry = crate::debian::parse_changelog_entry(&tree.join("debian/changelog")).ok()?;
|
||||
let parent = tree.parent()?;
|
||||
let changes_path = parent.join(format!(
|
||||
"{}_{}_{}.changes",
|
||||
package,
|
||||
entry.version.no_epoch(),
|
||||
arch
|
||||
));
|
||||
let changes = fs::read_to_string(&changes_path).ok()?;
|
||||
|
||||
// Only the current entry's binaries are the ones under test.
|
||||
let paragraph = parse_paragraphs(&changes).into_iter().next()?;
|
||||
let matches = paragraph.get("Source").map(str::trim) == Some(package)
|
||||
&& paragraph.get("Version").map(str::trim) == Some(version);
|
||||
if !matches {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut debs = Vec::new();
|
||||
for (field, value) in paragraph.iter() {
|
||||
if !field.starts_with("Checksums-") && field != "Files" {
|
||||
continue;
|
||||
}
|
||||
for line in value.lines() {
|
||||
// `<checksum> <size> <section> [<priority>] <filename>`
|
||||
if let Some(name) = line.split_whitespace().last()
|
||||
&& name.ends_with(".deb")
|
||||
{
|
||||
debs.push(parent.join(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
if debs.is_empty() || debs.iter().any(|deb| !deb.exists()) {
|
||||
return None;
|
||||
}
|
||||
// A tree file written after the changes file makes the output stale.
|
||||
let built = fs::metadata(&changes_path).ok()?.modified().ok()?;
|
||||
if tree_newer_than(tree, built) {
|
||||
return None;
|
||||
}
|
||||
Some(debs)
|
||||
}
|
||||
|
||||
/// Whether any tree content is newer than `built`. Skips `.git` and
|
||||
/// `.pc`: commits and quilt bookkeeping churn their mtimes without
|
||||
/// touching what the package contains.
|
||||
fn tree_newer_than(root: &Path, built: std::time::SystemTime) -> bool {
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name();
|
||||
if name == ".git" || name == ".pc" {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = fs::symlink_metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
} else if meta.modified().is_ok_and(|mtime| mtime > built) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Configure the testbed's APT setup and update it: pockets and PPAs on
|
||||
/// the archive sources (the same rules as the deb build), an optional
|
||||
/// upgrade, the caller's setup commands and injected packages.
|
||||
async fn configure_testbed(
|
||||
ctx: &Arc<crate::context::Context>,
|
||||
env: &HashMap<String, String>,
|
||||
series: &str,
|
||||
arch: &str,
|
||||
opts: &TestOptions<'_>,
|
||||
view: &dyn BuildView,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut sources = crate::apt::sources::load(Some(ctx.clone()))?;
|
||||
let mut modified = false;
|
||||
let mut added_ppas: Vec<(&str, &str)> = Vec::new();
|
||||
|
||||
for ppa in &opts.ppa {
|
||||
let (ppa_user, ppa_name) = crate::package_info::split_ppa(ppa)?;
|
||||
let base_url = crate::package_info::ppa_to_base_url(ppa_user, ppa_name);
|
||||
if sources.iter().any(|s| s.uri.contains(&base_url)) {
|
||||
continue;
|
||||
}
|
||||
let mut architectures = vec![crate::get_current_arch()];
|
||||
if arch != crate::get_current_arch() {
|
||||
architectures.push(arch.to_string());
|
||||
}
|
||||
sources.push(crate::apt::sources::SourceEntry {
|
||||
enabled: true,
|
||||
kind: crate::apt::sources::SourceKind::Deb,
|
||||
components: vec!["main".to_string()],
|
||||
architectures,
|
||||
signed_by: None,
|
||||
trusted: None,
|
||||
suite: vec![series.to_string()],
|
||||
uri: base_url,
|
||||
origin: None,
|
||||
});
|
||||
added_ppas.push((ppa_user, ppa_name));
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// Ubuntu: test dependencies live in universe as often as in main.
|
||||
let ubuntu_components = crate::distro_info::get_dist_components("ubuntu")?;
|
||||
if ubuntu_components.iter().any(|c| c == "universe") {
|
||||
for source in &mut sources {
|
||||
if crate::distro_info::is_official_source("ubuntu", &source.uri)
|
||||
&& !source.components.contains(&"universe".to_string())
|
||||
{
|
||||
source.components.push("universe".to_string());
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pocket) = &opts.pocket {
|
||||
let pocket_suite = format!("{series}-{pocket}");
|
||||
log::info!("Enabling pocket '{}' for test dependencies", pocket_suite);
|
||||
for source in &mut sources {
|
||||
if crate::deb::is_archive_source(&source.uri) && !source.suite.contains(&pocket_suite) {
|
||||
source.suite.push(pocket_suite.clone());
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
// proposed is NotAutomatic: without a pin apt would ignore it.
|
||||
if pocket.starts_with("proposed") {
|
||||
crate::deb::local::pin_pocket(&pocket_suite, ctx)?;
|
||||
}
|
||||
}
|
||||
|
||||
if modified {
|
||||
crate::apt::sources::save(Some(ctx.clone()), sources)?;
|
||||
for (user, ppa_name) in added_ppas {
|
||||
if let Err(e) =
|
||||
crate::apt::keyring::download_trust_ppa_key(Some(ctx.clone()), user, ppa_name).await
|
||||
{
|
||||
log::warn!("Failed to download PPA key for {user}/{ppa_name}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view.phase("Configuring testbed", Box::new(GenericClassifier::new()));
|
||||
let status = ctx
|
||||
.command("apt-get")
|
||||
.envs(env.clone())
|
||||
.arg("update")
|
||||
.status()
|
||||
.map_err(|e| format!("apt-get update failed inside the testbed: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err("apt-get update failed inside the testbed".into());
|
||||
}
|
||||
|
||||
if opts.apt_upgrade {
|
||||
for action in ["upgrade", "autopurge"] {
|
||||
let status = ctx
|
||||
.command("apt-get")
|
||||
.envs(env.clone())
|
||||
.arg("-y")
|
||||
.arg(action)
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !status.success() {
|
||||
return Err(format!("apt-get {action} failed inside the testbed").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for setup in &opts.setup_commands {
|
||||
let status = ctx
|
||||
.command("bash")
|
||||
.envs(env.clone())
|
||||
.arg("-e")
|
||||
.arg("-c")
|
||||
.arg(setup)
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !status.success() {
|
||||
return Err(format!("setup command failed: {setup}").into());
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.inject.is_empty() {
|
||||
crate::deb::local::install_injected_packages(&opts.inject, env, ctx.clone(), view, &None)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop into an interactive testbed shell (the `--shell`/
|
||||
/// `--shell-fail` debugging escape hatch).
|
||||
fn drop_shell(
|
||||
bed: &Bed,
|
||||
staged_tree: &str,
|
||||
env: &HashMap<String, String>,
|
||||
view: &dyn BuildView,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
view.suspend();
|
||||
log::info!("Dropping into the testbed shell (exit to continue)");
|
||||
let status = bed
|
||||
.ctx()
|
||||
.command("bash")
|
||||
.envs(env.iter())
|
||||
.current_dir(staged_tree)
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
log::warn!("Testbed shell exited with {}", status);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the run's host artifacts directory:
|
||||
/// `/var/tmp/pkh/tests/<source>-<series>-<arch>-<timestamp>/`.
|
||||
fn create_run_dir(package: &str, series: &str, arch: &str) -> std::io::Result<PathBuf> {
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S");
|
||||
let dir =
|
||||
PathBuf::from("/var/tmp/pkh/tests").join(format!("{package}-{series}-{arch}-{timestamp}"));
|
||||
fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// The binary package name of a staged .deb file name.
|
||||
fn deb_binary_name(path: &Path) -> Option<String> {
|
||||
let name = path.file_name()?.to_string_lossy();
|
||||
let stem = name.strip_suffix(".deb")?;
|
||||
Some(stem.split('_').next()?.to_string())
|
||||
}
|
||||
|
||||
/// Live terminal passthrough of captured lines, for `--verbose` runs
|
||||
/// without a live view.
|
||||
struct TerminalLines;
|
||||
|
||||
impl LineSink for TerminalLines {
|
||||
fn line(&self, stream: Stream, line: &str) {
|
||||
match stream {
|
||||
Stream::Stdout => println!("{line}"),
|
||||
Stream::Stderr => eprintln!("{line}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The freshness guard accepts the changes file matching the current
|
||||
/// changelog entry with its debs, and rejects a stale one.
|
||||
#[test]
|
||||
fn fresh_deb_output_freshness() {
|
||||
let base = tempfile::tempdir().unwrap();
|
||||
let tree = base.path().join("hello");
|
||||
fs::create_dir_all(tree.join("debian")).unwrap();
|
||||
fs::write(
|
||||
tree.join("debian/changelog"),
|
||||
"hello (2.10-4) noble; urgency=medium\n\n * bump\n\n -- A <a@b.c> Thu, 01 Jan 2026 00:00:00 +0000\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let changes_name = "hello_2.10-4_amd64.changes";
|
||||
let write_changes = |fresh: bool| {
|
||||
let content = format!(
|
||||
"Format: 1.8\nSource: hello\nVersion: 2.10-4\nChecksums-Sha256:\n \
|
||||
abc 123 utils {} \n",
|
||||
changes_name.replace(".changes", ".deb")
|
||||
);
|
||||
let path = base.path().join(changes_name);
|
||||
fs::write(&path, content).unwrap();
|
||||
if !fresh {
|
||||
// Push the tree strictly newer than the changes file.
|
||||
let newer = tree.join("debian/control");
|
||||
fs::write(&newer, "Source: hello\n").unwrap();
|
||||
let time = std::time::SystemTime::now() + std::time::Duration::from_secs(3600);
|
||||
let file = fs::File::options().append(true).open(&newer).unwrap();
|
||||
file.set_times(std::fs::FileTimes::new().set_modified(time))
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
// No changes file at all: nothing to reuse.
|
||||
assert!(fresh_deb_output(&tree, "hello", "2.10-4", "amd64").is_none());
|
||||
|
||||
// The deb the changes file lists must exist for the reuse.
|
||||
fs::write(base.path().join("hello_2.10-4_amd64.deb"), "deb").unwrap();
|
||||
|
||||
write_changes(true);
|
||||
let debs = fresh_deb_output(&tree, "hello", "2.10-4", "amd64").unwrap();
|
||||
assert_eq!(debs, vec![base.path().join("hello_2.10-4_amd64.deb")]);
|
||||
|
||||
// A different version or arch tests nothing.
|
||||
assert!(fresh_deb_output(&tree, "hello", "2.10-3", "amd64").is_none());
|
||||
assert!(fresh_deb_output(&tree, "hello", "2.10-4", "arm64").is_none());
|
||||
|
||||
// Tree content newer than the output makes it stale.
|
||||
write_changes(false);
|
||||
assert!(fresh_deb_output(&tree, "hello", "2.10-4", "amd64").is_none());
|
||||
}
|
||||
|
||||
/// The run directory is created fresh under the pkh tests root.
|
||||
#[test]
|
||||
fn run_dir_layout() {
|
||||
let dir = create_run_dir("hello", "noble", "amd64").unwrap();
|
||||
assert_eq!(dir.parent(), Some(Path::new("/var/tmp/pkh/tests")));
|
||||
assert!(
|
||||
dir.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("hello-noble-amd64-"))
|
||||
);
|
||||
assert!(dir.is_dir());
|
||||
fs::remove_dir(&dir).ok();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user