mod common;
use std::path::{Path, PathBuf};
use std::process::Command;
use common::{FixtureRelease, build_release, substitute_payload};
fn host_target() -> (&'static str, &'static str) {
match (std::env::consts::OS, std::env::consts::ARCH) {
("windows", "x86_64") => ("x86_64-pc-windows-msvc", "runner-manager.exe"),
("macos", "aarch64") => ("aarch64-apple-darwin", "runner-manager"),
("macos", "x86_64") => ("x86_64-apple-darwin", "runner-manager"),
("linux", "x86_64") => ("x86_64-unknown-linux-gnu", "runner-manager"),
("linux", "aarch64") => ("aarch64-unknown-linux-gnu", "runner-manager"),
(os, arch) => panic!(
"this suite has no published archive for {os}/{arch}. If the project starts \
publishing one, add it here and to `host_target` in `cli/update.rs`."
),
}
}
const RUNNING: &str = env!("CARGO_PKG_VERSION");
fn one_release_newer() -> String {
let mut parts = RUNNING.split('.');
let major: u64 = parts.next().expect("a major").parse().expect("a number");
let minor: u64 = parts.next().expect("a minor").parse().expect("a number");
let patch: u64 = parts.next().expect("a patch").parse().expect("a number");
format!("{major}.{minor}.{}", patch + 1)
}
fn spawn_past_etxtbsy(command: &mut Command) -> std::process::Output {
const ATTEMPTS: u32 = 50;
const BACKOFF: std::time::Duration = std::time::Duration::from_millis(20);
for _ in 0..ATTEMPTS {
match command.output() {
Ok(output) => return output,
Err(error) if error.raw_os_error() == Some(TEXT_FILE_BUSY) => {
std::thread::sleep(BACKOFF);
}
Err(error) => panic!("the copied binary must run: {error:?}"),
}
}
panic!(
"the copied binary was still reported busy after {:?}; that is far longer than a \
concurrent copy in this suite can hold a write descriptor, so it is a real failure \
rather than the race this retry exists for",
ATTEMPTS * BACKOFF
);
}
#[cfg(target_os = "linux")]
const TEXT_FILE_BUSY: i32 = 26;
#[cfg(not(target_os = "linux"))]
const TEXT_FILE_BUSY: i32 = 0;
struct Installed {
_root: tempfile::TempDir,
binary: PathBuf,
data: PathBuf,
}
impl Installed {
fn new() -> Self {
Self::under(&["bin"])
}
fn npm_global() -> Self {
let platform = match std::env::consts::OS {
"windows" => "runner-manager-win32-x64",
"macos" => "runner-manager-darwin-arm64",
_ => "runner-manager-linux-x64",
};
let lib: &[&str] = if cfg!(windows) { &[] } else { &["lib"] };
let mut components = vec!["prefix"];
components.extend_from_slice(lib);
components.extend_from_slice(&[
"node_modules",
"@ivan-murzak",
"runner-manager",
"node_modules",
"@ivan-murzak",
platform,
"bin",
]);
Self::under(&components)
}
fn npm_prefix(&self) -> String {
operator_path(&self._root.path().join("prefix"))
}
fn under(components: &[&str]) -> Self {
let root = tempfile::tempdir().expect("a temporary directory");
let bin = components
.iter()
.fold(root.path().to_path_buf(), |path, component| {
path.join(component)
});
std::fs::create_dir_all(&bin).expect("the install directory");
let binary = bin.join(format!("runner-manager{}", std::env::consts::EXE_SUFFIX));
std::fs::copy(env!("CARGO_BIN_EXE_runner-manager"), &binary)
.expect("copying the built binary into place");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755))
.expect("making the copy executable");
}
let data = root.path().join("data");
std::fs::create_dir_all(&data).expect("the data directory");
Self {
_root: root,
binary,
data,
}
}
fn update(&self, assets: &Path, arguments: &[&str]) -> Outcome {
self.update_with_path(assets, arguments, None)
}
fn update_with_path(
&self,
assets: &Path,
arguments: &[&str],
path: Option<&std::ffi::OsStr>,
) -> Outcome {
let mut command = Command::new(&self.binary);
if let Some(path) = path {
command.env("PATH", path);
}
for variable in [
"RUNNER_MANAGER_DATA_DIR",
"RUNNER_MANAGER_GITHUB_BASE_URL",
"RUST_LOG",
] {
command.env_remove(variable);
}
command
.env("RUNNER_MANAGER_UPDATE_BASE_URL", assets)
.arg("--data-dir")
.arg(&self.data)
.arg("update")
.args(arguments);
let output = spawn_past_etxtbsy(&mut command);
Outcome {
code: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n"),
stderr: String::from_utf8_lossy(&output.stderr).replace("\r\n", "\n"),
}
}
fn bytes(&self) -> Vec<u8> {
std::fs::read(&self.binary).expect("the installed binary must be readable")
}
}
struct Outcome {
code: i32,
stdout: String,
stderr: String,
}
impl Outcome {
fn both(&self) -> String {
format!("{}{}", self.stdout, self.stderr)
}
}
fn payload_of(release: &FixtureRelease) -> Vec<u8> {
let (target, binary) = host_target();
std::fs::read(release.staged(target).join(binary)).expect("the fixture payload")
}
#[test]
fn a_newer_release_replaces_the_running_binary() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
let before = installed.bytes();
let outcome = installed.update(&release.assets, &[]);
assert_eq!(
outcome.code,
0,
"update must succeed; output was:\n{}",
outcome.both()
);
assert!(
outcome.stdout.contains(&format!(
"Installed runner-manager {} to",
one_release_newer()
)),
"update must name the version it installed; output was:\n{}",
outcome.both()
);
assert!(
outcome.stdout.contains("SHA-256 OK:"),
"update must report the digest it checked; output was:\n{}",
outcome.both()
);
let after = installed.bytes();
assert_ne!(before, after, "the binary on disk must have been replaced");
assert_eq!(
after,
payload_of(&release),
"the installed file must be the binary the release archive carried"
);
#[cfg(windows)]
assert_eq!(
std::fs::read(
installed
.binary
.with_file_name("runner-manager-supervisor.exe")
)
.expect("the update must install the no-console companion"),
std::fs::read(
release
.staged("x86_64-pc-windows-msvc")
.join("runner-manager-supervisor.exe")
)
.expect("fixture supervisor"),
"a standalone update must leave the companion needed by service install"
);
}
#[test]
fn the_same_version_is_not_reinstalled() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), RUNNING);
let installed = Installed::new();
let before = installed.bytes();
let outcome = installed.update(&release.assets, &[]);
assert_eq!(
outcome.code,
0,
"being up to date is not a failure; output was:\n{}",
outcome.both()
);
assert!(
outcome
.stdout
.contains("is the newest release. Nothing to do."),
"output was:\n{}",
outcome.both()
);
assert_eq!(installed.bytes(), before, "nothing may be written");
}
#[test]
fn a_substituted_archive_is_refused_and_nothing_is_installed() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let (target, _) = host_target();
substitute_payload(&release, target);
let installed = Installed::new();
let before = installed.bytes();
let outcome = installed.update(&release.assets, &[]);
assert_eq!(
outcome.code,
20,
"a digest mismatch is `unusable_response`; output was:\n{}",
outcome.both()
);
assert!(
outcome.stderr.contains("CHECKSUM MISMATCH"),
"the refusal must say what happened; output was:\n{}",
outcome.both()
);
assert_eq!(
installed.bytes(),
before,
"a refused update must leave the binary that was working in place"
);
}
#[test]
fn check_reports_the_new_version_and_changes_nothing() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
let before = installed.bytes();
let outcome = installed.update(&release.assets, &["--check"]);
assert_eq!(outcome.code, 0, "output was:\n{}", outcome.both());
assert!(
outcome.stdout.contains(&format!(
"published {}",
one_release_newer()
)),
"output was:\n{}",
outcome.both()
);
assert!(
outcome
.stdout
.contains("Nothing has been changed, because --check was given."),
"output was:\n{}",
outcome.both()
);
assert!(
outcome
.stdout
.contains("Install it with: runner-manager update"),
"an updatable copy must be told the command that does it; output was:\n{}",
outcome.both()
);
assert_eq!(installed.bytes(), before, "nothing may be written");
}
#[test]
fn the_report_names_the_channel_and_the_file_it_would_replace() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
let outcome = installed.update(&release.assets, &["--check"]);
assert!(
outcome
.stdout
.contains("installed by release archive ("),
"output was:\n{}",
outcome.both()
);
assert!(
outcome
.stdout
.contains(&format!("installed {RUNNING}")),
"output was:\n{}",
outcome.both()
);
}
#[test]
fn a_build_in_a_checkout_is_refused() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let data = tempfile::tempdir().expect("a temporary directory");
let output = Command::new(env!("CARGO_BIN_EXE_runner-manager"))
.env_remove("RUNNER_MANAGER_DATA_DIR")
.env("RUNNER_MANAGER_UPDATE_BASE_URL", &release.assets)
.arg("--data-dir")
.arg(data.path())
.arg("update")
.output()
.expect("the binary must run");
let stderr = String::from_utf8_lossy(&output.stderr).replace("\r\n", "\n");
assert_eq!(
output.status.code(),
Some(22),
"a checkout build is `update_unsupported`; stderr was:\n{stderr}"
);
assert!(
stderr.contains("build in a checkout"),
"stderr was:\n{stderr}"
);
assert!(
stderr.contains("cargo build --release"),
"the refusal must name what to run instead; stderr was:\n{stderr}"
);
}
#[test]
fn check_says_when_this_copy_could_not_be_updated() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let data = tempfile::tempdir().expect("a temporary directory");
let output = Command::new(env!("CARGO_BIN_EXE_runner-manager"))
.env_remove("RUNNER_MANAGER_DATA_DIR")
.env("RUNNER_MANAGER_UPDATE_BASE_URL", &release.assets)
.arg("--data-dir")
.arg(data.path())
.arg("update")
.arg("--check")
.output()
.expect("the binary must run");
let stdout = String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n");
assert_eq!(output.status.code(), Some(0), "stdout was:\n{stdout}");
assert!(
stdout.contains("`runner-manager update` would refuse here:"),
"stdout was:\n{stdout}"
);
assert!(
!stdout.contains("Install it with: runner-manager update"),
"a dry run must not recommend a command that refuses; stdout was:\n{stdout}"
);
}
#[test]
fn force_check_does_not_recommend_overwriting_a_checkout_build() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), RUNNING);
let data = tempfile::tempdir().expect("a temporary directory");
let output = Command::new(env!("CARGO_BIN_EXE_runner-manager"))
.env_remove("RUNNER_MANAGER_DATA_DIR")
.env("RUNNER_MANAGER_UPDATE_BASE_URL", &release.assets)
.arg("--data-dir")
.arg(data.path())
.args(["update", "--check", "--force"])
.output()
.expect("the binary must run");
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(output.status.code(), Some(0), "{stdout}");
assert!(stdout.contains("would refuse here"), "{stdout}");
assert!(!stdout.contains("would back up and stage"), "{stdout}");
}
#[test]
fn an_http_asset_source_that_is_not_this_machine_is_refused() {
let data = tempfile::tempdir().expect("a temporary directory");
let output = Command::new(env!("CARGO_BIN_EXE_runner-manager"))
.env_remove("RUNNER_MANAGER_DATA_DIR")
.env(
"RUNNER_MANAGER_UPDATE_BASE_URL",
"https://releases.example.com/download",
)
.arg("--data-dir")
.arg(data.path())
.arg("update")
.arg("--check")
.output()
.expect("the binary must run");
let stderr = String::from_utf8_lossy(&output.stderr).replace("\r\n", "\n");
assert_eq!(
output.status.code(),
Some(9),
"an untrusted origin is `invalid_argument`; stderr was:\n{stderr}"
);
assert!(
stderr.contains("which is not this machine"),
"stderr was:\n{stderr}"
);
}
#[test]
fn a_release_without_this_platform_says_so() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let (target, _) = host_target();
let sums = std::fs::read_to_string(release.sums()).expect("the fixture SHA256SUMS");
let kept: Vec<&str> = sums.lines().filter(|line| !line.contains(target)).collect();
std::fs::write(release.sums(), format!("{}\n", kept.join("\n"))).expect("the trimmed sums");
let installed = Installed::new();
let outcome = installed.update(&release.assets, &["--check"]);
assert_eq!(
outcome.code,
15,
"a missing platform is `unsupported_host`; output was:\n{}",
outcome.both()
);
assert!(
outcome.stderr.contains("publishes no archive for"),
"output was:\n{}",
outcome.both()
);
}
fn write_install_record(data: &Path, source_binary: Option<&Path>) {
write_install_record_for(
data,
source_binary,
&data.join("state").join("bin").join("runner-manager"),
);
}
fn write_install_record_for(data: &Path, source_binary: Option<&Path>, binary: &Path) {
let config = data.join("config");
std::fs::create_dir_all(&config).expect("the config directory");
let source_line = match source_binary {
Some(path) => format!("source_binary = {}\n", toml_path(path)),
None => String::new(),
};
let record = format!(
"schema_version = 1\n\
service_name = \"com.example.runner-manager\"\n\
manager = \"launchd\"\n\
start_mode = \"boot\"\n\
account = \"root\"\n\
binary = {binary}\n\
{source_line}\
arguments = [\"daemon\", \"run\"]\n\
restart_delay_secs = 5\n\
restart_reset_secs = 60\n\
log_file = {log}\n\
starts_on_demand = false\n\
definition_path = \"/Library/LaunchDaemons/com.example.runner-manager.plist\"\n\
installed_at = \"2026-09-01T00:00:00Z\"\n\
installed_by_version = \"0.0.1\"\n\
\n\
[directories]\n\
config = {config_dir}\n\
state = {state}\n\
runtime = {runtime}\n\
logs = {logs}\n",
binary = toml_path(binary),
log = toml_path(&data.join("logs").join("runner-manager")),
config_dir = toml_path(&config),
state = toml_path(&data.join("state")),
runtime = toml_path(&data.join("runtime")),
logs = toml_path(&data.join("logs")),
);
std::fs::write(config.join("service.toml"), record).expect("the install record");
}
fn toml_path(path: &Path) -> String {
format!("'{}'", path.display())
}
fn operator_path(path: &Path) -> String {
let canonical = std::fs::canonicalize(path).expect("an existing path");
let rendered = canonical.display().to_string();
rendered
.strip_prefix(r"\\?\")
.map_or(rendered.clone(), str::to_owned)
}
fn write_script(directory: &Path, name: &str, windows_body: &str, unix_body: &str) -> PathBuf {
std::fs::create_dir_all(directory).expect("the script directory");
if cfg!(windows) {
let script = directory.join(format!("{name}.cmd"));
std::fs::write(&script, format!("@echo off\r\n{windows_body}\r\n")).expect("the script");
script
} else {
let script = directory.join(name);
std::fs::write(&script, format!("#!/bin/sh\n{unix_body}\n")).expect("the script");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755))
.expect("making the script executable");
}
script
}
}
fn path_with_first(directory: &Path) -> std::ffi::OsString {
let inherited = std::env::var_os("PATH").unwrap_or_default();
std::env::join_paths(
std::iter::once(directory.to_path_buf()).chain(std::env::split_paths(&inherited)),
)
.expect("a joinable PATH")
}
#[test]
fn an_npm_copy_is_updated_by_the_npm_on_path_in_its_own_prefix() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let newer = one_release_newer();
let release = build_release(fixtures.path(), &newer);
let installed = Installed::npm_global();
let tools = fixtures.path().join("tools");
let recorded = tools.join("npm-arguments.txt");
write_script(
&tools,
"npm",
"for %%a in (%*) do >>\"%~dp0npm-arguments.txt\" echo %%~a",
"for a in \"$@\"; do printf '%s\\n' \"$a\" >> \"$(dirname \"$0\")/npm-arguments.txt\"; done",
);
let outcome = installed.update_with_path(&release.assets, &[], Some(&path_with_first(&tools)));
assert_eq!(outcome.code, 0, "output was:\n{}", outcome.both());
let arguments = std::fs::read_to_string(&recorded)
.unwrap_or_else(|error| panic!("npm was never run ({error}):\n{}", outcome.both()));
let arguments: Vec<&str> = arguments.lines().map(str::trim).collect();
let package = format!("@ivan-murzak/runner-manager@{newer}");
assert_eq!(
arguments,
[
"install",
"--global",
"--prefix",
installed.npm_prefix().as_str(),
package.as_str()
],
"output was:\n{}",
outcome.both()
);
assert!(
outcome.stdout.contains(&format!("still reports {RUNNING}")),
"output was:\n{}",
outcome.both()
);
}
fn modified(path: &Path) -> std::time::SystemTime {
std::fs::metadata(path)
.and_then(|metadata| metadata.modified())
.expect("a modification time")
}
#[test]
fn an_older_service_behind_the_newest_release_is_swapped_not_nothing_to_do() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), RUNNING);
let installed = Installed::new();
let service = write_script(
&installed.data.join("state").join("bin"),
"runner-manager",
"echo runner-manager 0.0.1",
"echo runner-manager 0.0.1",
);
write_install_record_for(&installed.data, Some(&installed.binary), &service);
let long_ago =
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
let mark_long_ago = || {
std::fs::File::options()
.write(true)
.open(&installed.binary)
.and_then(|file| file.set_modified(long_ago))
.expect("an old modification time");
};
mark_long_ago();
let checked = installed.update(&release.assets, &["--check"]);
assert_eq!(checked.code, 0, "output was:\n{}", checked.both());
assert!(
checked.stdout.contains("still runs 0.0.1"),
"{}",
checked.both()
);
assert!(
checked.stdout.contains("swaps to") && checked.stdout.contains("as soon as it starts"),
"{}",
checked.both()
);
assert!(
!checked.stdout.contains("Nothing to do"),
"{}",
checked.both()
);
assert_eq!(
modified(&installed.binary),
long_ago,
"--check changed the copy"
);
let before = installed.bytes();
let outcome = installed.update(&release.assets, &[]);
assert_eq!(outcome.code, 0, "output was:\n{}", outcome.both());
assert!(
outcome.stdout.contains("still runs 0.0.1"),
"{}",
outcome.both()
);
assert!(
outcome
.stdout
.contains(&format!("swaps to {RUNNING} as soon as it starts")),
"{}",
outcome.both()
);
assert!(!outcome.stdout.contains("warning"), "{}", outcome.both());
assert!(
modified(&installed.binary) > long_ago,
"the copy the daemon watches was not marked changed:\n{}",
outcome.both()
);
assert_eq!(
installed.bytes(),
before,
"the copy's bytes must not change"
);
}
#[test]
fn an_older_service_from_another_copy_is_told_to_hand_over_with_force() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), RUNNING);
let installed = Installed::new();
let service = write_script(
&installed.data.join("state").join("bin"),
"runner-manager",
"echo runner-manager 0.0.1",
"echo runner-manager 0.0.1",
);
let elsewhere = installed.data.join("another").join("runner-manager");
write_install_record_for(&installed.data, Some(&elsewhere), &service);
let before = modified(&installed.binary);
let outcome = installed.update(&release.assets, &[]);
assert_eq!(outcome.code, 0, "output was:\n{}", outcome.both());
assert!(
outcome.stdout.contains("still runs 0.0.1"),
"{}",
outcome.both()
);
assert!(
outcome.stdout.contains("runner-manager update --force"),
"{}",
outcome.both()
);
assert_eq!(modified(&installed.binary), before);
}
#[test]
fn a_service_running_a_copy_is_told_it_will_hand_over_by_itself() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
write_install_record(&installed.data, Some(&installed.binary));
let outcome = installed.update(&release.assets, &[]);
assert_eq!(outcome.code, 0, "output was:\n{}", outcome.both());
assert!(
outcome
.stdout
.contains("The service runs its own copy of this binary."),
"output was:\n{}",
outcome.both()
);
assert!(
outcome.stdout.contains("Nothing else is needed."),
"output was:\n{}",
outcome.both()
);
}
#[test]
fn a_registration_with_no_recorded_source_is_told_to_reinstall() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
write_install_record(&installed.data, None);
let outcome = installed.update(&release.assets, &[]);
assert_eq!(outcome.code, 0, "output was:\n{}", outcome.both());
assert!(
outcome.stdout.contains("names a binary directly"),
"output was:\n{}",
outcome.both()
);
assert!(
outcome.stdout.contains("runner-manager service install"),
"the report must name the command that fixes it; output was:\n{}",
outcome.both()
);
}
#[test]
fn a_service_installed_from_another_binary_is_named_rather_than_assumed() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
let elsewhere = installed.data.join("another").join("runner-manager");
write_install_record(&installed.data, Some(&elsewhere));
let outcome = installed.update(&release.assets, &[]);
assert_eq!(outcome.code, 0, "output was:\n{}", outcome.both());
assert!(
outcome.stdout.contains("The service was installed from"),
"output was:\n{}",
outcome.both()
);
assert!(
outcome.stdout.contains(&elsewhere.display().to_string()),
"the report must name the other binary; output was:\n{}",
outcome.both()
);
}
#[test]
fn force_check_refuses_an_unmatched_service_and_preserves_both_files() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), RUNNING);
let installed = Installed::new();
let source = installed.data.join("another").join("runner-manager");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, b"operator source").unwrap();
write_install_record(&installed.data, Some(&source));
let before = installed.bytes();
let outcome = installed.update(&release.assets, &["--check", "--force"]);
assert_eq!(outcome.code, 0, "output was:\n{}", outcome.both());
assert!(
outcome.stdout.contains("would refuse here"),
"{}",
outcome.both()
);
assert!(
!outcome.stdout.contains("Install it with:"),
"{}",
outcome.both()
);
assert_eq!(installed.bytes(), before);
assert_eq!(std::fs::read(source).unwrap(), b"operator source");
}
#[test]
fn force_refuses_a_record_without_its_matching_live_service() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), RUNNING);
let installed = Installed::new();
let source = installed.data.join("another").join("runner-manager");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, b"unrelated source").unwrap();
write_install_record(&installed.data, Some(&source));
let outcome = installed.update(&release.assets, &["--force"]);
assert_ne!(outcome.code, 0, "output was:\n{}", outcome.both());
assert!(
outcome.stderr.contains("registration") || outcome.stderr.contains("service"),
"output was:\n{}",
outcome.both()
);
assert_eq!(std::fs::read(source).unwrap(), b"unrelated source");
}
#[test]
fn force_refuses_before_updating_cli_when_service_cannot_be_drained() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
let source = installed.data.join("another").join("runner-manager");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, b"unrelated source").unwrap();
write_install_record(&installed.data, Some(&source));
let before = installed.bytes();
let outcome = installed.update(&release.assets, &["--force"]);
assert_ne!(outcome.code, 0, "output was:\n{}", outcome.both());
assert_eq!(installed.bytes(), before, "CLI must not be half-updated");
assert_eq!(std::fs::read(source).unwrap(), b"unrelated source");
}
#[test]
fn a_host_with_no_service_is_told_nothing_about_one() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
let outcome = installed.update(&release.assets, &[]);
assert_eq!(outcome.code, 0, "output was:\n{}", outcome.both());
assert!(
!outcome.stdout.contains("The service"),
"output was:\n{}",
outcome.both()
);
}