refactor: rename aim to upm and extract appimage module
This commit is contained in:
parent
af13e98eb3
commit
863c57e473
117 changed files with 2622 additions and 887 deletions
383
crates/upm-core/tests/adapter_contract.rs
Normal file
383
crates/upm-core/tests/adapter_contract.rs
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
use upm_core::adapters::direct_url::DirectUrlAdapter;
|
||||
use upm_core::adapters::github::GitHubAdapter;
|
||||
use upm_core::adapters::gitlab::GitLabAdapter;
|
||||
use upm_core::adapters::sourceforge::SourceForgeAdapter;
|
||||
use upm_core::adapters::traits::{
|
||||
AdapterCapabilities, AdapterError, AdapterResolution, AdapterResolveOutcome, SourceAdapter,
|
||||
};
|
||||
use upm_core::app::query::resolve_query;
|
||||
use upm_core::domain::source::{
|
||||
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
|
||||
};
|
||||
|
||||
struct FileArtifactAdapter;
|
||||
|
||||
impl SourceAdapter for FileArtifactAdapter {
|
||||
fn id(&self) -> &'static str {
|
||||
"file"
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> AdapterCapabilities {
|
||||
AdapterCapabilities::exact_resolution_only()
|
||||
}
|
||||
|
||||
fn exact_source_kind(&self) -> Option<SourceKind> {
|
||||
Some(SourceKind::File)
|
||||
}
|
||||
|
||||
fn normalize(&self, _query: &str) -> Result<SourceRef, AdapterError> {
|
||||
Err(AdapterError::UnsupportedQuery)
|
||||
}
|
||||
|
||||
fn resolve(&self, source: &SourceRef) -> Result<AdapterResolution, AdapterError> {
|
||||
Ok(AdapterResolution {
|
||||
source: source.clone(),
|
||||
release: ResolvedRelease {
|
||||
version: "file".to_owned(),
|
||||
prerelease: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn file_source() -> SourceRef {
|
||||
SourceRef {
|
||||
kind: SourceKind::File,
|
||||
locator: "/tmp/team-app.AppImage".to_owned(),
|
||||
input_kind: SourceInputKind::File,
|
||||
normalized_kind: NormalizedSourceKind::File,
|
||||
canonical_locator: None,
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_capabilities_can_report_exact_resolution_only() {
|
||||
let capabilities = AdapterCapabilities::exact_resolution_only();
|
||||
assert!(!capabilities.supports_search);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_backed_resolvers_accept_only_their_own_source_kind() {
|
||||
let github_source = resolve_query("sharkdp/bat").unwrap();
|
||||
let gitlab_source = resolve_query("https://gitlab.com/example/team/app").unwrap();
|
||||
|
||||
let github_adapter: &dyn SourceAdapter = &GitHubAdapter;
|
||||
assert!(github_adapter.supports_source(&github_source));
|
||||
assert!(!github_adapter.supports_source(&gitlab_source));
|
||||
assert_eq!(
|
||||
github_adapter.resolve_source(&gitlab_source),
|
||||
Err(AdapterError::UnsupportedSource)
|
||||
);
|
||||
|
||||
let gitlab_adapter: &dyn SourceAdapter = &GitLabAdapter;
|
||||
assert!(gitlab_adapter.supports_source(&gitlab_source));
|
||||
assert!(!gitlab_adapter.supports_source(&github_source));
|
||||
assert_eq!(
|
||||
gitlab_adapter.resolve_source(&github_source),
|
||||
Err(AdapterError::UnsupportedSource)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_resolution_resolvers_accept_only_exact_artifact_kinds() {
|
||||
let direct_url_adapter: &dyn SourceAdapter = &DirectUrlAdapter;
|
||||
let file_adapter: &dyn SourceAdapter = &FileArtifactAdapter;
|
||||
let direct_url_source = resolve_query("https://example.com/team-app.AppImage").unwrap();
|
||||
let github_source = resolve_query("sharkdp/bat").unwrap();
|
||||
let file_source = file_source();
|
||||
|
||||
assert!(direct_url_adapter.supports_source(&direct_url_source));
|
||||
assert!(!direct_url_adapter.supports_source(&file_source));
|
||||
assert!(!direct_url_adapter.supports_source(&github_source));
|
||||
assert_eq!(
|
||||
direct_url_adapter.resolve_source(&github_source),
|
||||
Err(AdapterError::UnsupportedSource)
|
||||
);
|
||||
assert_eq!(
|
||||
direct_url_adapter.resolve_source(&file_source),
|
||||
Err(AdapterError::UnsupportedSource)
|
||||
);
|
||||
|
||||
let direct_resolution = direct_url_adapter
|
||||
.resolve_source(&direct_url_source)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
direct_resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
release: ResolvedRelease { version, .. },
|
||||
..
|
||||
}) if version == "unresolved"
|
||||
));
|
||||
|
||||
assert!(file_adapter.supports_source(&file_source));
|
||||
assert!(!file_adapter.supports_source(&direct_url_source));
|
||||
assert!(!file_adapter.supports_source(&github_source));
|
||||
assert_eq!(
|
||||
file_adapter.resolve_source(&direct_url_source),
|
||||
Err(AdapterError::UnsupportedSource)
|
||||
);
|
||||
|
||||
let file_resolution = file_adapter.resolve_source(&file_source).unwrap();
|
||||
assert!(matches!(
|
||||
file_resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
source,
|
||||
release: ResolvedRelease { version, .. },
|
||||
}) if source.kind == SourceKind::File && version == "file"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolvers_can_return_no_installable_artifact_without_looking_unsupported() {
|
||||
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
|
||||
let source = resolve_query("https://sourceforge.net/projects/team-app/").unwrap();
|
||||
|
||||
let resolution = adapter.resolve_source(&source).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::NoInstallableArtifact { source }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_installable_artifact_outcomes_still_reject_unsupported_source_kinds() {
|
||||
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
|
||||
let unsupported_source = resolve_query("sharkdp/bat").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
adapter.resolve_source(&unsupported_source),
|
||||
Err(AdapterError::UnsupportedSource)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_latest_download_sources_resolve_through_trait() {
|
||||
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
|
||||
|
||||
let result = adapter
|
||||
.normalize("https://sourceforge.net/projects/team-app/files/latest/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.kind, SourceKind::SourceForge);
|
||||
|
||||
let resolution = adapter.resolve_source(&result).unwrap();
|
||||
assert!(matches!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
source,
|
||||
release: ResolvedRelease { version, .. },
|
||||
}) if source.kind == SourceKind::SourceForge
|
||||
&& source.locator == "https://sourceforge.net/projects/team-app/files/latest/download"
|
||||
&& version == "latest"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlab_candidate_sources_can_resolve_to_repository_semantics() {
|
||||
let adapter: &dyn SourceAdapter = &GitLabAdapter;
|
||||
|
||||
let result = adapter
|
||||
.normalize("https://gitlab.com/acme/platform/releases/team-app")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.kind, SourceKind::GitLab);
|
||||
assert_eq!(
|
||||
result.normalized_kind,
|
||||
NormalizedSourceKind::GitLabCandidate
|
||||
);
|
||||
|
||||
let resolution = adapter.resolve_source(&result).unwrap();
|
||||
assert!(matches!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
source,
|
||||
release: ResolvedRelease { version, .. },
|
||||
}) if source.kind == SourceKind::GitLab
|
||||
&& source.locator == "https://gitlab.com/acme/platform/releases/team-app"
|
||||
&& source.canonical_locator.as_deref() == Some("acme/platform/releases/team-app")
|
||||
&& source.normalized_kind == NormalizedSourceKind::GitLab
|
||||
&& source.tracks_latest
|
||||
&& version == "latest"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_candidate_sources_can_resolve_to_latest_download() {
|
||||
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
|
||||
|
||||
let result = adapter
|
||||
.normalize("https://sourceforge.net/projects/team-app/files/releases/stable/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.kind, SourceKind::SourceForge);
|
||||
assert_eq!(
|
||||
result.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
|
||||
let resolution = adapter.resolve_source(&result).unwrap();
|
||||
assert!(matches!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
source,
|
||||
release: ResolvedRelease { version, .. },
|
||||
}) if source.kind == SourceKind::SourceForge
|
||||
&& source.locator
|
||||
== "https://sourceforge.net/projects/team-app/files/releases/stable/download"
|
||||
&& version == "latest"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_version_folder_candidates_can_resolve_to_latest_download() {
|
||||
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
|
||||
|
||||
let result = adapter
|
||||
.normalize("https://sourceforge.net/projects/team-app/files/releases/v1-0/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.kind, SourceKind::SourceForge);
|
||||
assert_eq!(
|
||||
result.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
|
||||
let resolution = adapter.resolve_source(&result).unwrap();
|
||||
assert!(matches!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
source,
|
||||
release: ResolvedRelease { version, .. },
|
||||
}) if source.kind == SourceKind::SourceForge
|
||||
&& source.locator
|
||||
== "https://sourceforge.net/projects/team-app/files/releases/v1-0/download"
|
||||
&& source.normalized_kind == NormalizedSourceKind::SourceForge
|
||||
&& source.tracks_latest
|
||||
&& version == "latest"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_prerelease_folder_candidates_can_resolve_to_latest_download() {
|
||||
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
|
||||
|
||||
let result = adapter
|
||||
.normalize("https://sourceforge.net/projects/team-app/files/releases/beta/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.kind, SourceKind::SourceForge);
|
||||
assert_eq!(
|
||||
result.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
|
||||
let resolution = adapter.resolve_source(&result).unwrap();
|
||||
assert!(matches!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
source,
|
||||
release: ResolvedRelease { version, .. },
|
||||
}) if source.kind == SourceKind::SourceForge
|
||||
&& source.locator
|
||||
== "https://sourceforge.net/projects/team-app/files/releases/beta/download"
|
||||
&& source.normalized_kind == NormalizedSourceKind::SourceForge
|
||||
&& source.tracks_latest
|
||||
&& version == "latest"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_dotted_release_folder_candidates_can_resolve_to_latest_download() {
|
||||
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
|
||||
|
||||
let result = adapter
|
||||
.normalize("https://sourceforge.net/projects/team-app/files/releases/2026.03/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.kind, SourceKind::SourceForge);
|
||||
assert_eq!(
|
||||
result.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
|
||||
let resolution = adapter.resolve_source(&result).unwrap();
|
||||
assert!(matches!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
source,
|
||||
release: ResolvedRelease { version, .. },
|
||||
}) if source.kind == SourceKind::SourceForge
|
||||
&& source.locator
|
||||
== "https://sourceforge.net/projects/team-app/files/releases/2026.03/download"
|
||||
&& source.normalized_kind == NormalizedSourceKind::SourceForge
|
||||
&& source.tracks_latest
|
||||
&& version == "latest"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_file_like_release_candidates_resolve_to_releases_root() {
|
||||
let adapter: &dyn SourceAdapter = &SourceForgeAdapter;
|
||||
|
||||
let result = adapter
|
||||
.normalize(
|
||||
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.kind, SourceKind::SourceForge);
|
||||
assert_eq!(
|
||||
result.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
assert_eq!(
|
||||
result.requested_asset_name.as_deref(),
|
||||
Some("team-app-1.0.0.AppImage")
|
||||
);
|
||||
|
||||
let resolution = adapter.resolve_source(&result).unwrap();
|
||||
assert!(matches!(
|
||||
resolution,
|
||||
AdapterResolveOutcome::Resolved(AdapterResolution {
|
||||
source,
|
||||
release: ResolvedRelease { version, .. },
|
||||
}) if source.kind == SourceKind::SourceForge
|
||||
&& source.locator
|
||||
== "https://sourceforge.net/projects/team-app/files/releases"
|
||||
&& source.normalized_kind == NormalizedSourceKind::SourceForge
|
||||
&& source.tracks_latest
|
||||
&& source.requested_asset_name.as_deref() == Some("team-app-1.0.0.AppImage")
|
||||
&& version == "latest"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_github_adapter_delegates_to_source_pipeline() {
|
||||
let adapter: &dyn SourceAdapter = &GitHubAdapter;
|
||||
|
||||
let result = adapter.normalize("sharkdp/bat").unwrap();
|
||||
|
||||
assert_eq!(result.normalized_kind.as_str(), "github-repository");
|
||||
assert_eq!(result.canonical_locator.as_deref(), Some("sharkdp/bat"));
|
||||
|
||||
let resolution = adapter.resolve(&result).unwrap();
|
||||
assert_eq!(resolution.release.version, "latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlab_adapter_normalizes_and_resolves_through_trait() {
|
||||
let adapter: &dyn SourceAdapter = &GitLabAdapter;
|
||||
|
||||
let result = adapter
|
||||
.normalize("https://gitlab.com/example/team/app")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.kind.as_str(), "gitlab");
|
||||
|
||||
let resolution = adapter.resolve(&result).unwrap();
|
||||
assert_eq!(resolution.release.version, "latest");
|
||||
}
|
||||
14
crates/upm-core/tests/adapter_smoke.rs
Normal file
14
crates/upm-core/tests/adapter_smoke.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
use upm_core::adapters::all_adapter_kinds;
|
||||
|
||||
#[test]
|
||||
fn all_expected_adapter_kinds_are_registered() {
|
||||
let kinds = all_adapter_kinds();
|
||||
|
||||
assert!(kinds.contains(&"github"));
|
||||
assert!(kinds.contains(&"gitlab"));
|
||||
assert!(kinds.contains(&"direct-url"));
|
||||
assert!(kinds.contains(&"zsync"));
|
||||
assert!(kinds.contains(&"sourceforge"));
|
||||
assert!(!kinds.contains(&"appimagehub"));
|
||||
assert!(!kinds.contains(&"custom-json"));
|
||||
}
|
||||
139
crates/upm-core/tests/checksum_verification.rs
Normal file
139
crates/upm-core/tests/checksum_verification.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use std::fs;
|
||||
|
||||
use tempfile::tempdir;
|
||||
use upm_core::integration::install::{InstallRequest, PayloadInstallError, execute_install};
|
||||
use upm_core::platform::DesktopHelpers;
|
||||
|
||||
const VALID_FIXTURE_SHA512: &str =
|
||||
"ZZma4ZD+9XB4GGTHCNZu8I92OY02YrEvIG89ZtRNi99W8SZKwWkmGZz/QyNBxqAt0XeiKtcR80/dMnKlwpcIWw==";
|
||||
|
||||
#[test]
|
||||
fn install_succeeds_with_valid_trusted_checksum() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = write_staged_payload(
|
||||
root.path(),
|
||||
b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
|
||||
);
|
||||
let final_payload_path = root.path().join("payloads/bat.AppImage");
|
||||
|
||||
let outcome = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: Some(VALID_FIXTURE_SHA512),
|
||||
weak_checksum_md5: None,
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.final_payload_path, final_payload_path);
|
||||
assert!(outcome.final_payload_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_succeeds_without_trusted_checksum() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
|
||||
let final_payload_path = root.path().join("payloads/bat.AppImage");
|
||||
|
||||
let outcome = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(outcome.final_payload_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_fails_before_commit_when_trusted_checksum_mismatches() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
|
||||
let final_payload_path = root.path().join("payloads/bat.AppImage");
|
||||
|
||||
let error = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: Some(VALID_FIXTURE_SHA512),
|
||||
weak_checksum_md5: None,
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, PayloadInstallError::ChecksumMismatch));
|
||||
assert!(!final_payload_path.exists());
|
||||
assert!(!staged_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_trusted_checksum_fails_before_commit() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
|
||||
let final_payload_path = root.path().join("payloads/bat.AppImage");
|
||||
|
||||
let error = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: Some("not-base64"),
|
||||
weak_checksum_md5: None,
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, PayloadInstallError::InvalidTrustedChecksum));
|
||||
assert!(!final_payload_path.exists());
|
||||
assert!(!staged_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_succeeds_with_valid_weak_md5_checksum() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
|
||||
let final_payload_path = root.path().join("payloads/bat.AppImage");
|
||||
|
||||
let outcome = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: Some("474a0eb1bbe0a6e62715ce83922a5bf7"),
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(outcome.final_payload_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_fails_before_commit_when_weak_md5_checksum_mismatches() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
|
||||
let final_payload_path = root.path().join("payloads/bat.AppImage");
|
||||
|
||||
let error = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: Some("00000000000000000000000000000000"),
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, PayloadInstallError::WeakChecksumMismatch));
|
||||
assert!(!final_payload_path.exists());
|
||||
assert!(!staged_path.exists());
|
||||
}
|
||||
|
||||
fn write_staged_payload(root: &std::path::Path, bytes: &[u8]) -> std::path::PathBuf {
|
||||
let staged_path = root.join("staging/bat.download");
|
||||
fs::create_dir_all(staged_path.parent().unwrap()).unwrap();
|
||||
fs::write(&staged_path, bytes).unwrap();
|
||||
staged_path
|
||||
}
|
||||
181
crates/upm-core/tests/download_pipeline.rs
Normal file
181
crates/upm-core/tests/download_pipeline.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
use std::fs;
|
||||
use std::io::{self, Cursor, Read};
|
||||
use std::time::Duration;
|
||||
|
||||
use tempfile::tempdir;
|
||||
use upm_core::app::add::{
|
||||
InstallAppError, download_to_staged_path_with_retries,
|
||||
stream_payload_to_staged_file_with_reporter,
|
||||
};
|
||||
use upm_core::app::progress::{NoopReporter, OperationEvent};
|
||||
use upm_core::integration::install::{InstallRequest, execute_install};
|
||||
use upm_core::platform::DesktopHelpers;
|
||||
use upm_core::source::github::HttpClientPolicy;
|
||||
|
||||
#[test]
|
||||
fn payload_streaming_writes_staged_file_and_reports_progress() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = root.path().join("staging/bat.download");
|
||||
let bytes = b"\x7fELFAppImage";
|
||||
let mut reader = Cursor::new(bytes.as_slice());
|
||||
let mut events = Vec::new();
|
||||
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
||||
|
||||
let written = stream_payload_to_staged_file_with_reporter(
|
||||
&mut reader,
|
||||
Some(bytes.len() as u64),
|
||||
&staged_path,
|
||||
&mut reporter,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(written, bytes.len() as u64);
|
||||
assert_eq!(
|
||||
fs::metadata(&staged_path).unwrap().len(),
|
||||
bytes.len() as u64
|
||||
);
|
||||
assert!(events.iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
OperationEvent::Progress {
|
||||
current,
|
||||
total: Some(total)
|
||||
} if *current == bytes.len() as u64 && *total == bytes.len() as u64
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_commits_from_staged_payload_path() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = root.path().join("staging/bat.download");
|
||||
let final_payload_path = root.path().join("payloads/bat.AppImage");
|
||||
fs::create_dir_all(staged_path.parent().unwrap()).unwrap();
|
||||
fs::write(&staged_path, b"\x7fELFAppImage").unwrap();
|
||||
|
||||
let outcome = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.final_payload_path, final_payload_path);
|
||||
assert!(outcome.final_payload_path.exists());
|
||||
assert!(!staged_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_streaming_download_removes_partial_staged_payload() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = root.path().join("staging/bat.download");
|
||||
let mut reader = FailingReader::new(b"\x7fELFpartial".to_vec(), 4);
|
||||
let mut reporter = NoopReporter;
|
||||
|
||||
let result = stream_payload_to_staged_file_with_reporter(
|
||||
&mut reader,
|
||||
Some(12),
|
||||
&staged_path,
|
||||
&mut reporter,
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(!staged_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_policy_retries_transient_failures_before_success() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = root.path().join("staging/bat.download");
|
||||
let bytes = b"\x7fELFAppImage";
|
||||
let mut attempts = 0;
|
||||
|
||||
let written = download_to_staged_path_with_retries(
|
||||
&staged_path,
|
||||
&mut NoopReporter,
|
||||
HttpClientPolicy {
|
||||
timeout: Duration::from_secs(30),
|
||||
max_retries: 3,
|
||||
},
|
||||
|| {
|
||||
attempts += 1;
|
||||
if attempts == 1 {
|
||||
return Err(InstallAppError::DownloadIo(io::Error::other(
|
||||
"transient failure",
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((
|
||||
Box::new(Cursor::new(bytes.to_vec())) as Box<dyn Read>,
|
||||
Some(bytes.len() as u64),
|
||||
))
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(attempts, 2);
|
||||
assert_eq!(written, bytes.len() as u64);
|
||||
assert!(staged_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_exhaustion_returns_error_and_cleans_staged_payload() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = root.path().join("staging/bat.download");
|
||||
let mut attempts = 0;
|
||||
|
||||
let result = download_to_staged_path_with_retries(
|
||||
&staged_path,
|
||||
&mut NoopReporter,
|
||||
HttpClientPolicy {
|
||||
timeout: Duration::from_secs(30),
|
||||
max_retries: 2,
|
||||
},
|
||||
|| {
|
||||
attempts += 1;
|
||||
Ok((
|
||||
Box::new(FailingReader::new(b"\x7fELFpartial".to_vec(), 4)) as Box<dyn Read>,
|
||||
Some(12),
|
||||
))
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(attempts, 2);
|
||||
assert!(!staged_path.exists());
|
||||
}
|
||||
|
||||
struct FailingReader {
|
||||
bytes: Vec<u8>,
|
||||
chunk_size: usize,
|
||||
position: usize,
|
||||
}
|
||||
|
||||
impl FailingReader {
|
||||
fn new(bytes: Vec<u8>, chunk_size: usize) -> Self {
|
||||
Self {
|
||||
bytes,
|
||||
chunk_size,
|
||||
position: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for FailingReader {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||
if self.position >= self.chunk_size {
|
||||
return Err(io::Error::other("fixture read failure"));
|
||||
}
|
||||
|
||||
let remaining = self.chunk_size - self.position;
|
||||
let to_read = remaining
|
||||
.min(buffer.len())
|
||||
.min(self.bytes.len() - self.position);
|
||||
buffer[..to_read].copy_from_slice(&self.bytes[self.position..self.position + to_read]);
|
||||
self.position += to_read;
|
||||
Ok(to_read)
|
||||
}
|
||||
}
|
||||
3
crates/upm-core/tests/fixtures/example.zsync
vendored
Normal file
3
crates/upm-core/tests/fixtures/example.zsync
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
zsync: 0.6.2
|
||||
Filename: T3-Code-0.0.11-x86_64.AppImage
|
||||
URL: https://example.test/T3-Code-0.0.11-x86_64.AppImage
|
||||
3
crates/upm-core/tests/fixtures/latest-linux.yml
vendored
Normal file
3
crates/upm-core/tests/fixtures/latest-linux.yml
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version: 0.0.11
|
||||
path: T3-Code-0.0.11-x86_64.AppImage
|
||||
sha512: example-sha
|
||||
88
crates/upm-core/tests/github_add_flow.rs
Normal file
88
crates/upm-core/tests/github_add_flow.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
use upm_core::app::add::{build_add_plan_with, materialize_app_record, prefer_latest_tracking};
|
||||
use upm_core::app::query::resolve_query;
|
||||
use upm_core::source::github::FixtureGitHubTransport;
|
||||
|
||||
#[test]
|
||||
fn github_adapter_can_normalize_owner_repo_source() {
|
||||
let source = resolve_query("sharkdp/bat").unwrap();
|
||||
|
||||
assert_eq!(source.kind.as_str(), "github");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_flow_builds_github_plan_from_owner_repo_query() {
|
||||
let plan = build_add_plan_with("sharkdp/bat", &FixtureGitHubTransport).unwrap();
|
||||
|
||||
assert_eq!(plan.resolution.source.kind.as_str(), "github");
|
||||
assert_eq!(plan.resolution.source.locator, "sharkdp/bat");
|
||||
assert_eq!(plan.selected_artifact.selection_reason, "metadata-guided");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_plan_prefers_metadata_guided_appimage_when_available() {
|
||||
let plan = build_add_plan_with("pingdotgg/t3code", &FixtureGitHubTransport).unwrap();
|
||||
|
||||
assert_eq!(plan.selected_artifact.selection_reason, "metadata-guided");
|
||||
assert_eq!(
|
||||
plan.update_strategy.preferred.kind.as_str(),
|
||||
"electron-builder"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_old_release_url_requests_tracking_choice_prompt() {
|
||||
let plan = build_add_plan_with(
|
||||
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
|
||||
&FixtureGitHubTransport,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
plan.interactions
|
||||
.iter()
|
||||
.any(|item| item.key == "tracking-preference")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialized_record_preserves_source_and_strategy() {
|
||||
let query = "sharkdp/bat";
|
||||
let plan = build_add_plan_with(query, &FixtureGitHubTransport).unwrap();
|
||||
|
||||
let record = materialize_app_record(query, &plan).unwrap();
|
||||
|
||||
assert_eq!(record.stable_id, "sharkdp-bat");
|
||||
assert_eq!(record.display_name, "bat");
|
||||
assert_eq!(record.source_input.as_deref(), Some(query));
|
||||
assert_eq!(record.installed_version.as_deref(), Some("1.0.0"));
|
||||
assert_eq!(
|
||||
record
|
||||
.update_strategy
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.preferred
|
||||
.kind
|
||||
.as_str(),
|
||||
"electron-builder"
|
||||
);
|
||||
assert_eq!(record.source.as_ref().unwrap().locator, query);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_tracking_choice_promotes_non_direct_update_channel() {
|
||||
let plan = build_add_plan_with(
|
||||
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
|
||||
&FixtureGitHubTransport,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = prefer_latest_tracking(plan);
|
||||
|
||||
assert!(resolved.interactions.is_empty());
|
||||
assert_eq!(resolved.resolution.source.locator, "pingdotgg/t3code");
|
||||
assert!(resolved.resolution.source.tracks_latest);
|
||||
assert_ne!(
|
||||
resolved.update_strategy.preferred.kind.as_str(),
|
||||
"direct-asset-lineage"
|
||||
);
|
||||
}
|
||||
44
crates/upm-core/tests/github_source_discovery.rs
Normal file
44
crates/upm-core/tests/github_source_discovery.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use std::time::Duration;
|
||||
use upm_core::app::query::resolve_query;
|
||||
use upm_core::source::github::{
|
||||
FixtureGitHubTransport, discover_github_candidates_with, http_client_policy,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn discovery_reports_appimage_assets_and_latest_linux_yml() {
|
||||
let source = resolve_query("pingdotgg/t3code").unwrap();
|
||||
let discovery = discover_github_candidates_with(&source, &FixtureGitHubTransport).unwrap();
|
||||
|
||||
assert!(
|
||||
discovery
|
||||
.assets
|
||||
.iter()
|
||||
.any(|asset| asset.name.ends_with(".AppImage"))
|
||||
);
|
||||
assert!(
|
||||
discovery
|
||||
.metadata_documents
|
||||
.iter()
|
||||
.any(|doc| doc.url.ends_with("latest-linux.yml"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_marks_explicit_older_release_against_latest_fixture_release() {
|
||||
let source = resolve_query(
|
||||
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
|
||||
)
|
||||
.unwrap();
|
||||
let discovery = discover_github_candidates_with(&source, &FixtureGitHubTransport).unwrap();
|
||||
|
||||
assert_eq!(discovery.releases[0].tag, "v0.0.12");
|
||||
assert!(discovery.requested_is_older_release);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_http_policy_uses_explicit_timeout_and_retry_defaults() {
|
||||
let policy = http_client_policy();
|
||||
|
||||
assert_eq!(policy.timeout, Duration::from_secs(30));
|
||||
assert_eq!(policy.max_retries, 3);
|
||||
}
|
||||
47
crates/upm-core/tests/identity_resolution.rs
Normal file
47
crates/upm-core/tests/identity_resolution.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use upm_core::app::identity::{IdentityFallback, resolve_identity};
|
||||
use upm_core::domain::app::IdentityConfidence;
|
||||
|
||||
#[test]
|
||||
fn unresolved_identity_can_fall_back_to_url() {
|
||||
let identity = resolve_identity(
|
||||
None,
|
||||
None,
|
||||
Some("https://example.com/app.AppImage"),
|
||||
IdentityFallback::AllowRawUrl,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(identity.stable_id.contains("example.com"));
|
||||
assert_eq!(identity.confidence, IdentityConfidence::RawUrlFallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_id_is_treated_as_confident() {
|
||||
let identity = resolve_identity(
|
||||
Some("Bat"),
|
||||
Some("sharkdp/bat"),
|
||||
Some("https://github.com/sharkdp/bat/releases"),
|
||||
IdentityFallback::AllowRawUrl,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(identity.stable_id, "sharkdp-bat");
|
||||
assert_eq!(identity.display_name, "Bat");
|
||||
assert_eq!(identity.confidence, IdentityConfidence::Confident);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifiers_containing_dot_dot_are_rejected() {
|
||||
let error = resolve_identity(
|
||||
Some("Bat"),
|
||||
Some(".."),
|
||||
Some("https://example.com/app.AppImage"),
|
||||
IdentityFallback::AllowRawUrl,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
upm_core::app::identity::ResolveIdentityError::InvalidStableId
|
||||
);
|
||||
}
|
||||
136
crates/upm-core/tests/install_failures.rs
Normal file
136
crates/upm-core/tests/install_failures.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::tempdir;
|
||||
use upm_core::app::add::{BuildAddPlanError, build_add_plan_with};
|
||||
use upm_core::app::query::ResolveQueryError;
|
||||
use upm_core::app::update::execute_updates;
|
||||
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use upm_core::domain::source::SourceKind;
|
||||
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceRef};
|
||||
use upm_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
|
||||
use upm_core::platform::DesktopHelpers;
|
||||
use upm_core::source::github::FixtureGitHubTransport;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn integration_failure_removes_new_payload_and_generated_files() {
|
||||
let root = tempdir().unwrap();
|
||||
let staging_root = root.path().join("staging");
|
||||
let payload_root = root.path().join("payloads");
|
||||
let blocking_path = root.path().join("not-a-directory");
|
||||
|
||||
fs::create_dir(&staging_root).unwrap();
|
||||
fs::create_dir(&payload_root).unwrap();
|
||||
fs::write(&blocking_path, "blocker").unwrap();
|
||||
let staged_path = staging_root.join("bat.download");
|
||||
fs::write(&staged_path, b"\x7fELFAppImage").unwrap();
|
||||
|
||||
let final_payload_path = payload_root.join("bat.AppImage");
|
||||
let desktop_entry_path = blocking_path.join("upm-bat.desktop");
|
||||
let error = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
desktop: Some(DesktopIntegrationRequest {
|
||||
desktop_entry_path: &desktop_entry_path,
|
||||
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
|
||||
icon_path: None,
|
||||
icon_bytes: None,
|
||||
}),
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("desktop integration failed"));
|
||||
assert!(!final_payload_path.exists());
|
||||
assert!(!desktop_entry_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_queries_remain_distinct_from_provider_resolution_failures() {
|
||||
let error =
|
||||
build_add_plan_with("https://gitlab.com/example", &FixtureGitHubTransport).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
BuildAddPlanError::Query(ResolveQueryError::Unsupported)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_sourceforge_project_without_latest_download_reports_no_installable_artifact() {
|
||||
let error = build_add_plan_with(
|
||||
"https://sourceforge.net/projects/team-app/",
|
||||
&FixtureGitHubTransport,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
match error {
|
||||
BuildAddPlanError::NoInstallableArtifact { source } => {
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.locator, "https://sourceforge.net/projects/team-app/");
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
|
||||
}
|
||||
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("UPM_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/upm-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");
|
||||
}
|
||||
611
crates/upm-core/tests/install_integration.rs
Normal file
611
crates/upm-core/tests/install_integration.rs
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::tempdir;
|
||||
use upm_core::app::add::{build_add_plan_with_reporter, install_app_with_reporter};
|
||||
use upm_core::app::progress::{OperationEvent, OperationStage};
|
||||
use upm_core::domain::app::InstallScope;
|
||||
use upm_core::domain::source::{NormalizedSourceKind, SourceKind};
|
||||
use upm_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
|
||||
use upm_core::platform::DesktopHelpers;
|
||||
use upm_core::source::github::FixtureGitHubTransport;
|
||||
|
||||
fn write_staged_payload(root: &std::path::Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
|
||||
let staged_path = root.join("staging").join(format!("{name}.download"));
|
||||
fs::create_dir_all(staged_path.parent().unwrap()).unwrap();
|
||||
fs::write(&staged_path, bytes).unwrap();
|
||||
staged_path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_writes_desktop_entry_and_reports_refresh_warning_only() {
|
||||
let root = tempdir().unwrap();
|
||||
let payload_root = root.path().join("payloads");
|
||||
let desktop_root = root.path().join("applications");
|
||||
|
||||
fs::create_dir(&payload_root).unwrap();
|
||||
fs::create_dir(&desktop_root).unwrap();
|
||||
let staged_path = write_staged_payload(root.path(), "bat", b"\x7fELFAppImage");
|
||||
|
||||
let outcome = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &payload_root.join("bat.AppImage"),
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
desktop: Some(DesktopIntegrationRequest {
|
||||
desktop_entry_path: &desktop_root.join("upm-bat.desktop"),
|
||||
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
|
||||
icon_path: None,
|
||||
icon_bytes: None,
|
||||
}),
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(outcome.desktop_entry_path.unwrap().exists());
|
||||
assert!(!outcome.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_executes_refresh_helpers_when_available() {
|
||||
let root = tempdir().unwrap();
|
||||
let payload_root = root.path().join("payloads");
|
||||
let desktop_root = root.path().join("applications");
|
||||
let helper_root = root.path().join("helpers");
|
||||
let log_path = root.path().join("helpers.log");
|
||||
|
||||
fs::create_dir(&payload_root).unwrap();
|
||||
fs::create_dir(&desktop_root).unwrap();
|
||||
fs::create_dir(&helper_root).unwrap();
|
||||
let staged_path = write_staged_payload(
|
||||
root.path(),
|
||||
"bat",
|
||||
b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
|
||||
);
|
||||
|
||||
let update_helper = helper_root.join("update-desktop-database");
|
||||
let icon_helper = helper_root.join("gtk-update-icon-cache");
|
||||
fs::write(
|
||||
&update_helper,
|
||||
format!("#!/bin/sh\necho desktop:$1 >> {}\n", log_path.display()),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
&icon_helper,
|
||||
format!("#!/bin/sh\necho icon:$3 >> {}\n", log_path.display()),
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&update_helper, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::set_permissions(&icon_helper, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let icon_root = root.path().join("icons/hicolor/256x256/apps");
|
||||
fs::create_dir_all(&icon_root).unwrap();
|
||||
|
||||
let outcome = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &payload_root.join("bat.AppImage"),
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
desktop: Some(DesktopIntegrationRequest {
|
||||
desktop_entry_path: &desktop_root.join("upm-bat.desktop"),
|
||||
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
|
||||
icon_path: Some(&icon_root.join("bat.png")),
|
||||
icon_bytes: None,
|
||||
}),
|
||||
helpers: DesktopHelpers {
|
||||
update_desktop_database: true,
|
||||
gtk_update_icon_cache: true,
|
||||
update_desktop_database_path: Some(update_helper),
|
||||
gtk_update_icon_cache_path: Some(icon_helper),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(outcome.warnings.is_empty());
|
||||
let log = fs::read_to_string(&log_path).unwrap();
|
||||
assert!(log.contains("desktop:"));
|
||||
assert!(log.contains("icon:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_extracts_icon_from_appimage_payload_when_icon_path_is_requested() {
|
||||
let root = tempdir().unwrap();
|
||||
let payload_root = root.path().join("payloads");
|
||||
let desktop_root = root.path().join("applications");
|
||||
let icon_root = root.path().join("icons/hicolor/256x256/apps");
|
||||
|
||||
fs::create_dir(&payload_root).unwrap();
|
||||
fs::create_dir(&desktop_root).unwrap();
|
||||
fs::create_dir_all(&icon_root).unwrap();
|
||||
let staged_path = write_staged_payload(
|
||||
root.path(),
|
||||
"bat",
|
||||
b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
|
||||
);
|
||||
|
||||
let outcome = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &payload_root.join("bat.AppImage"),
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: None,
|
||||
desktop: Some(DesktopIntegrationRequest {
|
||||
desktop_entry_path: &desktop_root.join("upm-bat.desktop"),
|
||||
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
|
||||
icon_path: Some(&icon_root.join("bat.png")),
|
||||
icon_bytes: None,
|
||||
}),
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let icon_path = outcome.icon_path.unwrap();
|
||||
assert!(icon_path.exists());
|
||||
assert!(
|
||||
fs::read(&icon_path)
|
||||
.unwrap()
|
||||
.starts_with(b"\x89PNG\r\n\x1a\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_app_reports_operation_stages_in_order() {
|
||||
let root = tempdir().unwrap();
|
||||
let mut events: Vec<OperationEvent> = Vec::new();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
||||
|
||||
let plan = build_add_plan_with_reporter("sharkdp/bat", &FixtureGitHubTransport, &mut reporter)
|
||||
.unwrap();
|
||||
|
||||
let installed = install_app_with_reporter(
|
||||
"sharkdp/bat",
|
||||
&plan,
|
||||
root.path(),
|
||||
InstallScope::User,
|
||||
&mut reporter,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(installed.record.stable_id, "sharkdp-bat");
|
||||
assert!(events.contains(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::ResolveQuery,
|
||||
message: "resolving source".to_owned(),
|
||||
}));
|
||||
assert!(events.contains(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::DiscoverRelease,
|
||||
message: "discovering release".to_owned(),
|
||||
}));
|
||||
assert!(events.contains(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::SelectArtifact,
|
||||
message: "selecting artifact".to_owned(),
|
||||
}));
|
||||
assert!(events.contains(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::DownloadArtifact,
|
||||
message: "downloading artifact".to_owned(),
|
||||
}));
|
||||
assert!(events.contains(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::StagePayload,
|
||||
message: "staging payload".to_owned(),
|
||||
}));
|
||||
assert!(events.iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
OperationEvent::Progress {
|
||||
current,
|
||||
total: Some(total)
|
||||
} if *current == *total
|
||||
)
|
||||
}));
|
||||
assert!(events.contains(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::WriteDesktopEntry,
|
||||
message: "writing desktop entry".to_owned(),
|
||||
}));
|
||||
assert!(events.iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
OperationEvent::StageChanged {
|
||||
stage: OperationStage::RefreshIntegration,
|
||||
..
|
||||
}
|
||||
)
|
||||
}));
|
||||
|
||||
let stage_order = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
OperationEvent::StageChanged { stage, .. } => Some(*stage),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(stage_order.windows(2).any(|window| {
|
||||
window
|
||||
== [
|
||||
OperationStage::ResolveQuery,
|
||||
OperationStage::DiscoverRelease,
|
||||
]
|
||||
}));
|
||||
assert!(stage_order.windows(2).any(|window| {
|
||||
window
|
||||
== [
|
||||
OperationStage::DiscoverRelease,
|
||||
OperationStage::SelectArtifact,
|
||||
]
|
||||
}));
|
||||
assert!(stage_order.windows(2).any(|window| {
|
||||
window
|
||||
== [
|
||||
OperationStage::SelectArtifact,
|
||||
OperationStage::DownloadArtifact,
|
||||
]
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_app_sanitizes_desktop_entry_display_names() {
|
||||
let root = tempdir().unwrap();
|
||||
let mut reporter = Vec::new();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let mut capture = |event: &OperationEvent| reporter.push(event.clone());
|
||||
let mut plan =
|
||||
build_add_plan_with_reporter("sharkdp/bat", &FixtureGitHubTransport, &mut capture).unwrap();
|
||||
plan.display_name_hint = Some("Bat\nExec=evil".to_owned());
|
||||
|
||||
let installed = install_app_with_reporter(
|
||||
"sharkdp/bat",
|
||||
&plan,
|
||||
root.path(),
|
||||
InstallScope::User,
|
||||
&mut capture,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let desktop_path = installed
|
||||
.install_outcome
|
||||
.desktop_entry_path
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let contents = fs::read_to_string(desktop_path).unwrap();
|
||||
|
||||
assert!(contents.contains("Name=Bat Exec=evil"));
|
||||
assert_eq!(
|
||||
contents
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("Exec="))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlab_source_builds_concrete_install_candidate() {
|
||||
let mut events: Vec<OperationEvent> = Vec::new();
|
||||
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
||||
|
||||
let plan = build_add_plan_with_reporter(
|
||||
"https://gitlab.com/example/team-app",
|
||||
&FixtureGitHubTransport,
|
||||
&mut reporter,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(plan.resolution.source.kind, SourceKind::GitLab);
|
||||
assert_eq!(
|
||||
plan.resolution.source.locator,
|
||||
"https://gitlab.com/example/team-app"
|
||||
);
|
||||
assert_eq!(plan.resolution.release.version, "latest");
|
||||
assert_eq!(
|
||||
plan.selected_artifact.url,
|
||||
"https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage"
|
||||
);
|
||||
assert_eq!(plan.selected_artifact.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
|
||||
assert!(events.contains(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::DiscoverRelease,
|
||||
message: "discovering release".to_owned(),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlab_candidate_builds_concrete_install_candidate() {
|
||||
let mut events: Vec<OperationEvent> = Vec::new();
|
||||
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
||||
|
||||
let query = "https://gitlab.com/acme/platform/releases/team-app";
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
assert_eq!(plan.resolution.source.kind, SourceKind::GitLab);
|
||||
assert_eq!(plan.resolution.source.locator, query);
|
||||
assert_eq!(
|
||||
plan.resolution.source.canonical_locator.as_deref(),
|
||||
Some("acme/platform/releases/team-app")
|
||||
);
|
||||
assert_eq!(
|
||||
plan.resolution.source.normalized_kind,
|
||||
NormalizedSourceKind::GitLab
|
||||
);
|
||||
assert_eq!(plan.resolution.release.version, "latest");
|
||||
assert_eq!(
|
||||
plan.selected_artifact.url,
|
||||
"https://gitlab.com/acme/platform/releases/team-app/-/releases/permalink/latest/downloads/team-app.AppImage"
|
||||
);
|
||||
assert_eq!(plan.selected_artifact.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
|
||||
assert!(events.contains(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::DiscoverRelease,
|
||||
message: "discovering release".to_owned(),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlab_install_preserves_truthful_gitlab_origin() {
|
||||
let root = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let mut reporter = |_event: &OperationEvent| {};
|
||||
let query = "https://gitlab.com/example/team-app";
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
let installed =
|
||||
install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(installed.record.source_input.as_deref(), Some(query));
|
||||
assert_eq!(
|
||||
installed.record.installed_version.as_deref(),
|
||||
Some("latest")
|
||||
);
|
||||
assert_eq!(installed.source.kind, SourceKind::GitLab);
|
||||
assert_eq!(installed.source.locator, query);
|
||||
assert_eq!(
|
||||
installed.source.canonical_locator.as_deref(),
|
||||
Some("example/team-app")
|
||||
);
|
||||
assert_eq!(
|
||||
installed.selected_artifact.url,
|
||||
"https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_url_source_uses_exact_input_resolution() {
|
||||
let mut reporter = |_event: &OperationEvent| {};
|
||||
let query = "https://example.com/downloads/team-app.AppImage";
|
||||
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
assert_eq!(plan.resolution.source.kind, SourceKind::DirectUrl);
|
||||
assert_eq!(plan.resolution.source.locator, query);
|
||||
assert_eq!(plan.resolution.release.version, "unresolved");
|
||||
assert_eq!(plan.selected_artifact.url, query);
|
||||
assert_eq!(plan.selected_artifact.version, "unresolved");
|
||||
assert_eq!(plan.selected_artifact.selection_reason, "exact-input");
|
||||
assert_eq!(plan.update_strategy.preferred.locator, query);
|
||||
assert_eq!(plan.update_strategy.preferred.reason, "exact-input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_url_install_preserves_truthful_direct_url_origin() {
|
||||
let root = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let mut reporter = |_event: &OperationEvent| {};
|
||||
let query = "https://sourceforge.net/projects/team-app/files/team-app-1.0.0.AppImage/download";
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
let installed =
|
||||
install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(installed.record.source_input.as_deref(), Some(query));
|
||||
assert_eq!(
|
||||
installed.record.installed_version.as_deref(),
|
||||
Some("unresolved")
|
||||
);
|
||||
assert_eq!(installed.source.kind, SourceKind::DirectUrl);
|
||||
assert_eq!(installed.source.locator, query);
|
||||
assert_eq!(installed.selected_artifact.url, query);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_candidate_builds_concrete_install_candidate() {
|
||||
let mut events: Vec<OperationEvent> = Vec::new();
|
||||
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
||||
|
||||
let query = "https://sourceforge.net/projects/team-app/files/releases/stable/download";
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
assert_eq!(plan.resolution.source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(plan.resolution.source.locator, query);
|
||||
assert_eq!(plan.resolution.release.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.url, query);
|
||||
assert_eq!(plan.selected_artifact.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
|
||||
assert_eq!(plan.update_strategy.preferred.locator, query);
|
||||
assert_eq!(plan.update_strategy.preferred.reason, "provider-release");
|
||||
assert!(events.contains(&OperationEvent::StageChanged {
|
||||
stage: OperationStage::DiscoverRelease,
|
||||
message: "discovering release".to_owned(),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_release_folder_builds_concrete_install_candidate() {
|
||||
let mut reporter = |_event: &OperationEvent| {};
|
||||
let query = "https://sourceforge.net/projects/team-app/files/releases/beta/download";
|
||||
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
assert_eq!(plan.resolution.source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(plan.resolution.source.locator, query);
|
||||
assert_eq!(
|
||||
plan.resolution.source.normalized_kind,
|
||||
NormalizedSourceKind::SourceForge
|
||||
);
|
||||
assert!(plan.resolution.source.tracks_latest);
|
||||
assert_eq!(plan.resolution.release.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.url, query);
|
||||
assert_eq!(plan.selected_artifact.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
|
||||
assert_eq!(plan.update_strategy.preferred.locator, query);
|
||||
assert_eq!(plan.update_strategy.preferred.reason, "provider-release");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_latest_download_builds_concrete_install_candidate() {
|
||||
let mut reporter = |_event: &OperationEvent| {};
|
||||
let query = "https://sourceforge.net/projects/team-app/files/latest/download";
|
||||
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
assert_eq!(plan.resolution.source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(plan.resolution.source.locator, query);
|
||||
assert_eq!(plan.resolution.release.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.url, query);
|
||||
assert_eq!(plan.selected_artifact.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_latest_download_install_preserves_truthful_origin() {
|
||||
let root = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let mut reporter = |_event: &OperationEvent| {};
|
||||
let query = "https://sourceforge.net/projects/team-app/files/latest/download";
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
let installed =
|
||||
install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(installed.record.source_input.as_deref(), Some(query));
|
||||
assert_eq!(
|
||||
installed.record.installed_version.as_deref(),
|
||||
Some("latest")
|
||||
);
|
||||
assert_eq!(installed.source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(installed.source.locator, query);
|
||||
assert_eq!(
|
||||
installed.source.canonical_locator.as_deref(),
|
||||
Some("team-app")
|
||||
);
|
||||
assert_eq!(installed.selected_artifact.url, query);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_release_folder_install_preserves_truthful_origin() {
|
||||
let root = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let mut reporter = |_event: &OperationEvent| {};
|
||||
let query = "https://sourceforge.net/projects/team-app/files/releases/beta/download";
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
let installed =
|
||||
install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(installed.record.source_input.as_deref(), Some(query));
|
||||
assert_eq!(
|
||||
installed.record.installed_version.as_deref(),
|
||||
Some("latest")
|
||||
);
|
||||
assert_eq!(installed.source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(installed.source.locator, query);
|
||||
assert_eq!(
|
||||
installed.source.canonical_locator.as_deref(),
|
||||
Some("team-app")
|
||||
);
|
||||
assert_eq!(
|
||||
installed.source.normalized_kind,
|
||||
NormalizedSourceKind::SourceForge
|
||||
);
|
||||
assert_eq!(installed.selected_artifact.url, query);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_file_like_release_download_uses_releases_root_for_source_and_original_url_for_artifact()
|
||||
{
|
||||
let mut reporter = |_event: &OperationEvent| {};
|
||||
let query =
|
||||
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download";
|
||||
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
assert_eq!(plan.resolution.source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(
|
||||
plan.resolution.source.locator,
|
||||
"https://sourceforge.net/projects/team-app/files/releases"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.resolution.source.requested_asset_name.as_deref(),
|
||||
Some("team-app-1.0.0.AppImage")
|
||||
);
|
||||
assert_eq!(plan.resolution.release.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.url, query);
|
||||
assert_eq!(plan.selected_artifact.version, "latest");
|
||||
assert_eq!(plan.selected_artifact.selection_reason, "provider-release");
|
||||
assert_eq!(
|
||||
plan.update_strategy.preferred.locator,
|
||||
"https://sourceforge.net/projects/team-app/files/releases"
|
||||
);
|
||||
assert_eq!(plan.update_strategy.preferred.reason, "provider-release");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sourceforge_file_like_release_download_install_preserves_input_but_stores_releases_root() {
|
||||
let root = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let mut reporter = |_event: &OperationEvent| {};
|
||||
let query =
|
||||
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download";
|
||||
let plan = build_add_plan_with_reporter(query, &FixtureGitHubTransport, &mut reporter).unwrap();
|
||||
|
||||
let installed =
|
||||
install_app_with_reporter(query, &plan, root.path(), InstallScope::User, &mut reporter)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(installed.record.source_input.as_deref(), Some(query));
|
||||
assert_eq!(
|
||||
installed.record.installed_version.as_deref(),
|
||||
Some("latest")
|
||||
);
|
||||
assert_eq!(installed.source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(
|
||||
installed.source.locator,
|
||||
"https://sourceforge.net/projects/team-app/files/releases"
|
||||
);
|
||||
assert_eq!(
|
||||
installed.source.requested_asset_name.as_deref(),
|
||||
Some("team-app-1.0.0.AppImage")
|
||||
);
|
||||
assert_eq!(
|
||||
installed.source.canonical_locator.as_deref(),
|
||||
Some("team-app")
|
||||
);
|
||||
assert_eq!(installed.selected_artifact.url, query);
|
||||
}
|
||||
28
crates/upm-core/tests/install_paths.rs
Normal file
28
crates/upm-core/tests/install_paths.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
use std::path::Path;
|
||||
|
||||
use upm_core::domain::app::InstallScope;
|
||||
use upm_core::integration::paths::{desktop_entry_path, managed_appimage_path};
|
||||
|
||||
#[test]
|
||||
fn user_scope_path_lands_under_home_managed_dir() {
|
||||
let path = managed_appimage_path(Path::new("/home/test"), InstallScope::User, "bat");
|
||||
|
||||
assert_eq!(
|
||||
path,
|
||||
Path::new("/home/test/.local/lib/upm/appimages/bat.AppImage")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_scope_path_lands_under_opt_upm_dir() {
|
||||
let path = managed_appimage_path(Path::new("/home/test"), InstallScope::System, "bat");
|
||||
|
||||
assert_eq!(path, Path::new("/opt/upm/appimages/bat.AppImage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_scope_desktop_entry_uses_upm_prefix() {
|
||||
let path = desktop_entry_path(Path::new("/home/test"), InstallScope::System, "bat");
|
||||
|
||||
assert_eq!(path, Path::new("/usr/share/applications/upm-bat.desktop"));
|
||||
}
|
||||
33
crates/upm-core/tests/install_payload.rs
Normal file
33
crates/upm-core/tests/install_payload.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::tempdir;
|
||||
use upm_core::integration::install::stage_and_commit_payload;
|
||||
|
||||
#[test]
|
||||
fn payload_commit_moves_staged_appimage_into_final_location() {
|
||||
let root = tempdir().unwrap();
|
||||
let staging_root = root.path().join("staging");
|
||||
let payload_root = root.path().join("payloads");
|
||||
fs::create_dir(&staging_root).unwrap();
|
||||
fs::create_dir(&payload_root).unwrap();
|
||||
|
||||
let staged_path = staging_root.join("bat.download");
|
||||
fs::write(&staged_path, b"\x7fELFAppImage").unwrap();
|
||||
let final_payload_path = payload_root.join("bat.AppImage");
|
||||
let outcome = stage_and_commit_payload(&staged_path, &final_payload_path).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
outcome
|
||||
.final_payload_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str()),
|
||||
Some("AppImage")
|
||||
);
|
||||
assert!(outcome.final_payload_path.exists());
|
||||
|
||||
let mode = fs::metadata(&outcome.final_payload_path)
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o111, 0o111);
|
||||
}
|
||||
49
crates/upm-core/tests/install_policy.rs
Normal file
49
crates/upm-core/tests/install_policy.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use std::path::Path;
|
||||
use upm_core::integration::policy::{IntegrationMode, resolve_install_policy};
|
||||
use upm_core::platform::{DistroFamily, HostCapabilities, InstallScope};
|
||||
|
||||
#[test]
|
||||
fn immutable_system_request_downgrades_to_user_when_allowed() {
|
||||
let capabilities = HostCapabilities::immutable_user_only();
|
||||
let policy =
|
||||
resolve_install_policy(DistroFamily::Immutable, InstallScope::System, &capabilities)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(policy.scope, InstallScope::User);
|
||||
assert_eq!(policy.integration_mode, IntegrationMode::Degraded);
|
||||
assert!(!policy.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nix_system_request_is_denied() {
|
||||
let error = resolve_install_policy(
|
||||
DistroFamily::Nix,
|
||||
InstallScope::System,
|
||||
&HostCapabilities::default(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.contains("not supported on Nix hosts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_policy_uses_managed_payload_and_native_integration_roots() {
|
||||
let policy = resolve_install_policy(
|
||||
DistroFamily::Fedora,
|
||||
InstallScope::System,
|
||||
&HostCapabilities::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(policy.scope, InstallScope::System);
|
||||
assert_eq!(policy.payload_root, Path::new("/opt/upm/appimages"));
|
||||
assert_eq!(
|
||||
policy.desktop_entry_root,
|
||||
Path::new("/usr/share/applications")
|
||||
);
|
||||
assert_eq!(
|
||||
policy.icon_root,
|
||||
Path::new("/usr/share/icons/hicolor/256x256/apps")
|
||||
);
|
||||
assert_eq!(policy.integration_mode, IntegrationMode::Full);
|
||||
}
|
||||
8
crates/upm-core/tests/install_scope.rs
Normal file
8
crates/upm-core/tests/install_scope.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
use upm_core::app::scope::{ScopeOverride, resolve_install_scope};
|
||||
use upm_core::domain::app::InstallScope;
|
||||
|
||||
#[test]
|
||||
fn explicit_scope_override_beats_effective_user() {
|
||||
let scope = resolve_install_scope(false, ScopeOverride::System);
|
||||
assert_eq!(scope, InstallScope::System);
|
||||
}
|
||||
11
crates/upm-core/tests/metadata_contract.rs
Normal file
11
crates/upm-core/tests/metadata_contract.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
use upm_core::domain::update::ParsedMetadataKind;
|
||||
use upm_core::metadata::{MetadataDocument, parse_document};
|
||||
|
||||
#[test]
|
||||
fn unknown_document_returns_typed_warning_not_panic() {
|
||||
let doc = MetadataDocument::plain_text("https://example.test/notes.txt", b"not metadata");
|
||||
let result = parse_document(&doc).unwrap();
|
||||
|
||||
assert_eq!(result.kind, ParsedMetadataKind::Unknown);
|
||||
assert!(!result.warnings.is_empty());
|
||||
}
|
||||
15
crates/upm-core/tests/metadata_electron_builder.rs
Normal file
15
crates/upm-core/tests/metadata_electron_builder.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
use upm_core::domain::update::ParsedMetadataKind;
|
||||
use upm_core::metadata::{MetadataDocument, parse_document};
|
||||
|
||||
#[test]
|
||||
fn parses_latest_linux_yml_into_download_hints() {
|
||||
let raw = include_bytes!("fixtures/latest-linux.yml");
|
||||
let doc = MetadataDocument::yaml("https://example.test/latest-linux.yml", raw);
|
||||
let result = parse_document(&doc).unwrap();
|
||||
|
||||
assert_eq!(result.kind, ParsedMetadataKind::ElectronBuilder);
|
||||
assert_eq!(
|
||||
result.hints.primary_download.as_deref(),
|
||||
Some("T3-Code-0.0.11-x86_64.AppImage")
|
||||
);
|
||||
}
|
||||
12
crates/upm-core/tests/metadata_zsync.rs
Normal file
12
crates/upm-core/tests/metadata_zsync.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
use upm_core::domain::update::ParsedMetadataKind;
|
||||
use upm_core::metadata::{MetadataDocument, parse_document};
|
||||
|
||||
#[test]
|
||||
fn parses_zsync_document_into_channel_hints() {
|
||||
let raw = include_bytes!("fixtures/example.zsync");
|
||||
let doc = MetadataDocument::plain_text("https://example.test/app.AppImage.zsync", raw);
|
||||
let result = parse_document(&doc).unwrap();
|
||||
|
||||
assert_eq!(result.kind, ParsedMetadataKind::Zsync);
|
||||
assert!(result.hints.primary_download.is_some());
|
||||
}
|
||||
52
crates/upm-core/tests/platform_detection.rs
Normal file
52
crates/upm-core/tests/platform_detection.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::tempdir;
|
||||
use upm_core::platform::capabilities::{probe_desktop_helpers, probe_writable_roots};
|
||||
use upm_core::platform::distro::{DistroFamily, detect_distro_family};
|
||||
|
||||
#[test]
|
||||
fn detects_fedora_family_from_os_release() {
|
||||
let distro = detect_distro_family("ID=fedora\nID_LIKE=rhel centos\n");
|
||||
assert_eq!(distro, DistroFamily::Fedora);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_immutable_family_from_variant_id() {
|
||||
let distro = detect_distro_family("ID=fedora\nVARIANT_ID=silverblue\n");
|
||||
assert_eq!(distro, DistroFamily::Immutable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probes_desktop_helpers_from_search_paths() {
|
||||
let helper_dir = tempdir().unwrap();
|
||||
let update_desktop_database = helper_dir.path().join("update-desktop-database");
|
||||
let gtk_update_icon_cache = helper_dir.path().join("gtk-update-icon-cache");
|
||||
|
||||
fs::write(&update_desktop_database, "#!/bin/sh\n").unwrap();
|
||||
fs::write(>k_update_icon_cache, "#!/bin/sh\n").unwrap();
|
||||
fs::set_permissions(&update_desktop_database, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::set_permissions(>k_update_icon_cache, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let helpers = probe_desktop_helpers(&[helper_dir.path()]);
|
||||
|
||||
assert!(helpers.update_desktop_database);
|
||||
assert!(helpers.gtk_update_icon_cache);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probes_writable_roots_from_candidate_directories() {
|
||||
let root = tempdir().unwrap();
|
||||
let payload = root.path().join("payload");
|
||||
let desktop_entries = root.path().join("applications");
|
||||
let icons = root.path().join("icons");
|
||||
|
||||
fs::create_dir(&payload).unwrap();
|
||||
fs::create_dir(&desktop_entries).unwrap();
|
||||
fs::create_dir(&icons).unwrap();
|
||||
|
||||
let writable = probe_writable_roots(&payload, &desktop_entries, &icons);
|
||||
|
||||
assert!(writable.payload);
|
||||
assert!(writable.desktop_entries);
|
||||
assert!(writable.icons);
|
||||
}
|
||||
159
crates/upm-core/tests/provider_registry.rs
Normal file
159
crates/upm-core/tests/provider_registry.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
use upm_core::app::add::{AddSecurityPolicy, build_add_plan_with_registered_providers};
|
||||
use upm_core::app::providers::{ExternalAddProvider, ExternalAddResolution, ProviderRegistry};
|
||||
use upm_core::app::search::{SearchProvider, build_search_results_with_registered_providers};
|
||||
use upm_core::domain::search::{SearchInstallStatus, SearchQuery, SearchResult};
|
||||
use upm_core::domain::source::{
|
||||
NormalizedSourceKind, ResolvedRelease, SourceInputKind, SourceKind, SourceRef,
|
||||
};
|
||||
use upm_core::domain::update::{
|
||||
ArtifactCandidate, ChannelPreference, UpdateChannelKind, UpdateStrategy,
|
||||
};
|
||||
use upm_core::source::github::FixtureGitHubTransport;
|
||||
|
||||
struct StubSearchProvider;
|
||||
|
||||
impl SearchProvider for StubSearchProvider {
|
||||
fn search(
|
||||
&self,
|
||||
_query: &SearchQuery,
|
||||
) -> Result<Vec<SearchResult>, upm_core::app::search::SearchProviderError> {
|
||||
Ok(vec![SearchResult {
|
||||
provider_id: "external-search".to_owned(),
|
||||
display_name: "Firefox Nightly".to_owned(),
|
||||
description: Some("Provided by external registry".to_owned()),
|
||||
source_locator: "https://example.invalid/firefox-nightly".to_owned(),
|
||||
install_query: "external/firefox-nightly".to_owned(),
|
||||
canonical_locator: "external/firefox-nightly".to_owned(),
|
||||
version: Some("2026.03.21".to_owned()),
|
||||
install_status: SearchInstallStatus::Available,
|
||||
}])
|
||||
}
|
||||
}
|
||||
|
||||
struct StubExternalAddProvider;
|
||||
|
||||
impl ExternalAddProvider for StubExternalAddProvider {
|
||||
fn id(&self) -> &'static str {
|
||||
"stub-appimage"
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
&self,
|
||||
source: &SourceRef,
|
||||
) -> Result<Option<ExternalAddResolution>, upm_core::adapters::traits::AdapterError> {
|
||||
Ok(
|
||||
(source.kind == SourceKind::AppImageHub).then(|| ExternalAddResolution {
|
||||
resolution: upm_core::adapters::traits::AdapterResolution {
|
||||
source: SourceRef {
|
||||
kind: SourceKind::AppImageHub,
|
||||
locator: source.locator.clone(),
|
||||
input_kind: SourceInputKind::AppImageHubShorthand,
|
||||
normalized_kind: NormalizedSourceKind::AppImageHub,
|
||||
canonical_locator: Some("2338455".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
},
|
||||
release: ResolvedRelease {
|
||||
version: "stable".to_owned(),
|
||||
prerelease: false,
|
||||
},
|
||||
},
|
||||
selected_artifact: ArtifactCandidate {
|
||||
url: "https://downloads.example.invalid/firefox.AppImage".to_owned(),
|
||||
version: "stable".to_owned(),
|
||||
arch: Some("x86_64".to_owned()),
|
||||
trusted_checksum: None,
|
||||
weak_checksum_md5: Some("deadbeef".to_owned()),
|
||||
selection_reason: "provider-release".to_owned(),
|
||||
},
|
||||
update_strategy: UpdateStrategy {
|
||||
preferred: ChannelPreference {
|
||||
kind: UpdateChannelKind::DirectAsset,
|
||||
locator: "https://downloads.example.invalid/firefox.AppImage".to_owned(),
|
||||
reason: "provider-release".to_owned(),
|
||||
},
|
||||
alternates: Vec::new(),
|
||||
},
|
||||
display_name_hint: Some(
|
||||
"Firefox by Mozilla - Official AppImage Edition".to_owned(),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_search_results_with_registered_providers_uses_external_hits() {
|
||||
let query = SearchQuery::new("firefox");
|
||||
let search_provider = StubSearchProvider;
|
||||
let providers = ProviderRegistry {
|
||||
search_providers: vec![&search_provider],
|
||||
external_add_providers: Vec::new(),
|
||||
};
|
||||
|
||||
let results = build_search_results_with_registered_providers(&query, &[], &providers).unwrap();
|
||||
|
||||
let external_hit = results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.find(|hit| hit.provider_id == "external-search")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(external_hit.install_query, "external/firefox-nightly");
|
||||
assert!(
|
||||
results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.all(|hit| hit.provider_id != "appimagehub")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_add_plan_with_registered_providers_requires_external_provider_for_appimagehub() {
|
||||
let registry = ProviderRegistry::default();
|
||||
|
||||
let error = build_add_plan_with_registered_providers(
|
||||
"appimagehub/2338455",
|
||||
&FixtureGitHubTransport,
|
||||
®istry,
|
||||
AddSecurityPolicy::default(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
upm_core::app::add::BuildAddPlanError::NoInstallableArtifact { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_add_plan_with_registered_providers_delegates_appimagehub_like_sources() {
|
||||
let provider = StubExternalAddProvider;
|
||||
let registry = ProviderRegistry {
|
||||
search_providers: Vec::new(),
|
||||
external_add_providers: vec![&provider],
|
||||
};
|
||||
|
||||
let plan = build_add_plan_with_registered_providers(
|
||||
"appimagehub/2338455",
|
||||
&FixtureGitHubTransport,
|
||||
®istry,
|
||||
AddSecurityPolicy::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(plan.resolution.source.kind, SourceKind::AppImageHub);
|
||||
assert_eq!(
|
||||
plan.resolution.source.canonical_locator.as_deref(),
|
||||
Some("2338455")
|
||||
);
|
||||
assert_eq!(
|
||||
plan.selected_artifact.url,
|
||||
"https://downloads.example.invalid/firefox.AppImage"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.display_name_hint.as_deref(),
|
||||
Some("Firefox by Mozilla - Official AppImage Edition")
|
||||
);
|
||||
}
|
||||
419
crates/upm-core/tests/query_resolution.rs
Normal file
419
crates/upm-core/tests/query_resolution.rs
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
use upm_core::app::query::resolve_query;
|
||||
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind};
|
||||
|
||||
#[test]
|
||||
fn owner_repo_defaults_to_github() {
|
||||
let source = resolve_query("sharkdp/bat").unwrap();
|
||||
assert_eq!(source.kind, SourceKind::GitHub);
|
||||
assert_eq!(source.input_kind, SourceInputKind::RepoShorthand);
|
||||
assert_eq!(
|
||||
source.normalized_kind,
|
||||
NormalizedSourceKind::GitHubRepository
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_github_release_asset_url() {
|
||||
let source = resolve_query(
|
||||
"https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.input_kind, SourceInputKind::GitHubReleaseAssetUrl);
|
||||
assert_eq!(
|
||||
source.normalized_kind,
|
||||
NormalizedSourceKind::GitHubReleaseAsset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_appimagehub_item_url() {
|
||||
let source = resolve_query("https://www.appimagehub.com/p/2338455").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::AppImageHub);
|
||||
assert_eq!(source.input_kind, SourceInputKind::AppImageHubUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::AppImageHub);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("2338455"));
|
||||
assert!(source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_appimagehub_id_shorthand() {
|
||||
let source = resolve_query("appimagehub/2338455").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::AppImageHub);
|
||||
assert_eq!(source.input_kind, SourceInputKind::AppImageHubShorthand);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::AppImageHub);
|
||||
assert_eq!(source.locator, "https://www.appimagehub.com/p/2338455");
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("2338455"));
|
||||
assert!(source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_gitlab_repository_url() {
|
||||
let source = resolve_query("https://gitlab.com/example/team-app").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::GitLab);
|
||||
assert_eq!(source.input_kind, SourceInputKind::GitLabUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::GitLab);
|
||||
assert_eq!(
|
||||
source.canonical_locator.as_deref(),
|
||||
Some("example/team-app")
|
||||
);
|
||||
assert!(source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_gitlab_release_like_url() {
|
||||
let source = resolve_query("https://gitlab.com/example/team-app/-/releases/v1.2.3").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::GitLab);
|
||||
assert_eq!(source.input_kind, SourceInputKind::GitLabUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::GitLab);
|
||||
assert_eq!(
|
||||
source.canonical_locator.as_deref(),
|
||||
Some("example/team-app")
|
||||
);
|
||||
assert_eq!(source.requested_tag.as_deref(), Some("v1.2.3"));
|
||||
assert!(!source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_gitlab_subgroup_repository_url() {
|
||||
let source = resolve_query("https://gitlab.com/example/platform/team-app").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::GitLab);
|
||||
assert_eq!(
|
||||
source.canonical_locator.as_deref(),
|
||||
Some("example/platform/team-app")
|
||||
);
|
||||
assert!(source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_gitlab_deep_subgroup_repository_url() {
|
||||
let source = resolve_query("https://gitlab.com/example/platform/apps/team-app").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::GitLab);
|
||||
assert_eq!(
|
||||
source.canonical_locator.as_deref(),
|
||||
Some("example/platform/apps/team-app")
|
||||
);
|
||||
assert!(source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_gitlab_repository_with_reserved_namespace_segment() {
|
||||
let source = resolve_query("https://gitlab.com/example/releases/team-app").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::GitLab);
|
||||
assert_eq!(
|
||||
source.canonical_locator.as_deref(),
|
||||
Some("example/releases/team-app")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_gitlab_two_segment_repository_with_reserved_slug() {
|
||||
let source = resolve_query("https://gitlab.com/example/issues").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::GitLab);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("example/issues"));
|
||||
assert!(source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_sourceforge_project_url() {
|
||||
let source = resolve_query("https://sourceforge.net/projects/team-app/").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::SourceForge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_sourceforge_files_url() {
|
||||
let source =
|
||||
resolve_query("https://sourceforge.net/projects/team-app/files/latest/download").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::SourceForge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_direct_url_classification() {
|
||||
let source = resolve_query("https://example.com/downloads/team-app.AppImage").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::DirectUrl);
|
||||
assert_eq!(source.input_kind, SourceInputKind::DirectUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_single_segment_sourceforge_release_download_as_candidate() {
|
||||
let source = resolve_query(
|
||||
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(
|
||||
source.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
|
||||
assert_eq!(
|
||||
source.requested_asset_name.as_deref(),
|
||||
Some("team-app-1.0.0.AppImage")
|
||||
);
|
||||
assert!(!source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_sourceforge_releases_root_as_provider_source() {
|
||||
let source = resolve_query("https://sourceforge.net/projects/team-app/files/releases").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::SourceForge);
|
||||
assert_eq!(
|
||||
source.locator,
|
||||
"https://sourceforge.net/projects/team-app/files/releases"
|
||||
);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
|
||||
assert!(source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_sourceforge_root_download_url_as_direct_url() {
|
||||
let source = resolve_query(
|
||||
"https://sourceforge.net/projects/team-app/files/team-app-1.0.0.AppImage/download",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::DirectUrl);
|
||||
assert_eq!(source.input_kind, SourceInputKind::DirectUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_sourceforge_extensionless_root_download_url_as_direct_url() {
|
||||
let source =
|
||||
resolve_query("https://sourceforge.net/projects/team-app/files/team-app/download").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::DirectUrl);
|
||||
assert_eq!(source.input_kind, SourceInputKind::DirectUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::DirectUrl);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_single_segment_sourceforge_release_download_with_query_as_candidate() {
|
||||
let source = resolve_query(
|
||||
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download?use_mirror=pilotfiber",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(
|
||||
source.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
|
||||
assert_eq!(
|
||||
source.requested_asset_name.as_deref(),
|
||||
Some("team-app-1.0.0.AppImage")
|
||||
);
|
||||
assert!(!source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_gitlab_url() {
|
||||
let error = resolve_query("https://gitlab.com/example").unwrap_err();
|
||||
|
||||
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_gitlab_url_shape() {
|
||||
let error = resolve_query("https://gitlab.com/example/team-app/-/issues").unwrap_err();
|
||||
|
||||
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_gitlab_nested_resource_url() {
|
||||
let error = resolve_query("https://gitlab.com/example/team-app/issues").unwrap_err();
|
||||
|
||||
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_gitlab_release_permalink_url() {
|
||||
let error = resolve_query("https://gitlab.com/example/team-app/-/releases/permalink/latest")
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_gitlab_issue_detail_url() {
|
||||
let error = resolve_query("https://gitlab.com/example/team-app/issues/1").unwrap_err();
|
||||
|
||||
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_gitlab_blob_url() {
|
||||
let error =
|
||||
resolve_query("https://gitlab.com/example/team-app/blob/main/README.md").unwrap_err();
|
||||
|
||||
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_ambiguous_gitlab_deep_reserved_segment_as_candidate() {
|
||||
let source = resolve_query("https://gitlab.com/acme/platform/releases/team-app").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::GitLab);
|
||||
assert_eq!(source.input_kind, SourceInputKind::GitLabUrl);
|
||||
assert_eq!(
|
||||
source.normalized_kind,
|
||||
NormalizedSourceKind::GitLabCandidate
|
||||
);
|
||||
assert_eq!(source.canonical_locator, None);
|
||||
assert!(!source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_gitlab_packages_url() {
|
||||
let error = resolve_query("https://gitlab.com/example/team-app/packages").unwrap_err();
|
||||
|
||||
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_sourceforge_url() {
|
||||
let error = resolve_query("https://sourceforge.net/projects/").unwrap_err();
|
||||
|
||||
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_appimagehub_shorthand() {
|
||||
let error = resolve_query("appimagehub/firefox").unwrap_err();
|
||||
|
||||
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_sourceforge_url_shape() {
|
||||
let error = resolve_query("https://sourceforge.net/projects/team-app/rss").unwrap_err();
|
||||
|
||||
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_sourceforge_files_releases_shape_as_provider_source() {
|
||||
let source = resolve_query("https://sourceforge.net/projects/team-app/files/releases").unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(source.normalized_kind, NormalizedSourceKind::SourceForge);
|
||||
assert_eq!(
|
||||
source.locator,
|
||||
"https://sourceforge.net/projects/team-app/files/releases"
|
||||
);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
|
||||
assert!(source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_sourceforge_folder_download_shape() {
|
||||
let error = resolve_query("https://sourceforge.net/projects/team-app/files/releases/download")
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error, upm_core::app::query::ResolveQueryError::Unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_ambiguous_sourceforge_nested_folder_download_as_candidate() {
|
||||
let source =
|
||||
resolve_query("https://sourceforge.net/projects/team-app/files/releases/stable/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(
|
||||
source.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
|
||||
assert!(!source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_extensionless_sourceforge_release_folder_download_as_candidate() {
|
||||
let source =
|
||||
resolve_query("https://sourceforge.net/projects/team-app/files/releases/team-app/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(
|
||||
source.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
|
||||
assert!(!source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_ambiguous_sourceforge_version_folder_download_as_candidate() {
|
||||
let source =
|
||||
resolve_query("https://sourceforge.net/projects/team-app/files/releases/v1-0/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(
|
||||
source.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
|
||||
assert!(!source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_prerelease_named_sourceforge_release_folder_download_as_candidate() {
|
||||
let source =
|
||||
resolve_query("https://sourceforge.net/projects/team-app/files/releases/beta/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(
|
||||
source.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
|
||||
assert!(!source.tracks_latest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_dotted_sourceforge_release_folder_download_as_candidate() {
|
||||
let source =
|
||||
resolve_query("https://sourceforge.net/projects/team-app/files/releases/2026.03/download")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.kind, SourceKind::SourceForge);
|
||||
assert_eq!(source.input_kind, SourceInputKind::SourceForgeUrl);
|
||||
assert_eq!(
|
||||
source.normalized_kind,
|
||||
NormalizedSourceKind::SourceForgeCandidate
|
||||
);
|
||||
assert_eq!(source.canonical_locator.as_deref(), Some("team-app"));
|
||||
assert!(!source.tracks_latest);
|
||||
}
|
||||
288
crates/upm-core/tests/registry_roundtrip.rs
Normal file
288
crates/upm-core/tests/registry_roundtrip.rs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
use tempfile::tempdir;
|
||||
use upm_core::registry::store::RegistryStore;
|
||||
|
||||
#[test]
|
||||
fn registry_round_trips_app_records() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = RegistryStore::new(dir.path().join("registry.toml"));
|
||||
let loaded = store.load().unwrap();
|
||||
assert!(loaded.apps.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_round_trips_update_strategy_and_alternates() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = RegistryStore::new(dir.path().join("registry.toml"));
|
||||
let registry = upm_core::registry::model::Registry {
|
||||
version: 1,
|
||||
apps: vec![upm_core::domain::app::AppRecord {
|
||||
stable_id: "t3code".to_owned(),
|
||||
display_name: "T3 Code".to_owned(),
|
||||
source_input: Some("pingdotgg/t3code".to_owned()),
|
||||
source: None,
|
||||
installed_version: Some("0.0.11".to_owned()),
|
||||
update_strategy: Some(upm_core::domain::update::UpdateStrategy {
|
||||
preferred: upm_core::domain::update::ChannelPreference {
|
||||
kind: upm_core::domain::update::UpdateChannelKind::DirectAsset,
|
||||
locator: "https://example.test/app.AppImage".to_owned(),
|
||||
reason: "install-origin-match".to_owned(),
|
||||
},
|
||||
alternates: vec![
|
||||
upm_core::domain::update::ChannelPreference {
|
||||
kind: upm_core::domain::update::UpdateChannelKind::GitHubReleases,
|
||||
locator: "pingdotgg/t3code".to_owned(),
|
||||
reason: "heuristic-match".to_owned(),
|
||||
},
|
||||
upm_core::domain::update::ChannelPreference {
|
||||
kind: upm_core::domain::update::UpdateChannelKind::ElectronBuilder,
|
||||
locator: "https://example.test/latest-linux.yml".to_owned(),
|
||||
reason: "metadata-guided".to_owned(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}],
|
||||
};
|
||||
|
||||
store.save(®istry).unwrap();
|
||||
let loaded = store.load().unwrap();
|
||||
|
||||
let strategy = loaded.apps[0].update_strategy.as_ref().unwrap();
|
||||
assert_eq!(strategy.preferred.reason, "install-origin-match");
|
||||
assert_eq!(strategy.alternates.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_round_trips_install_metadata() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = RegistryStore::new(dir.path().join("registry.toml"));
|
||||
let registry = upm_core::registry::model::Registry {
|
||||
version: 1,
|
||||
apps: vec![upm_core::domain::app::AppRecord {
|
||||
stable_id: "t3code".to_owned(),
|
||||
display_name: "T3 Code".to_owned(),
|
||||
source_input: Some("pingdotgg/t3code".to_owned()),
|
||||
source: None,
|
||||
installed_version: Some("0.0.11".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(upm_core::domain::app::InstallMetadata {
|
||||
scope: upm_core::domain::app::InstallScope::User,
|
||||
payload_path: Some(
|
||||
"/tmp/install-home/.local/lib/upm/appimages/t3code.AppImage".to_owned(),
|
||||
),
|
||||
desktop_entry_path: Some(
|
||||
"/tmp/install-home/.local/share/applications/upm-t3code.desktop".to_owned(),
|
||||
),
|
||||
icon_path: Some(
|
||||
"/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png"
|
||||
.to_owned(),
|
||||
),
|
||||
}),
|
||||
}],
|
||||
};
|
||||
|
||||
store.save(®istry).unwrap();
|
||||
let loaded = store.load().unwrap();
|
||||
|
||||
let install = loaded.apps[0].install.as_ref().unwrap();
|
||||
assert_eq!(install.scope, upm_core::domain::app::InstallScope::User);
|
||||
assert_eq!(
|
||||
install.payload_path.as_deref(),
|
||||
Some("/tmp/install-home/.local/lib/upm/appimages/t3code.AppImage")
|
||||
);
|
||||
assert_eq!(
|
||||
install.desktop_entry_path.as_deref(),
|
||||
Some("/tmp/install-home/.local/share/applications/upm-t3code.desktop")
|
||||
);
|
||||
assert_eq!(
|
||||
install.icon_path.as_deref(),
|
||||
Some("/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_round_trips_source_identity_for_new_provider_kinds() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = RegistryStore::new(dir.path().join("registry.toml"));
|
||||
let registry = upm_core::registry::model::Registry {
|
||||
version: 1,
|
||||
apps: vec![
|
||||
upm_core::domain::app::AppRecord {
|
||||
stable_id: "example-team-app".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some("https://gitlab.com/example/team-app".to_owned()),
|
||||
source: Some(upm_core::domain::source::SourceRef {
|
||||
kind: upm_core::domain::source::SourceKind::GitLab,
|
||||
locator: "https://gitlab.com/example/team-app".to_owned(),
|
||||
input_kind: upm_core::domain::source::SourceInputKind::GitLabUrl,
|
||||
normalized_kind: upm_core::domain::source::NormalizedSourceKind::GitLab,
|
||||
canonical_locator: Some("example/team-app".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some("latest".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
},
|
||||
upm_core::domain::app::AppRecord {
|
||||
stable_id: "team-app".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some(
|
||||
"https://sourceforge.net/projects/team-app/files/latest/download".to_owned(),
|
||||
),
|
||||
source: Some(upm_core::domain::source::SourceRef {
|
||||
kind: upm_core::domain::source::SourceKind::SourceForge,
|
||||
locator: "https://sourceforge.net/projects/team-app/files/latest/download"
|
||||
.to_owned(),
|
||||
input_kind: upm_core::domain::source::SourceInputKind::SourceForgeUrl,
|
||||
normalized_kind: upm_core::domain::source::NormalizedSourceKind::SourceForge,
|
||||
canonical_locator: Some("team-app".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some("latest".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
},
|
||||
upm_core::domain::app::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(upm_core::domain::source::SourceRef {
|
||||
kind: upm_core::domain::source::SourceKind::DirectUrl,
|
||||
locator: "https://example.com/downloads/team-app.AppImage".to_owned(),
|
||||
input_kind: upm_core::domain::source::SourceInputKind::DirectUrl,
|
||||
normalized_kind: upm_core::domain::source::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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
store.save(®istry).unwrap();
|
||||
let loaded = store.load().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
loaded.apps[0].source.as_ref().unwrap().kind.as_str(),
|
||||
"gitlab"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[0]
|
||||
.source
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.canonical_locator
|
||||
.as_deref(),
|
||||
Some("example/team-app")
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[1].source.as_ref().unwrap().kind.as_str(),
|
||||
"sourceforge"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[1].source.as_ref().unwrap().locator,
|
||||
"https://sourceforge.net/projects/team-app/files/latest/download"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[2].source.as_ref().unwrap().kind.as_str(),
|
||||
"direct-url"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.apps[2].source.as_ref().unwrap().locator,
|
||||
"https://example.com/downloads/team-app.AppImage"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_save_is_atomic_and_cleans_up_temp_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let store = RegistryStore::new(registry_path.clone());
|
||||
|
||||
store
|
||||
.save(&upm_core::registry::model::Registry {
|
||||
version: 1,
|
||||
apps: vec![upm_core::domain::app::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,
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(registry_path.exists());
|
||||
assert!(!dir.path().join("registry.toml.tmp").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_exclusive_lock_rejects_second_mutator() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = RegistryStore::new(dir.path().join("registry.toml"));
|
||||
let _guard = store.lock_exclusive().unwrap();
|
||||
|
||||
let error = store.lock_exclusive().unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
upm_core::registry::store::RegistryStoreError::LockUnavailable
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_mutate_exclusive_reloads_and_writes_latest_state() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = RegistryStore::new(dir.path().join("registry.toml"));
|
||||
store
|
||||
.save(&upm_core::registry::model::Registry {
|
||||
version: 1,
|
||||
apps: vec![upm_core::domain::app::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,
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.mutate_exclusive(|registry| {
|
||||
registry.apps.push(upm_core::domain::app::AppRecord {
|
||||
stable_id: "t3code".to_owned(),
|
||||
display_name: "T3 Code".to_owned(),
|
||||
source_input: None,
|
||||
source: None,
|
||||
installed_version: None,
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let loaded = store.load().unwrap();
|
||||
assert_eq!(loaded.apps.len(), 2);
|
||||
assert_eq!(loaded.apps[0].stable_id, "bat");
|
||||
assert_eq!(loaded.apps[1].stable_id, "t3code");
|
||||
}
|
||||
196
crates/upm-core/tests/remove_flow.rs
Normal file
196
crates/upm-core/tests/remove_flow.rs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
use upm_core::app::interaction::{InteractionKind, InteractionRequest};
|
||||
use upm_core::app::list::build_list_rows;
|
||||
use upm_core::app::progress::{OperationEvent, OperationStage};
|
||||
use upm_core::app::remove::{
|
||||
build_removal_plan, remove_registered_app_with_reporter, resolve_registered_app,
|
||||
};
|
||||
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||
|
||||
#[test]
|
||||
fn remove_flow_rejects_unknown_app_names() {
|
||||
let result = resolve_registered_app("bat", &[]);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_flow_returns_display_rows_for_registered_apps() {
|
||||
let rows = build_list_rows(&[AppRecord {
|
||||
stable_id: "bat".to_owned(),
|
||||
display_name: "Bat".to_owned(),
|
||||
source_input: Some("sharkdp/bat".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::GitHub,
|
||||
input_kind: SourceInputKind::RepoShorthand,
|
||||
normalized_kind: NormalizedSourceKind::GitHubRepository,
|
||||
locator: "sharkdp/bat".to_owned(),
|
||||
canonical_locator: Some("sharkdp/bat".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some("0.25.0".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}]);
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].stable_id, "bat");
|
||||
assert_eq!(rows[0].display_name, "Bat");
|
||||
assert_eq!(rows[0].version.as_deref(), Some("0.25.0"));
|
||||
assert_eq!(rows[0].source, "sharkdp/bat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_remove_matches_include_stable_ids_for_client_choice() {
|
||||
let apps = [
|
||||
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: "bat-nightly".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 = resolve_registered_app("Bat", &apps).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
upm_core::app::remove::ResolveRegisteredAppError::Ambiguous {
|
||||
request: InteractionRequest {
|
||||
key: "select-registered-app".to_owned(),
|
||||
kind: InteractionKind::SelectRegisteredApp {
|
||||
query: "Bat".to_owned(),
|
||||
matches: vec!["Bat (bat)".to_owned(), "Bat (bat-nightly)".to_owned()],
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_plan_prefers_persisted_install_metadata_paths() {
|
||||
let app = 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: Some(InstallMetadata {
|
||||
scope: InstallScope::System,
|
||||
payload_path: Some("/opt/upm/appimages/bat.AppImage".to_owned()),
|
||||
desktop_entry_path: Some("/usr/share/applications/upm-bat.desktop".to_owned()),
|
||||
icon_path: Some("/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned()),
|
||||
}),
|
||||
};
|
||||
|
||||
let plan = build_removal_plan(&app, Path::new("/home/test"));
|
||||
|
||||
assert_eq!(plan.stable_id, "bat");
|
||||
assert_eq!(
|
||||
plan.artifact_paths,
|
||||
vec![
|
||||
"/opt/upm/appimages/bat.AppImage".to_owned(),
|
||||
"/usr/share/applications/upm-bat.desktop".to_owned(),
|
||||
"/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_plan_falls_back_to_derived_managed_user_paths() {
|
||||
let app = 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,
|
||||
};
|
||||
|
||||
let plan = build_removal_plan(&app, Path::new("/home/test"));
|
||||
|
||||
assert_eq!(
|
||||
plan.artifact_paths,
|
||||
vec![
|
||||
"/home/test/.local/lib/upm/appimages/bat.AppImage".to_owned(),
|
||||
"/home/test/.local/share/applications/upm-bat.desktop".to_owned(),
|
||||
"/home/test/.local/share/icons/hicolor/256x256/apps/bat.png".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_flow_reports_resolution_and_cleanup_events() {
|
||||
let install_home = tempdir().unwrap();
|
||||
let app = 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: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: Some(
|
||||
install_home
|
||||
.path()
|
||||
.join(".local/lib/upm/appimages/bat.AppImage")
|
||||
.display()
|
||||
.to_string(),
|
||||
),
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
}),
|
||||
};
|
||||
let mut events: Vec<OperationEvent> = Vec::new();
|
||||
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
||||
|
||||
let result =
|
||||
remove_registered_app_with_reporter("bat", &[app], install_home.path(), &mut reporter)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.removed.stable_id, "bat");
|
||||
assert_eq!(result.removed_paths.len(), 0);
|
||||
assert!(events.iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
OperationEvent::StageChanged {
|
||||
stage: OperationStage::ResolveQuery,
|
||||
..
|
||||
}
|
||||
)
|
||||
}));
|
||||
assert!(events.iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
OperationEvent::StageChanged {
|
||||
stage: OperationStage::Finalize,
|
||||
..
|
||||
}
|
||||
)
|
||||
}));
|
||||
}
|
||||
212
crates/upm-core/tests/search_github.rs
Normal file
212
crates/upm-core/tests/search_github.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
use upm_core::app::search::{
|
||||
GitHubSearchProvider, SearchProvider, SearchProviderError, build_search_results_with,
|
||||
};
|
||||
use upm_core::domain::app::AppRecord;
|
||||
use upm_core::domain::search::{SearchInstallStatus, SearchQuery};
|
||||
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||
use upm_core::source::github::{FixtureGitHubTransport, search_github_repositories_with};
|
||||
|
||||
#[test]
|
||||
fn github_fixtures_return_normalized_remote_hits() {
|
||||
let query = SearchQuery::new("bat");
|
||||
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
|
||||
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
|
||||
|
||||
assert_eq!(query.remote_limit, 10);
|
||||
assert!(results.installed_matches.is_empty());
|
||||
assert!(results.warnings.is_empty());
|
||||
assert_eq!(results.remote_hits.len(), 3);
|
||||
|
||||
let first = &results.remote_hits[0];
|
||||
assert_eq!(first.provider_id, "github");
|
||||
assert_eq!(first.display_name, "sharkdp/bat");
|
||||
assert_eq!(
|
||||
first.description.as_deref(),
|
||||
Some("A cat(1) clone with wings.")
|
||||
);
|
||||
assert_eq!(first.source_locator, "https://github.com/sharkdp/bat");
|
||||
assert_eq!(first.install_query, "sharkdp/bat");
|
||||
assert_eq!(first.canonical_locator, "sharkdp/bat");
|
||||
assert_eq!(first.version.as_deref(), Some("1.0.0"));
|
||||
assert_eq!(first.install_status, SearchInstallStatus::Available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_search_respects_limit_and_fixture_order() {
|
||||
let query = SearchQuery::with_remote_limit("bat", 2);
|
||||
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
|
||||
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
|
||||
|
||||
let locators = results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.map(|hit| hit.canonical_locator.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(locators, vec!["sharkdp/bat", "astatine/bat"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_search_ranks_full_name_matches_above_description_only_matches() {
|
||||
let query = SearchQuery::new("pingdotgg");
|
||||
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
|
||||
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
|
||||
|
||||
let locators = results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.map(|hit| hit.canonical_locator.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(locators[0], "pingdotgg/t3code");
|
||||
assert_eq!(locators, vec!["pingdotgg/t3code"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_search_backfills_description_matches_after_name_matches() {
|
||||
let query = SearchQuery::with_remote_limit("pingdotgg", 3);
|
||||
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
|
||||
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
|
||||
|
||||
let locators = results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.map(|hit| hit.canonical_locator.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(locators, vec!["pingdotgg/t3code"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_search_only_returns_repositories_with_appimage_release_assets() {
|
||||
let query = SearchQuery::new("pingdotgg");
|
||||
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
|
||||
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
|
||||
|
||||
assert!(
|
||||
results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.all(|hit| hit.canonical_locator == "pingdotgg/t3code")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_name_only_search_excludes_description_only_matches() {
|
||||
let hits =
|
||||
search_github_repositories_with("pingdotgg in:name", 10, &FixtureGitHubTransport).unwrap();
|
||||
|
||||
let locators = hits
|
||||
.iter()
|
||||
.map(|hit| hit.full_name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(locators, vec!["pingdotgg/t3code"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_search_results_can_carry_local_matches_and_warnings() {
|
||||
let query = SearchQuery::new("bat");
|
||||
let installed = vec![AppRecord {
|
||||
stable_id: "bat".to_owned(),
|
||||
display_name: "Bat".to_owned(),
|
||||
source_input: None,
|
||||
source: None,
|
||||
installed_version: Some("1.0.0".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}];
|
||||
let provider = FailingProvider;
|
||||
|
||||
let results = build_search_results_with(&query, &installed, &[&provider]).unwrap();
|
||||
|
||||
assert!(results.remote_hits.is_empty());
|
||||
assert_eq!(results.installed_matches.len(), 1);
|
||||
assert_eq!(results.installed_matches[0].stable_id, "bat");
|
||||
assert_eq!(results.installed_matches[0].display_name, "Bat");
|
||||
assert_eq!(results.warnings.len(), 1);
|
||||
assert_eq!(results.warnings[0].provider_id.as_deref(), Some("github"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_search_marks_matching_current_install_as_installed() {
|
||||
let query = SearchQuery::new("bat");
|
||||
let installed = vec![installed_github_app("sharkdp/bat", "1.0.0")];
|
||||
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
|
||||
let results = build_search_results_with(&query, &installed, &[&provider]).unwrap();
|
||||
let bat = results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.find(|hit| hit.install_query == "sharkdp/bat")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
bat.install_status,
|
||||
SearchInstallStatus::Installed {
|
||||
installed_version: Some("1.0.0".to_owned()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_search_marks_older_install_as_update_available() {
|
||||
let query = SearchQuery::new("pingdotgg");
|
||||
let installed = vec![installed_github_app("pingdotgg/t3code", "0.0.11")];
|
||||
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
|
||||
let results = build_search_results_with(&query, &installed, &[&provider]).unwrap();
|
||||
let t3code = results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.find(|hit| hit.install_query == "pingdotgg/t3code")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(t3code.version.as_deref(), Some("0.0.12"));
|
||||
assert_eq!(
|
||||
t3code.install_status,
|
||||
SearchInstallStatus::UpdateAvailable {
|
||||
installed_version: Some("0.0.11".to_owned()),
|
||||
latest_version: Some("0.0.12".to_owned()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn installed_github_app(locator: &str, installed_version: &str) -> AppRecord {
|
||||
AppRecord {
|
||||
stable_id: locator.replace('/', "-"),
|
||||
display_name: locator.split('/').next_back().unwrap().to_owned(),
|
||||
source_input: Some(locator.to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::GitHub,
|
||||
locator: locator.to_owned(),
|
||||
input_kind: SourceInputKind::RepoShorthand,
|
||||
normalized_kind: NormalizedSourceKind::GitHubRepository,
|
||||
canonical_locator: Some(locator.to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some(installed_version.to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}
|
||||
}
|
||||
|
||||
struct FailingProvider;
|
||||
|
||||
impl SearchProvider for FailingProvider {
|
||||
fn search(
|
||||
&self,
|
||||
_query: &SearchQuery,
|
||||
) -> Result<Vec<upm_core::domain::search::SearchResult>, SearchProviderError> {
|
||||
Err(SearchProviderError::new("github", "fixture rate limit"))
|
||||
}
|
||||
}
|
||||
303
crates/upm-core/tests/show_resolution.rs
Normal file
303
crates/upm-core/tests/show_resolution.rs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
use upm_core::app::show::{build_show_result, build_show_result_with};
|
||||
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use upm_core::domain::show::{ShowResult, ShowResultError};
|
||||
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||
use upm_core::domain::update::{
|
||||
ChannelPreference, MetadataHints, ParsedMetadata, ParsedMetadataKind, UpdateChannelKind,
|
||||
UpdateStrategy,
|
||||
};
|
||||
use upm_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/upm-bat.desktop".to_owned()),
|
||||
icon_path: Some("/tmp/upm-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,
|
||||
upm_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 { .. }
|
||||
));
|
||||
}
|
||||
584
crates/upm-core/tests/update_planning.rs
Normal file
584
crates/upm-core/tests/update_planning.rs
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::tempdir;
|
||||
use upm_core::app::add::AddSecurityPolicy;
|
||||
use upm_core::app::progress::{NoopReporter, OperationEvent, OperationStage};
|
||||
use upm_core::app::update::{
|
||||
build_update_plan, execute_updates, execute_updates_with_reporter,
|
||||
execute_updates_with_reporter_and_policy,
|
||||
};
|
||||
use upm_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use upm_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||
use upm_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
|
||||
use upm_core::integration::paths::managed_appimage_path;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn empty_registry_produces_empty_plan() {
|
||||
let plan = build_update_plan(&[]).unwrap();
|
||||
|
||||
assert!(plan.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_apps_are_carried_into_review_plan() {
|
||||
let apps = [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,
|
||||
}];
|
||||
|
||||
let plan = build_update_plan(&apps).unwrap();
|
||||
|
||||
assert_eq!(plan.items.len(), 1);
|
||||
assert_eq!(plan.items[0].stable_id, "bat");
|
||||
assert_eq!(plan.items[0].selection_reason, "install-origin-match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_plan_uses_alternate_channel_after_preferred_failure() {
|
||||
let apps = [AppRecord {
|
||||
stable_id: "t3code".to_owned(),
|
||||
display_name: "T3 Code".to_owned(),
|
||||
source_input: Some("pingdotgg/t3code".to_owned()),
|
||||
source: None,
|
||||
installed_version: Some("0.0.11".to_owned()),
|
||||
update_strategy: Some(UpdateStrategy {
|
||||
preferred: ChannelPreference {
|
||||
kind: UpdateChannelKind::GitHubReleases,
|
||||
locator: "fail://github".to_owned(),
|
||||
reason: "install-origin-match".to_owned(),
|
||||
},
|
||||
alternates: vec![ChannelPreference {
|
||||
kind: UpdateChannelKind::ElectronBuilder,
|
||||
locator: "https://example.test/latest-linux.yml".to_owned(),
|
||||
reason: "metadata-guided".to_owned(),
|
||||
}],
|
||||
}),
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}];
|
||||
|
||||
let plan = build_update_plan(&apps).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
plan.items[0].selected_channel.kind.as_str(),
|
||||
"electron-builder"
|
||||
);
|
||||
assert_eq!(plan.items[0].selection_reason, "preferred-channel-failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_update_keeps_previous_app_record() {
|
||||
let install_home = tempdir().unwrap();
|
||||
let previous = AppRecord {
|
||||
stable_id: "legacy-bat".to_owned(),
|
||||
display_name: "Legacy Bat".to_owned(),
|
||||
source_input: None,
|
||||
source: None,
|
||||
installed_version: Some("0.9.0".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: None,
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
}),
|
||||
};
|
||||
|
||||
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
|
||||
|
||||
assert_eq!(result.apps, vec![previous]);
|
||||
assert_eq!(result.updated_count(), 0);
|
||||
assert_eq!(result.failed_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_execution_reports_per_app_lifecycle_events() {
|
||||
let install_home = tempdir().unwrap();
|
||||
let app = AppRecord {
|
||||
stable_id: "legacy-bat".to_owned(),
|
||||
display_name: "Legacy Bat".to_owned(),
|
||||
source_input: None,
|
||||
source: None,
|
||||
installed_version: Some("0.9.0".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: None,
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
}),
|
||||
};
|
||||
let mut events: Vec<OperationEvent> = Vec::new();
|
||||
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
||||
|
||||
let result = execute_updates_with_reporter(
|
||||
std::slice::from_ref(&app),
|
||||
install_home.path(),
|
||||
&mut reporter,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.failed_count(), 1);
|
||||
assert!(events.iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
OperationEvent::StageChanged {
|
||||
stage: OperationStage::ResolveQuery,
|
||||
..
|
||||
}
|
||||
)
|
||||
}));
|
||||
assert!(events.iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
OperationEvent::Failed {
|
||||
stage: OperationStage::ResolveQuery,
|
||||
..
|
||||
}
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_plan_uses_direct_asset_fallback_for_direct_url_origin() {
|
||||
let apps = [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 plan = build_update_plan(&apps).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
plan.items[0].selected_channel.kind,
|
||||
UpdateChannelKind::DirectAsset
|
||||
);
|
||||
assert_eq!(
|
||||
plan.items[0].selected_channel.locator,
|
||||
"https://example.com/downloads/team-app.AppImage"
|
||||
);
|
||||
assert_eq!(plan.items[0].selection_reason, "install-origin-match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_execution_rebuilds_gitlab_source_without_rewriting_origin() {
|
||||
let install_home = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let previous = AppRecord {
|
||||
stable_id: "example-team-app".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some("https://gitlab.com/example/team-app".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::GitLab,
|
||||
locator: "https://gitlab.com/example/team-app".to_owned(),
|
||||
input_kind: SourceInputKind::GitLabUrl,
|
||||
normalized_kind: NormalizedSourceKind::GitLab,
|
||||
canonical_locator: Some("example/team-app".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some("latest".to_owned()),
|
||||
update_strategy: Some(UpdateStrategy {
|
||||
preferred: ChannelPreference {
|
||||
kind: UpdateChannelKind::DirectAsset,
|
||||
locator: "https://gitlab.com/example/team-app/-/releases/permalink/latest/downloads/team-app.AppImage"
|
||||
.to_owned(),
|
||||
reason: "provider-release".to_owned(),
|
||||
},
|
||||
alternates: Vec::new(),
|
||||
}),
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: None,
|
||||
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_eq!(result.failed_count(), 0);
|
||||
assert_eq!(
|
||||
result.apps[0].source.as_ref().unwrap().kind,
|
||||
SourceKind::GitLab
|
||||
);
|
||||
assert_eq!(
|
||||
result.apps[0].source.as_ref().unwrap().locator,
|
||||
"https://gitlab.com/example/team-app"
|
||||
);
|
||||
assert_eq!(
|
||||
result.apps[0]
|
||||
.source
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.canonical_locator
|
||||
.as_deref(),
|
||||
Some("example/team-app")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_execution_rebuilds_sourceforge_release_folder_without_rewriting_origin() {
|
||||
let install_home = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let previous = AppRecord {
|
||||
stable_id: "team-app".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some(
|
||||
"https://sourceforge.net/projects/team-app/files/releases/beta/download".to_owned(),
|
||||
),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::SourceForge,
|
||||
locator: "https://sourceforge.net/projects/team-app/files/releases/beta/download"
|
||||
.to_owned(),
|
||||
input_kind: SourceInputKind::SourceForgeUrl,
|
||||
normalized_kind: NormalizedSourceKind::SourceForge,
|
||||
canonical_locator: Some("team-app".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some("latest".to_owned()),
|
||||
update_strategy: Some(UpdateStrategy {
|
||||
preferred: ChannelPreference {
|
||||
kind: UpdateChannelKind::DirectAsset,
|
||||
locator: "https://sourceforge.net/projects/team-app/files/releases/beta/download"
|
||||
.to_owned(),
|
||||
reason: "provider-release".to_owned(),
|
||||
},
|
||||
alternates: Vec::new(),
|
||||
}),
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: None,
|
||||
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_eq!(result.failed_count(), 0);
|
||||
assert_eq!(
|
||||
result.apps[0].source.as_ref().unwrap().kind,
|
||||
SourceKind::SourceForge
|
||||
);
|
||||
assert_eq!(
|
||||
result.apps[0].source.as_ref().unwrap().locator,
|
||||
"https://sourceforge.net/projects/team-app/files/releases/beta/download"
|
||||
);
|
||||
assert_eq!(
|
||||
result.apps[0]
|
||||
.source
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.canonical_locator
|
||||
.as_deref(),
|
||||
Some("team-app")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_http_updates_are_rejected_by_default() {
|
||||
let _guard = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let install_home = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let previous = AppRecord {
|
||||
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some("http://example.com/downloads/team-app.AppImage".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::DirectUrl,
|
||||
locator: "http://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: None,
|
||||
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(), 0);
|
||||
assert_eq!(result.failed_count(), 1);
|
||||
assert!(matches!(
|
||||
&result.items[0].status,
|
||||
upm_core::domain::update::UpdateExecutionStatus::Failed { reason }
|
||||
if reason.contains("InsecureHttpSource")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_http_updates_can_be_allowed_by_policy() {
|
||||
let _guard = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let install_home = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let previous = AppRecord {
|
||||
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some("http://example.com/downloads/team-app.AppImage".to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::DirectUrl,
|
||||
locator: "http://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: None,
|
||||
desktop_entry_path: None,
|
||||
icon_path: None,
|
||||
}),
|
||||
};
|
||||
|
||||
let result = execute_updates_with_reporter_and_policy(
|
||||
std::slice::from_ref(&previous),
|
||||
install_home.path(),
|
||||
&mut NoopReporter,
|
||||
AddSecurityPolicy {
|
||||
allow_http_user_sources: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_count(), 1);
|
||||
assert_eq!(result.failed_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_execution_uses_stored_sourceforge_releases_root_for_file_like_inputs() {
|
||||
let install_home = tempdir().unwrap();
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("UPM_GITHUB_FIXTURE_MODE", "1");
|
||||
}
|
||||
|
||||
let previous = AppRecord {
|
||||
stable_id: "team-app".to_owned(),
|
||||
display_name: "team-app".to_owned(),
|
||||
source_input: Some(
|
||||
"https://sourceforge.net/projects/team-app/files/releases/team-app-1.0.0.AppImage/download"
|
||||
.to_owned(),
|
||||
),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::SourceForge,
|
||||
locator: "https://sourceforge.net/projects/team-app/files/releases".to_owned(),
|
||||
input_kind: SourceInputKind::SourceForgeUrl,
|
||||
normalized_kind: NormalizedSourceKind::SourceForge,
|
||||
canonical_locator: Some("team-app".to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: Some("team-app-1.0.0.AppImage".to_owned()),
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some("latest".to_owned()),
|
||||
update_strategy: Some(UpdateStrategy {
|
||||
preferred: ChannelPreference {
|
||||
kind: UpdateChannelKind::DirectAsset,
|
||||
locator: "https://sourceforge.net/projects/team-app/files/releases".to_owned(),
|
||||
reason: "provider-release".to_owned(),
|
||||
},
|
||||
alternates: Vec::new(),
|
||||
}),
|
||||
metadata: Vec::new(),
|
||||
install: Some(InstallMetadata {
|
||||
scope: InstallScope::User,
|
||||
payload_path: None,
|
||||
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_eq!(result.failed_count(), 0);
|
||||
assert_eq!(
|
||||
result.apps[0].source.as_ref().unwrap().locator,
|
||||
"https://sourceforge.net/projects/team-app/files/releases"
|
||||
);
|
||||
assert_eq!(
|
||||
result.apps[0]
|
||||
.source
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.requested_asset_name
|
||||
.as_deref(),
|
||||
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("UPM_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("UPM_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/upm/rollback")
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue