From 06485c5884a34c4b59acc44330f7ed7d67ca4d7a Mon Sep 17 00:00:00 2001 From: Lucas Fernandes Nogueira Date: Tue, 22 Sep 2026 18:51:05 -0300 Subject: [PATCH] test(updater): also check .deb and .rpm on e2e test (#3619) * test(updater): also check .deb and .rpm on e2e test * fix test on mac * install rpm on ci * improve update server, fix rpm crash --- .github/workflows/integration-tests.yml | 4 +- .../updater/tests/app-updater/tests/update.rs | 800 +++++++++++++----- 2 files changed, 568 insertions(+), 236 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 292172cab..27993d2fc 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -13,6 +13,7 @@ on: paths: - '.github/workflows/integration-tests.yml' - 'plugins/updater/src/**' + - 'plugins/updater/tests/**' pull_request: branches: - v1 @@ -20,6 +21,7 @@ on: paths: - '.github/workflows/integration-tests.yml' - 'plugins/updater/src/**' + - 'plugins/updater/tests/**' permissions: contents: read @@ -48,7 +50,7 @@ jobs: if: matrix.platform == 'ubuntu-22.04' run: | sudo apt-get update - sudo apt-get install -y webkit2gtk-4.0 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev libfuse2 + sudo apt-get install -y webkit2gtk-4.0 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev libfuse2 rpm - uses: Swatinem/rust-cache@v2 with: diff --git a/plugins/updater/tests/app-updater/tests/update.rs b/plugins/updater/tests/app-updater/tests/update.rs index 34020578d..a04253f68 100644 --- a/plugins/updater/tests/app-updater/tests/update.rs +++ b/plugins/updater/tests/app-updater/tests/update.rs @@ -8,7 +8,7 @@ use std::{ collections::HashMap, fs::File, path::{Path, PathBuf}, - process::Command, + process::{Command, Stdio}, sync::{Arc, Mutex}, }; @@ -24,6 +24,54 @@ const UPDATED_EXIT_CODE: i32 = 0; const ERROR_EXIT_CODE: i32 = 1; const UP_TO_DATE_EXIT_CODE: i32 = 2; +/// The port the endpoint in the app's `tauri.conf.json` points at. +const UPDATE_PORT: u16 = 3007; + +/// The server a test serves its update manifest and bundle from, shut down when it goes out of +/// scope. +/// +/// Every test binds the same port and `BUILD_LOCK` keeps them from overlapping, so each one has to +/// leave the port free. Shutting the server down by hand at the end of the test does not: a panic +/// anywhere above it leaves the server listening, and because `localhost` resolves to both loopback +/// addresses the next test binds the other one instead of failing. It then answers with the +/// previous test's manifest and serves the previous test's bundle, so one broken test fails the +/// ones after it with an error about the wrong package. +struct UpdaterServer { + server: Arc, + requests: Option>, +} + +impl UpdaterServer { + /// Serves `localhost:port`, answering every request with `handler`. + fn spawn(port: u16, handler: impl Fn(tiny_http::Request) + Send + 'static) -> Self { + let server = Arc::new( + tiny_http::Server::http(format!("localhost:{port}")) + .unwrap_or_else(|e| panic!("failed to start updater server on {port}: {e}")), + ); + + let server_ = server.clone(); + Self { + requests: Some(std::thread::spawn(move || { + for request in server_.incoming_requests() { + handler(request); + } + })), + server, + } + } +} + +impl Drop for UpdaterServer { + fn drop(&mut self) { + // `unblock` only ends `incoming_requests`; the port is released when the last `Arc` to the + // server is dropped, so the request thread has to be gone before the next test binds it + self.server.unblock(); + if let Some(requests) = self.requests.take() { + let _ = requests.join(); + } + } +} + #[derive(Serialize)] struct Config { version: &'static str, @@ -38,6 +86,10 @@ struct Config { #[serde(rename_all = "camelCase")] struct BundleConfig { create_updater_artifacts: Updater, + /// Merged into `bundle.linux` of the app's `tauri.conf.json`. Only set by the RPM test, for + /// `rpm.provides` (see `rpm_self_provides`). + #[serde(skip_serializing_if = "Option::is_none")] + linux: Option, } #[derive(Serialize)] @@ -110,6 +162,154 @@ impl BundleTarget { } } +#[cfg(target_os = "linux")] +impl BundleTarget { + /// Where `cargo tauri build --bundles ` leaves the bundle of the given app version. + fn bundle_path(self, root_dir: &Path, version: &str) -> PathBuf { + root_dir.join(match self { + Self::AppImage => { + format!("target/release/bundle/appimage/app-updater_{version}_amd64.AppImage") + } + Self::Deb => format!("target/release/bundle/deb/app-updater_{version}_amd64.deb"), + Self::Rpm => format!("target/release/bundle/rpm/app-updater-{version}-1.x86_64.rpm"), + _ => unreachable!("{} is not a Linux bundle", self.name()), + }) + } + + /// The package manager the updater hands this bundle to, for the Linux packages. + fn package_manager(self) -> Option<&'static str> { + match self { + Self::Deb => Some("dpkg"), + Self::Rpm => Some("rpm"), + _ => None, + } + } + + /// Whether this host can install the package without asking anything: the package manager must + /// be there with a database it can read, the runtime the app links against must be installed, + /// and `sudo` must not need a password. The update itself escalates through `pkexec`, then a + /// graphical password prompt, then `sudo`, and in a non-interactive run only the last one can + /// succeed. + fn installable(self) -> Result<(), String> { + let Some(manager) = self.package_manager() else { + return Ok(()); + }; + // listing the installed packages tells both that the manager is there and that its database + // works. `rpm` passes this on a Debian host too, against the empty database `apt install + // rpm` leaves behind; `rpm_self_provides` is what keeps the install from resolving the + // app's dependencies against it. + let list_packages: &[&str] = match self { + Self::Deb => &["-l"], + _ => &["-qa"], + }; + if !command_succeeds(manager, list_packages) { + return Err(format!( + "{manager} is not installed or its database is not readable" + )); + } + // so nothing checks webkit2gtk at install time, but the installed app still needs it to + // run. `ldconfig` lists it whichever manager put it there, and lives in an `sbin` that is + // not always on `PATH` + let webkit_installed = ["ldconfig", "/usr/sbin/ldconfig", "/sbin/ldconfig"] + .iter() + .any(|ldconfig| command_stdout_contains(ldconfig, &["-p"], "libwebkit2gtk-4.1.so.0")); + if !webkit_installed { + return Err("the webkit2gtk 4.1 runtime is not installed".into()); + } + if !command_succeeds("sudo", &["-n", "true"]) { + return Err("sudo needs a password".into()); + } + Ok(()) + } +} + +#[cfg(target_os = "linux")] +fn command_succeeds(program: &str, args: &[&str]) -> bool { + Command::new(program) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +#[cfg(target_os = "linux")] +fn command_stdout_contains(program: &str, args: &[&str], needle: &str) -> bool { + Command::new(program) + .args(args) + .stderr(Stdio::null()) + .output() + .map(|output| String::from_utf8_lossy(&output.stdout).contains(needle)) + .unwrap_or(false) +} + +/// The libraries the Tauri CLI declares as RPM `Requires` of an app using wry, as +/// `Runtime::linux_dependencies` names them, with the suffix it appends on a 64 bit target. +#[cfg(target_os = "linux")] +const RPM_RUNTIME_LIBRARIES: &[&str] = + &["libwebkit2gtk-4.1.so.0()(64bit)", "libgtk-3.so.0()(64bit)"]; + +/// `bundle.linux` declaring the RPM `Requires` of `RPM_RUNTIME_LIBRARIES` the host's RPM database +/// cannot resolve as `Provides` of the package itself, or `None` when it resolves all of them (and +/// for a bundle that is not an RPM). +/// +/// `rpm` resolves `Requires` against its own database, and on a Debian host that database is the +/// empty one `apt install rpm` leaves behind: the libraries are there, installed by `apt` - which +/// is what `installable` checks with `ldconfig` - but nothing in the RPM database provides them, so +/// both the `rpm -i` that installs the app under test and the `rpm -U` the updater runs fail on +/// them. A package's own `Provides` satisfy its own `Requires`, so declaring them is what tells +/// `rpm` what the dynamic linker already knows. On an RPM distro the real packages provide them and +/// nothing is declared. +#[cfg(target_os = "linux")] +fn rpm_self_provides(bundle_target: BundleTarget) -> Option { + if !matches!(bundle_target, BundleTarget::Rpm) { + return None; + } + + let unresolved = RPM_RUNTIME_LIBRARIES + .iter() + .filter(|library| !command_succeeds("rpm", &["-q", "--whatprovides", library])) + .collect::>(); + if unresolved.is_empty() { + return None; + } + + Some(serde_json::json!({ "rpm": { "provides": unresolved } })) +} + +/// Installs the package system wide, which is where the updater will later install the update over +/// it, and returns the path of the installed executable. +#[cfg(target_os = "linux")] +fn install_package(bundle_target: BundleTarget, package: &Path) -> PathBuf { + let manager = bundle_target + .package_manager() + .expect("not a Linux package"); + // whatever an earlier run left behind + remove_package(bundle_target); + let status = Command::new("sudo") + .args(["-n", manager, "-i"]) + .arg(package) + .status() + .expect("failed to run sudo"); + assert!(status.success(), "failed to install {}", package.display()); + PathBuf::from("/usr/bin/app-updater") +} + +#[cfg(target_os = "linux")] +fn remove_package(bundle_target: BundleTarget) { + let (manager, remove) = match bundle_target { + BundleTarget::Deb => ("dpkg", "-r"), + BundleTarget::Rpm => ("rpm", "-e"), + _ => return, + }; + let _ = Command::new("sudo") + .args(["-n", manager, remove, "app-updater"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + impl Default for BundleTarget { fn default() -> Self { #[cfg(any(target_os = "macos", target_os = "ios"))] @@ -130,15 +330,26 @@ impl Default for BundleTarget { /// /// Windows drives the executable `cargo build` leaves in `target/release`, which no bundler /// touches, so there is nothing to copy there. -fn stage_app_under_test(root_dir: &Path, target: &str) -> PathBuf { +/// +/// A Linux package is not copied but installed: the updater hands the downloaded package to the +/// package manager, which installs it over the one the app runs from. +fn stage_app_under_test(root_dir: &Path, target: &str, bundle_target: BundleTarget) -> PathBuf { #[cfg(windows)] { - let _ = target; + let _ = (target, bundle_target); return root_dir.join("target/release/app-updater.exe"); } + #[cfg(target_os = "linux")] + { + if bundle_target.package_manager().is_some() { + return install_package(bundle_target, &bundle_target.bundle_path(root_dir, "0.1.0")); + } + } + #[cfg(not(windows))] { + let _ = bundle_target; let bundle_path = test_cases(root_dir, "0.1.0", target.to_string()) .first() .unwrap() @@ -186,7 +397,7 @@ fn target_to_platforms( platform, PlatformUpdate { signature, - url: "http://localhost:3007/download".into(), + url: format!("http://localhost:{UPDATE_PORT}/download"), with_elevated_task: false, }, ); @@ -195,49 +406,68 @@ fn target_to_platforms( platforms } +/// A bundle to build, the update platform key the server announces it under (none for a manifest +/// without an update) and the exit codes expected from running the app once per entry. +type TestCase = (BundleTarget, PathBuf, Option, Vec); + #[cfg(target_os = "linux")] -fn test_cases( - root_dir: &Path, - version: &str, - target: String, -) -> Vec<(BundleTarget, PathBuf, Option, Vec)> { +fn test_cases(root_dir: &Path, version: &str, target: String) -> Vec { + let appimage = BundleTarget::AppImage.bundle_path(root_dir, version); vec![ // update using fallback ( BundleTarget::AppImage, - root_dir.join(format!( - "target/release/bundle/appimage/app-updater_{version}_amd64.AppImage" - )), + appimage.clone(), Some(target.clone()), vec![UPDATED_EXIT_CODE, UP_TO_DATE_EXIT_CODE], ), // update using full name ( BundleTarget::AppImage, - root_dir.join(format!( - "target/release/bundle/appimage/app-updater_{version}_amd64.AppImage" - )), + appimage.clone(), Some(format!("{target}-{}", BundleTarget::AppImage.name())), vec![UPDATED_EXIT_CODE, UP_TO_DATE_EXIT_CODE], ), // no update ( BundleTarget::AppImage, - root_dir.join(format!( - "target/release/bundle/appimage/app-updater_{version}_amd64.AppImage" - )), + appimage, None, vec![ERROR_EXIT_CODE], ), ] } -#[cfg(target_os = "macos")] -fn test_cases( +/// The cases of a Linux package: an update announced under the generic target and one under the +/// package specific target. There is no "no update" case, the AppImage one covers that path. +#[cfg(target_os = "linux")] +fn package_test_cases( root_dir: &Path, - _version: &str, + version: &str, target: String, -) -> Vec<(BundleTarget, PathBuf, Option, Vec)> { + bundle_target: BundleTarget, +) -> Vec { + let package = bundle_target.bundle_path(root_dir, version); + vec![ + // update using fallback + ( + bundle_target, + package.clone(), + Some(target.clone()), + vec![UPDATED_EXIT_CODE, UP_TO_DATE_EXIT_CODE], + ), + // update using full name + ( + bundle_target, + package, + Some(format!("{target}-{}", bundle_target.name())), + vec![UPDATED_EXIT_CODE, UP_TO_DATE_EXIT_CODE], + ), + ] +} + +#[cfg(target_os = "macos")] +fn test_cases(root_dir: &Path, _version: &str, target: String) -> Vec { vec![ ( BundleTarget::App, @@ -280,11 +510,7 @@ fn bundle_path(root_dir: &Path, _version: &str, v1compatible: bool) -> PathBuf { } #[cfg(windows)] -fn test_cases( - root_dir: &Path, - version: &str, - target: String, -) -> Vec<(BundleTarget, PathBuf, Option, Vec)> { +fn test_cases(root_dir: &Path, version: &str, target: String) -> Vec { vec![ ( BundleTarget::Nsis, @@ -338,6 +564,7 @@ fn test_cases( } #[test] +#[ignore = "needs the tauri CLI, a display and minutes of build time; the integration tests workflow runs it with --ignored"] fn update_app() { let _lock = BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -346,11 +573,12 @@ fn update_app() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let root_dir = manifest_dir.join("../../../.."); - for mut config in [ + for config in [ Config { version: "1.0.0", bundle: BundleConfig { create_updater_artifacts: Updater::Bool(true), + linux: None, }, plugins: None, }, @@ -358,162 +586,218 @@ fn update_app() { version: "1.0.0", bundle: BundleConfig { create_updater_artifacts: Updater::String(V1Compatible::V1Compatible), + linux: None, }, plugins: None, }, ] { - let v1_compatible = matches!( - config.bundle.create_updater_artifacts, - Updater::String(V1Compatible::V1Compatible) + let cases = test_cases(&root_dir, "1.0.0", target.clone()); + run_update_cases(&manifest_dir, &root_dir, &target, config, cases); + } +} + +/// The updater installs a deb through `dpkg` and an rpm through `rpm`, so this runs the installed +/// app and lets the update replace it system wide. It only runs where that can happen without any +/// prompt (see `BundleTarget::installable`) and removes the package when done. +#[cfg(target_os = "linux")] +#[test] +#[ignore = "needs the tauri CLI, a display and minutes of build time; the integration tests workflow runs it with --ignored"] +fn update_app_deb() { + update_linux_package(BundleTarget::Deb); +} + +/// See `update_app_deb`. +#[cfg(target_os = "linux")] +#[test] +#[ignore = "needs the tauri CLI, a display and minutes of build time; the integration tests workflow runs it with --ignored"] +fn update_app_rpm() { + update_linux_package(BundleTarget::Rpm); +} + +#[cfg(target_os = "linux")] +fn update_linux_package(bundle_target: BundleTarget) { + let _lock = BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + if let Err(reason) = bundle_target.installable() { + // the integration tests workflow installs everything this needs, so a skip there means the + // test quietly stopped covering the package - libtest swallows the message on a pass + assert!( + std::env::var_os("CI").is_none(), + "cannot run the {} update test: {reason}", + bundle_target.name() + ); + eprintln!( + "skipping the {} update test: {reason}", + bundle_target.name() + ); + return; + } + + let target = + tauri_plugin_updater::target().expect("running updater test in an unsupported platform"); + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let root_dir = manifest_dir.join("../../../.."); + + // the legacy v1 updater never supported these packages, so only the v2 artifacts are tested + let config = Config { + version: "1.0.0", + bundle: BundleConfig { + create_updater_artifacts: Updater::Bool(true), + linux: rpm_self_provides(bundle_target), + }, + plugins: None, + }; + let cases = package_test_cases(&root_dir, "1.0.0", target.clone(), bundle_target); + run_update_cases(&manifest_dir, &root_dir, &target, config, cases); +} + +/// Bundles 1.0.0 as each case's target and serves it, then bundles 0.1.0, runs it once per expected +/// exit code and checks what it reports. +fn run_update_cases( + manifest_dir: &Path, + root_dir: &Path, + target: &str, + mut config: Config, + cases: Vec, +) { + let v1_compatible = matches!( + config.bundle.create_updater_artifacts, + Updater::String(V1Compatible::V1Compatible) + ); + + let updater_zip_ext = if v1_compatible { + if cfg!(windows) { + Some("zip") + } else { + Some("tar.gz") + } + } else if cfg!(target_os = "macos") { + Some("tar.gz") + } else { + None + }; + + for (bundle_target, out_bundle_path, update_platform, status_checks) in cases { + // bundle app update + config.version = "1.0.0"; + build_app(manifest_dir, &config, Some(bundle_target)); + + let bundle_updater_ext = if v1_compatible { + out_bundle_path + .extension() + .unwrap() + .to_str() + .unwrap() + .replace("exe", "nsis") + } else { + out_bundle_path + .extension() + .unwrap() + .to_str() + .unwrap() + .to_string() + }; + let updater_extension = if let Some(updater_zip_ext) = updater_zip_ext { + format!("{bundle_updater_ext}.{updater_zip_ext}") + } else { + bundle_updater_ext + }; + let signature_extension = format!("{updater_extension}.sig"); + let signature_path = out_bundle_path.with_extension(signature_extension); + let signature = std::fs::read_to_string(&signature_path).unwrap_or_else(|_| { + panic!("failed to read signature file {}", signature_path.display()) + }); + let out_updater_path = out_bundle_path.with_extension(updater_extension); + let updater_path = root_dir.join(format!( + "target/release/{}", + out_updater_path.file_name().unwrap().to_str().unwrap() + )); + std::fs::rename(&out_updater_path, &updater_path).expect("failed to rename bundle"); + + // start the updater server, shut down at the end of the iteration even if a case panics + let _server = UpdaterServer::spawn(UPDATE_PORT, move |request| match request.url() { + "/" => { + let platforms = target_to_platforms(update_platform.clone(), signature.clone()); + + let body = serde_json::to_vec(&Update { + version: "1.0.0".into(), + date: time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap(), + platforms, + }) + .unwrap(); + let len = body.len(); + let response = tiny_http::Response::new( + tiny_http::StatusCode(200), + Vec::new(), + std::io::Cursor::new(body), + Some(len), + None, + ); + let _ = request.respond(response); + } + "/download" => { + let _ = request.respond(tiny_http::Response::from_file( + File::open(&updater_path).unwrap_or_else(|_| { + panic!("failed to open updater bundle {}", updater_path.display()) + }), + )); + } + _ => (), + }); + + config.version = "0.1.0"; + + // bundle initial app version; Linux and macOS run the bundle itself, so it cannot be + // skipped there + build_app( + manifest_dir, + &config, + if cfg!(windows) { + None + } else { + Some(bundle_target) + }, ); - let updater_zip_ext = if v1_compatible { - if cfg!(windows) { - Some("zip") - } else { - Some("tar.gz") - } - } else if cfg!(target_os = "macos") { - Some("tar.gz") - } else { - None - }; + let app_path = stage_app_under_test(root_dir, target, bundle_target); - for (bundle_target, out_bundle_path, update_platform, status_checks) in - test_cases(&root_dir, "1.0.0", target.clone()) + for expected_exit_code in status_checks { + let mut binary_cmd = if cfg!(target_os = "macos") { + Command::new(app_path.join("Contents/MacOS/app-updater")) + } else if cfg!(target_os = "linux") + && std::env::var("CI").map(|v| v == "true").unwrap_or_default() + { + let mut c = Command::new("xvfb-run"); + c.arg("--auto-servernum").arg(&app_path); + c + } else { + Command::new(&app_path) + }; + + binary_cmd.env("TARGET", bundle_target.name()); + + let status = binary_cmd + .status() + .unwrap_or_else(|e| panic!("failed to run {}: {e}", app_path.display())); + let code = status.code().unwrap_or(-1); + + if code != expected_exit_code { + panic!( + "failed to run app bundled as {}, expected exit code {expected_exit_code}, got {code}", bundle_target.name() + ); + } + #[cfg(windows)] + if code == UPDATED_EXIT_CODE { + // wait for the update to finish + std::thread::sleep(std::time::Duration::from_secs(5)); + } + } + + #[cfg(target_os = "linux")] { - // bundle app update - config.version = "1.0.0"; - build_app(&manifest_dir, &config, Some(bundle_target)); - - let bundle_updater_ext = if v1_compatible { - out_bundle_path - .extension() - .unwrap() - .to_str() - .unwrap() - .replace("exe", "nsis") - } else { - out_bundle_path - .extension() - .unwrap() - .to_str() - .unwrap() - .to_string() - }; - let updater_extension = if let Some(updater_zip_ext) = updater_zip_ext { - format!("{bundle_updater_ext}.{updater_zip_ext}") - } else { - bundle_updater_ext - }; - let signature_extension = format!("{updater_extension}.sig"); - let signature_path = out_bundle_path.with_extension(signature_extension); - let signature = std::fs::read_to_string(&signature_path).unwrap_or_else(|_| { - panic!("failed to read signature file {}", signature_path.display()) - }); - let out_updater_path = out_bundle_path.with_extension(updater_extension); - let updater_path = root_dir.join(format!( - "target/release/{}", - out_updater_path.file_name().unwrap().to_str().unwrap() - )); - std::fs::rename(&out_updater_path, &updater_path).expect("failed to rename bundle"); - - // start the updater server - let server = Arc::new( - tiny_http::Server::http("localhost:3007").expect("failed to start updater server"), - ); - - let server_ = server.clone(); - std::thread::spawn(move || { - for request in server_.incoming_requests() { - match request.url() { - "/" => { - let platforms = - target_to_platforms(update_platform.clone(), signature.clone()); - - let body = serde_json::to_vec(&Update { - version: "1.0.0".into(), - date: time::OffsetDateTime::now_utc() - .format(&time::format_description::well_known::Rfc3339) - .unwrap(), - platforms, - }) - .unwrap(); - let len = body.len(); - let response = tiny_http::Response::new( - tiny_http::StatusCode(200), - Vec::new(), - std::io::Cursor::new(body), - Some(len), - None, - ); - let _ = request.respond(response); - } - "/download" => { - let _ = request.respond(tiny_http::Response::from_file( - File::open(&updater_path).unwrap_or_else(|_| { - panic!( - "failed to open updater bundle {}", - updater_path.display() - ) - }), - )); - } - _ => (), - } - } - }); - - config.version = "0.1.0"; - - // bundle initial app version; Linux and macOS run the bundle itself, so it cannot be - // skipped there - build_app( - &manifest_dir, - &config, - if cfg!(windows) { - None - } else { - Some(bundle_target) - }, - ); - - let app_path = stage_app_under_test(&root_dir, &target); - - for expected_exit_code in status_checks { - let mut binary_cmd = if cfg!(target_os = "macos") { - Command::new(app_path.join("Contents/MacOS/app-updater")) - } else if cfg!(target_os = "linux") - && std::env::var("CI").map(|v| v == "true").unwrap_or_default() - { - let mut c = Command::new("xvfb-run"); - c.arg("--auto-servernum").arg(&app_path); - c - } else { - Command::new(&app_path) - }; - - binary_cmd.env("TARGET", bundle_target.name()); - - let status = binary_cmd - .status() - .unwrap_or_else(|e| panic!("failed to run {}: {e}", app_path.display())); - let code = status.code().unwrap_or(-1); - - if code != expected_exit_code { - panic!( - "failed to run app bundled as {}, expected exit code {expected_exit_code}, got {code}", bundle_target.name() - ); - } - #[cfg(windows)] - if code == UPDATED_EXIT_CODE { - // wait for the update to finish - std::thread::sleep(std::time::Duration::from_secs(5)); - } - } - - // graceful shutdown - server.unblock(); + remove_package(bundle_target); } } } @@ -536,14 +820,65 @@ fn app_binary(root_dir: &Path) -> PathBuf { }) } -/// Runs the app against the update server, restoring the binary from `pristine` first. +/// How the app under test has to be built for an install to stay contained. /// -/// A case that clears the version check goes on to actually install, and on Linux installing means -/// writing the downloaded bytes over the running executable. Every case therefore has to start -/// from an untouched binary, or the first one that passes leaves the update behind as the app. +/// Windows and Linux install over the executable itself, so a bare `cargo build` is enough there. +/// macOS replaces the whole `.app` the running executable sits in, and for an executable that is +/// not in one the updater falls back to the directory merely holding it — `target/release`, which +/// is also where the bundle being served and the build cache live. So macOS bundles the app and +/// runs it out of a staged copy of that `.app`, which the install is welcome to replace. +#[cfg(target_os = "macos")] +const APP_UNDER_TEST_BUNDLE: Option = Some(BundleTarget::App); +#[cfg(not(target_os = "macos"))] +const APP_UNDER_TEST_BUNDLE: Option = None; + +/// Where `build_app` leaves the app under test. +fn built_app(root_dir: &Path) -> PathBuf { + #[cfg(target_os = "macos")] + return root_dir.join("target/release/bundle/macos/app-updater.app"); + #[cfg(not(target_os = "macos"))] + return app_binary(root_dir); +} + +/// The copy the test runs, kept out of the way of both the bundler and the build cache. +fn staged_app(root_dir: &Path) -> PathBuf { + #[cfg(target_os = "macos")] + return root_dir.join("target/release/app-under-test-signed/app-updater.app"); + #[cfg(not(target_os = "macos"))] + return app_binary(root_dir); +} + +/// The executable to launch inside the staged app. +fn staged_binary(root_dir: &Path) -> PathBuf { + #[cfg(target_os = "macos")] + return staged_app(root_dir).join("Contents/MacOS/app-updater"); + #[cfg(not(target_os = "macos"))] + return staged_app(root_dir); +} + +/// Puts the pristine 0.1.0 app back where the test runs it, replacing whatever an install left. +fn restore_app(root_dir: &Path, pristine: &Path) { + let app = staged_app(root_dir); + let _ = std::fs::remove_dir_all(&app); + let _ = std::fs::remove_file(&app); + std::fs::create_dir_all(app.parent().unwrap()).expect("failed to create the staging directory"); + copy_recursively(pristine, &app).unwrap_or_else(|e| { + panic!( + "failed to restore {} from {}: {e}", + app.display(), + pristine.display() + ) + }); +} + +/// Runs the app against the update server, restoring it from `pristine` first. +/// +/// A case that clears the version check goes on to actually install, and installing means writing +/// over what the app runs from. Every case therefore has to start from an untouched copy, or the +/// first one that passes leaves the update behind as the app. fn run_app(root_dir: &Path, pristine: &Path) -> String { - let binary = app_binary(root_dir); - std::fs::copy(pristine, &binary).expect("failed to restore the app binary"); + restore_app(root_dir, pristine); + let binary = staged_binary(root_dir); let mut command = if cfg!(target_os = "linux") && std::env::var("CI").map(|v| v == "true").unwrap_or_default() @@ -568,6 +903,7 @@ fn run_app(root_dir: &Path, pristine: &Path) -> String { /// reaches the installer while an accepted one does. Both exit non-zero, which is why these assert /// on the error message rather than the exit code. #[test] +#[ignore = "needs the tauri CLI, a display and minutes of build time; the integration tests workflow runs it with --ignored"] fn update_validates_signed_version() { let _lock = BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner()); @@ -584,6 +920,7 @@ fn update_validates_signed_version() { version: "1.0.0", bundle: BundleConfig { create_updater_artifacts: Updater::Bool(true), + linux: None, }, plugins: None, }, @@ -624,57 +961,48 @@ fn update_validates_signed_version() { version: "1.0.0".into(), })); - let server = Arc::new( - tiny_http::Server::http(format!("localhost:{SIGNED_VERSION_PORT}")) - .expect("failed to start updater server"), - ); - - let server_ = server.clone(); let served_ = served.clone(); let target_ = target.clone(); let updater_path_ = updater_path.clone(); - std::thread::spawn(move || { - for request in server_.incoming_requests() { - match request.url() { - "/" => { - let mut platforms = HashMap::new(); - platforms.insert( - target_.clone(), - PlatformUpdate { - // always the genuine 1.0.0 signature; only the version above it moves - signature: signature.clone(), - url: format!("http://localhost:{SIGNED_VERSION_PORT}/download"), - with_elevated_task: false, - }, - ); + // shut down at the end of the test even if a check below panics + let _server = UpdaterServer::spawn(SIGNED_VERSION_PORT, move |request| match request.url() { + "/" => { + let mut platforms = HashMap::new(); + platforms.insert( + target_.clone(), + PlatformUpdate { + // always the genuine 1.0.0 signature; only the version above it moves + signature: signature.clone(), + url: format!("http://localhost:{SIGNED_VERSION_PORT}/download"), + with_elevated_task: false, + }, + ); - let body = serde_json::to_vec(&Update { - version: served_.lock().unwrap().version.clone(), - date: time::OffsetDateTime::now_utc() - .format(&time::format_description::well_known::Rfc3339) - .unwrap(), - platforms, - }) - .unwrap(); - let len = body.len(); - let _ = request.respond(tiny_http::Response::new( - tiny_http::StatusCode(200), - Vec::new(), - std::io::Cursor::new(body), - Some(len), - None, - )); - } - "/download" => { - let _ = request.respond(tiny_http::Response::from_file( - File::open(&updater_path_).unwrap_or_else(|_| { - panic!("failed to open updater bundle {}", updater_path_.display()) - }), - )); - } - _ => (), - } + let body = serde_json::to_vec(&Update { + version: served_.lock().unwrap().version.clone(), + date: time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap(), + platforms, + }) + .unwrap(); + let len = body.len(); + let _ = request.respond(tiny_http::Response::new( + tiny_http::StatusCode(200), + Vec::new(), + std::io::Cursor::new(body), + Some(len), + None, + )); } + "/download" => { + let _ = request.respond(tiny_http::Response::from_file( + File::open(&updater_path_).unwrap_or_else(|_| { + panic!("failed to open updater bundle {}", updater_path_.display()) + }), + )); + } + _ => (), }); // `requireSignedVersion` is baked in by `generate_context!`, so each value needs its own build @@ -686,6 +1014,7 @@ fn update_validates_signed_version() { version: "0.1.0", bundle: BundleConfig { create_updater_artifacts: Updater::Bool(true), + linux: None, }, plugins: Some(serde_json::json!({ "updater": { @@ -694,10 +1023,12 @@ fn update_validates_signed_version() { } })), }, - None, + APP_UNDER_TEST_BUNDLE, ); - std::fs::copy(app_binary(&root_dir), &pristine) - .expect("failed to keep a pristine copy of the app binary"); + let _ = std::fs::remove_dir_all(&pristine); + let _ = std::fs::remove_file(&pristine); + copy_recursively(&built_app(&root_dir), &pristine) + .expect("failed to keep a pristine copy of the app"); }; let check = |announced: &str, expected: Option<&str>, unexpected: Option<&str>| { @@ -730,9 +1061,8 @@ fn update_validates_signed_version() { // a signature that names a version is held to it whether or not the option is on check("1.5.0", Some(MISMATCH_ERROR), None); - server.unblock(); - - // leave a runnable binary behind rather than whichever update was installed last - let _ = std::fs::copy(&pristine, app_binary(&root_dir)); + // leave the 0.1.0 app behind rather than whichever update was installed last + restore_app(&root_dir, &pristine); + let _ = std::fs::remove_dir_all(&pristine); let _ = std::fs::remove_file(&pristine); }