kernel quest reads bugzilla's REST API and picks one bug through a seeded xorshift, so a pick is reproducible in a test and varied in a person's hands. kernel test builds under spawn_blocking and boots under qemu with the serial console streamed line by line, and it reports what qemu did instead of flattening a failed boot into a success. patch create writes a whole series when --range is given. git format-patch does the naming and the numbering because that is already its job, the cover letter comes from --cover-letter, and a series.toml beside the patches records the range with both ends as hashes, the revision, the files and the blurb. patch submit takes a directory or several files and sends them in one git send-email invocation, and patch update re-rolls a directory through --reroll-count, deleting the superseded revision only once the new one is complete. doctor runs its checks in the order a first run hits them, prints one line and a fix hint for each, and names the first hard failure in the summary. A missing qemu or a missing mail route warns and keeps exit 0, because --dry-run is where the loop actually stops on a machine without an SMTP route. ~/.config/spectral/config.toml holds the tree path, the patch directory, always-CC addresses and an identity, with the environment winning over the file and the file over the built-in default. spectral init verifies or clones a tree and writes it once: a second run with the same tree says so and writes nothing, and a run that would change an existing answer is refused until --force. tests/ grew a fixture for each of those. The harness controls PATH and git's configuration, and its PATH can hold a git that answers send-email either way, so a host without that support still passes rather than deciding the result. Two review lanes ran over this work and this commit carries their fixes. A re-roll regenerated base..HEAD, so a commit that landed after create would have silently joined the set and been mailed: the range is recorded now and a re-roll regenerates exactly it. A patch file that was already gone made update fail forever while the sidecar described a revision that was not on disk: a missing file is the state the re-roll wanted. An empty blurb aborted after the files were written: it is checked before anything is generated. A tree that is not a git repository was reported as a missing revision. init cloned before it refused. The bugzilla request had no timeout, a signal-ended child printed the bare word "signal", CommandSpawn dropped the io reason, and the printed send-email line quoted only arguments with spaces, so --in-reply-to <id@host> pasted as a redirection. Checked with cargo fmt --all -- --check, cargo clippy --all-targets --locked -- -D warnings, cargo test --locked (96 passed, 4 ignored) and cargo doc --no-deps. The four ignored tests want the host's own checkpatch, get_maintainer and bugzilla, and all four pass when asked for.
343 lines
12 KiB
Rust
343 lines
12 KiB
Rust
//! Fixture helpers shared by the CLI tests.
|
|
//!
|
|
//! A test never touches the machine it runs on. `HOME`, `SPECTRAL_KERNEL`, and
|
|
//! git's global configuration all point into a temp directory that deletes
|
|
//! itself when the test ends, so `~/.spectral` is never written and the
|
|
//! developer's git config never leaks in.
|
|
//!
|
|
//! The kernel tree, when a test wants one, is a real git repository holding the
|
|
//! two scripts spectral shells out to, a MAINTAINERS entry, and one commit.
|
|
|
|
#![allow(dead_code)] // each test binary uses a different part of this set
|
|
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Output};
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
|
|
/// The checkpatch stand-in.
|
|
///
|
|
/// The real one is a kernel script whose output moves with the kernel version,
|
|
/// which is not something a test can assert on. This records how it was called,
|
|
/// then says what the test asked for through the environment:
|
|
/// `FIXTURE_SUMMARY` is printed as checkpatch's summary line, `FIXTURE_EXIT` is
|
|
/// the exit status, and `FIXTURE_RECORD` names the file the call is noted in.
|
|
const CHECKPATCH: &str = r#"#!/bin/sh
|
|
: "${FIXTURE_RECORD:=.checkpatch-call}"
|
|
{
|
|
echo "argv: $*"
|
|
echo "cwd: $(pwd)"
|
|
cat
|
|
} >> "$FIXTURE_RECORD"
|
|
[ -n "$FIXTURE_SUMMARY" ] && printf '%s\n' "$FIXTURE_SUMMARY"
|
|
exit "${FIXTURE_EXIT:-0}"
|
|
"#;
|
|
|
|
/// The get_maintainer stand-in, for the same reason.
|
|
///
|
|
/// One line per role the real script emits, including a git signer whose
|
|
/// address is already on the `To:` line, so the dedup is exercised.
|
|
const GET_MAINTAINER: &str = r#"#!/bin/sh
|
|
echo "argv: $*" >> .getmaintainer-call
|
|
echo "Fixture Maintainer <[email protected]> (maintainer:FIXTURE DRIVER)"
|
|
echo "[email protected] (open list:FIXTURE DRIVER)"
|
|
echo "Fixture Reviewer <[email protected]> (reviewer:FIXTURE REVIEWERS)"
|
|
echo "[email protected] (open list:FIXTURE REVIEWERS)"
|
|
echo "\"GitAuthor: Fixture\" <[email protected]> (authored,added_lines)"
|
|
"#;
|
|
|
|
/// The file `return a;` is committed as, and the edit most tests start from.
|
|
pub const COMMITTED: &str = "static int foo(int a)\n{\n\treturn a;\n}\n";
|
|
pub const EDITED: &str = "static int foo(int a)\n{\n\treturn a + 1;\n}\n";
|
|
|
|
/// A temp directory that removes itself when the test ends.
|
|
#[derive(Debug)]
|
|
pub struct TempDir {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl TempDir {
|
|
fn new(label: &str) -> Self {
|
|
static NEXT: AtomicU32 = AtomicU32::new(0);
|
|
let path = std::env::temp_dir().join(format!(
|
|
"spectral-test-{}-{label}-{}",
|
|
std::process::id(),
|
|
NEXT.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
std::fs::create_dir_all(&path).expect("create the temp directory");
|
|
|
|
Self { path }
|
|
}
|
|
|
|
pub fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
|
|
pub fn join(&self, rel: &str) -> PathBuf {
|
|
self.path.join(rel)
|
|
}
|
|
}
|
|
|
|
impl Drop for TempDir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.path);
|
|
}
|
|
}
|
|
|
|
/// A throwaway home directory, and a kernel tree when the test wants one.
|
|
pub struct Fixture {
|
|
home: TempDir,
|
|
root: TempDir,
|
|
}
|
|
|
|
impl Fixture {
|
|
/// A home with nothing in it but a git identity.
|
|
pub fn bare() -> Self {
|
|
let fixture = Self {
|
|
home: TempDir::new("home"),
|
|
root: TempDir::new("root"),
|
|
};
|
|
std::fs::write(
|
|
fixture.home.join("gitconfig"),
|
|
"[user]\n\tname = Fixture\n\temail = [email protected]\n",
|
|
)
|
|
.expect("write the fixture git config");
|
|
|
|
fixture
|
|
}
|
|
|
|
/// A git repository shaped like a kernel tree.
|
|
pub fn kernel_tree() -> Self {
|
|
let fixture = Self::bare();
|
|
// Pinned: `create` defaults to `--base master`, the kernel's branch,
|
|
// and the fixture should not inherit whatever a machine's git defaults
|
|
// to for a new repository.
|
|
fixture.git(&["init", "--quiet", "--initial-branch=master"]);
|
|
fixture.git(&["config", "user.name", "Fixture"]);
|
|
fixture.git(&["config", "user.email", "[email protected]"]);
|
|
fixture.write(
|
|
"MAINTAINERS",
|
|
"FIXTURE DRIVER\nM:\tFixture Maintainer <[email protected]>\nL:\t[email protected]\nS:\tMaintained\nF:\tdrivers/foo/*\n\nFIXTURE REVIEWERS\nR:\tFixture Reviewer <[email protected]>\nL:\t[email protected]\nS:\tOdd Fixes\nF:\tdrivers/foo/*\n",
|
|
);
|
|
fixture.write("drivers/foo/bar.c", COMMITTED);
|
|
fixture.write_script("scripts/checkpatch.pl", CHECKPATCH);
|
|
fixture.write_script("scripts/get_maintainer.pl", GET_MAINTAINER);
|
|
fixture.commit_all("fixture: add bar");
|
|
|
|
fixture
|
|
}
|
|
|
|
/// The directory the tests treat as `$HOME`.
|
|
pub fn home(&self) -> &Path {
|
|
self.home.path()
|
|
}
|
|
|
|
/// The kernel tree, which is also the working directory the binary runs in.
|
|
pub fn tree(&self) -> &Path {
|
|
self.root.path()
|
|
}
|
|
|
|
pub fn join(&self, rel: &str) -> PathBuf {
|
|
self.root.join(rel)
|
|
}
|
|
|
|
/// Write a file into the tree, making its directory first.
|
|
pub fn write(&self, rel: &str, contents: &str) {
|
|
let path = self.join(rel);
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).expect("create the file's directory");
|
|
}
|
|
std::fs::write(&path, contents).expect("write the file");
|
|
}
|
|
|
|
/// Write an executable script into the tree.
|
|
pub fn write_script(&self, rel: &str, body: &str) {
|
|
self.write(rel, body);
|
|
let path = self.join(rel);
|
|
let mut permissions = std::fs::metadata(&path)
|
|
.expect("stat the script")
|
|
.permissions();
|
|
permissions.set_mode(0o755);
|
|
std::fs::set_permissions(&path, permissions).expect("make the script executable");
|
|
}
|
|
|
|
/// Write an executable script into a directory of its own.
|
|
fn write_script_in(&self, dir: &Path, name: &str, body: &str) {
|
|
let path = dir.join(name);
|
|
std::fs::write(&path, body).expect("write the script");
|
|
let mut permissions = std::fs::metadata(&path)
|
|
.expect("stat the script")
|
|
.permissions();
|
|
permissions.set_mode(0o755);
|
|
std::fs::set_permissions(&path, permissions).expect("make the script executable");
|
|
}
|
|
|
|
pub fn read(&self, rel: &str) -> String {
|
|
std::fs::read_to_string(self.join(rel)).expect("read the file")
|
|
}
|
|
|
|
/// Run git in the tree and return its trimmed stdout.
|
|
pub fn git(&self, args: &[&str]) -> String {
|
|
let output = self.git_command(args).output().expect("run git");
|
|
assert!(
|
|
output.status.success(),
|
|
"git {} failed: {}",
|
|
args.join(" "),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
String::from_utf8_lossy(&output.stdout)
|
|
.trim_end()
|
|
.to_owned()
|
|
}
|
|
|
|
/// Run git expecting a failure, and hand back its stderr.
|
|
///
|
|
/// Used to pin the things spectral works around, such as send-email
|
|
/// refusing a bare diff.
|
|
pub fn git_must_fail(&self, args: &[&str]) -> String {
|
|
let output = self.git_command(args).output().expect("run git");
|
|
assert!(
|
|
!output.status.success(),
|
|
"git {} was supposed to fail",
|
|
args.join(" ")
|
|
);
|
|
|
|
String::from_utf8_lossy(&output.stderr)
|
|
.trim_end()
|
|
.to_owned()
|
|
}
|
|
|
|
pub fn commit_all(&self, message: &str) {
|
|
self.git(&["add", "-A"]);
|
|
self.git(&["commit", "--quiet", "-m", message]);
|
|
}
|
|
|
|
/// Do the work on a branch of its own, the way the loop expects: `create`
|
|
/// diffs the branch against `master`, so committing straight to `master`
|
|
/// leaves it nothing to send.
|
|
pub fn work_on_a_branch(&self) {
|
|
self.git(&["checkout", "--quiet", "-b", "work"]);
|
|
}
|
|
|
|
/// Run the built binary with the fixture's environment.
|
|
pub fn cli(&self, args: &[&str]) -> Output {
|
|
self.cli_env(args, &[])
|
|
}
|
|
|
|
/// Run the built binary without `$SPECTRAL_KERNEL`, so the config file and
|
|
/// the built-in default are the only things left to resolve a tree from.
|
|
///
|
|
/// `XDG_CONFIG_HOME` points into the fixture, which is where the test's
|
|
/// config file lives.
|
|
pub fn cli_without_the_tree_variable(&self, args: &[&str], env: &[(&str, &str)]) -> Output {
|
|
let mut command = Command::new(env!("CARGO_BIN_EXE_spectral"));
|
|
command
|
|
.args(args)
|
|
.current_dir(self.root.path())
|
|
.env_clear()
|
|
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
|
.env("HOME", self.home.path())
|
|
.env("XDG_CONFIG_HOME", self.home.join("config"))
|
|
.env("GIT_CONFIG_GLOBAL", self.home.join("gitconfig"))
|
|
.env("GIT_CONFIG_SYSTEM", "/dev/null");
|
|
|
|
for (key, value) in env {
|
|
command.env(key, value);
|
|
}
|
|
|
|
command.output().expect("run spectral")
|
|
}
|
|
|
|
/// Write a config file into the fixture's own config directory.
|
|
pub fn write_config(&self, contents: &str) -> PathBuf {
|
|
let path = self.home.join("config/spectral/config.toml");
|
|
std::fs::create_dir_all(path.parent().expect("the config directory"))
|
|
.expect("create the config directory");
|
|
std::fs::write(&path, contents).expect("write the config file");
|
|
|
|
path
|
|
}
|
|
|
|
/// Run the built binary with extra environment on top of the fixture's.
|
|
pub fn cli_env(&self, args: &[&str], env: &[(&str, &str)]) -> Output {
|
|
let mut command = Command::new(env!("CARGO_BIN_EXE_spectral"));
|
|
command
|
|
.args(args)
|
|
.current_dir(self.root.path())
|
|
.env_clear()
|
|
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
|
.env("HOME", self.home.path())
|
|
.env("SPECTRAL_KERNEL", self.root.path())
|
|
.env("GIT_CONFIG_GLOBAL", self.home.join("gitconfig"))
|
|
.env("GIT_CONFIG_SYSTEM", "/dev/null");
|
|
|
|
for (key, value) in env {
|
|
command.env(key, value);
|
|
}
|
|
|
|
command.output().expect("run spectral")
|
|
}
|
|
|
|
fn git_command(&self, args: &[&str]) -> Command {
|
|
let mut command = Command::new("git");
|
|
command
|
|
.arg("-C")
|
|
.arg(self.root.path())
|
|
.args(args)
|
|
.env("HOME", self.home.path())
|
|
.env("GIT_CONFIG_GLOBAL", self.home.join("gitconfig"))
|
|
.env("GIT_CONFIG_SYSTEM", "/dev/null");
|
|
|
|
command
|
|
}
|
|
|
|
/// A `PATH` holding symlinks to exactly these programs, found on the
|
|
/// machine's own path.
|
|
///
|
|
/// This is how a test takes a tool away: uninstalling qemu to prove the
|
|
/// missing-qemu path would be absurd, and pointing `PATH` somewhere narrow
|
|
/// asserts the same thing without touching the host.
|
|
pub fn restricted_path(&self, programs: &[&str]) -> PathBuf {
|
|
let bin = self.home.join("bin");
|
|
std::fs::create_dir_all(&bin).expect("create the restricted bin directory");
|
|
|
|
for program in programs {
|
|
let found = self.program(program);
|
|
std::os::unix::fs::symlink(found, bin.join(program)).expect("link the program");
|
|
}
|
|
|
|
bin
|
|
}
|
|
|
|
/// A `PATH` whose `git` is the machine's, except that `send-email` answers
|
|
/// with `answer` instead of running.
|
|
///
|
|
/// The harness controls `PATH` and git's configuration but not git's own
|
|
/// exec-path, so whether the host has git's send-email support installed is
|
|
/// otherwise inherited from the machine. This makes the answer the test's,
|
|
/// in both directions.
|
|
pub fn path_with_send_email(&self, answer: u32) -> PathBuf {
|
|
let bin = self.home.join("gitbin");
|
|
std::fs::create_dir_all(&bin).expect("create the git bin directory");
|
|
let real = self.program("git");
|
|
let wrapper = format!(
|
|
"#!/bin/sh\nif [ \"$1\" = send-email ]; then exit {answer}; fi\nexec {} \"$@\"\n",
|
|
real.display()
|
|
);
|
|
self.write_script_in(&bin, "git", &wrapper);
|
|
|
|
bin
|
|
}
|
|
|
|
/// The machine's own copy of a program, found on its `PATH`.
|
|
fn program(&self, name: &str) -> PathBuf {
|
|
std::env::var("PATH")
|
|
.unwrap_or_default()
|
|
.split(':')
|
|
.map(|dir| Path::new(dir).join(name))
|
|
.find(|candidate| candidate.is_file())
|
|
.unwrap_or_else(|| panic!("{name} is not on this machine's PATH"))
|
|
}
|
|
}
|