use std::fs::{self, File};
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::{Mutex, MutexGuard};
fn serial() -> MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
struct Home {
_dir: tempfile::TempDir,
root: PathBuf,
}
impl Home {
fn new() -> Self {
let dir = tempfile::Builder::new().prefix("appimg-test-").tempdir().unwrap();
let root = dir.path().to_path_buf();
for sub in ["home", "data", "config", "tmp", "downloads"] {
fs::create_dir_all(root.join(sub)).unwrap();
}
Self { _dir: dir, root }
}
fn run(&self, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_appimg"))
.arg("--no-color")
.args(args)
.env("HOME", self.root.join("home"))
.env("XDG_DATA_HOME", self.root.join("data"))
.env("XDG_CONFIG_HOME", self.root.join("config"))
.env("TMPDIR", self.root.join("tmp"))
.env_remove("APPIMG_DIR")
.stdin(Stdio::null())
.output()
.unwrap()
}
fn entry(&self, slug: &str) -> String {
fs::read_to_string(self.root.join(format!("data/applications/{slug}.desktop"))).unwrap()
}
fn appimage(&self, file_name: &str, metainfo_urls: Option<&str>) -> PathBuf {
let dir = self.root.join("downloads");
let payload = dir.join(format!(".payload-{file_name}"));
fs::create_dir_all(&payload).unwrap();
fs::write(
payload.join("fakeapp.desktop"),
"[Desktop Entry]\nType=Application\nName=Fake App\nExec=AppRun\nIcon=fakeapp\n\
Categories=Utility;\n",
)
.unwrap();
if let Some(urls) = metainfo_urls {
let metainfo = payload.join("usr/share/metainfo");
fs::create_dir_all(&metainfo).unwrap();
fs::write(
metainfo.join("org.example.FakeApp.metainfo.xml"),
format!("<?xml version=\"1.0\"?>\n<component>\n {urls}\n</component>\n"),
)
.unwrap();
}
let script = format!(
"#!/bin/sh\nif [ \"$1\" != \"--appimage-extract\" ]; then exit 0; fi\n\
mkdir -p squashfs-root\ncp -R '{}/.' squashfs-root/\n",
payload.display()
);
let path = dir.join(file_name);
let partial = dir.join(format!(".{file_name}.partial"));
let mut file = File::create(&partial).unwrap();
file.write_all(script.as_bytes()).unwrap();
drop(file);
fs::set_permissions(&partial, fs::Permissions::from_mode(0o755)).unwrap();
fs::rename(&partial, &path).unwrap();
path
}
fn install(&self, appimage: &Path, extra: &[&str]) -> Output {
let mut args = vec!["--yes", "install", appimage.to_str().unwrap()];
args.extend_from_slice(extra);
self.run(&args)
}
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
#[test]
fn install_takes_an_update_source_and_refuses_what_is_none() {
let _serial = serial();
let home = Home::new();
let appimage = home.appimage("Fake_App-1.0.0-x86_64.AppImage", None);
let refused = home.install(&appimage, &["--update-source", "fake/app"]);
assert_eq!(refused.status.code(), Some(1), "{}", stderr(&refused));
assert!(
stderr(&refused).contains("\"fake/app\" is not an update source"),
"{}",
stderr(&refused)
);
assert!(!home.root.join("data/applications/fake-app.desktop").exists());
let installed = home.install(&appimage, &["--update-source", "https://github.com/fake/app"]);
assert!(installed.status.success(), "{}", stderr(&installed));
assert!(home.entry("fake-app").contains("\nX-AppImg-UpdateSource=github:fake/app\n"));
assert!(stdout(&installed).contains("updates from github:fake/app"), "{}", stdout(&installed));
}
#[test]
fn the_appstream_suggestion_is_taken_with_yes_and_left_out_on_a_pipe() {
let _serial = serial();
let urls = "<url type=\"vcs-browser\">https://github.com/fake/app</url>";
let home = Home::new();
let appimage = home.appimage("Fake_App-1.0.0.AppImage", Some(urls));
let output = home.install(&appimage, &[]);
assert!(output.status.success(), "{}", stderr(&output));
assert!(
stdout(&output).contains("The AppStream metadata links github:fake/app, so updates come"),
"{}",
stdout(&output)
);
assert!(home.entry("fake-app").contains("\nX-AppImg-UpdateSource=github:fake/app\n"));
let home = Home::new();
let appimage = home.appimage("Fake_App-1.0.0.AppImage", Some(urls));
let output = home.run(&["install", appimage.to_str().unwrap()]);
assert!(output.status.success(), "{}", stderr(&output));
assert!(
stdout(&output).contains("Pass --update-source github:fake/app"),
"{}",
stdout(&output)
);
assert!(home.entry("fake-app").contains("\nX-AppImg-UpdateSource=manual\n"));
let home = Home::new();
let appimage = home.appimage("Fake_App-1.0.0.AppImage", Some(urls));
let output = home.install(&appimage, &["--update-source", "https://example.com/App.AppImage"]);
assert!(output.status.success(), "{}", stderr(&output));
assert!(!stdout(&output).contains("AppStream"), "{}", stdout(&output));
assert!(home.entry("fake-app").contains("UpdateSource=https://example.com/App.AppImage\n"));
}
#[test]
fn update_source_shows_sets_and_clears() {
let _serial = serial();
let home = Home::new();
let appimage = home.appimage("Fake_App-1.0.0.AppImage", None);
assert!(home.install(&appimage, &[]).status.success());
let shown = home.run(&["update-source", "fake-app"]);
assert!(shown.status.success(), "{}", stderr(&shown));
let text = stdout(&shown);
assert!(text.contains(&format!("Origin {}", appimage.display())), "{text}");
assert!(text.contains("Update source manual"), "{text}");
assert!(text.contains("Updates from nothing, it is updated manually"), "{text}");
assert!(text.contains("appimg update-source fake-app <URL|github:owner/repo>"), "{text}");
let set = home.run(&["update-source", "fake-app", "github:fake/app@continuous"]);
assert!(set.status.success(), "{}", stderr(&set));
assert!(stdout(&set).contains("updates from github:fake/app@continuous from now on"));
assert!(home
.entry("fake-app")
.contains("\nX-AppImg-UpdateSource=github:fake/app@continuous\n"));
let shown = stdout(&home.run(&["update-source", "fake-app"]));
assert!(shown.contains("github:fake/app, the release tagged continuous"), "{shown}");
let again = home.run(&["update-source", "fake-app", "github:fake/app@continuous"]);
assert_eq!(again.status.code(), Some(3));
assert!(stdout(&again).contains("Nothing changed."));
let before = home.entry("fake-app");
let refused = home.run(&["update-source", "fake-app", "nonsense"]);
assert_eq!(refused.status.code(), Some(1));
assert!(
stderr(&refused).contains("\"nonsense\" is not an update source"),
"{}",
stderr(&refused)
);
assert_eq!(home.entry("fake-app"), before);
let both = home.run(&["update-source", "fake-app", "github:fake/app", "--clear"]);
assert_eq!(both.status.code(), Some(2));
let cleared = home.run(&["update-source", "fake-app", "--clear"]);
assert!(cleared.status.success(), "{}", stderr(&cleared));
assert!(stdout(&cleared).contains("is updated manually from now on"));
assert!(home.entry("fake-app").contains("\nX-AppImg-UpdateSource=manual\n"));
}
#[test]
fn updating_a_manual_app_says_why_and_what_to_do() {
let _serial = serial();
let home = Home::new();
let appimage = home.appimage("Fake_App-1.0.0.AppImage", None);
assert!(home.install(&appimage, &[]).status.success());
let one = home.run(&["update", "fake-app"]);
assert_eq!(one.status.code(), Some(1));
let message = stderr(&one);
assert!(message.contains("fake-app is updated manually"), "{message}");
assert!(message.contains("appimg update-source fake-app <URL|github:owner/repo>"), "{message}");
assert!(!stdout(&one).contains("up to date"), "{}", stdout(&one));
let all = home.run(&["update", "--all"]);
assert_eq!(all.status.code(), Some(3), "{}", stderr(&all));
let text = stdout(&all);
assert!(text.contains("Fake App is updated manually, skipped."), "{text}");
assert!(text.contains("Everything with an update source is up to date."), "{text}");
let check = stdout(&home.run(&["update", "--all", "--check"]));
assert!(check.contains("manual"), "{check}");
assert!(check.contains("no update source, set one with: appimg update-source fake-app"));
let list = stdout(&home.run(&["list"]));
assert!(list.contains("manual"), "{list}");
let json = stdout(&home.run(&["list", "--json"]));
assert!(json.contains("\"update_source\":\"manual\""), "{json}");
}