debian: add the DEP-8 tests-control parser
Parse debian/tests/control into runnable test descriptions: one test per Tests: script and per Test-Command value, carrying the shared Depends/Restrictions/Tests-Directory stanza fields and the autopkgtest command1 enumeration (test-name overrides advance the counter). Depends substitution is deliberately left raw: expanding @ and friends needs the built binary list, so it belongs to the runner. First piece of pkh test (see plans/pkh-test.md); the runner expands these stanzas against the deb822 primitives.
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
//! - [`files`]: `debian/files` artifact registry (`Dpkg::Dist::Files`)
|
//! - [`files`]: `debian/files` artifact registry (`Dpkg::Dist::Files`)
|
||||||
//! - [`version`]: Debian version splitting/validation/comparison
|
//! - [`version`]: Debian version splitting/validation/comparison
|
||||||
//! - [`changelog`]: `debian/changelog` entry parsing
|
//! - [`changelog`]: `debian/changelog` entry parsing
|
||||||
|
//! - [`tests_control`]: DEP-8 test metadata (`debian/tests/control`)
|
||||||
|
|
||||||
pub mod arch;
|
pub mod arch;
|
||||||
pub mod changelog;
|
pub mod changelog;
|
||||||
@@ -17,6 +18,7 @@ pub mod checksums;
|
|||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod tests_control;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
pub use changelog::{
|
pub use changelog::{
|
||||||
@@ -28,4 +30,5 @@ pub use control::{
|
|||||||
ControlInfo, Paragraph, parse_paragraphs, strip_clearsigned_armour, write_paragraph,
|
ControlInfo, Paragraph, parse_paragraphs, strip_clearsigned_armour, write_paragraph,
|
||||||
};
|
};
|
||||||
pub use files::{FilesEntry, FilesList};
|
pub use files::{FilesEntry, FilesList};
|
||||||
|
pub use tests_control::{Test, TestsControl};
|
||||||
pub use version::DebianVersion;
|
pub use version::DebianVersion;
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
//! DEP-8 as-installed test metadata (`debian/tests/control`).
|
||||||
|
//!
|
||||||
|
//! Parses the test control file into runnable test descriptions: one
|
||||||
|
//! [`Test`] per `Tests:` script and per `Test-Command:` value, carrying
|
||||||
|
//! the stanza fields they share (`Depends:`, `Restrictions:`,
|
||||||
|
//! `Tests-Directory:`). The `@`-family substitution of `Depends:` needs
|
||||||
|
//! the list of built binaries and is runner-side logic — this module
|
||||||
|
//! only hands over the raw value.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::debian::control::parse_paragraphs;
|
||||||
|
|
||||||
|
/// One runnable DEP-8 test.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Test {
|
||||||
|
/// Test name: the `Tests:` script path as written, the `test-name:`
|
||||||
|
/// value, or the `command1`, `command2`, ... enumeration of
|
||||||
|
/// `Test-Command:` stanzas. The enumeration advances once per
|
||||||
|
/// `Test-Command:` stanza whether or not `test-name:` overrides the
|
||||||
|
/// name.
|
||||||
|
pub name: String,
|
||||||
|
/// Script path relative to the tests directory (`Tests:` stanzas):
|
||||||
|
/// `debian/tests/` or the stanza's [`Test::tests_directory`], with
|
||||||
|
/// subdirectories as written (`upstream/foo`).
|
||||||
|
pub script: Option<String>,
|
||||||
|
/// Inline shell command run under `bash -e` (`Test-Command:` stanzas).
|
||||||
|
pub command: Option<String>,
|
||||||
|
/// Raw `Depends:` value as written; `None` when absent, meaning the
|
||||||
|
/// DEP-8 default `@` (all built binaries).
|
||||||
|
pub depends: Option<String>,
|
||||||
|
/// Declared restrictions, in declaration order, as written
|
||||||
|
/// (e.g. `allow-stderr`).
|
||||||
|
pub restrictions: Vec<String>,
|
||||||
|
/// `Tests-Directory:` replacing the `debian/tests` path segment for
|
||||||
|
/// this stanza's scripts (relative path).
|
||||||
|
pub tests_directory: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Test {
|
||||||
|
/// Whether the test declares `restriction` (case-insensitive).
|
||||||
|
pub fn has_restriction(&self, restriction: &str) -> bool {
|
||||||
|
self.restrictions
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.eq_ignore_ascii_case(restriction))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed `debian/tests/control`: the runnable tests, in declaration
|
||||||
|
/// order.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct TestsControl {
|
||||||
|
/// One entry per `Tests:` script and per `Test-Command:` stanza.
|
||||||
|
pub tests: Vec<Test>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestsControl {
|
||||||
|
/// Parse the test control file at `path`.
|
||||||
|
pub fn parse(path: &Path) -> Result<TestsControl, Box<dyn std::error::Error>> {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
Ok(TestsControl::parse_content(&content)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the test control file from its content.
|
||||||
|
///
|
||||||
|
/// Every non-empty stanza must carry exactly one of `Tests:` or
|
||||||
|
/// `Test-Command:` (a script list expands to one test per script,
|
||||||
|
/// sharing the stanza fields); `test-name:` is only valid on a
|
||||||
|
/// `Test-Command:` stanza. A file without stanzas parses to an empty
|
||||||
|
/// [`TestsControl`] — whether the package declares tests at all is
|
||||||
|
/// the caller's check (`Testsuite: autopkgtest`).
|
||||||
|
pub fn parse_content(content: &str) -> Result<TestsControl, String> {
|
||||||
|
let mut tests_control = TestsControl::default();
|
||||||
|
let mut command_index = 0usize;
|
||||||
|
for (index, paragraph) in parse_paragraphs(content).iter().enumerate() {
|
||||||
|
let stanza = index + 1;
|
||||||
|
let tests_field = non_empty(paragraph.get("Tests"));
|
||||||
|
let command_field = non_empty(paragraph.get("Test-Command"));
|
||||||
|
let test_name = non_empty(paragraph.get("test-name"));
|
||||||
|
let depends = non_empty(paragraph.get("Depends")).map(String::from);
|
||||||
|
let restrictions: Vec<String> = non_empty(paragraph.get("Restrictions"))
|
||||||
|
.map(|value| value.split_whitespace().map(String::from).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let tests_directory = non_empty(paragraph.get("Tests-Directory")).map(String::from);
|
||||||
|
|
||||||
|
match (tests_field, command_field) {
|
||||||
|
(Some(_), Some(_)) => {
|
||||||
|
return Err(format!(
|
||||||
|
"stanza {stanza}: Tests and Test-Command are mutually exclusive"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(None, None) => {
|
||||||
|
return Err(format!(
|
||||||
|
"stanza {stanza}: missing a Tests or Test-Command field"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(Some(scripts), None) => {
|
||||||
|
if test_name.is_some() {
|
||||||
|
return Err(format!(
|
||||||
|
"stanza {stanza}: test-name only applies to a Test-Command stanza"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for script in scripts.split_whitespace() {
|
||||||
|
tests_control.tests.push(Test {
|
||||||
|
name: script.to_string(),
|
||||||
|
script: Some(script.to_string()),
|
||||||
|
command: None,
|
||||||
|
depends: depends.clone(),
|
||||||
|
restrictions: restrictions.clone(),
|
||||||
|
tests_directory: tests_directory.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, Some(command)) => {
|
||||||
|
command_index += 1;
|
||||||
|
let name = test_name
|
||||||
|
.map(String::from)
|
||||||
|
.unwrap_or_else(|| format!("command{command_index}"));
|
||||||
|
tests_control.tests.push(Test {
|
||||||
|
name,
|
||||||
|
script: None,
|
||||||
|
command: Some(command.to_string()),
|
||||||
|
depends: depends.clone(),
|
||||||
|
restrictions: restrictions.clone(),
|
||||||
|
tests_directory: tests_directory.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(tests_control)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A field value with its surrounding whitespace trimmed; `None` when the
|
||||||
|
/// field is absent or whitespace-only.
|
||||||
|
fn non_empty(value: Option<&str>) -> Option<&str> {
|
||||||
|
value.map(str::trim).filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A realistic control file: a restrictions stanza, a multi-script
|
||||||
|
/// stanza sharing its fields, a Tests-Directory stanza and two
|
||||||
|
/// Test-Command stanzas (one enumerated, one named).
|
||||||
|
#[test]
|
||||||
|
fn parse_golden_multi_stanza() {
|
||||||
|
let control = TestsControl::parse_content(
|
||||||
|
"# a leading comment\n\
|
||||||
|
\n\
|
||||||
|
Tests: smoke\n\
|
||||||
|
Restrictions: allow-stderr needs-root\n\
|
||||||
|
Depends: @, build-essential\n\
|
||||||
|
\n\
|
||||||
|
Tests: one two\n\
|
||||||
|
Depends: @\n\
|
||||||
|
\n\
|
||||||
|
Tests: checks/cli\n\
|
||||||
|
Tests-Directory: tests\n\
|
||||||
|
\n\
|
||||||
|
Test-Command: hello --version\n\
|
||||||
|
Depends: @builddeps@\n\
|
||||||
|
\n\
|
||||||
|
Test-Command: python3 -c \"print('ok')\"\n\
|
||||||
|
test-name: py-smoke\n\
|
||||||
|
Restrictions: superficial skippable\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let names: Vec<&str> = control.tests.iter().map(|t| t.name.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
names,
|
||||||
|
["smoke", "one", "two", "checks/cli", "command1", "py-smoke"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let smoke = &control.tests[0];
|
||||||
|
assert_eq!(smoke.script.as_deref(), Some("smoke"));
|
||||||
|
assert_eq!(smoke.depends.as_deref(), Some("@, build-essential"));
|
||||||
|
assert!(smoke.has_restriction("NEEDS-ROOT"));
|
||||||
|
assert!(!smoke.has_restriction("skippable"));
|
||||||
|
|
||||||
|
// The multi-script stanza's fields are shared by every test.
|
||||||
|
assert_eq!(control.tests[1].depends, control.tests[2].depends);
|
||||||
|
assert_eq!(control.tests[2].script.as_deref(), Some("two"));
|
||||||
|
|
||||||
|
let cli = &control.tests[3];
|
||||||
|
assert_eq!(cli.tests_directory.as_deref(), Some("tests"));
|
||||||
|
assert_eq!(cli.depends, None); // absent: the runner defaults to @
|
||||||
|
|
||||||
|
let enumerated = &control.tests[4];
|
||||||
|
assert_eq!(enumerated.command.as_deref(), Some("hello --version"));
|
||||||
|
assert_eq!(enumerated.script, None);
|
||||||
|
|
||||||
|
let named = &control.tests[5];
|
||||||
|
assert_eq!(named.name, "py-smoke");
|
||||||
|
assert!(named.has_restriction("skippable") && named.has_restriction("superficial"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The command enumeration advances per Test-Command stanza, even
|
||||||
|
/// when test-name overrides the name.
|
||||||
|
#[test]
|
||||||
|
fn command_numbering_skips_named_stanzas() {
|
||||||
|
let control = TestsControl::parse_content(
|
||||||
|
"Test-Command: a\n\
|
||||||
|
test-name: named\n\
|
||||||
|
\n\
|
||||||
|
Test-Command: b\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
control
|
||||||
|
.tests
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["named", "command2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Continuation lines join field values; comments inside a value are
|
||||||
|
/// not possible, but comment lines between fields are ignored.
|
||||||
|
#[test]
|
||||||
|
fn continuation_lines_join_values() {
|
||||||
|
let control = TestsControl::parse_content(concat!(
|
||||||
|
"Tests: smoke\n",
|
||||||
|
"Depends: @,\n",
|
||||||
|
" glib-network-service,\n",
|
||||||
|
"# comment line\n",
|
||||||
|
" locales-all\n",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
control.tests[0].depends.as_deref(),
|
||||||
|
Some("@,\nglib-network-service,\nlocales-all")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty file (or only comments) parses to no tests.
|
||||||
|
#[test]
|
||||||
|
fn empty_file_parses_to_no_tests() {
|
||||||
|
assert!(TestsControl::parse_content("").unwrap().tests.is_empty());
|
||||||
|
assert!(
|
||||||
|
TestsControl::parse_content("# nothing\n\n")
|
||||||
|
.unwrap()
|
||||||
|
.tests
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Malformed stanzas are errors, naming the stanza.
|
||||||
|
#[test]
|
||||||
|
fn malformed_stanzas_are_errors() {
|
||||||
|
for (content, expected) in [
|
||||||
|
("Depends: @\n", "missing a Tests or Test-Command"),
|
||||||
|
("Tests: a\nTest-Command: b\n", "mutually exclusive"),
|
||||||
|
(
|
||||||
|
"Test-Command: b\ntest-name: x\n\nTests: a\ntest-name: y\n",
|
||||||
|
"only applies",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let error = TestsControl::parse_content(content).unwrap_err();
|
||||||
|
assert!(error.contains(expected), "{error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -15,7 +15,7 @@ pub(crate) mod data;
|
|||||||
/// Build a Debian package into a binary (.deb)
|
/// Build a Debian package into a binary (.deb)
|
||||||
pub mod deb;
|
pub mod deb;
|
||||||
/// Reusable Debian format primitives (control/deb822, checksums, versions,
|
/// Reusable Debian format primitives (control/deb822, checksums, versions,
|
||||||
/// changelog entries, artifact registries)
|
/// changelog entries, test metadata, artifact registries)
|
||||||
pub mod debian;
|
pub mod debian;
|
||||||
/// Obtain general information about distribution, series, etc
|
/// Obtain general information about distribution, series, etc
|
||||||
pub mod distro_info;
|
pub mod distro_info;
|
||||||
|
|||||||
Reference in New Issue
Block a user