Add show inspection and rollback-safe update UX
This commit is contained in:
parent
27a1b806cd
commit
1ad2f8a532
16 changed files with 2187 additions and 7 deletions
|
|
@ -1,12 +1,18 @@
|
|||
use aim_core::app::add::{BuildAddPlanError, build_add_plan_with};
|
||||
use aim_core::app::query::ResolveQueryError;
|
||||
use aim_core::app::update::execute_updates;
|
||||
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use aim_core::domain::source::SourceKind;
|
||||
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceRef};
|
||||
use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
|
||||
use aim_core::platform::DesktopHelpers;
|
||||
use aim_core::source::github::FixtureGitHubTransport;
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::tempdir;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn integration_failure_removes_new_payload_and_generated_files() {
|
||||
let root = tempdir().unwrap();
|
||||
|
|
@ -69,3 +75,61 @@ fn supported_sourceforge_project_without_latest_download_reports_no_installable_
|
|||
other => panic!("expected no-installable-artifact error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_update_restores_tracked_desktop_and_icon_files() {
|
||||
let _guard = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let root = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
|
||||
std::env::set_var("DISPLAY", ":99");
|
||||
std::env::set_var("XDG_CURRENT_DESKTOP", "test");
|
||||
}
|
||||
|
||||
let payload_path = root.path().join("tracked/team-app.AppImage");
|
||||
let desktop_path = root.path().join("tracked/aim-team-app.desktop");
|
||||
let icon_path = root.path().join("tracked/team-app.png");
|
||||
fs::create_dir_all(payload_path.parent().unwrap()).unwrap();
|
||||
fs::write(&payload_path, b"previous-payload").unwrap();
|
||||
fs::write(&desktop_path, b"previous-desktop").unwrap();
|
||||
fs::write(&icon_path, b"previous-icon").unwrap();
|
||||
|
||||
let blocking_applications_root = root.path().join(".local/share/applications");
|
||||
fs::create_dir_all(blocking_applications_root.parent().unwrap()).unwrap();
|
||||
fs::write(&blocking_applications_root, b"blocker").unwrap();
|
||||
|
||||
let previous = AppRecord {
|
||||
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
|
||||
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::DirectUrl,
|
||||
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
input_kind: SourceInputKind::DirectUrl,
|
||||
normalized_kind: NormalizedSourceKind::DirectUrl,
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
}),
|
||||
installed_version: Some("unresolved".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: Some(payload_path.display().to_string()),
|
||||
desktop_entry_path: Some(desktop_path.display().to_string()),
|
||||
icon_path: Some(icon_path.display().to_string()),
|
||||
}),
|
||||
};
|
||||
|
||||
let result = execute_updates(std::slice::from_ref(&previous), root.path()).unwrap();
|
||||
|
||||
assert_eq!(result.failed_count(), 1);
|
||||
assert_eq!(fs::read(&payload_path).unwrap(), b"previous-payload");
|
||||
assert_eq!(fs::read(&desktop_path).unwrap(), b"previous-desktop");
|
||||
assert_eq!(fs::read(&icon_path).unwrap(), b"previous-icon");
|
||||
}
|
||||
|
|
|
|||
303
crates/aim-core/tests/show_resolution.rs
Normal file
303
crates/aim-core/tests/show_resolution.rs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
use aim_core::app::show::{build_show_result, build_show_result_with};
|
||||
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use aim_core::domain::show::{ShowResult, ShowResultError};
|
||||
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||
use aim_core::domain::update::{
|
||||
ChannelPreference, MetadataHints, ParsedMetadata, ParsedMetadataKind, UpdateChannelKind,
|
||||
UpdateStrategy,
|
||||
};
|
||||
use aim_core::source::github::FixtureGitHubTransport;
|
||||
|
||||
#[test]
|
||||
fn exact_installed_match_returns_installed_details() {
|
||||
let apps = vec![AppRecord {
|
||||
stable_id: "legacy-bat".to_owned(),
|
||||
display_name: "Legacy Bat".to_owned(),
|
||||
source_input: Some("sharkdp/bat".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::GitHub,
|
||||
locator: "https://github.com/sharkdp/bat".to_owned(),
|
||||
input_kind: SourceInputKind::RepoShorthand,
|
||||
normalized_kind: NormalizedSourceKind::GitHubRepository,
|
||||
canonical_locator: Some("sharkdp/bat".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some("0.24.0".to_owned()),
|
||||
update_strategy: Some(UpdateStrategy {
|
||||
preferred: ChannelPreference {
|
||||
kind: UpdateChannelKind::GitHubReleases,
|
||||
locator: "sharkdp/bat".to_owned(),
|
||||
reason: "install-origin-match".to_owned(),
|
||||
},
|
||||
alternates: Vec::new(),
|
||||
}),
|
||||
metadata: vec![ParsedMetadata {
|
||||
kind: ParsedMetadataKind::ElectronBuilder,
|
||||
hints: MetadataHints {
|
||||
version: Some("0.24.0".to_owned()),
|
||||
primary_download: Some("https://example.test/bat.AppImage".to_owned()),
|
||||
checksum: Some("sha256:abcd".to_owned()),
|
||||
architecture: Some("x86_64".to_owned()),
|
||||
channel_label: None,
|
||||
},
|
||||
warnings: Vec::new(),
|
||||
confidence: 90,
|
||||
}],
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: Some("/tmp/bat.AppImage".to_owned()),
|
||||
desktop_entry_path: Some("/tmp/aim-bat.desktop".to_owned()),
|
||||
icon_path: Some("/tmp/aim-bat.png".to_owned()),
|
||||
}),
|
||||
}];
|
||||
|
||||
let result = build_show_result("legacy-bat", &apps).unwrap();
|
||||
|
||||
match result {
|
||||
ShowResult::Installed(installed) => {
|
||||
assert_eq!(installed.stable_id, "legacy-bat");
|
||||
assert_eq!(installed.display_name, "Legacy Bat");
|
||||
assert_eq!(installed.installed_version.as_deref(), Some("0.24.0"));
|
||||
assert_eq!(installed.install_scope, Some(InstallScope::User));
|
||||
assert_eq!(
|
||||
installed.source.as_ref().unwrap().locator,
|
||||
"https://github.com/sharkdp/bat"
|
||||
);
|
||||
assert_eq!(
|
||||
installed.tracked_paths.payload_path.as_deref(),
|
||||
Some("/tmp/bat.AppImage")
|
||||
);
|
||||
assert!(installed.update_strategy.is_some());
|
||||
assert_eq!(installed.metadata.len(), 1);
|
||||
}
|
||||
other => panic!("expected installed result, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_source_lineage_matches_before_remote_fallback() {
|
||||
let apps = vec![AppRecord {
|
||||
stable_id: "legacy-bat".to_owned(),
|
||||
display_name: "Legacy Bat".to_owned(),
|
||||
source_input: Some("sharkdp/bat".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::GitHub,
|
||||
locator: "https://github.com/sharkdp/bat".to_owned(),
|
||||
input_kind: SourceInputKind::RepoShorthand,
|
||||
normalized_kind: NormalizedSourceKind::GitHubRepository,
|
||||
canonical_locator: Some("sharkdp/bat".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some("0.24.0".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}];
|
||||
|
||||
let result = build_show_result_with("sharkdp/bat", &apps, &FixtureGitHubTransport).unwrap();
|
||||
|
||||
match result {
|
||||
ShowResult::Installed(installed) => {
|
||||
assert_eq!(installed.stable_id, "legacy-bat");
|
||||
assert_eq!(installed.source_input.as_deref(), Some("sharkdp/bat"));
|
||||
}
|
||||
other => panic!("expected installed result, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_direct_url_show_omits_unresolved_version() {
|
||||
let apps = vec![AppRecord {
|
||||
stable_id: "team-app".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::DirectUrl,
|
||||
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
input_kind: SourceInputKind::DirectUrl,
|
||||
normalized_kind: NormalizedSourceKind::DirectUrl,
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
}),
|
||||
installed_version: Some("unresolved".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}];
|
||||
|
||||
let result = build_show_result("team-app", &apps).unwrap();
|
||||
|
||||
match result {
|
||||
ShowResult::Installed(installed) => {
|
||||
assert_eq!(installed.installed_version, None);
|
||||
assert_eq!(
|
||||
installed.source.as_ref().unwrap().kind,
|
||||
SourceKind::DirectUrl
|
||||
);
|
||||
}
|
||||
other => panic!("expected installed result, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_installed_match_falls_back_to_remote_resolution() {
|
||||
let result = build_show_result_with("sharkdp/bat", &[], &FixtureGitHubTransport).unwrap();
|
||||
|
||||
match result {
|
||||
ShowResult::Remote(remote) => {
|
||||
assert_eq!(remote.source.kind, SourceKind::GitHub);
|
||||
assert_eq!(
|
||||
remote.source.canonical_locator.as_deref(),
|
||||
Some("sharkdp/bat")
|
||||
);
|
||||
assert!(remote.artifact.url.ends_with("Bat-1.0.0-x86_64.AppImage"));
|
||||
assert_eq!(remote.artifact.version.as_deref(), Some("1.0.0"));
|
||||
assert!(remote.artifact.trusted_checksum.is_some());
|
||||
assert!(!remote.artifact.selection_reason.is_empty());
|
||||
assert!(remote.interactions.is_empty());
|
||||
assert!(remote.warnings.is_empty());
|
||||
}
|
||||
other => panic!("expected remote result, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_show_projects_tracking_preference_interaction() {
|
||||
let result = build_show_result_with(
|
||||
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
|
||||
&[],
|
||||
&FixtureGitHubTransport,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
match result {
|
||||
ShowResult::Remote(remote) => {
|
||||
assert!(remote.interactions.iter().any(|interaction| matches!(
|
||||
interaction,
|
||||
aim_core::domain::show::RemoteInteractionSummary::ChooseTrackingPreference { .. }
|
||||
)));
|
||||
}
|
||||
other => panic!("expected remote result, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_url_remote_show_omits_unresolved_version() {
|
||||
let result = build_show_result_with(
|
||||
"https://example.com/downloads/team-app.AppImage",
|
||||
&[],
|
||||
&FixtureGitHubTransport,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
match result {
|
||||
ShowResult::Remote(remote) => {
|
||||
assert_eq!(remote.source.kind, SourceKind::DirectUrl);
|
||||
assert_eq!(remote.artifact.version, None);
|
||||
assert_eq!(
|
||||
remote.artifact.url,
|
||||
"https://example.com/downloads/team-app.AppImage"
|
||||
);
|
||||
}
|
||||
other => panic!("expected remote result, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_installed_matches_return_dedicated_error() {
|
||||
let apps = vec![
|
||||
AppRecord {
|
||||
stable_id: "bat".to_owned(),
|
||||
display_name: "Bat".to_owned(),
|
||||
source_input: None,
|
||||
source: None,
|
||||
installed_version: None,
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
},
|
||||
AppRecord {
|
||||
stable_id: "legacy-bat".to_owned(),
|
||||
display_name: "Bat".to_owned(),
|
||||
source_input: None,
|
||||
source: None,
|
||||
installed_version: None,
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
},
|
||||
];
|
||||
|
||||
let error = build_show_result("bat", &apps).unwrap_err();
|
||||
|
||||
match error {
|
||||
ShowResultError::AmbiguousInstalledMatch { matches, .. } => {
|
||||
assert_eq!(matches.len(), 2);
|
||||
assert!(matches.iter().any(|item: &String| item.contains("bat")));
|
||||
assert!(
|
||||
matches
|
||||
.iter()
|
||||
.any(|item: &String| item.contains("legacy-bat"))
|
||||
);
|
||||
}
|
||||
other => panic!("expected ambiguous installed match, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_installed_match_blocks_valid_remote_fallback() {
|
||||
let apps = vec![
|
||||
AppRecord {
|
||||
stable_id: "bat-alpha".to_owned(),
|
||||
display_name: "sharkdp/bat".to_owned(),
|
||||
source_input: None,
|
||||
source: None,
|
||||
installed_version: None,
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
},
|
||||
AppRecord {
|
||||
stable_id: "bat-beta".to_owned(),
|
||||
display_name: "sharkdp/bat".to_owned(),
|
||||
source_input: None,
|
||||
source: None,
|
||||
installed_version: None,
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
},
|
||||
];
|
||||
|
||||
let error = build_show_result_with("sharkdp/bat", &apps, &FixtureGitHubTransport).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
ShowResultError::AmbiguousInstalledMatch { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_query_stays_distinct_from_no_installable_artifact() {
|
||||
let unsupported =
|
||||
build_show_result_with("https://gitlab.com/example", &[], &FixtureGitHubTransport)
|
||||
.unwrap_err();
|
||||
let no_artifact = build_show_result_with(
|
||||
"https://sourceforge.net/projects/team-app/",
|
||||
&[],
|
||||
&FixtureGitHubTransport,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(unsupported, ShowResultError::UnsupportedQuery));
|
||||
assert!(matches!(
|
||||
no_artifact,
|
||||
ShowResultError::NoInstallableArtifact { .. }
|
||||
));
|
||||
}
|
||||
|
|
@ -3,8 +3,13 @@ use aim_core::app::update::{build_update_plan, execute_updates, execute_updates_
|
|||
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||
use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
|
||||
use aim_core::integration::paths::managed_appimage_path;
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::tempdir;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn empty_registry_produces_empty_plan() {
|
||||
let plan = build_update_plan(&[]).unwrap();
|
||||
|
|
@ -367,3 +372,112 @@ fn update_execution_uses_stored_sourceforge_releases_root_for_file_like_inputs()
|
|||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_update_restores_previous_payload_contents() {
|
||||
let _guard = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let install_home = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
|
||||
std::env::set_var("DISPLAY", ":99");
|
||||
std::env::set_var("XDG_CURRENT_DESKTOP", "test");
|
||||
}
|
||||
|
||||
let stable_id = "url-example.com-downloads-team-app.appimage";
|
||||
let payload_path = managed_appimage_path(install_home.path(), InstallScope::User, stable_id);
|
||||
fs::create_dir_all(payload_path.parent().unwrap()).unwrap();
|
||||
fs::write(&payload_path, b"previous-payload").unwrap();
|
||||
|
||||
let desktop_root = install_home.path().join(".local/share/applications");
|
||||
fs::create_dir_all(desktop_root.parent().unwrap()).unwrap();
|
||||
fs::write(&desktop_root, b"blocker").unwrap();
|
||||
|
||||
let previous = AppRecord {
|
||||
stable_id: stable_id.to_owned(),
|
||||
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::DirectUrl,
|
||||
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
input_kind: SourceInputKind::DirectUrl,
|
||||
normalized_kind: NormalizedSourceKind::DirectUrl,
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
}),
|
||||
installed_version: Some("unresolved".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: Some(payload_path.display().to_string()),
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
}),
|
||||
};
|
||||
|
||||
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
|
||||
|
||||
assert_eq!(result.failed_count(), 1);
|
||||
assert_eq!(result.apps, vec![previous]);
|
||||
assert_eq!(fs::read(&payload_path).unwrap(), b"previous-payload");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_update_removes_rollback_staging_directory() {
|
||||
let _guard = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let install_home = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
|
||||
std::env::remove_var("DISPLAY");
|
||||
std::env::remove_var("WAYLAND_DISPLAY");
|
||||
std::env::remove_var("XDG_CURRENT_DESKTOP");
|
||||
}
|
||||
|
||||
let stable_id = "url-example.com-downloads-team-app.appimage";
|
||||
let payload_path = managed_appimage_path(install_home.path(), InstallScope::User, stable_id);
|
||||
fs::create_dir_all(payload_path.parent().unwrap()).unwrap();
|
||||
fs::write(&payload_path, b"previous-payload").unwrap();
|
||||
|
||||
let previous = AppRecord {
|
||||
stable_id: stable_id.to_owned(),
|
||||
display_name: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
source_input: Some("https://example.com/downloads/team-app.AppImage".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::DirectUrl,
|
||||
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
input_kind: SourceInputKind::DirectUrl,
|
||||
normalized_kind: NormalizedSourceKind::DirectUrl,
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
}),
|
||||
installed_version: Some("unresolved".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: Some(payload_path.display().to_string()),
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
}),
|
||||
};
|
||||
|
||||
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
|
||||
|
||||
assert_eq!(result.updated_count(), 1);
|
||||
assert!(
|
||||
!install_home
|
||||
.path()
|
||||
.join(".local/share/aim/rollback")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue