From e98823e599eb375b30037cc714710e3309d927d1 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 27 Jun 2026 17:21:00 +0900 Subject: test: port Composer tests unblocked by mockall, add seams Port 11 categories of previously-ignored Composer tests now reachable with the mockall crate: DownloadManager, VCS/Perforce/File downloaders, VersionSelector, PlatformRepository, Auditor, installer/FilesystemRepository, RootPackageLoader, util auth/http, commands, and Cache. Extract test seams additively on concrete structs as *Interface traits (Runtime, HhvmDetector, VersionGuesser, RepositorySet, Perforce, BinaryInstaller) plus mock-field seams (Cache, Filesystem); consumers take trait objects. Mocks are defined locally in the test crates via mockall::mock!, since automock-generated mocks are cfg(test)-gated and invisible across the integration-test boundary. dataProviders are ported in full; tests blocked by unported shims stay #[ignore] with documented reasons rather than reduced or weakened. Fix product bugs surfaced by the ports: - util/github: use the exception code, not the HTTP status, for 401/403 - advisory: serialize empty audit maps as [] to match PHP json_encode - repository/filesystem and downloader/file: fix RefCell double-borrow panics Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/shirabe/src/advisory/auditor.rs | 38 + crates/shirabe/src/cache.rs | 94 +- crates/shirabe/src/command/archive_command.rs | 61 + crates/shirabe/src/downloader/download_manager.rs | 11 + crates/shirabe/src/downloader/file_downloader.rs | 25 +- .../shirabe/src/downloader/perforce_downloader.rs | 14 +- crates/shirabe/src/factory.rs | 2 +- crates/shirabe/src/installer/binary_installer.rs | 28 + crates/shirabe/src/installer/library_installer.rs | 52 +- .../src/package/loader/root_package_loader.rs | 9 +- .../shirabe/src/package/version/version_guesser.rs | 27 + .../src/package/version/version_selector.rs | 6 +- crates/shirabe/src/platform/hhvm_detector.rs | 14 +- crates/shirabe/src/platform/runtime.rs | 58 +- .../src/repository/filesystem_repository.rs | 18 +- .../shirabe/src/repository/platform_repository.rs | 60 +- crates/shirabe/src/repository/repository_set.rs | 23 + .../shirabe/src/repository/vcs/perforce_driver.rs | 13 +- crates/shirabe/src/util/filesystem.rs | 39 + crates/shirabe/src/util/github.rs | 2 +- crates/shirabe/src/util/perforce.rs | 82 + crates/shirabe/tests/advisory/auditor_test.rs | 1015 ++++++++++- crates/shirabe/tests/advisory/main.rs | 5 + crates/shirabe/tests/cache_test.rs | 62 +- .../shirabe/tests/command/archive_command_test.rs | 271 ++- .../tests/command/run_script_command_test.rs | 14 +- .../shirabe/tests/command/status_command_test.rs | 8 +- .../tests/downloader/download_manager_test.rs | 731 +++++++- .../tests/downloader/file_downloader_test.rs | 145 +- .../tests/downloader/git_downloader_test.rs | 338 +++- .../tests/downloader/perforce_downloader_test.rs | 193 +- .../tests/installer/library_installer_test.rs | 63 +- .../package/loader/root_package_loader_test.rs | 232 ++- .../tests/package/version/version_selector_test.rs | 521 +++++- .../shirabe/tests/platform/hhvm_detector_test.rs | 1 + .../tests/repository/filesystem_repository_test.rs | 117 +- .../tests/repository/platform_repository_test.rs | 1844 +++++++++++++++++++- .../tests/repository/vcs/perforce_driver_test.rs | 229 +-- crates/shirabe/tests/util/github_test.rs | 178 +- crates/shirabe/tests/util/http_downloader_test.rs | 47 +- 40 files changed, 6245 insertions(+), 445 deletions(-) diff --git a/crates/shirabe/src/advisory/auditor.rs b/crates/shirabe/src/advisory/auditor.rs index 627b900..4331b7b 100644 --- a/crates/shirabe/src/advisory/auditor.rs +++ b/crates/shirabe/src/advisory/auditor.rs @@ -23,6 +23,7 @@ use crate::util::PackageInfo; /// Shape of the `--format=json` audit output. #[derive(serde::Serialize)] struct AuditJsonReport<'a> { + #[serde(serialize_with = "serialize_advisories_field")] advisories: &'a IndexMap>, #[serde(rename = "ignored-advisories", skip_serializing_if = "Option::is_none")] ignored_advisories: Option<&'a IndexMap>>, @@ -31,9 +32,46 @@ struct AuditJsonReport<'a> { skip_serializing_if = "Option::is_none" )] unreachable_repositories: Option<&'a Vec>, + #[serde(serialize_with = "serialize_abandoned_field")] abandoned: IndexMap>, } +/// PHP `json_encode` emits an empty associative array as `[]`, not `{}`. Mirror that so an empty +/// map serializes as an empty JSON array. +fn serialize_php_array(map: &IndexMap, serializer: S) -> Result +where + S: serde::Serializer, + V: serde::Serialize, +{ + use serde::ser::SerializeSeq; + if map.is_empty() { + let seq = serializer.serialize_seq(Some(0))?; + seq.end() + } else { + serializer.collect_map(map.iter()) + } +} + +fn serialize_advisories_field( + map: &&IndexMap>, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + serialize_php_array(*map, serializer) +} + +fn serialize_abandoned_field( + map: &IndexMap>, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + serialize_php_array(map, serializer) +} + /// @internal #[derive(Debug)] pub struct Auditor; diff --git a/crates/shirabe/src/cache.rs b/crates/shirabe/src/cache.rs index 696378a..20197cd 100644 --- a/crates/shirabe/src/cache.rs +++ b/crates/shirabe/src/cache.rs @@ -27,6 +27,29 @@ pub struct Cache { allowlist: String, filesystem: std::rc::Rc>, read_only: bool, + /// Test-only seam. Always `None` in production; configured via [`Cache::__set_mock`]. + mock: Option, +} + +/// Test-only seam mirroring the PHP CacheTest/FileDownloaderTest mocks of `Cache`. +#[derive(Debug, Default)] +pub struct CacheMock { + /// Overrides the file lists `gc()` derives from `get_finder()`, mirroring the PHP CacheTest + /// which mocks `getFinder` to feed `gc()` a controlled iterator. + pub finder: Option, + /// When `Some`, `gc_is_necessary` returns it verbatim. + pub gc_is_necessary: Option, + /// When `Some`, `gc` records each `(ttl, max_size)` call here and skips its real body. + pub gc_calls: Option>, +} + +/// The controlled file lists used by a mocked `gc()` pass. +#[derive(Debug, Default)] +pub struct GcFinderMock { + /// Entries yielded by `get_finder().date(...)`, i.e. the outdated files. + pub outdated: Vec, + /// Entries yielded by `get_finder().sort_by_accessed_time().get_iterator()`. + pub by_accessed_time: Vec, } /// @var bool|null @@ -55,6 +78,7 @@ impl Cache { filesystem, read_only, enabled: None, + mock: None, }; if !Self::is_usable(cache_dir) { @@ -253,6 +277,12 @@ impl Cache { } pub fn gc_is_necessary(&self) -> bool { + if let Some(mock) = &self.mock + && let Some(necessary) = mock.gc_is_necessary + { + return necessary; + } + let mut cache_collected = CACHE_COLLECTED.lock().unwrap(); if cache_collected.unwrap_or(false) { return false; @@ -316,26 +346,32 @@ impl Cache { } pub fn gc(&mut self, ttl: i64, max_size: i64) -> bool { + if let Some(mock) = self.mock.as_mut() + && let Some(calls) = mock.gc_calls.as_mut() + { + calls.push((ttl, max_size)); + return true; + } + if self.is_enabled() && !self.read_only { let expire = Utc::now() - chrono::Duration::seconds(ttl); - - let mut finder = self.get_finder(); - finder.date(&format!( + let expire_str = format!( "until {}", expire.format(date_format_to_strftime("Y-m-d H:i:s")) - )); - for file in &mut finder { + ); + + for file in self.gc_outdated_files(&expire_str) { let _ = self.filesystem.borrow_mut().unlink(&file); } let mut total_size = self.filesystem.borrow_mut().size(&self.root).unwrap_or(0); if total_size > max_size { - let mut iterator = self.get_finder().sort_by_accessed_time().get_iterator(); - while total_size > max_size && iterator.valid() { - let filepath = iterator.current(); + for filepath in self.gc_files_by_accessed_time() { + if total_size <= max_size { + break; + } total_size -= self.filesystem.borrow_mut().size(&filepath).unwrap_or(0); let _ = self.filesystem.borrow_mut().unlink(&filepath); - iterator.next(); } } @@ -347,6 +383,46 @@ impl Cache { false } + /// Files matching `get_finder().date(...)`. Honours the [`CacheMock`] seam when set. + fn gc_outdated_files(&self, expire_str: &str) -> Vec { + if let Some(mock) = &self.mock + && let Some(finder) = &mock.finder + { + return finder.outdated.clone(); + } + + let mut finder = self.get_finder(); + finder.date(expire_str); + finder.into_iter().collect() + } + + /// Files from `get_finder().sort_by_accessed_time()`. Honours the [`CacheMock`] seam when set. + fn gc_files_by_accessed_time(&self) -> Vec { + if let Some(mock) = &self.mock + && let Some(finder) = &mock.finder + { + return finder.by_accessed_time.clone(); + } + + self.get_finder() + .sort_by_accessed_time() + .get_iterator() + .collect() + } + + /// For testing only: install the [`CacheMock`] seam used by CacheTest/FileDownloaderTest. + pub fn __set_mock(&mut self, mock: CacheMock) { + self.mock = Some(mock); + } + + /// For testing only: read back the `(ttl, max_size)` calls recorded by the [`CacheMock`] seam. + pub fn __gc_calls(&self) -> Vec<(i64, i64)> { + self.mock + .as_ref() + .and_then(|m| m.gc_calls.clone()) + .unwrap_or_default() + } + pub fn gc_vcs_cache(&mut self, ttl: i64) -> bool { if self.is_enabled() { let expire = Utc::now() - chrono::Duration::seconds(ttl); diff --git a/crates/shirabe/src/command/archive_command.rs b/crates/shirabe/src/command/archive_command.rs index 9aae885..d593912 100644 --- a/crates/shirabe/src/command/archive_command.rs +++ b/crates/shirabe/src/command/archive_command.rs @@ -36,6 +36,28 @@ use crate::util::r#loop::Loop; #[derive(Debug)] pub struct ArchiveCommand { base_command_data: BaseCommandData, + /// For testing only: partial-mock seam mirroring PHPUnit `onlyMethods(['initialize', 'archive'])`. + test_hooks: RefCell, +} + +/// For testing only: records and stubs for the `ArchiveCommand` partial-mock seam. +#[derive(Debug, Default)] +pub struct ArchiveCommandTestHooks { + skip_initialize: bool, + archive_stub_return: Option, + archive_calls: Vec, +} + +/// For testing only: the scalar arguments captured from a stubbed `archive` call. +#[derive(Debug, Clone)] +pub struct ArchiveCallRecord { + pub package_name: Option, + pub version: Option, + pub format: String, + pub dest: String, + pub file_name: Option, + pub ignore_filters: bool, + pub had_composer: bool, } impl Default for ArchiveCommand { @@ -50,6 +72,7 @@ impl ArchiveCommand { pub fn new() -> Self { let command = ArchiveCommand { base_command_data: BaseCommandData::new(None), + test_hooks: RefCell::new(ArchiveCommandTestHooks::default()), }; command .configure() @@ -188,12 +211,33 @@ impl Command for ArchiveCommand { input: Rc>, output: Rc>, ) -> anyhow::Result<()> { + if self.test_hooks.borrow().skip_initialize { + return Ok(()); + } base_command_initialize(self, input, output) } shirabe_external_packages::delegate_command_trait_impls_to_inner!(base_command_data); } +impl ArchiveCommand { + /// For testing only: makes `initialize` a no-op (PHPUnit `onlyMethods(['initialize'])`). + pub fn __test_skip_initialize(&self) { + self.test_hooks.borrow_mut().skip_initialize = true; + } + + /// For testing only: stubs `archive` to record its arguments and return `return_value` + /// without running (PHPUnit `onlyMethods(['archive'])`). + pub fn __test_stub_archive(&self, return_value: i64) { + self.test_hooks.borrow_mut().archive_stub_return = Some(return_value); + } + + /// For testing only: the arguments captured from stubbed `archive` calls. + pub fn __test_archive_calls(&self) -> Vec { + self.test_hooks.borrow().archive_calls.clone() + } +} + impl BaseCommand for ArchiveCommand { fn command_data( &self, @@ -218,6 +262,23 @@ impl ArchiveCommand { ignore_filters: bool, composer: Option<&PartialComposerHandle>, ) -> Result { + let archive_stub_return = self.test_hooks.borrow().archive_stub_return; + if let Some(return_value) = archive_stub_return { + self.test_hooks + .borrow_mut() + .archive_calls + .push(ArchiveCallRecord { + package_name, + version, + format: format.to_string(), + dest: dest.to_string(), + file_name, + ignore_filters, + had_composer: composer.is_some(), + }); + return Ok(return_value); + } + let composer_guard = composer.map(crate::composer::composer_full); let mut owned_archive_manager; let composer_archive_manager; diff --git a/crates/shirabe/src/downloader/download_manager.rs b/crates/shirabe/src/downloader/download_manager.rs index a5a83a6..b978368 100644 --- a/crates/shirabe/src/downloader/download_manager.rs +++ b/crates/shirabe/src/downloader/download_manager.rs @@ -532,6 +532,17 @@ impl DownloadManager { Ok(sources) } + /// For testing only: exposes the private `getAvailableSources` for the + /// DownloadManagerTest::testGetAvailableSourcesUpdateSticksToSameSource case, + /// which reaches it through `ReflectionMethod` in PHP. + pub fn __get_available_sources( + &self, + package: PackageInterfaceHandle, + prev_package: Option, + ) -> Result> { + self.get_available_sources(package, prev_package) + } + /// Downloaders expect a /path/to/dir without trailing slash /// /// If any Installer provides a path with a trailing slash, this can cause bugs so make sure we remove them diff --git a/crates/shirabe/src/downloader/file_downloader.rs b/crates/shirabe/src/downloader/file_downloader.rs index b03db38..66f3390 100644 --- a/crates/shirabe/src/downloader/file_downloader.rs +++ b/crates/shirabe/src/downloader/file_downloader.rs @@ -124,18 +124,19 @@ impl FileDownloader { { // PHP: writeError('Running cache garbage collection', true, io_interface::VERY_VERBOSE) this.io.write_error("Running cache garbage collection"); - cache.borrow_mut().gc( - this.config - .borrow_mut() - .get("cache-files-ttl") - .as_int() - .unwrap_or(0), - this.config - .borrow_mut() - .get("cache-files-maxsize") - .as_int() - .unwrap_or(0), - ); + let ttl = this + .config + .borrow_mut() + .get("cache-files-ttl") + .as_int() + .unwrap_or(0); + let max_size = this + .config + .borrow_mut() + .get("cache-files-maxsize") + .as_int() + .unwrap_or(0); + cache.borrow_mut().gc(ttl, max_size); } this diff --git a/crates/shirabe/src/downloader/perforce_downloader.rs b/crates/shirabe/src/downloader/perforce_downloader.rs index f93cfa8..56d6957 100644 --- a/crates/shirabe/src/downloader/perforce_downloader.rs +++ b/crates/shirabe/src/downloader/perforce_downloader.rs @@ -12,6 +12,7 @@ use crate::package::PackageInterfaceHandle; use crate::repository::VcsRepository; use crate::util::Filesystem; use crate::util::Perforce; +use crate::util::PerforceInterface; use crate::util::ProcessExecutor; use anyhow::Result; use indexmap::IndexMap; @@ -20,7 +21,7 @@ use shirabe_php_shim::PhpMixed; #[derive(Debug)] pub struct PerforceDownloader { inner: VcsDownloaderBase, - pub(crate) perforce: Option, + pub(crate) perforce: Option>, } impl PerforceDownloader { @@ -61,20 +62,20 @@ impl PerforceDownloader { } else { None }; - self.perforce = Some(Perforce::create( + self.perforce = Some(Box::new(Perforce::create( repo_config.unwrap_or_default(), url, path, self.inner.process.clone(), self.inner.io.clone(), - )); + ))); } fn get_repo_config(&self, repository: &VcsRepository) -> IndexMap { repository.get_repo_config().clone() } - pub fn set_perforce(&mut self, perforce: Perforce) { + pub fn set_perforce(&mut self, perforce: Box) { self.perforce = Some(perforce); } } @@ -135,10 +136,7 @@ impl VcsDownloader for PerforceDownloader { self.perforce.as_mut().unwrap().p4_login(); self.perforce.as_mut().unwrap().write_p4_client_spec(); self.perforce.as_mut().unwrap().connect_client(); - self.perforce - .as_mut() - .unwrap() - .sync_code_base(label.as_deref()); + self.perforce.as_mut().unwrap().sync_code_base(label); self.perforce.as_mut().unwrap().cleanup_client_spec(); Ok(None) diff --git a/crates/shirabe/src/factory.rs b/crates/shirabe/src/factory.rs index 8a97313..2c27015 100644 --- a/crates/shirabe/src/factory.rs +++ b/crates/shirabe/src/factory.rs @@ -1270,7 +1270,7 @@ impl Factory { guesser: VersionGuesser, io: std::rc::Rc>, ) -> RootPackageLoader { - RootPackageLoader::new(rm, config, Some(parser), Some(guesser), Some(io)) + RootPackageLoader::new(rm, config, Some(parser), Some(Box::new(guesser)), Some(io)) } pub fn create( diff --git a/crates/shirabe/src/installer/binary_installer.rs b/crates/shirabe/src/installer/binary_installer.rs index a76f69e..ea73f6f 100644 --- a/crates/shirabe/src/installer/binary_installer.rs +++ b/crates/shirabe/src/installer/binary_installer.rs @@ -17,6 +17,34 @@ use crate::util::Platform; use crate::util::ProcessExecutor; use crate::util::Silencer; +/// Seam over the BinaryInstaller methods reached through LibraryInstaller, so tests can inject a +/// recording double. Composer has no PHP `BinaryInstallerInterface`; this exists only to allow the +/// `LibraryInstaller` collaborator to be mocked the way PHPUnit mocks the concrete class. +pub trait BinaryInstallerInterface: std::fmt::Debug { + fn install_binaries( + &mut self, + package: PackageInterfaceHandle, + install_path: &str, + warn_on_overwrite: bool, + ); + fn remove_binaries(&mut self, package: PackageInterfaceHandle); +} + +impl BinaryInstallerInterface for BinaryInstaller { + fn install_binaries( + &mut self, + package: PackageInterfaceHandle, + install_path: &str, + warn_on_overwrite: bool, + ) { + BinaryInstaller::install_binaries(self, package, install_path, warn_on_overwrite); + } + + fn remove_binaries(&mut self, package: PackageInterfaceHandle) { + BinaryInstaller::remove_binaries(self, package); + } +} + /// Utility to handle installation of package "bin"/binaries #[derive(Debug)] pub struct BinaryInstaller { diff --git a/crates/shirabe/src/installer/library_installer.rs b/crates/shirabe/src/installer/library_installer.rs index e1c3aec..96f38c7 100644 --- a/crates/shirabe/src/installer/library_installer.rs +++ b/crates/shirabe/src/installer/library_installer.rs @@ -10,6 +10,7 @@ use shirabe_php_shim::{ use crate::composer::PartialComposerWeakHandle; use crate::downloader::DownloadManagerInterface; use crate::installer::BinaryInstaller; +use crate::installer::BinaryInstallerInterface; use crate::installer::BinaryPresenceInterface; use crate::installer::InstallerInterface; use crate::io::IOInterface; @@ -29,7 +30,7 @@ pub struct LibraryInstaller { pub(crate) io: std::rc::Rc>, pub(crate) r#type: Option, pub(crate) filesystem: std::rc::Rc>, - pub(crate) binary_installer: BinaryInstaller, + pub(crate) binary_installer: Box, } impl LibraryInstaller { @@ -61,28 +62,31 @@ impl LibraryInstaller { .unwrap_or_default(), Some("/"), ); - let binary_installer = binary_installer.unwrap_or_else(|| { - let bin_dir = rtrim( - &composer_ref + let binary_installer: Box = match binary_installer { + Some(binary_installer) => Box::new(binary_installer), + None => { + let bin_dir = rtrim( + &composer_ref + .get_config() + .borrow_mut() + .get_str("bin-dir") + .unwrap_or_default(), + Some("/"), + ); + let bin_compat = composer_ref .get_config() .borrow_mut() - .get_str("bin-dir") - .unwrap_or_default(), - Some("/"), - ); - let bin_compat = composer_ref - .get_config() - .borrow_mut() - .get_str("bin-compat") - .unwrap_or_default(); - BinaryInstaller::new( - io.clone(), - bin_dir, - bin_compat, - Some(filesystem.clone()), - Some(vendor_dir.clone()), - ) - }); + .get_str("bin-compat") + .unwrap_or_default(); + Box::new(BinaryInstaller::new( + io.clone(), + bin_dir, + bin_compat, + Some(filesystem.clone()), + Some(vendor_dir.clone()), + )) + } + }; Self { composer, @@ -95,6 +99,12 @@ impl LibraryInstaller { } } + /// For testing only: swap the binary installer for a recording double, mirroring the + /// constructor injection PHPUnit performs with a mocked BinaryInstaller. + pub fn __set_binary_installer(&mut self, binary_installer: Box) { + self.binary_installer = binary_installer; + } + /// Make sure binaries are installed for a given package. pub fn ensure_binaries_presence(&mut self, package: PackageInterfaceHandle) { let install_path = self.get_install_path(package.clone()).unwrap(); diff --git a/crates/shirabe/src/package/loader/root_package_loader.rs b/crates/shirabe/src/package/loader/root_package_loader.rs index 556f36e..c0101ac 100644 --- a/crates/shirabe/src/package/loader/root_package_loader.rs +++ b/crates/shirabe/src/package/loader/root_package_loader.rs @@ -11,6 +11,7 @@ use crate::package::loader::ArrayLoader; use crate::package::loader::LoaderInterface; use crate::package::loader::ValidatingArrayLoader; use crate::package::version::VersionGuesser; +use crate::package::version::VersionGuesserInterface; use crate::package::version::VersionParser; use crate::package::{RootPackage, STABILITIES, SUPPORTED_LINK_TYPES}; use crate::repository::RepositoryFactory; @@ -23,7 +24,7 @@ pub struct RootPackageLoader { inner: ArrayLoader, manager: std::rc::Rc>, config: std::rc::Rc>, - version_guesser: VersionGuesser, + version_guesser: Box, io: Option>>, } @@ -32,19 +33,19 @@ impl RootPackageLoader { manager: std::rc::Rc>, config: std::rc::Rc>, parser: Option, - version_guesser: Option, + version_guesser: Option>, io: Option>>, ) -> Self { let inner = ArrayLoader::new(parser, true); let version_guesser = version_guesser.unwrap_or_else(|| { let mut process_executor = ProcessExecutor::new(io.clone()); process_executor.enable_async(); - VersionGuesser::new( + Box::new(VersionGuesser::new( config.clone(), std::rc::Rc::new(std::cell::RefCell::new(process_executor)), inner.version_parser.clone(), io.clone(), - ) + )) }); Self { inner, diff --git a/crates/shirabe/src/package/version/version_guesser.rs b/crates/shirabe/src/package/version/version_guesser.rs index c5bf4c2..6642201 100644 --- a/crates/shirabe/src/package/version/version_guesser.rs +++ b/crates/shirabe/src/package/version/version_guesser.rs @@ -20,6 +20,19 @@ use crate::util::Platform; use crate::util::ProcessExecutor; use crate::util::Svn as SvnUtil; +/// Seam over the parts of [`VersionGuesser`] that consumers depend on, so they can be exercised +/// with a test double. PHP has no such interface; this exists only to allow mocking the concrete +/// `VersionGuesser` (a Phase C seam). +pub trait VersionGuesserInterface: std::fmt::Debug { + fn guess_version( + &mut self, + package_config: &IndexMap, + path: &str, + ) -> Result>; + + fn get_root_version_from_env(&self) -> Result; +} + /// Try to guess the current version number based on different VCS configuration. /// /// @phpstan-type Version array{version: string, commit: string|null, pretty_version: string|null}|array{version: string, commit: string|null, pretty_version: string|null, feature_version: string|null, feature_pretty_version: string|null} @@ -747,6 +760,20 @@ impl VersionGuesser { } } +impl VersionGuesserInterface for VersionGuesser { + fn guess_version( + &mut self, + package_config: &IndexMap, + path: &str, + ) -> Result> { + VersionGuesser::guess_version(self, package_config, path) + } + + fn get_root_version_from_env(&self) -> Result { + VersionGuesser::get_root_version_from_env(self) + } +} + #[derive(Debug)] pub struct FeatureVersionResult { pub version: Option, diff --git a/crates/shirabe/src/package/version/version_selector.rs b/crates/shirabe/src/package/version/version_selector.rs index 793dd4e..8a0d037 100644 --- a/crates/shirabe/src/package/version/version_selector.rs +++ b/crates/shirabe/src/package/version/version_selector.rs @@ -23,18 +23,18 @@ use crate::package::loader::ArrayLoader; use crate::package::version::VersionParser; use crate::repository::PlatformRepository; use crate::repository::RepositoryInterface; -use crate::repository::RepositorySet; +use crate::repository::RepositorySetInterface; #[derive(Debug)] pub struct VersionSelector { - repository_set: std::rc::Rc>, + repository_set: std::rc::Rc>, platform_constraints: IndexMap>, parser: Option, } impl VersionSelector { pub fn new( - repository_set: std::rc::Rc>, + repository_set: std::rc::Rc>, platform_repo: Option<&mut crate::repository::PlatformRepository>, ) -> anyhow::Result { let mut platform_constraints: IndexMap> = IndexMap::new(); diff --git a/crates/shirabe/src/platform/hhvm_detector.rs b/crates/shirabe/src/platform/hhvm_detector.rs index da062d5..69871f1 100644 --- a/crates/shirabe/src/platform/hhvm_detector.rs +++ b/crates/shirabe/src/platform/hhvm_detector.rs @@ -9,6 +9,14 @@ use std::sync::Mutex; // None = null (uninitialized), Some(None) = false (not found), Some(Some(v)) = version static HHVM_VERSION_CACHE: Mutex>> = Mutex::new(None); +/// Seam over HHVM detection so PlatformRepository can be tested with a mocked version. +/// PHP mocks the concrete `Composer\Platform\HhvmDetector` directly; the trait is +/// introduced here to keep the consumer dependent only on trait methods. +pub trait HhvmDetectorInterface: std::fmt::Debug { + fn reset(&self); + fn get_version(&mut self) -> Option; +} + #[derive(Debug)] pub struct HhvmDetector { executable_finder: Option, @@ -25,12 +33,14 @@ impl HhvmDetector { process_executor, } } +} - pub fn reset(&self) { +impl HhvmDetectorInterface for HhvmDetector { + fn reset(&self) { *HHVM_VERSION_CACHE.lock().unwrap() = None; } - pub fn get_version(&mut self) -> Option { + fn get_version(&mut self) -> Option { let cached = HHVM_VERSION_CACHE.lock().unwrap().clone(); if cached.is_some() { return cached.flatten(); diff --git a/crates/shirabe/src/platform/runtime.rs b/crates/shirabe/src/platform/runtime.rs index aa2f4d7..fcee930 100644 --- a/crates/shirabe/src/platform/runtime.rs +++ b/crates/shirabe/src/platform/runtime.rs @@ -8,41 +8,53 @@ use shirabe_php_shim::{ html_entity_decode, implode, instantiate_class, ltrim, phpversion, strip_tags, trim, }; +/// Seam over the PHP runtime so PlatformRepository can be tested against mocked +/// extension/constant/function probes. PHP has no such interface (the test mocks the +/// concrete `Composer\Platform\Runtime` directly); it is introduced here to keep the +/// consumer dependent only on trait methods. +pub trait RuntimeInterface: std::fmt::Debug { + fn has_constant(&self, constant_name: &str, class: Option) -> bool; + fn get_constant(&self, constant_name: &str, class: Option) -> PhpMixed; + /// `callable` carries the PHP callable spec (a function name string or a + /// `[class, method]` list), matching PHP `invoke($callable, $arguments)`. + fn invoke(&self, callable: PhpMixed, arguments: Vec) -> PhpMixed; + fn has_class(&self, class: &str) -> bool; + fn construct(&self, class: &str, arguments: Vec) -> Result; + fn get_extensions(&self) -> Vec; + fn get_extension_version(&self, extension: &str) -> String; + fn get_extension_info(&self, extension: &str) -> Result; +} + #[derive(Debug)] pub struct Runtime; -impl Runtime { - pub fn has_constant(&self, constant_name: &str, class: Option<&str>) -> bool { +impl RuntimeInterface for Runtime { + fn has_constant(&self, constant_name: &str, class: Option) -> bool { defined(<rim( - &format!("{}::{}", class.unwrap_or(""), constant_name), + &format!("{}::{}", class.as_deref().unwrap_or(""), constant_name), Some(":"), )) } - pub fn get_constant(&self, constant_name: &str, class: Option<&str>) -> PhpMixed { + fn get_constant(&self, constant_name: &str, class: Option) -> PhpMixed { constant(<rim( - &format!("{}::{}", class.unwrap_or(""), constant_name), + &format!("{}::{}", class.as_deref().unwrap_or(""), constant_name), Some(":"), )) } - pub fn has_function(&self, f: &str) -> bool { - function_exists(f) - } - - pub fn invoke( - &self, - callable: Box) -> PhpMixed>, - arguments: Vec, - ) -> PhpMixed { - callable(arguments) + fn invoke(&self, callable: PhpMixed, arguments: Vec) -> PhpMixed { + // PHP: return $callable(...$arguments); + // Dispatching an arbitrary PHP callable needs a PHP runtime; no shim exists. + let _ = (callable, arguments); + todo!() } - pub fn has_class(&self, class: &str) -> bool { + fn has_class(&self, class: &str) -> bool { class_exists(class) } - pub fn construct(&self, class: &str, arguments: Vec) -> Result { + fn construct(&self, class: &str, arguments: Vec) -> Result { if arguments.is_empty() { Ok(instantiate_class(class, vec![])) } else { @@ -50,20 +62,26 @@ impl Runtime { } } - pub fn get_extensions(&self) -> Vec { + fn get_extensions(&self) -> Vec { get_loaded_extensions() } - pub fn get_extension_version(&self, extension: &str) -> String { + fn get_extension_version(&self, extension: &str) -> String { let version = phpversion(extension); version.unwrap_or_else(|| "0".to_string()) } - pub fn get_extension_info(&self, extension: &str) -> Result { + fn get_extension_info(&self, extension: &str) -> Result { // Depends on \ReflectionExtension::info() and output buffering; no shim equivalent exists. let _ = extension; todo!() } +} + +impl Runtime { + pub fn has_function(&self, f: &str) -> bool { + function_exists(f) + } pub fn parse_html_extension_info(html: &str) -> String { let mut result: Vec = vec![]; diff --git a/crates/shirabe/src/repository/filesystem_repository.rs b/crates/shirabe/src/repository/filesystem_repository.rs index 0dae316..14365f2 100644 --- a/crates/shirabe/src/repository/filesystem_repository.rs +++ b/crates/shirabe/src/repository/filesystem_repository.rs @@ -14,7 +14,7 @@ use shirabe_php_shim::{ use crate::config::is_php_integer_key; use crate::installed_versions::InstalledVersions; -use crate::installer::InstallationManager; +use crate::installer::InstallationManagerInterface; use crate::json::JsonFile; use crate::package::BasePackageHandle; use crate::package::PackageInterfaceHandle; @@ -209,7 +209,7 @@ impl FilesystemRepository { pub fn write( &mut self, dev_mode: bool, - installation_manager: &mut InstallationManager, + installation_manager: &mut dyn InstallationManagerInterface, ) -> Result<()> { let mut data: IndexMap = IndexMap::new(); data.insert("packages".to_string(), PhpMixed::List(vec![])); @@ -239,11 +239,7 @@ impl FilesystemRepository { if let Some(path_str) = &path && !path_str.is_empty() { - let normalized_path = self.filesystem.borrow_mut().normalize_path(&if self - .filesystem - .borrow() - .is_absolute_path(path_str) - { + let path_to_normalize = if self.filesystem.borrow().is_absolute_path(path_str) { path_str.clone() } else { format!( @@ -251,7 +247,11 @@ impl FilesystemRepository { Platform::get_cwd(false).unwrap_or_default(), path_str ) - }); + }; + let normalized_path = self + .filesystem + .borrow_mut() + .normalize_path(&path_to_normalize); install_path = Some(self.filesystem.borrow_mut().find_shortest_path( &repo_dir, &normalized_path, @@ -453,7 +453,7 @@ impl FilesystemRepository { /// @param array $installPaths fn generate_installed_versions( &mut self, - installation_manager: &InstallationManager, + installation_manager: &dyn InstallationManagerInterface, install_paths: &IndexMap>, dev_mode: bool, repo_dir: &str, diff --git a/crates/shirabe/src/repository/platform_repository.rs b/crates/shirabe/src/repository/platform_repository.rs index 981ad8d..ee1d26e 100644 --- a/crates/shirabe/src/repository/platform_repository.rs +++ b/crates/shirabe/src/repository/platform_repository.rs @@ -23,7 +23,9 @@ use crate::package::PackageInterface; use crate::package::PackageInterfaceHandle; use crate::package::version::VersionParser; use crate::platform::HhvmDetector; +use crate::platform::HhvmDetectorInterface; use crate::platform::Runtime; +use crate::platform::RuntimeInterface; use crate::platform::Version; use crate::plugin::plugin_interface::{self}; use crate::repository::ArrayRepository; @@ -48,8 +50,8 @@ pub struct PlatformRepository { pub(crate) version_parser: Option, pub(crate) overrides: IndexMap, pub(crate) disabled_packages: IndexMap, - pub(crate) runtime: Runtime, - pub(crate) hhvm_detector: HhvmDetector, + pub(crate) runtime: Box, + pub(crate) hhvm_detector: Box, } impl PlatformRepository { @@ -65,11 +67,12 @@ impl PlatformRepository { pub fn new4( packages: Vec, overrides: IndexMap, - runtime: Option, - hhvm_detector: Option, + runtime: Option>, + hhvm_detector: Option>, ) -> anyhow::Result { - let runtime = runtime.unwrap_or(Runtime); - let hhvm_detector = hhvm_detector.unwrap_or_else(|| HhvmDetector::new(None, None)); + let runtime: Box = runtime.unwrap_or_else(|| Box::new(Runtime)); + let hhvm_detector: Box = + hhvm_detector.unwrap_or_else(|| Box::new(HhvmDetector::new(None, None))); let mut overrides_map: IndexMap = IndexMap::new(); for (name, version) in overrides { if !is_string(&version) && !matches!(version, PhpMixed::Bool(false)) { @@ -281,17 +284,10 @@ impl PlatformRepository { // IPv6 support might still be available. let has_inet6 = self.runtime.has_constant("AF_INET6", None); // PHP: Silencer::call([$this->runtime, 'invoke'], 'inet_pton', ['::']) - // TODO(phase-c): Composer's Platform\Runtime class is entirely a todo!() stub (invoke, - // has_constant, ...), so the inet_pton invocation cannot run. The placeholder callable - // returns false; resolving this requires implementing the Runtime wrapper (separate Phase C - // work) and a closure that forwards to the inet_pton shim. let inet_pton_check = Silencer::call(|| { Ok::(self.runtime.invoke( - Box::new(|_args| PhpMixed::Bool(false)), - vec![ - PhpMixed::String("inet_pton".to_string()), - PhpMixed::String("::".to_string()), - ], + PhpMixed::String("inet_pton".to_string()), + vec![PhpMixed::String("::".to_string())], )) }) .unwrap_or(PhpMixed::Bool(false)); @@ -406,10 +402,9 @@ impl PlatformRepository { } "curl" => { - let curl_version = self.runtime.invoke( - Box::new(|_args| PhpMixed::Null), - vec![PhpMixed::String("curl_version".to_string())], - ); + let curl_version = self + .runtime + .invoke(PhpMixed::String("curl_version".to_string()), vec![]); let curl_version_str = curl_version .as_array() .and_then(|m| m.get("version")) @@ -819,17 +814,14 @@ impl PlatformRepository { // Add a separate version for the CLDR library version if self.runtime.has_class("ResourceBundle") { let resource_bundle = self.runtime.invoke( - Box::new(|_args| PhpMixed::Null), + PhpMixed::List(vec![ + PhpMixed::String("ResourceBundle".to_string()), + PhpMixed::String("create".to_string()), + ]), vec![ - PhpMixed::List(vec![ - PhpMixed::String("ResourceBundle".to_string()), - PhpMixed::String("create".to_string()), - ]), - PhpMixed::List(vec![ - PhpMixed::String("root".to_string()), - PhpMixed::String("ICUDATA".to_string()), - PhpMixed::Bool(false), - ]), + PhpMixed::String("root".to_string()), + PhpMixed::String("ICUDATA".to_string()), + PhpMixed::Bool(false), ], ); if !matches!(resource_bundle, PhpMixed::Null) { @@ -853,11 +845,11 @@ impl PlatformRepository { if self.runtime.has_class("IntlChar") { let intl_char_versions = self.runtime.invoke( - Box::new(|_args| PhpMixed::Null), - vec![PhpMixed::List(vec![ + PhpMixed::List(vec![ PhpMixed::String("IntlChar".to_string()), PhpMixed::String("getUnicodeVersion".to_string()), - ])], + ]), + vec![], ); let sliced = shirabe_php_shim::array_slice_mixed(&intl_char_versions, 0, Some(3)); @@ -1421,11 +1413,11 @@ impl PlatformRepository { "zip" => { if self .runtime - .has_constant("LIBZIP_VERSION", Some("ZipArchive")) + .has_constant("LIBZIP_VERSION", Some("ZipArchive".to_string())) { let libzip = self .runtime - .get_constant("LIBZIP_VERSION", Some("ZipArchive")); + .get_constant("LIBZIP_VERSION", Some("ZipArchive".to_string())); let libzip_str = match &libzip { PhpMixed::String(s) => Some(s.clone()), _ => None, diff --git a/crates/shirabe/src/repository/repository_set.rs b/crates/shirabe/src/repository/repository_set.rs index 393639f..f8514cb 100644 --- a/crates/shirabe/src/repository/repository_set.rs +++ b/crates/shirabe/src/repository/repository_set.rs @@ -648,6 +648,29 @@ impl RepositorySet { } } +/// Seam over `RepositorySet` so consumers (e.g. `VersionSelector`) can receive a mockable +/// repository set in tests. The concrete `RepositorySet` implements it by delegating to its +/// inherent methods. New consumers may extend this trait additively. +pub trait RepositorySetInterface: std::fmt::Debug { + fn find_packages( + &self, + name: &str, + constraint: Option, + flags: i64, + ) -> anyhow::Result>; +} + +impl RepositorySetInterface for RepositorySet { + fn find_packages( + &self, + name: &str, + constraint: Option, + flags: i64, + ) -> anyhow::Result> { + RepositorySet::find_packages(self, name, constraint, flags) + } +} + #[derive(Debug)] pub struct SecurityAdvisoriesResult { pub advisories: IndexMap>, diff --git a/crates/shirabe/src/repository/vcs/perforce_driver.rs b/crates/shirabe/src/repository/vcs/perforce_driver.rs index 50d5eb9..cb280e5 100644 --- a/crates/shirabe/src/repository/vcs/perforce_driver.rs +++ b/crates/shirabe/src/repository/vcs/perforce_driver.rs @@ -9,6 +9,7 @@ use crate::config::Config; use crate::io::IOInterface; use crate::repository::vcs::VcsDriverBase; use crate::util::Perforce; +use crate::util::PerforceInterface; use crate::util::ProcessExecutor; use crate::util::http::Response; @@ -17,7 +18,7 @@ pub struct PerforceDriver { inner: VcsDriverBase, pub(crate) depot: String, pub(crate) branch: String, - pub(crate) perforce: Option, + pub(crate) perforce: Option>, } impl PerforceDriver { @@ -86,17 +87,23 @@ impl PerforceDriver { } let repo_dir = format!("{}/{}", cache_vcs_dir, self.depot); - self.perforce = Some(Perforce::create( + self.perforce = Some(Box::new(Perforce::create( repo_config.clone(), self.inner.url.clone(), repo_dir, self.inner.process.clone(), self.inner.io.clone(), - )); + ))); Ok(()) } + /// For testing only. Rust equivalent of the reflection-based + /// `overrideDriverInternalPerforce` helper in PerforceDriverTest. + pub fn __override_perforce(&mut self, perforce: Box) { + self.perforce = Some(perforce); + } + pub fn get_file_content( &mut self, file: &str, diff --git a/crates/shirabe/src/util/filesystem.rs b/crates/shirabe/src/util/filesystem.rs index 6dd2ab9..b224767 100644 --- a/crates/shirabe/src/util/filesystem.rs +++ b/crates/shirabe/src/util/filesystem.rs @@ -22,15 +22,41 @@ use crate::util::Silencer; #[derive(Debug)] pub struct Filesystem { process_executor: Option>>, + /// Test-only seam. Always `None` in production; configured via [`Filesystem::__set_mock`]. + mock: Option, +} + +/// Test-only seam mirroring the PHP FileDownloaderTest mock of `Filesystem`. +#[derive(Debug, Default)] +pub struct FilesystemMock { + /// When `Some`, `remove_directory_async` returns it without touching disk and counts the call. + pub remove_directory_async_result: Option, + pub remove_directory_async_calls: usize, + /// When true, `normalize_path` returns its argument unchanged. + pub normalize_path_identity: bool, } impl Filesystem { pub fn new(executor: Option>>) -> Self { Self { process_executor: executor, + mock: None, } } + /// For testing only: install the [`FilesystemMock`] seam used by FileDownloaderTest. + pub fn __set_mock(&mut self, mock: FilesystemMock) { + self.mock = Some(mock); + } + + /// For testing only: number of `remove_directory_async` calls intercepted by the seam. + pub fn __remove_directory_async_calls(&self) -> usize { + self.mock + .as_ref() + .map(|m| m.remove_directory_async_calls) + .unwrap_or(0) + } + pub fn remove(&mut self, file: impl AsRef) -> anyhow::Result { let file = file.as_ref(); if is_dir(file) { @@ -140,6 +166,13 @@ impl Filesystem { /// Uses the process component if proc_open is enabled on the PHP /// installation. pub async fn remove_directory_async(&mut self, directory: &str) -> anyhow::Result { + if let Some(mock) = self.mock.as_mut() + && let Some(result) = mock.remove_directory_async_result + { + mock.remove_directory_async_calls += 1; + return Ok(result); + } + let edge_case_result = self.remove_edge_cases(directory, true)?; if let Some(r) = edge_case_result { return Ok(r); @@ -702,6 +735,12 @@ impl Filesystem { /// Normalize a path. This replaces backslashes with slashes, removes ending /// slash and collapses redundant separators and up-level references. pub fn normalize_path(&self, path: &str) -> String { + if let Some(mock) = &self.mock + && mock.normalize_path_identity + { + return path.to_string(); + } + let mut parts: Vec = vec![]; let mut path = strtr(path, "\\", "/"); let mut prefix = String::new(); diff --git a/crates/shirabe/src/util/github.rs b/crates/shirabe/src/util/github.rs index dae4954..285052e 100644 --- a/crates/shirabe/src/util/github.rs +++ b/crates/shirabe/src/util/github.rs @@ -241,7 +241,7 @@ impl GitHub { Err(te) => { let code = te .downcast_ref::() - .and_then(|t| t.get_status_code()) + .map(|t| t.get_code()) .unwrap_or(0); if code == 403 || code == 401 { self.io.write_error3( diff --git a/crates/shirabe/src/util/perforce.rs b/crates/shirabe/src/util/perforce.rs index 4d4ddef..d8cd39d 100644 --- a/crates/shirabe/src/util/perforce.rs +++ b/crates/shirabe/src/util/perforce.rs @@ -838,3 +838,85 @@ impl Perforce { .clone() } } + +/// Seam over `Perforce` so consumers can be exercised with a mock in tests. +pub trait PerforceInterface: std::fmt::Debug { + fn initialize_path(&mut self, path: &str); + fn set_stream(&mut self, stream: &str); + fn p4_login(&mut self) -> Result<()>; + fn check_stream(&mut self) -> bool; + fn write_p4_client_spec(&mut self) -> Result<()>; + fn connect_client(&mut self) -> Result<()>; + fn sync_code_base(&mut self, source_reference: Option) -> Result<()>; + fn cleanup_client_spec(&mut self); + fn get_commit_logs(&mut self, from_reference: &str, to_reference: &str) -> Option; + fn get_file_content(&mut self, file: &str, identifier: &str) -> Option; + fn get_branches(&mut self) -> IndexMap; + fn get_tags(&mut self) -> IndexMap; + fn get_user(&self) -> Option; + fn get_composer_information( + &mut self, + identifier: &str, + ) -> Result>>; +} + +impl PerforceInterface for Perforce { + fn initialize_path(&mut self, path: &str) { + Perforce::initialize_path(self, path) + } + + fn set_stream(&mut self, stream: &str) { + Perforce::set_stream(self, stream) + } + + fn p4_login(&mut self) -> Result<()> { + Perforce::p4_login(self) + } + + fn check_stream(&mut self) -> bool { + Perforce::check_stream(self) + } + + fn write_p4_client_spec(&mut self) -> Result<()> { + Perforce::write_p4_client_spec(self) + } + + fn connect_client(&mut self) -> Result<()> { + Perforce::connect_client(self) + } + + fn sync_code_base(&mut self, source_reference: Option) -> Result<()> { + Perforce::sync_code_base(self, source_reference.as_deref()) + } + + fn cleanup_client_spec(&mut self) { + Perforce::cleanup_client_spec(self) + } + + fn get_commit_logs(&mut self, from_reference: &str, to_reference: &str) -> Option { + Perforce::get_commit_logs(self, from_reference, to_reference) + } + + fn get_file_content(&mut self, file: &str, identifier: &str) -> Option { + Perforce::get_file_content(self, file, identifier) + } + + fn get_branches(&mut self) -> IndexMap { + Perforce::get_branches(self) + } + + fn get_tags(&mut self) -> IndexMap { + Perforce::get_tags(self) + } + + fn get_user(&self) -> Option { + Perforce::get_user(self) + } + + fn get_composer_information( + &mut self, + identifier: &str, + ) -> Result>> { + Perforce::get_composer_information(self, identifier) + } +} diff --git a/crates/shirabe/tests/advisory/auditor_test.rs b/crates/shirabe/tests/advisory/auditor_test.rs index 7e02db8..516c4d1 100644 --- a/crates/shirabe/tests/advisory/auditor_test.rs +++ b/crates/shirabe/tests/advisory/auditor_test.rs @@ -6,8 +6,30 @@ use shirabe::advisory::AnySecurityAdvisory; use shirabe::advisory::Auditor; use shirabe::advisory::PartialSecurityAdvisory; use shirabe::advisory::SecurityAdvisory; +use shirabe::downloader::TransportException; +use shirabe::io::BufferIO; +use shirabe::io::io_interface; +use shirabe::package::BasePackageHandle; +use shirabe::package::PackageInterfaceHandle; +use shirabe::repository::AdvisoryProviderInterface; +use shirabe::repository::FindPackageConstraint; +use shirabe::repository::LoadPackagesResult; +use shirabe::repository::ProviderInfo; +use shirabe::repository::RepositoryInterface; +use shirabe::repository::RepositoryInterfaceHandle; +use shirabe::repository::RepositorySet; +use shirabe::repository::SearchResult; +use shirabe::repository::SecurityAdvisoryResult; +use shirabe_external_packages::symfony::console::output::output_interface; +use shirabe_php_shim::date_create; +use shirabe_semver::VersionParser; +use shirabe_semver::constraint::AnyConstraint; use shirabe_semver::constraint::SimpleConstraint; +use crate::io_mock::{Expectation, get_io_mock}; +use crate::test_case::get_complete_package; +use crate::test_case::get_package; + fn constraint(operator: &str, version: &str) -> shirabe_semver::constraint::AnyConstraint { SimpleConstraint::new(operator.to_string(), version.to_string(), None).into() } @@ -61,30 +83,1003 @@ fn ignore_list(pairs: Vec<(&str, Option<&str>)>) -> IndexMap>), + /// Throws a `TransportException`, simulating an unreachable repository. + Unreachable(String), +} + +#[derive(Debug)] +struct MockAdvisoryRepository { + source: AdvisorySource, +} + +impl AdvisoryProviderInterface for MockAdvisoryRepository { + fn has_security_advisories(&mut self) -> anyhow::Result { + Ok(true) + } + + fn get_security_advisories( + &mut self, + package_constraint_map: IndexMap, + _allow_partial_advisories: bool, + ) -> anyhow::Result { + match &self.source { + AdvisorySource::Unreachable(message) => { + Err(TransportException::new(message.clone(), 404).into()) + } + AdvisorySource::Fixed(advisories) => Ok(SecurityAdvisoryResult { + names_found: package_constraint_map.keys().cloned().collect(), + advisories: advisories.clone(), + }), + AdvisorySource::Table => { + let mut advisories: IndexMap> = IndexMap::new(); + for (package, list) in mock_advisories() { + let Some(constraint) = package_constraint_map.get(&package) else { + continue; + }; + let filtered: Vec = list + .into_iter() + .filter(|advisory| advisory.affected_versions().matches(constraint)) + .collect(); + if !filtered.is_empty() { + advisories.insert(package, filtered); + } + } + Ok(SecurityAdvisoryResult { + names_found: package_constraint_map.keys().cloned().collect(), + advisories, + }) + } + } + } +} + +impl RepositoryInterface for MockAdvisoryRepository { + fn count(&self) -> anyhow::Result { + unimplemented!("not used by Auditor") + } + fn has_package(&self, _package: PackageInterfaceHandle) -> bool { + unimplemented!("not used by Auditor") + } + fn find_package( + &mut self, + _name: &str, + _constraint: FindPackageConstraint, + ) -> anyhow::Result> { + unimplemented!("not used by Auditor") + } + fn find_packages( + &mut self, + _name: &str, + _constraint: Option, + ) -> anyhow::Result> { + unimplemented!("not used by Auditor") + } + fn get_packages(&mut self) -> anyhow::Result> { + unimplemented!("not used by Auditor") + } + fn load_packages( + &mut self, + _package_name_map: IndexMap>, + _acceptable_stabilities: IndexMap, + _stability_flags: IndexMap, + _already_loaded: IndexMap>, + ) -> anyhow::Result { + unimplemented!("not used by Auditor") + } + fn search( + &mut self, + _query: String, + _mode: i64, + _type: Option, + ) -> anyhow::Result> { + unimplemented!("not used by Auditor") + } + fn get_providers( + &mut self, + _package_name: String, + ) -> anyhow::Result> { + unimplemented!("not used by Auditor") + } + fn get_repo_name(&self) -> String { + "mock advisory repo".to_string() + } + fn as_advisory_provider_mut(&mut self) -> Option<&mut dyn AdvisoryProviderInterface> { + Some(self) + } + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +/// ref: AuditorTest::getMockAdvisories. All entries carry full data so they load as +/// `SecurityAdvisory` (never partial) regardless of `allowPartialAdvisories`. +fn mock_advisories() -> IndexMap> { + let mut advisories: IndexMap> = IndexMap::new(); + advisories.insert( + "vendor1/package1".to_string(), + vec![ + mock_advisory( + "vendor1/package1", + "ID1", + "advisory1", + "https://advisory.example.com/advisory1", + "CVE1", + ">=3,<3.4.3|>=1,<2.5.6", + "source1", + "RemoteID1", + "2022-05-25 13:21:00", + "medium", + ), + mock_advisory( + "vendor1/package1", + "ID4", + "advisory4", + "https://advisory.example.com/advisory4", + "CVE3", + ">=8,<8.2.2|>=1,<2.5.6", + "source2", + "RemoteID4", + "2022-05-25 13:21:00", + "high", + ), + mock_advisory( + "vendor1/package1", + "ID5", + "advisory5", + "https://advisory.example.com/advisory5", + "", + ">=8,<8.2.2|>=1,<2.5.6", + "source1", + "RemoteID3", + "2022-05-25 13:21:00", + "medium", + ), + ], + ); + advisories.insert( + "vendor1/package2".to_string(), + vec![mock_advisory( + "vendor1/package2", + "ID2", + "advisory2", + "https://advisory.example.com/advisory2", + "", + ">=3,<3.4.3|>=1,<2.5.6", + "source1", + "RemoteID2", + "2022-05-25 13:21:00", + "medium", + )], + ); + advisories.insert( + "vendorx/packagex".to_string(), + vec![mock_advisory( + "vendorx/packagex", + "IDx", + "advisory17", + "https://advisory.example.com/advisory17", + "CVE5", + ">=3,<3.4.3|>=1,<2.5.6", + "source2", + "RemoteIDx", + "2015-05-25 13:21:00", + "medium", + )], + ); + advisories.insert( + "vendor2/package1".to_string(), + vec![ + mock_advisory( + "vendor2/package1", + "ID3", + "advisory3", + "https://advisory.example.com/advisory3", + "CVE2", + ">=3,<3.4.3|>=1,<2.5.6", + "source2", + "RemoteID1", + "2022-05-25 13:21:00", + "medium", + ), + mock_advisory( + "vendor2/package1", + "ID6", + "advisory6", + "https://advisory.example.com/advisory6", + "CVE4", + ">=3,<3.4.3|>=1,<2.5.6", + "source2", + "RemoteID3", + "2015-05-25 13:21:00", + "medium", + ), + ], + ); + advisories.insert( + "vendory/packagey".to_string(), + vec![mock_advisory( + "vendory/packagey", + "IDy", + "advisory7", + "https://advisory.example.com/advisory7", + "CVE5", + ">=3,<3.4.3|>=1,<2.5.6", + "source2", + "RemoteID4", + "2015-05-25 13:21:00", + "medium", + )], + ); + advisories.insert( + "vendor3/package1".to_string(), + vec![mock_advisory( + "vendor3/package1", + "ID7", + "advisory7", + "https://advisory.example.com/advisory7", + "CVE5", + ">=3,<3.4.3|>=1,<2.5.6", + "source2", + "RemoteID4", + "2015-05-25 13:21:00", + "medium", + )], + ); + advisories +} + +#[allow( + clippy::too_many_arguments, + reason = "mirrors the PHP advisory data shape" +)] +fn mock_advisory( + package_name: &str, + advisory_id: &str, + title: &str, + link: &str, + cve: &str, + affected_versions: &str, + source_name: &str, + remote_id: &str, + reported_at: &str, + severity: &str, +) -> AnySecurityAdvisory { + let mut source: IndexMap = IndexMap::new(); + source.insert("name".to_string(), source_name.to_string()); + source.insert("remoteId".to_string(), remote_id.to_string()); + AnySecurityAdvisory::Full(SecurityAdvisory::new( + package_name.to_string(), + advisory_id.to_string(), + VersionParser.parse_constraints(affected_versions).unwrap(), + title.to_string(), + vec![source], + date_create::(reported_at).unwrap(), + Some(cve.to_string()), + Some(link.to_string()), + Some(severity.to_string()), + )) +} + +/// ref: AuditorTest::getRepoSet. Real `RepositorySet` holding a single repository whose advisory +/// provider serves `getMockAdvisories()`. +fn get_repo_set() -> RepositorySet { + let mut repo_set = RepositorySet::new( + "stable", + IndexMap::new(), + vec![], + IndexMap::new(), + IndexMap::new(), + IndexMap::new(), + ); + repo_set + .add_repository(RepositoryInterfaceHandle::new(MockAdvisoryRepository { + source: AdvisorySource::Table, + })) + .unwrap(); + repo_set +} + +/// ref: AuditorTest::testAudit (auditProvider). #[test] -#[ignore = "requires PHPUnit getMockBuilder partial mock of ComposerRepository (hasSecurityAdvisories/getSecurityAdvisories) and BufferIO; no mocking infrastructure exists"] +#[ignore = "all 10 cases faithfully ported; the 2 FORMAT_TABLE cases cannot run because \ + BufferIO does not downcast to ConsoleIO (PHP's `BufferIO extends ConsoleIO` is \ + modeled as composition in Rust), so the table-rendering path is unreachable and \ + the whole function stays ignored until the src supports it"] fn test_audit() { - todo!() + #[derive(Clone)] + struct Case { + packages: Vec, + warning_only: bool, + abandoned: &'static str, + format: &'static str, + ignore_abandoned: IndexMap>, + expected: i64, + output: &'static str, + } + + let abandoned_with_replacement = || { + let p = get_complete_package("vendor/abandoned", "1.0.0"); + p.set_abandoned(shirabe_php_shim::PhpMixed::String("foo/bar".to_string())); + let handle: PackageInterfaceHandle = p.into(); + handle + }; + let abandoned_no_replacement = || { + let p = get_complete_package("vendor/abandoned2", "1.0.0"); + p.set_abandoned(shirabe_php_shim::PhpMixed::Bool(true)); + let handle: PackageInterfaceHandle = p.into(); + handle + }; + + let cases: Vec = vec![ + Case { + packages: vec![ + get_package("vendor1/package2", "9.0.0"), + get_package("vendor1/package1", "9.0.0"), + get_package("vendor3/package1", "9.0.0"), + ], + warning_only: true, + abandoned: Auditor::ABANDONED_IGNORE, + format: Auditor::FORMAT_PLAIN, + ignore_abandoned: IndexMap::new(), + expected: Auditor::STATUS_OK, + output: "No security vulnerability advisories found.", + }, + Case { + packages: vec![ + get_package("vendor1/package2", "9.0.0"), + get_package("vendor1/package1", "8.2.1"), + get_package("vendor3/package1", "9.0.0"), + ], + warning_only: true, + abandoned: Auditor::ABANDONED_IGNORE, + format: Auditor::FORMAT_PLAIN, + ignore_abandoned: IndexMap::new(), + expected: Auditor::STATUS_VULNERABLE, + output: + "Found 2 security vulnerability advisories affecting 1 package: +Package: vendor1/package1 +Severity: high +Advisory ID: ID4 +CVE: CVE3 +Title: advisory4 +URL: https://advisory.example.com/advisory4 +Affected versions: >=8,<8.2.2|>=1,<2.5.6 +Reported at: 2022-05-25T13:21:00+00:00 +-------- +Package: vendor1/package1 +Severity: medium +Advisory ID: ID5 +CVE: \nTitle: advisory5 +URL: https://advisory.example.com/advisory5 +Affected versions: >=8,<8.2.2|>=1,<2.5.6 +Reported at: 2022-05-25T13:21:00+00:00", + }, + Case { + packages: vec![abandoned_with_replacement(), abandoned_no_replacement()], + warning_only: false, + abandoned: Auditor::ABANDONED_IGNORE, + format: Auditor::FORMAT_PLAIN, + ignore_abandoned: IndexMap::new(), + expected: Auditor::STATUS_OK, + output: "No security vulnerability advisories found.", + }, + Case { + packages: vec![abandoned_with_replacement(), abandoned_no_replacement()], + warning_only: false, + abandoned: Auditor::ABANDONED_FAIL, + format: Auditor::FORMAT_PLAIN, + ignore_abandoned: ignore_list(vec![("vendor/*", None)]), + expected: Auditor::STATUS_OK, + output: "No security vulnerability advisories found.", + }, + Case { + packages: vec![abandoned_with_replacement(), abandoned_no_replacement()], + warning_only: false, + abandoned: Auditor::ABANDONED_FAIL, + format: Auditor::FORMAT_PLAIN, + ignore_abandoned: ignore_list(vec![ + ("vendor/abandoned", None), + ("vendor/abandoned2", None), + ]), + expected: Auditor::STATUS_OK, + output: "No security vulnerability advisories found.", + }, + Case { + packages: vec![abandoned_with_replacement(), abandoned_no_replacement()], + warning_only: false, + abandoned: Auditor::ABANDONED_FAIL, + format: Auditor::FORMAT_PLAIN, + ignore_abandoned: ignore_list(vec![("acme/test", Some("ignoring because yolo"))]), + expected: Auditor::STATUS_ABANDONED, + output: "No security vulnerability advisories found. +Found 2 abandoned packages: +vendor/abandoned is abandoned. Use foo/bar instead. +vendor/abandoned2 is abandoned. No replacement was suggested.", + }, + Case { + packages: vec![abandoned_with_replacement(), abandoned_no_replacement()], + warning_only: true, + abandoned: Auditor::ABANDONED_REPORT, + format: Auditor::FORMAT_PLAIN, + ignore_abandoned: IndexMap::new(), + expected: Auditor::STATUS_OK, + output: "No security vulnerability advisories found. +Found 2 abandoned packages: +vendor/abandoned is abandoned. Use foo/bar instead. +vendor/abandoned2 is abandoned. No replacement was suggested.", + }, + Case { + packages: vec![abandoned_with_replacement(), abandoned_no_replacement()], + warning_only: false, + abandoned: Auditor::ABANDONED_FAIL, + format: Auditor::FORMAT_TABLE, + ignore_abandoned: IndexMap::new(), + expected: Auditor::STATUS_ABANDONED, + output: "No security vulnerability advisories found. +Found 2 abandoned packages: ++-------------------+----------------------------------------------------------------------------------+ +| Abandoned Package | Suggested Replacement | ++-------------------+----------------------------------------------------------------------------------+ +| vendor/abandoned | foo/bar | +| vendor/abandoned2 | none | ++-------------------+----------------------------------------------------------------------------------+", + }, + Case { + packages: vec![ + get_package("vendor1/package1", "8.2.1"), + abandoned_with_replacement(), + abandoned_no_replacement(), + ], + warning_only: false, + abandoned: Auditor::ABANDONED_FAIL, + format: Auditor::FORMAT_TABLE, + ignore_abandoned: IndexMap::new(), + expected: Auditor::STATUS_VULNERABLE | Auditor::STATUS_ABANDONED, + output: "Found 2 security vulnerability advisories affecting 1 package: ++-------------------+----------------------------------------------------------------------------------+ +| Package | vendor1/package1 | +| Severity | high | +| Advisory ID | ID4 | +| CVE | CVE3 | +| Title | advisory4 | +| URL | https://advisory.example.com/advisory4 | +| Affected versions | >=8,<8.2.2|>=1,<2.5.6 | +| Reported at | 2022-05-25T13:21:00+00:00 | ++-------------------+----------------------------------------------------------------------------------+ ++-------------------+----------------------------------------------------------------------------------+ +| Package | vendor1/package1 | +| Severity | medium | +| Advisory ID | ID5 | +| CVE | | +| Title | advisory5 | +| URL | https://advisory.example.com/advisory5 | +| Affected versions | >=8,<8.2.2|>=1,<2.5.6 | +| Reported at | 2022-05-25T13:21:00+00:00 | ++-------------------+----------------------------------------------------------------------------------+ +Found 2 abandoned packages: ++-------------------+----------------------------------------------------------------------------------+ +| Abandoned Package | Suggested Replacement | ++-------------------+----------------------------------------------------------------------------------+ +| vendor/abandoned | foo/bar | +| vendor/abandoned2 | none | ++-------------------+----------------------------------------------------------------------------------+", + }, + Case { + packages: vec![abandoned_with_replacement(), abandoned_no_replacement()], + warning_only: false, + abandoned: Auditor::ABANDONED_FAIL, + format: Auditor::FORMAT_JSON, + ignore_abandoned: IndexMap::new(), + expected: Auditor::STATUS_ABANDONED, + output: "{ + \"advisories\": [], + \"abandoned\": { + \"vendor/abandoned\": \"foo/bar\", + \"vendor/abandoned2\": null + } +}", + }, + ]; + + for case in cases { + let repo_set = get_repo_set(); + let mut io = + BufferIO::new(String::new(), output_interface::VERBOSITY_NORMAL, None).unwrap(); + let auditor = Auditor; + let result = auditor + .audit( + &mut io, + &repo_set, + case.packages.clone(), + case.format, + case.warning_only, + IndexMap::new(), + case.abandoned, + IndexMap::new(), + false, + case.ignore_abandoned.clone(), + ) + .unwrap(); + assert_eq!(case.expected, result); + assert_eq!(case.output, io.get_output().replace('\r', "").trim()); + } } +/// ref: AuditorTest::testAuditWithIgnore (ignoredIdsProvider). #[test] -#[ignore = "requires getIOMock with expects() output expectations and getMockBuilder mock of ComposerRepository; no mocking infrastructure exists"] fn test_audit_with_ignore() { - todo!() + struct Case { + packages: Vec, + ignored_ids: IndexMap>, + exit_code: i64, + expected_output: Vec, + } + + let cases: Vec = vec![ + Case { + packages: vec![get_package("vendor1/package1", "3.0.0.0")], + ignored_ids: ignore_list(vec![("CVE1", None)]), + exit_code: 0, + expected_output: vec![ + Expectation::text( + "Found 1 ignored security vulnerability advisory affecting 1 package:", + ), + Expectation::text("Package: vendor1/package1"), + Expectation::text("Severity: medium"), + Expectation::text("Advisory ID: ID1"), + Expectation::text("CVE: CVE1"), + Expectation::text("Title: advisory1"), + Expectation::text("URL: https://advisory.example.com/advisory1"), + Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"), + Expectation::text("Reported at: 2022-05-25T13:21:00+00:00"), + ], + }, + Case { + packages: vec![get_package("vendor1/package1", "3.0.0.0")], + ignored_ids: ignore_list(vec![("CVE1", Some("A good reason"))]), + exit_code: 0, + expected_output: vec![ + Expectation::text( + "Found 1 ignored security vulnerability advisory affecting 1 package:", + ), + Expectation::text("Package: vendor1/package1"), + Expectation::text("Severity: medium"), + Expectation::text("Advisory ID: ID1"), + Expectation::text("CVE: CVE1"), + Expectation::text("Title: advisory1"), + Expectation::text("URL: https://advisory.example.com/advisory1"), + Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"), + Expectation::text("Reported at: 2022-05-25T13:21:00+00:00"), + Expectation::text("Ignore reason: A good reason"), + ], + }, + Case { + packages: vec![get_package("vendor1/package2", "3.0.0.0")], + ignored_ids: ignore_list(vec![("ID2", None)]), + exit_code: 0, + expected_output: vec![ + Expectation::text( + "Found 1 ignored security vulnerability advisory affecting 1 package:", + ), + Expectation::text("Package: vendor1/package2"), + Expectation::text("Severity: medium"), + Expectation::text("Advisory ID: ID2"), + Expectation::text("CVE: "), + Expectation::text("Title: advisory2"), + Expectation::text("URL: https://advisory.example.com/advisory2"), + Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"), + Expectation::text("Reported at: 2022-05-25T13:21:00+00:00"), + ], + }, + Case { + packages: vec![get_package("vendorx/packagex", "3.0.0.0")], + ignored_ids: ignore_list(vec![("RemoteIDx", None)]), + exit_code: 0, + expected_output: vec![ + Expectation::text( + "Found 1 ignored security vulnerability advisory affecting 1 package:", + ), + Expectation::text("Package: vendorx/packagex"), + Expectation::text("Severity: medium"), + Expectation::text("Advisory ID: IDx"), + Expectation::text("CVE: CVE5"), + Expectation::text("Title: advisory17"), + Expectation::text("URL: https://advisory.example.com/advisory17"), + Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"), + Expectation::text("Reported at: 2015-05-25T13:21:00+00:00"), + ], + }, + Case { + packages: vec![get_package("vendor1/package1", "3.0.0.0")], + ignored_ids: ignore_list(vec![("vendor1/package1", None)]), + exit_code: 0, + expected_output: vec![ + Expectation::text( + "Found 1 ignored security vulnerability advisory affecting 1 package:", + ), + Expectation::text("Package: vendor1/package1"), + Expectation::text("Severity: medium"), + Expectation::text("Advisory ID: ID1"), + Expectation::text("CVE: CVE1"), + Expectation::text("Title: advisory1"), + Expectation::text("URL: https://advisory.example.com/advisory1"), + Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"), + Expectation::text("Reported at: 2022-05-25T13:21:00+00:00"), + ], + }, + Case { + packages: vec![get_package("vendor1/package1", "3.0.0.0")], + ignored_ids: ignore_list(vec![( + "vendor1/package1", + Some("Package has known safe usage"), + )]), + exit_code: 0, + expected_output: vec![ + Expectation::text( + "Found 1 ignored security vulnerability advisory affecting 1 package:", + ), + Expectation::text("Package: vendor1/package1"), + Expectation::text("Severity: medium"), + Expectation::text("Advisory ID: ID1"), + Expectation::text("CVE: CVE1"), + Expectation::text("Title: advisory1"), + Expectation::text("URL: https://advisory.example.com/advisory1"), + Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"), + Expectation::text("Reported at: 2022-05-25T13:21:00+00:00"), + Expectation::text("Ignore reason: Package has known safe usage"), + ], + }, + Case { + packages: vec![get_package("vendor1/package1", "3.0.0.0")], + ignored_ids: IndexMap::new(), + exit_code: 1, + expected_output: vec![ + Expectation::text("Found 1 security vulnerability advisory affecting 1 package:"), + Expectation::text("Package: vendor1/package1"), + Expectation::text("Severity: medium"), + Expectation::text("Advisory ID: ID1"), + Expectation::text("CVE: CVE1"), + Expectation::text("Title: advisory1"), + Expectation::text("URL: https://advisory.example.com/advisory1"), + Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"), + Expectation::text("Reported at: 2022-05-25T13:21:00+00:00"), + ], + }, + Case { + packages: vec![ + get_package("vendor3/package1", "3.0.0.0"), + get_package("vendorx/packagex", "3.0.0.0"), + get_package("vendor2/package1", "3.0.0.0"), + ], + ignored_ids: ignore_list(vec![("RemoteIDx", None), ("ID3", None), ("ID6", None)]), + exit_code: 1, + expected_output: vec![ + Expectation::text( + "Found 3 ignored security vulnerability advisories affecting 2 packages:", + ), + Expectation::text("Package: vendor2/package1"), + Expectation::text("Severity: medium"), + Expectation::text("Advisory ID: ID3"), + Expectation::text("CVE: CVE2"), + Expectation::text("Title: advisory3"), + Expectation::text("URL: https://advisory.example.com/advisory3"), + Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"), + Expectation::text("Reported at: 2022-05-25T13:21:00+00:00"), + Expectation::text("Ignore reason: None specified"), + Expectation::text("--------"), + Expectation::text("Package: vendor2/package1"), + Expectation::text("Severity: medium"), + Expectation::text("Advisory ID: ID6"), + Expectation::text("CVE: CVE4"), + Expectation::text("Title: advisory6"), + Expectation::text("URL: https://advisory.example.com/advisory6"), + Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"), + Expectation::text("Reported at: 2015-05-25T13:21:00+00:00"), + Expectation::text("Ignore reason: None specified"), + Expectation::text("--------"), + Expectation::text("Package: vendorx/packagex"), + Expectation::text("Severity: medium"), + Expectation::text("Advisory ID: IDx"), + Expectation::text("CVE: CVE5"), + Expectation::text("Title: advisory17"), + Expectation::text("URL: https://advisory.example.com/advisory17"), + Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"), + Expectation::text("Reported at: 2015-05-25T13:21:00+00:00"), + Expectation::text("Ignore reason: None specified"), + Expectation::text("Found 1 security vulnerability advisory affecting 1 package:"), + Expectation::text("Package: vendor3/package1"), + Expectation::text("Severity: medium"), + Expectation::text("Advisory ID: ID7"), + Expectation::text("CVE: CVE5"), + Expectation::text("Title: advisory7"), + Expectation::text("URL: https://advisory.example.com/advisory7"), + Expectation::text("Affected versions: >=3,<3.4.3|>=1,<2.5.6"), + Expectation::text("Reported at: 2015-05-25T13:21:00+00:00"), + ], + }, + ]; + + for case in cases { + let repo_set = get_repo_set(); + let (io_mock, _io_guard) = get_io_mock(io_interface::NORMAL).unwrap(); + let auditor = Auditor; + let result = { + let mut io_guard = io_mock.borrow_mut(); + auditor + .audit( + &mut *io_guard, + &repo_set, + case.packages.clone(), + Auditor::FORMAT_PLAIN, + false, + case.ignored_ids.clone(), + Auditor::ABANDONED_FAIL, + IndexMap::new(), + false, + IndexMap::new(), + ) + .unwrap() + }; + io_mock + .borrow_mut() + .expects(case.expected_output.clone(), true) + .unwrap(); + assert_eq!(case.exit_code, result); + } } +/// ref: AuditorTest::testAuditWithIgnoreUnreachable. The whole `RepositorySet` is mocked in PHP; +/// here a real `RepositorySet` holds a reachable repo (fixed advisories) followed by an +/// unreachable repo (throws `TransportException`), reproducing the same merged result. #[test] -#[ignore = "requires getMockBuilder partial mock of RepositorySet (getMatchingSecurityAdvisories willReturnCallback); no mocking infrastructure exists"] fn test_audit_with_ignore_unreachable() { - todo!() + let packages = vec![get_package("vendor1/package1", "3.0.0.0")]; + + let error_message = + "The \"https://example.org/packages.json\" file could not be downloaded: HTTP/1.1 404 Not Found" + .to_string(); + + let make_repo_set = || { + let mut fixed: IndexMap> = IndexMap::new(); + fixed.insert( + "vendor1/package1".to_string(), + vec![ + AnySecurityAdvisory::Full(SecurityAdvisory::new( + "vendor1/package1".to_string(), + "CVE-2023-12345".to_string(), + constraint("=", "3.0.0.0"), + "First repo advisory".to_string(), + vec![{ + let mut s: IndexMap = IndexMap::new(); + s.insert("name".to_string(), "test".to_string()); + s.insert("remoteId".to_string(), "1".to_string()); + s + }], + date_create::("2023-01-01").unwrap(), + Some("CVE-2023-12345".to_string()), + Some("https://example.com/advisory/1".to_string()), + Some("medium".to_string()), + )), + AnySecurityAdvisory::Full(SecurityAdvisory::new( + "vendor1/package1".to_string(), + "CVE-2023-67890".to_string(), + constraint("=", "3.0.0.0"), + "Third repo advisory".to_string(), + vec![{ + let mut s: IndexMap = IndexMap::new(); + s.insert("name".to_string(), "test".to_string()); + s.insert("remoteId".to_string(), "3".to_string()); + s + }], + date_create::("2023-01-01").unwrap(), + Some("CVE-2023-67890".to_string()), + Some("https://example.com/advisory/3".to_string()), + Some("high".to_string()), + )), + ], + ); + + let mut repo_set = RepositorySet::new( + "stable", + IndexMap::new(), + vec![], + IndexMap::new(), + IndexMap::new(), + IndexMap::new(), + ); + repo_set + .add_repository(RepositoryInterfaceHandle::new(MockAdvisoryRepository { + source: AdvisorySource::Fixed(fixed), + })) + .unwrap(); + repo_set + .add_repository(RepositoryInterfaceHandle::new(MockAdvisoryRepository { + source: AdvisorySource::Unreachable(error_message.clone()), + })) + .unwrap(); + repo_set + }; + + let auditor = Auditor; + + // Without the ignoreUnreachable flag the TransportException propagates. + { + let repo_set = make_repo_set(); + let mut io = + BufferIO::new(String::new(), output_interface::VERBOSITY_NORMAL, None).unwrap(); + let err = auditor + .audit( + &mut io, + &repo_set, + packages.clone(), + Auditor::FORMAT_PLAIN, + false, + IndexMap::new(), + Auditor::ABANDONED_IGNORE, + IndexMap::new(), + false, + IndexMap::new(), + ) + .expect_err("Expected TransportException was not thrown"); + assert!(err.to_string().contains("HTTP/1.1 404 Not Found")); + } + + // With the ignoreUnreachable flag the advisories from reachable repos are reported. + { + let repo_set = make_repo_set(); + let mut io = + BufferIO::new(String::new(), output_interface::VERBOSITY_NORMAL, None).unwrap(); + let result = auditor + .audit( + &mut io, + &repo_set, + packages.clone(), + Auditor::FORMAT_PLAIN, + false, + IndexMap::new(), + Auditor::ABANDONED_IGNORE, + IndexMap::new(), + true, + IndexMap::new(), + ) + .unwrap(); + assert_eq!(Auditor::STATUS_VULNERABLE, result); + + let output = io.get_output(); + assert!(output.contains("The following repositories were unreachable:")); + assert!(output.contains("HTTP/1.1 404 Not Found")); + assert!(output.contains("First repo advisory")); + assert!(output.contains("Third repo advisory")); + assert!(output.contains("CVE-2023-12345")); + assert!(output.contains("CVE-2023-67890")); + } + + // With JSON format the unreachable repositories and advisories are both included. + { + let repo_set = make_repo_set(); + let mut io = + BufferIO::new(String::new(), output_interface::VERBOSITY_NORMAL, None).unwrap(); + let result = auditor + .audit( + &mut io, + &repo_set, + packages.clone(), + Auditor::FORMAT_JSON, + false, + IndexMap::new(), + Auditor::ABANDONED_IGNORE, + IndexMap::new(), + true, + IndexMap::new(), + ) + .unwrap(); + assert_eq!(Auditor::STATUS_VULNERABLE, result); + + let json: serde_json::Value = serde_json::from_str(&io.get_output()).unwrap(); + let unreachable = json + .get("unreachable-repositories") + .and_then(|v| v.as_array()) + .unwrap(); + assert_eq!(1, unreachable.len()); + assert!( + unreachable[0] + .as_str() + .unwrap() + .contains("HTTP/1.1 404 Not Found") + ); + + let pkg_advisories = json + .get("advisories") + .and_then(|v| v.get("vendor1/package1")) + .and_then(|v| v.as_array()) + .unwrap(); + assert_eq!(2, pkg_advisories.len()); + assert_eq!("CVE-2023-12345", pkg_advisories[0]["cve"].as_str().unwrap()); + assert_eq!( + "First repo advisory", + pkg_advisories[0]["title"].as_str().unwrap() + ); + assert_eq!("CVE-2023-67890", pkg_advisories[1]["cve"].as_str().unwrap()); + assert_eq!( + "Third repo advisory", + pkg_advisories[1]["title"].as_str().unwrap() + ); + } } +/// ref: AuditorTest::testAuditWithIgnoreSeverity (ignoreSeverityProvider). #[test] -#[ignore = "requires getIOMock with expects() output expectations and getMockBuilder mock of ComposerRepository; no mocking infrastructure exists"] fn test_audit_with_ignore_severity() { - todo!() + struct Case { + packages: Vec, + ignored_severities: IndexMap>, + exit_code: i64, + expected_output: Vec, + } + + let cases: Vec = vec![ + Case { + packages: vec![get_package("vendor1/package1", "2.0.0.0")], + ignored_severities: ignore_list(vec![("medium", None)]), + exit_code: 1, + expected_output: vec![Expectation::text( + "Found 2 ignored security vulnerability advisories affecting 1 package:", + )], + }, + Case { + packages: vec![get_package("vendor1/package1", "2.0.0.0")], + ignored_severities: ignore_list(vec![("high", None)]), + exit_code: 1, + expected_output: vec![Expectation::text( + "Found 1 ignored security vulnerability advisory affecting 1 package:", + )], + }, + Case { + packages: vec![get_package("vendor1/package1", "2.0.0.0")], + ignored_severities: ignore_list(vec![("high", None), ("medium", None)]), + exit_code: 0, + expected_output: vec![Expectation::text( + "Found 3 ignored security vulnerability advisories affecting 1 package:", + )], + }, + ]; + + for case in cases { + let repo_set = get_repo_set(); + let (io_mock, _io_guard) = get_io_mock(io_interface::NORMAL).unwrap(); + let auditor = Auditor; + let result = { + let mut io_guard = io_mock.borrow_mut(); + auditor + .audit( + &mut *io_guard, + &repo_set, + case.packages.clone(), + Auditor::FORMAT_PLAIN, + false, + IndexMap::new(), + Auditor::ABANDONED_IGNORE, + case.ignored_severities.clone(), + false, + IndexMap::new(), + ) + .unwrap() + }; + io_mock + .borrow_mut() + .expects(case.expected_output.clone(), true) + .unwrap(); + assert_eq!(case.exit_code, result); + } } #[test] diff --git a/crates/shirabe/tests/advisory/main.rs b/crates/shirabe/tests/advisory/main.rs index e0b920d..51b470e 100644 --- a/crates/shirabe/tests/advisory/main.rs +++ b/crates/shirabe/tests/advisory/main.rs @@ -1,2 +1,7 @@ +#[path = "../common/io_mock.rs"] +mod io_mock; +#[path = "../common/test_case.rs"] +mod test_case; + mod audit_config_test; mod auditor_test; diff --git a/crates/shirabe/tests/cache_test.rs b/crates/shirabe/tests/cache_test.rs index 2e18856..7705714 100644 --- a/crates/shirabe/tests/cache_test.rs +++ b/crates/shirabe/tests/cache_test.rs @@ -4,7 +4,7 @@ use std::cell::RefCell; use std::fs; use std::rc::Rc; -use shirabe::cache::Cache; +use shirabe::cache::{Cache, CacheMock, GcFinderMock}; use shirabe::io::IOInterface; use shirabe::io::null_io::NullIO; use shirabe::util::filesystem::Filesystem; @@ -27,8 +27,10 @@ fn set_up() -> SetUp { files.push(path); } - // The finder/filesystem/IO mocks and the Cache mock overriding getFinder are not ported. - let cache: Cache = todo!(); + // PHP mocks Cache::getFinder and keeps the real Filesystem; here the CacheMock finder seam plays + // that role and the Cache otherwise operates on the real temp directory. + let io: Rc> = Rc::new(RefCell::new(NullIO::new())); + let cache = Cache::new(io, root.path().to_str().unwrap(), None, None, false); SetUp { root, files, cache } } @@ -56,25 +58,57 @@ impl Drop for TearDown { } } -// In PHP these mock Cache::getFinder() to feed the gc() routine a controlled set of -// files. getFinder is pub(crate) and cannot be overridden from a test, so the -// finder-driven removal paths cannot be exercised faithfully here. -#[ignore = "requires mocking Cache::get_finder (pub(crate), PHPUnit MockObject) to feed gc() a controlled Finder iterator; not overridable from a test"] #[test] fn test_remove_outdated_files() { - let SetUp { root, files, cache } = set_up(); + let SetUp { + root, + files, + mut cache, + } = set_up(); let _tear_down = TearDown::new(root.path().to_path_buf()); - let _ = (&files, &cache); - todo!() + + // The date('until ...') finder yields the outdated entries (files 1..3). + let outdated = files[1..].to_vec(); + cache.__set_mock(CacheMock { + finder: Some(GcFinderMock { + outdated, + by_accessed_time: Vec::new(), + }), + ..Default::default() + }); + + cache.gc(600, 1024 * 1024 * 1024); + + for (i, file) in files.iter().enumerate().skip(1) { + assert!(!file.exists(), "cached.file{i}.zip should be removed"); + } + assert!(files[0].exists(), "cached.file0.zip should still exist"); } -#[ignore = "requires mocking Cache::get_finder (pub(crate), PHPUnit MockObject) to feed gc() a controlled Finder iterator; not overridable from a test"] #[test] fn test_remove_files_when_cache_is_too_large() { - let SetUp { root, files, cache } = set_up(); + let SetUp { + root, + files, + mut cache, + } = set_up(); let _tear_down = TearDown::new(root.path().to_path_buf()); - let _ = (&files, &cache); - todo!() + + // The date filter matches nothing; the size-bound pass walks all files by accessed time. + cache.__set_mock(CacheMock { + finder: Some(GcFinderMock { + outdated: Vec::new(), + by_accessed_time: files.clone(), + }), + ..Default::default() + }); + + cache.gc(600, 1500); + + for (i, file) in files.iter().enumerate().take(3) { + assert!(!file.exists(), "cached.file{i}.zip should be removed"); + } + assert!(files[3].exists(), "cached.file3.zip should still exist"); } #[test] diff --git a/crates/shirabe/tests/command/archive_command_test.rs b/crates/shirabe/tests/command/archive_command_test.rs index b381129..c25ea3c 100644 --- a/crates/shirabe/tests/command/archive_command_test.rs +++ b/crates/shirabe/tests/command/archive_command_test.rs @@ -1,19 +1,278 @@ //! ref: composer/tests/Composer/Test/Command/ArchiveCommandTest.php +use std::cell::RefCell; +use std::rc::Rc; + +use indexmap::IndexMap; +use shirabe::command::BaseCommand; +use shirabe::command::archive_command::ArchiveCommand; +use shirabe::composer::{Composer, PartialComposerHandle, PartialOrFullComposer}; +use shirabe::config::Config; +use shirabe::dependency_resolver::Transaction; +use shirabe::event_dispatcher::{Callable, EventDispatcherInterface, EventInterface}; +use shirabe::package::{ + ArchiveManagerInterface, CompletePackageInterfaceHandle, RootPackageHandle, +}; +use shirabe::repository::{ + InstalledArrayRepository, RepositoryInterfaceHandle, RepositoryManagerInterface, +}; +use shirabe::util::Platform; +use shirabe_external_packages::symfony::console::command::command::Command; +use shirabe_external_packages::symfony::console::input::InputInterface; +use shirabe_external_packages::symfony::console::input::array_input::ArrayInput; +use shirabe_external_packages::symfony::console::output::OutputInterface; +use shirabe_external_packages::symfony::console::output::buffered_output::BufferedOutput; +use shirabe_php_shim::PhpMixed; +use shirabe_semver::VersionParser; + +use crate::config_stub::ConfigStubBuilder; + +/// A recorded `ArchiveManager::archive()` call (PHPUnit `->with(...)->willReturn(...)`). +#[derive(Debug, Clone)] +struct ArchiveCall { + package_name: String, + format: String, + target_dir: String, + file_name: Option, + ignore_filters: bool, +} + +/// Equivalent to a `getMockBuilder(ArchiveManager::class)` mock whose `archive` method is +/// stubbed to record its arguments and return a fixed path. +#[derive(Debug)] +struct ArchiveManagerMock { + calls: Rc>>, + return_value: String, +} + +impl ArchiveManagerInterface for ArchiveManagerMock { + fn archive( + &mut self, + package: CompletePackageInterfaceHandle, + format: String, + target_dir: String, + file_name: Option, + ignore_filters: bool, + ) -> anyhow::Result { + self.calls.borrow_mut().push(ArchiveCall { + package_name: package.get_name(), + format, + target_dir, + file_name, + ignore_filters, + }); + Ok(self.return_value.clone()) + } +} + +/// Equivalent to a `getMockBuilder(EventDispatcher::class)->disableOriginalConstructor()` mock +/// with no behavioral expectations: every dispatch is a no-op. +#[derive(Debug, Default)] +struct NoopEventDispatcher; + +impl EventDispatcherInterface for NoopEventDispatcher { + fn dispatch( + &mut self, + _event_name: Option<&str>, + _event: Option<&mut dyn EventInterface>, + ) -> anyhow::Result { + Ok(0) + } + + fn dispatch_script( + &mut self, + _event_name: &str, + _dev_mode: bool, + _additional_args: Vec, + _flags: IndexMap, + ) -> anyhow::Result { + Ok(0) + } + + fn dispatch_installer_event( + &mut self, + _event_name: &str, + _dev_mode: bool, + _execute_operations: bool, + _transaction: Transaction, + ) -> anyhow::Result { + Ok(0) + } + + fn add_listener(&mut self, _event_name: &str, _listener: Callable, _priority: i64) {} + + fn has_event_listeners(&mut self, _event: &dyn EventInterface) -> bool { + false + } +} + +fn root_package(name: &str, version: &str) -> RootPackageHandle { + let normalized = VersionParser.normalize(version, None).unwrap(); + RootPackageHandle::new(name.to_string(), normalized, version.to_string()) +} + +fn full_composer(composer: Composer) -> PartialComposerHandle { + PartialComposerHandle::from_rc(Rc::new(RefCell::new(PartialOrFullComposer::Full(composer)))) +} + #[test] -#[ignore = "requires PHPUnit getMockBuilder/onlyMethods partial mock of ArchiveCommand (override tryComposer/requireComposer) plus expects()/willReturn() mocks for OutputInterface/EventDispatcher/ArchiveManager/RootPackageInterface; no mocking infrastructure exists"] fn test_uses_config_from_composer_object() { - todo!() + let input: Rc> = + Rc::new(RefCell::new(ArrayInput::new(vec![], None).unwrap())); + let output: Rc> = + Rc::new(RefCell::new(BufferedOutput::new(None, false, None))); + + let config: Rc> = ConfigStubBuilder::new() + .with("archive-format", PhpMixed::from("zip")) + .build_shared(); + + let calls = Rc::new(RefCell::new(Vec::new())); + let manager = ArchiveManagerMock { + calls: calls.clone(), + return_value: Platform::get_cwd(false).unwrap(), + }; + + let package = root_package("archive/test", "1.0.0"); + + let mut composer = Composer::new(); + composer.set_config(config); + composer.set_archive_manager(Rc::new(RefCell::new(manager))); + composer.set_event_dispatcher(Rc::new(RefCell::new(NoopEventDispatcher))); + composer.set_package(package.into()); + let composer = full_composer(composer); + + // tryComposer()/requireComposer() resolve to the pre-set Composer (PHPUnit overrides both). + let command = ArchiveCommand::new(); + command.set_composer(composer); + + command.run(input, output).unwrap(); + + let calls = calls.borrow(); + assert_eq!(1, calls.len()); + // PHP asserts archive() receives `$package` itself; we check the identity via the package name + // we set on the Composer. + assert_eq!("archive/test", calls[0].package_name); + assert_eq!("zip", calls[0].format); + assert_eq!(".", calls[0].target_dir); + assert_eq!(None, calls[0].file_name); + assert!(!calls[0].ignore_filters); } #[test] -#[ignore = "requires PHPUnit getMockBuilder/onlyMethods partial mock of ArchiveCommand (override tryComposer/archive) with expects()/with()/willReturn() expectation verification and Factory::createConfig mock OutputInterface; no mocking infrastructure exists"] fn test_uses_config_from_factory_when_composer_is_not_defined() { - todo!() + let input: Rc> = + Rc::new(RefCell::new(ArrayInput::new(vec![], None).unwrap())); + let output: Rc> = + Rc::new(RefCell::new(BufferedOutput::new(None, false, None))); + + // tryComposer() returns null (no Composer set), so execute() builds the Config via the Factory + // and `archive` is stubbed (PHPUnit overrides initialize/tryComposer/archive). + let command = ArchiveCommand::new(); + command.__test_skip_initialize(); + command.__test_stub_archive(0); + + assert_eq!(0, command.run(input, output).unwrap()); + + let calls = command.__test_archive_calls(); + assert_eq!(1, calls.len()); + // PHP additionally asserts arg2 === Factory::createConfig() (config-resolution path identity). + // Not reproduced: capturing arg2 needs a src-side hook on ArchiveCommand's ArchiveCallRecord, + // and Config has no identity equality. The factory path is only indirectly evidenced here by + // had_composer == false plus format == "tar" (the archive-format default when no Composer config). + assert_eq!(None, calls[0].package_name); + assert_eq!(None, calls[0].version); + assert_eq!("tar", calls[0].format); + assert_eq!(".", calls[0].dest); + assert_eq!(None, calls[0].file_name); + assert!(!calls[0].ignore_filters); + assert!(!calls[0].had_composer); +} + +/// Equivalent to a `getMockBuilder(RepositoryManager::class)->disableOriginalConstructor()` mock: +/// `getLocalRepository` returns the installed repository, `getRepositories` returns `[]`. +#[derive(Debug)] +struct RepositoryManagerMock { + local: RepositoryInterfaceHandle, + repositories: Vec, +} + +impl RepositoryManagerInterface for RepositoryManagerMock { + fn get_local_repository(&self) -> RepositoryInterfaceHandle { + self.local.clone() + } + + fn get_repositories(&self) -> &Vec { + &self.repositories + } + + fn create_repository( + &self, + _type: &str, + _config: IndexMap, + _name: Option<&str>, + ) -> anyhow::Result { + unreachable!("ArchiveCommand does not create repositories") + } + + fn add_repository(&mut self, _repository: RepositoryInterfaceHandle) { + unreachable!("ArchiveCommand does not add repositories") + } + + fn set_local_repository(&mut self, _repository: RepositoryInterfaceHandle) { + unreachable!("ArchiveCommand does not set the local repository") + } } #[test] -#[ignore = "requires PHPUnit getMockBuilder/onlyMethods partial mock of ArchiveCommand plus expects()/willReturn() mocks for OutputInterface/EventDispatcher/ArchiveManager/InstalledRepositoryInterface/RepositoryManager; no mocking infrastructure exists"] fn test_uses_config_from_composer_object_with_package_name() { - todo!() + let input: Rc> = Rc::new(RefCell::new( + ArrayInput::new( + vec![(PhpMixed::from("package"), PhpMixed::from("foo/bar"))], + None, + ) + .unwrap(), + )); + let output: Rc> = + Rc::new(RefCell::new(BufferedOutput::new(None, false, None))); + + let config: Rc> = ConfigStubBuilder::new() + .with("archive-format", PhpMixed::from("zip")) + .build_shared(); + + let calls = Rc::new(RefCell::new(Vec::new())); + let manager = ArchiveManagerMock { + calls: calls.clone(), + return_value: Platform::get_cwd(false).unwrap(), + }; + + let package = root_package("foo/bar", "1.0.0"); + + // The local repository resolves `foo/bar` (PHPUnit mocks InstalledRepositoryInterface::loadPackages). + let installed = + InstalledArrayRepository::new_with_packages(vec![package.clone().into()]).unwrap(); + let repository_manager = RepositoryManagerMock { + local: RepositoryInterfaceHandle::new(installed), + repositories: vec![], + }; + + let mut composer = Composer::new(); + composer.set_config(config); + composer.set_archive_manager(Rc::new(RefCell::new(manager))); + composer.set_event_dispatcher(Rc::new(RefCell::new(NoopEventDispatcher))); + composer.set_package(package.into()); + composer.set_repository_manager(Rc::new(RefCell::new(repository_manager))); + let composer = full_composer(composer); + + let command = ArchiveCommand::new(); + command.set_composer(composer); + + command.run(input, output).unwrap(); + + let calls = calls.borrow(); + assert_eq!(1, calls.len()); + assert_eq!("foo/bar", calls[0].package_name); + assert_eq!("zip", calls[0].format); + assert_eq!(".", calls[0].target_dir); + assert_eq!(None, calls[0].file_name); + assert!(!calls[0].ignore_filters); } diff --git a/crates/shirabe/tests/command/run_script_command_test.rs b/crates/shirabe/tests/command/run_script_command_test.rs index 8e96cf3..6cd4a2e 100644 --- a/crates/shirabe/tests/command/run_script_command_test.rs +++ b/crates/shirabe/tests/command/run_script_command_test.rs @@ -1,11 +1,21 @@ //! ref: composer/tests/Composer/Test/Command/RunScriptCommandTest.php +use shirabe_php_shim::PhpMixed; + use crate::test_case::{RunOptions, get_application_tester, init_temp_composer}; use serial_test::serial; -use shirabe_php_shim::PhpMixed; +/// ref: RunScriptCommandTest::testDetectAndPassDevModeToEventAndToDispatching +/// +/// The `getDevOptions` dataProvider drives four `(dev, noDev)` cases; for each, PHP asserts that the +/// `ScriptEvent` passed to `hasEventListeners` matches the script name AND its `isDevMode()` equals +/// the computed dev mode (`dev || !noDev`) -- the latter being the whole point of the test. #[test] -#[ignore = "requires PHPUnit getMockBuilder/onlyMethods partial mock of RunScriptCommand (override requireComposer/initialize/etc) plus expects()/with()/willReturn()/returnValueMap mocks of InputInterface/OutputInterface/EventDispatcher with a callback constraint on ScriptEvent; no mocking infrastructure exists"] +#[ignore = "PHP asserts the ScriptEvent passed to hasEventListeners has isDevMode() == (dev || !no_dev), \ + but EventInterface (src/event_dispatcher/event.rs:49) has no as_any/downcast seam, so a \ + trait object cannot reach the concrete ScriptEvent::is_dev_mode (src/script/event.rs:51). \ + Reaching it would require adding as_any to EventInterface, i.e. a src change, which is \ + forbidden here -- so the faithful body is currently inexpressible and is left as todo!()."] fn test_detect_and_pass_dev_mode_to_event_and_to_dispatching() { todo!() } diff --git a/crates/shirabe/tests/command/status_command_test.rs b/crates/shirabe/tests/command/status_command_test.rs index aa0ecc6..f10df7b 100644 --- a/crates/shirabe/tests/command/status_command_test.rs +++ b/crates/shirabe/tests/command/status_command_test.rs @@ -39,9 +39,11 @@ fn test_no_local_changes() { drop(tear_down); } -#[ignore = "exercises `install` over the network (downloads composer/class-map-generator from a git \ - source or smarty/smarty from a dist zip), then mutates the installed package and runs \ - `status`; the install path needs real network access"] +#[ignore = "not a partial-mock test: it runs `install` to download composer/class-map-generator (git \ + source) or smarty/smarty (dist zip), mutates the installed package, then runs `status`. \ + Porting without network would require fabricating a real git checkout under vendor/, a \ + `.git` metadata repo, and wiring download-manager routing so GitDownloader::get_local_changes \ + runs `git status` against it — a full VCS integration fixture, not a SUT seam"] #[test] fn test_locally_modified_packages() { todo!() diff --git a/crates/shirabe/tests/downloader/download_manager_test.rs b/crates/shirabe/tests/downloader/download_manager_test.rs index 2405b4b..10fd1a9 100644 --- a/crates/shirabe/tests/downloader/download_manager_test.rs +++ b/crates/shirabe/tests/downloader/download_manager_test.rs @@ -3,23 +3,94 @@ use std::cell::RefCell; use std::rc::Rc; +use indexmap::IndexMap; use shirabe::downloader::DownloaderInterface; use shirabe::downloader::download_manager::DownloadManager; use shirabe::io::IOInterface; use shirabe::package::handle::{CompletePackageHandle, PackageInterfaceHandle}; +use shirabe_php_shim::{PhpMixed, RuntimeException}; use shirabe_semver::VersionParser; use crate::downloader_stub::DownloaderStub; use crate::io_stub::IOStub; +// PHP mocks `Composer\Downloader\DownloaderInterface` with getMockBuilder. +mockall::mock! { + #[derive(Debug)] + pub Downloader {} + #[async_trait::async_trait(?Send)] + impl DownloaderInterface for Downloader { + fn get_installation_source(&self) -> String; + async fn download( + &mut self, + package: PackageInterfaceHandle, + path: &str, + prev_package: Option, + output: bool, + ) -> anyhow::Result>; + async fn prepare( + &mut self, + r#type: &str, + package: PackageInterfaceHandle, + path: &str, + prev_package: Option, + ) -> anyhow::Result>; + async fn install( + &mut self, + package: PackageInterfaceHandle, + path: &str, + output: bool, + ) -> anyhow::Result>; + async fn update( + &mut self, + initial: PackageInterfaceHandle, + target: PackageInterfaceHandle, + path: &str, + ) -> anyhow::Result>; + async fn remove( + &mut self, + package: PackageInterfaceHandle, + path: &str, + output: bool, + ) -> anyhow::Result>; + async fn cleanup( + &mut self, + r#type: &str, + package: PackageInterfaceHandle, + path: &str, + prev_package: Option, + ) -> anyhow::Result>; + } +} + +fn run(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(future) +} + /// ref: DownloadManagerTest::createPackageMock /// /// PHPUnit returns a `PackageInterface` mock; a real CompletePackage with the /// relevant fields left at their defaults is an equivalent stand-in for the /// installation-source/type dispatch logic exercised by the ported cases. fn create_package_mock() -> PackageInterfaceHandle { - let norm_version = VersionParser.normalize("1.0.0", None).unwrap(); - CompletePackageHandle::new("dummy/pkg".to_string(), norm_version, "1.0.0".to_string()).into() + make_package("dummy/pkg", false) +} + +/// Real package stand-in whose dev flag is derived from the version stability, so +/// `isDev()` can be controlled (`dev-master` => dev, `1.0.0` => stable). +fn make_package(name: &str, is_dev: bool) -> PackageInterfaceHandle { + let (version, pretty) = if is_dev { + ("dev-master".to_string(), "dev-master".to_string()) + } else { + ( + VersionParser.normalize("1.0.0", None).unwrap(), + "1.0.0".to_string(), + ) + }; + CompletePackageHandle::new(name.to_string(), version, pretty).into() } /// ref: DownloadManagerTest::createDownloaderMock @@ -27,6 +98,22 @@ fn create_downloader_mock() -> Rc> { Rc::new(RefCell::new(DownloaderStub::new())) as Rc> } +/// A `createDownloaderMock()` whose `getInstallationSource()` reports the given +/// source, matching the type under which it is registered with the manager so the +/// real `getDownloaderForPackage` dispatch resolves to it. +fn downloader_mock(installation_source: &str) -> MockDownloader { + let mut downloader = MockDownloader::new(); + let source = installation_source.to_string(); + downloader + .expect_get_installation_source() + .returning(move || source.clone()); + downloader +} + +fn as_dyn(downloader: MockDownloader) -> Rc> { + Rc::new(RefCell::new(downloader)) as Rc> +} + fn create_manager() -> DownloadManager { let io = Rc::new(RefCell::new(IOStub::new())) as Rc>; DownloadManager::new(io, false, None) @@ -73,170 +160,698 @@ fn test_get_downloader_for_metapackage() { ); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloader"] #[test] fn test_get_downloader_for_correctly_installed_dist_package() { - todo!() + let package = create_package_mock(); + package.set_installation_source(Some("dist".to_string())); + package.set_dist_type(Some("pear".to_string())); + + let downloader = as_dyn(downloader_mock("dist")); + + let mut manager = create_manager(); + manager.set_downloader("pear", downloader.clone()); + + let result = manager + .get_downloader_for_package(package) + .unwrap() + .unwrap(); + assert!(Rc::ptr_eq(&downloader, &result)); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloader"] +// The LogicException message uses get_class($downloader); the equivalent +// `shirabe_php_shim::get_class_obj` is still a `todo!()`, so building the error +// panics before `getDownloaderForPackage` can return it. +#[ignore = "requires shirabe_php_shim::get_class_obj (PHP get_class), still todo!()"] #[test] fn test_get_downloader_for_incorrectly_installed_dist_package() { - todo!() + let package = create_package_mock(); + package.set_installation_source(Some("dist".to_string())); + package.set_dist_type(Some("git".to_string())); + + let downloader = as_dyn(downloader_mock("source")); + + let mut manager = create_manager(); + manager.set_downloader("git", downloader); + + // LogicException: the resolved downloader is a source downloader. + assert!(manager.get_downloader_for_package(package).is_err()); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloader"] #[test] fn test_get_downloader_for_correctly_installed_source_package() { - todo!() + let package = create_package_mock(); + package.set_installation_source(Some("source".to_string())); + package.__set_source_type(Some("git".to_string())); + + let downloader = as_dyn(downloader_mock("source")); + + let mut manager = create_manager(); + manager.set_downloader("git", downloader.clone()); + + let result = manager + .get_downloader_for_package(package) + .unwrap() + .unwrap(); + assert!(Rc::ptr_eq(&downloader, &result)); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloader"] +// See test_get_downloader_for_incorrectly_installed_dist_package: the LogicException +// path depends on the still-unimplemented get_class_obj shim. +#[ignore = "requires shirabe_php_shim::get_class_obj (PHP get_class), still todo!()"] #[test] fn test_get_downloader_for_incorrectly_installed_source_package() { - todo!() + let package = create_package_mock(); + package.set_installation_source(Some("source".to_string())); + package.__set_source_type(Some("pear".to_string())); + + let downloader = as_dyn(downloader_mock("dist")); + + let mut manager = create_manager(); + manager.set_downloader("pear", downloader); + + assert!(manager.get_downloader_for_package(package).is_err()); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_full_package_download() { - todo!() + let package = create_package_mock(); + package.__set_source_type(Some("git".to_string())); + package.set_dist_type(Some("pear".to_string())); + + let mut downloader = downloader_mock("dist"); + downloader + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("pear", as_dyn(downloader)); + + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("dist")); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_full_package_download_failover() { - todo!() + let package = create_package_mock(); + package.__set_source_type(Some("git".to_string())); + package.set_dist_type(Some("pear".to_string())); + + // dist downloader fails, source downloader (tried next) succeeds. + let mut downloader_fail = downloader_mock("dist"); + downloader_fail + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| { + Err(RuntimeException { + message: "Foo".to_string(), + code: 0, + } + .into()) + }); + + let mut downloader_success = downloader_mock("source"); + downloader_success + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("pear", as_dyn(downloader_fail)); + manager.set_downloader("git", as_dyn(downloader_success)); + + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + // PHP asserts setInstallationSource was called with 'dist' then 'source' + // (withConsecutive/exactly(2)). The real package retains only the final + // installation_source, so the dist-before-failover step is instead evidenced by + // both downloaders' expect_download().times(1): the 'dist' downloader is reached + // and fails, then the 'source' downloader is reached and succeeds. A faithful + // ordering check would need a src-side set-history hook on Package. + assert_eq!(package.get_installation_source().as_deref(), Some("source")); } -#[ignore = "requires PHPUnit mock of PackageInterface (createPackageMock)"] #[test] fn test_bad_package_download() { - todo!() + let package = create_package_mock(); + // getSourceType() => null, getDistType() => null. + + let manager = create_manager(); + + assert!(run(manager.download(package, "target_dir", None)).is_err()); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_dist_only_package_download() { - todo!() + let package = create_package_mock(); + package.set_dist_type(Some("pear".to_string())); + + let mut downloader = downloader_mock("dist"); + downloader + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("pear", as_dyn(downloader)); + + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("dist")); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_source_only_package_download() { - todo!() + let package = create_package_mock(); + package.__set_source_type(Some("git".to_string())); + + let mut downloader = downloader_mock("source"); + downloader + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("git", as_dyn(downloader)); + + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("source")); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_metapackage_package_download() { - todo!() + // There is no downloader for metapackages, so getDownloaderForPackage yields none. + let package = create_package_mock(); + package.__set_source_type(Some("git".to_string())); + package.__set_type("metapackage".to_string()); + + let manager = create_manager(); + + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("source")); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_full_package_download_with_source_preferred() { - todo!() + let package = create_package_mock(); + package.__set_source_type(Some("git".to_string())); + package.set_dist_type(Some("pear".to_string())); + + let mut downloader = downloader_mock("source"); + downloader + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("git", as_dyn(downloader)); + + manager.set_prefer_source(true); + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("source")); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_dist_only_package_download_with_source_preferred() { - todo!() + let package = create_package_mock(); + package.set_dist_type(Some("pear".to_string())); + + let mut downloader = downloader_mock("dist"); + downloader + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("pear", as_dyn(downloader)); + + manager.set_prefer_source(true); + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("dist")); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_source_only_package_download_with_source_preferred() { - todo!() + let package = create_package_mock(); + package.__set_source_type(Some("git".to_string())); + + let mut downloader = downloader_mock("source"); + downloader + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("git", as_dyn(downloader)); + + manager.set_prefer_source(true); + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("source")); } -#[ignore = "requires PHPUnit mock of PackageInterface (createPackageMock)"] #[test] fn test_bad_package_download_with_source_preferred() { - todo!() + let package = create_package_mock(); + // getSourceType() => null, getDistType() => null. + + let mut manager = create_manager(); + manager.set_prefer_source(true); + + assert!(run(manager.download(package, "target_dir", None)).is_err()); } -#[ignore = "requires PHPUnit mocks of PackageInterface and DownloaderInterface"] #[test] fn test_update_dist_with_equal_types() { - todo!() + let initial = create_package_mock(); + initial.set_installation_source(Some("dist".to_string())); + initial.set_dist_type(Some("zip".to_string())); + + let target = create_package_mock(); + target.set_installation_source(Some("dist".to_string())); + target.set_dist_type(Some("zip".to_string())); + + let mut zip_downloader = downloader_mock("dist"); + zip_downloader + .expect_update() + .times(1) + .withf(|_initial, _target, path| path == "vendor/bundles/FOS/UserBundle") + .returning(|_, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("zip", as_dyn(zip_downloader)); + + run(manager.update(initial, target, "vendor/bundles/FOS/UserBundle")).unwrap(); } -#[ignore = "requires PHPUnit mocks of PackageInterface and DownloaderInterface"] #[test] fn test_update_dist_with_not_equal_types() { - todo!() + let initial = create_package_mock(); + initial.set_installation_source(Some("dist".to_string())); + initial.set_dist_type(Some("xz".to_string())); + + let target = create_package_mock(); + target.set_installation_source(Some("dist".to_string())); + target.set_dist_type(Some("zip".to_string())); + + let mut xz_downloader = downloader_mock("dist"); + xz_downloader + .expect_remove() + .times(1) + .withf(|_pkg, path, _output| path == "vendor/bundles/FOS/UserBundle") + .returning(|_, _, _| Ok(None)); + + let mut zip_downloader = downloader_mock("dist"); + zip_downloader + .expect_install() + .times(1) + .withf(|_pkg, path, _output| path == "vendor/bundles/FOS/UserBundle") + .returning(|_, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("xz", as_dyn(xz_downloader)); + manager.set_downloader("zip", as_dyn(zip_downloader)); + + run(manager.update(initial, target, "vendor/bundles/FOS/UserBundle")).unwrap(); } -#[ignore = "requires PHPUnit mock of PackageInterface and ReflectionMethod for private getAvailableSources"] #[test] fn test_get_available_sources_update_sticks_to_same_source() { - todo!() + // ref: updatesProvider. Columns: prevPkgSource, prevPkgIsDev, targetAvailable, + // targetIsDev, expected. + struct Case { + prev_pkg_source: Option<&'static str>, + prev_pkg_is_dev: bool, + target_available: &'static [&'static str], + target_is_dev: bool, + expected: &'static [&'static str], + } + + let cases = [ + // updates keep previous source as preference + Case { + prev_pkg_source: Some("source"), + prev_pkg_is_dev: false, + target_available: &["source", "dist"], + target_is_dev: false, + expected: &["source", "dist"], + }, + Case { + prev_pkg_source: Some("dist"), + prev_pkg_is_dev: false, + target_available: &["source", "dist"], + target_is_dev: false, + expected: &["dist", "source"], + }, + // updates do not keep previous source if target package does not have it + Case { + prev_pkg_source: Some("source"), + prev_pkg_is_dev: false, + target_available: &["dist"], + target_is_dev: false, + expected: &["dist"], + }, + Case { + prev_pkg_source: Some("dist"), + prev_pkg_is_dev: false, + target_available: &["source"], + target_is_dev: false, + expected: &["source"], + }, + // updates do not keep previous source if target is dev and prev wasn't dev and installed from dist + Case { + prev_pkg_source: Some("source"), + prev_pkg_is_dev: false, + target_available: &["source", "dist"], + target_is_dev: true, + expected: &["source", "dist"], + }, + Case { + prev_pkg_source: Some("dist"), + prev_pkg_is_dev: false, + target_available: &["source", "dist"], + target_is_dev: true, + expected: &["source", "dist"], + }, + // install picks the right default + Case { + prev_pkg_source: None, + prev_pkg_is_dev: false, + target_available: &["source", "dist"], + target_is_dev: true, + expected: &["source", "dist"], + }, + Case { + prev_pkg_source: None, + prev_pkg_is_dev: false, + target_available: &["dist"], + target_is_dev: true, + expected: &["dist"], + }, + Case { + prev_pkg_source: None, + prev_pkg_is_dev: false, + target_available: &["source"], + target_is_dev: true, + expected: &["source"], + }, + Case { + prev_pkg_source: None, + prev_pkg_is_dev: false, + target_available: &["source", "dist"], + target_is_dev: false, + expected: &["dist", "source"], + }, + Case { + prev_pkg_source: None, + prev_pkg_is_dev: false, + target_available: &["dist"], + target_is_dev: false, + expected: &["dist"], + }, + Case { + prev_pkg_source: None, + prev_pkg_is_dev: false, + target_available: &["source"], + target_is_dev: false, + expected: &["source"], + }, + ]; + + let manager = create_manager(); + + for case in cases { + let initial = case.prev_pkg_source.map(|source| { + let package = make_package("dummy/pkg", case.prev_pkg_is_dev); + package.set_installation_source(Some(source.to_string())); + package + }); + + let target = make_package("dummy/pkg", case.target_is_dev); + if case.target_available.contains(&"source") { + target.__set_source_type(Some("git".to_string())); + } + if case.target_available.contains(&"dist") { + target.set_dist_type(Some("zip".to_string())); + } + + let result = manager.__get_available_sources(target, initial).unwrap(); + let expected: Vec = case.expected.iter().map(|s| s.to_string()).collect(); + assert_eq!(result, expected); + } } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_update_metapackage() { - todo!() + // There is no downloader for metapackages. + let initial = create_package_mock(); + initial.__set_type("metapackage".to_string()); + let target = create_package_mock(); + target.__set_type("metapackage".to_string()); + + let manager = create_manager(); + + assert!( + run(manager.update(initial, target, "vendor/pkg")) + .unwrap() + .is_none() + ); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_remove() { - todo!() + let package = create_package_mock(); + package.set_installation_source(Some("dist".to_string())); + package.set_dist_type(Some("pear".to_string())); + + let mut pear_downloader = downloader_mock("dist"); + pear_downloader + .expect_remove() + .times(1) + .withf(|_pkg, path, _output| path == "vendor/bundles/FOS/UserBundle") + .returning(|_, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("pear", as_dyn(pear_downloader)); + + run(manager.remove(package, "vendor/bundles/FOS/UserBundle")).unwrap(); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] #[test] fn test_metapackage_remove() { - todo!() + // There is no downloader for metapackages. + let package = create_package_mock(); + package.__set_type("metapackage".to_string()); + + let manager = create_manager(); + + assert!( + run(manager.remove(package, "vendor/bundles/FOS/UserBundle")) + .unwrap() + .is_none() + ); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] +/// @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference #[test] fn test_install_preference_without_preference_dev() { - todo!() + let package = make_package("dummy/pkg", true); + package.__set_source_type(Some("git".to_string())); + package.set_dist_type(Some("pear".to_string())); + + let mut downloader = downloader_mock("source"); + downloader + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("git", as_dyn(downloader)); + + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("source")); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] +/// @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference #[test] fn test_install_preference_without_preference_no_dev() { - todo!() + let package = make_package("dummy/pkg", false); + package.__set_source_type(Some("git".to_string())); + package.set_dist_type(Some("pear".to_string())); + + let mut downloader = downloader_mock("dist"); + downloader + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("pear", as_dyn(downloader)); + + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("dist")); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] +/// @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference #[test] fn test_install_preference_without_match_dev() { - todo!() + let package = make_package("bar/package", true); + package.__set_source_type(Some("git".to_string())); + package.set_dist_type(Some("pear".to_string())); + + let mut downloader = downloader_mock("source"); + downloader + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("git", as_dyn(downloader)); + manager.set_preferences(IndexMap::from([( + "foo/*".to_string(), + "source".to_string(), + )])); + + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("source")); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] +/// @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference #[test] fn test_install_preference_without_match_no_dev() { - todo!() + let package = make_package("bar/package", false); + package.__set_source_type(Some("git".to_string())); + package.set_dist_type(Some("pear".to_string())); + + let mut downloader = downloader_mock("dist"); + downloader + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("pear", as_dyn(downloader)); + manager.set_preferences(IndexMap::from([( + "foo/*".to_string(), + "source".to_string(), + )])); + + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("dist")); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] +/// @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference #[test] fn test_install_preference_with_match_auto_dev() { - todo!() + let package = make_package("foo/package", true); + package.__set_source_type(Some("git".to_string())); + package.set_dist_type(Some("pear".to_string())); + + let mut downloader = downloader_mock("source"); + downloader + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("git", as_dyn(downloader)); + manager.set_preferences(IndexMap::from([("foo/*".to_string(), "auto".to_string())])); + + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("source")); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] +/// @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference #[test] fn test_install_preference_with_match_auto_no_dev() { - todo!() + let package = make_package("foo/package", false); + package.__set_source_type(Some("git".to_string())); + package.set_dist_type(Some("pear".to_string())); + + let mut downloader = downloader_mock("dist"); + downloader + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("pear", as_dyn(downloader)); + manager.set_preferences(IndexMap::from([("foo/*".to_string(), "auto".to_string())])); + + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("dist")); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] +/// @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference #[test] fn test_install_preference_with_match_source() { - todo!() + let package = make_package("foo/package", false); + package.__set_source_type(Some("git".to_string())); + package.set_dist_type(Some("pear".to_string())); + + let mut downloader = downloader_mock("source"); + downloader + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("git", as_dyn(downloader)); + manager.set_preferences(IndexMap::from([( + "foo/*".to_string(), + "source".to_string(), + )])); + + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("source")); } -#[ignore = "requires PHPUnit mocks and partial mock of DownloadManager::getDownloaderForPackage"] +/// @covers Composer\Downloader\DownloadManager::resolvePackageInstallPreference #[test] fn test_install_preference_with_match_dist() { - todo!() + let package = make_package("foo/package", false); + package.__set_source_type(Some("git".to_string())); + package.set_dist_type(Some("pear".to_string())); + + let mut downloader = downloader_mock("dist"); + downloader + .expect_download() + .times(1) + .withf(|_pkg, path, _prev, _output| path == "target_dir") + .returning(|_, _, _, _| Ok(None)); + + let mut manager = create_manager(); + manager.set_downloader("pear", as_dyn(downloader)); + manager.set_preferences(IndexMap::from([("foo/*".to_string(), "dist".to_string())])); + + run(manager.download(package.clone(), "target_dir", None)).unwrap(); + + assert_eq!(package.get_installation_source().as_deref(), Some("dist")); } diff --git a/crates/shirabe/tests/downloader/file_downloader_test.rs b/crates/shirabe/tests/downloader/file_downloader_test.rs index cb09c74..6f7025e 100644 --- a/crates/shirabe/tests/downloader/file_downloader_test.rs +++ b/crates/shirabe/tests/downloader/file_downloader_test.rs @@ -5,14 +5,16 @@ use std::rc::Rc; use indexmap::IndexMap; use serial_test::serial; +use shirabe::cache::{Cache, CacheMock}; use shirabe::config::Config; use shirabe::downloader::DownloaderInterface; use shirabe::downloader::FileDownloader; use shirabe::io::IOInterface; +use shirabe::io::io_interface; use shirabe::io::null_io::NullIO; use shirabe::package::handle::{CompletePackageHandle, PackageInterfaceHandle}; use shirabe::util::HttpDownloader; -use shirabe::util::filesystem::Filesystem; +use shirabe::util::filesystem::{Filesystem, FilesystemMock}; use shirabe::util::r#loop::Loop; use shirabe_php_shim::{ InvalidArgumentException, PhpMixed, RuntimeException, UnexpectedValueException, @@ -21,6 +23,7 @@ use shirabe_semver::VersionParser; use tempfile::TempDir; use crate::http_downloader_mock::get_http_downloader_mock; +use crate::io_mock::{Expectation, get_io_mock}; use shirabe::util::http_downloader::HttpDownloaderMockHandler; /// ref: TestCase::getPackage (default class CompletePackage) @@ -193,9 +196,61 @@ fn test_download_with_custom_cache_key() { } #[test] -#[ignore = "requires a Cache mock with gcIsNecessary/gc expectation tracking; Cache is a concrete struct with no test hook for asserting gc() was called once"] +#[serial] +#[ignore = "PHP Config::get('cache-files-ttl') casts the string via (int) (Config.php:396-398), turning '99999999' into 99999999, but shirabe's PhpMixed::as_int returns None for String so Config::get yields 0 and the assertion fails. Faithful port stays failing until the src is fixed."] fn test_cache_garbage_collection_is_called() { - todo!() + let expected_ttl: i64 = 99999999; + + let mut config_options: IndexMap = IndexMap::new(); + config_options.insert( + "cache-files-ttl".to_string(), + PhpMixed::String("99999999".to_string()), + ); + config_options.insert( + "cache-files-maxsize".to_string(), + PhpMixed::String("500M".to_string()), + ); + let config = get_config(config_options); + + // The PHP Cache mock forces gcIsNecessary() true and records the single gc() call; the CacheMock + // seam plays both roles here. + let tmp_dir = TempDir::new().unwrap(); + let io: Rc> = Rc::new(RefCell::new(NullIO::new())); + let mut cache = Cache::new(io, tmp_dir.path().to_str().unwrap(), None, None, false); + cache.__set_mock(CacheMock { + gc_is_necessary: Some(true), + gc_calls: Some(Vec::new()), + ..Default::default() + }); + let cache = Rc::new(RefCell::new(cache)); + + let (http_downloader, _guard) = get_http_downloader_mock( + Vec::new(), + false, + HttpDownloaderMockHandler { + status: 200, + body: "file~".to_string(), + headers: Vec::new(), + }, + ); + + let io: Rc> = Rc::new(RefCell::new(NullIO::new())); + let _downloader = FileDownloader::new( + io, + config, + http_downloader, + None, + Some(cache.clone()), + None, + None, + ); + + let gc_calls = cache.borrow().__gc_calls(); + assert_eq!(gc_calls.len(), 1, "gc should be called exactly once"); + assert_eq!( + gc_calls[0].0, expected_ttl, + "gc should receive the configured ttl" + ); } #[test] @@ -245,8 +300,86 @@ fn test_download_file_with_invalid_checksum() { } #[test] -#[ignore = "requires a Filesystem mock of removeDirectoryAsync/normalizePath plus IOMock output expectations; Filesystem is a concrete struct with no async-removal test hook"] +#[serial] fn test_downgrade_shows_appropriate_message() { - let _ = Filesystem::new(None); - todo!() + let old_package = get_package("dummy/pkg", "1.2.0"); + let new_package = get_package("dummy/pkg", "1.0.0"); + new_package.set_dist_url(Some("http://example.com/script.js".to_string())); + + let (io_mock, _io_guard) = get_io_mock(io_interface::DEBUG).unwrap(); + io_mock + .borrow_mut() + .expects( + vec![ + Expectation::text_regex("{Downloading .*}"), + Expectation::text_regex("{Downgrading .*}"), + ], + false, + ) + .unwrap(); + + let tmp_dir = TempDir::new().unwrap(); + let path = tmp_dir.path().to_string_lossy().into_owned(); + let mut config_options: IndexMap = IndexMap::new(); + config_options.insert( + "vendor-dir".to_string(), + PhpMixed::String(format!("{}/vendor", path)), + ); + let config = get_config(config_options); + + // PHP mocks Filesystem so removeDirectoryAsync is a no-op (resolve(true)) and normalizePath is an + // identity; the FilesystemMock seam reproduces both so update()'s remove/install do not disturb + // the pre-staged download file. + let mut filesystem = Filesystem::new(None); + filesystem.__set_mock(FilesystemMock { + remove_directory_async_result: Some(true), + normalize_path_identity: true, + ..Default::default() + }); + let filesystem = Rc::new(RefCell::new(filesystem)); + + let (http_downloader, _guard) = get_http_downloader_mock( + Vec::new(), + false, + HttpDownloaderMockHandler { + status: 200, + body: "file~".to_string(), + headers: Vec::new(), + }, + ); + + let io: Rc> = io_mock.clone(); + let mut downloader = FileDownloader::new( + io, + config, + http_downloader.clone(), + None, + None, + Some(filesystem.clone()), + None, + ); + + // make sure the file expected to be downloaded is on disk already + let dl_file = downloader.__get_file_name(new_package.clone(), &path); + let dir = std::path::Path::new(&dl_file).parent().unwrap(); + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(&dl_file, b"").unwrap(); + + let mut loop_ = Loop::new(http_downloader, None); + let promise = Box::pin(async { + downloader + .download(new_package.clone(), &path, Some(old_package.clone()), true) + .await + .map(|_| ()) + }); + run(loop_.wait(vec![promise], None)).expect("download should succeed"); + + run(downloader.update(old_package.clone(), new_package.clone(), &path)) + .expect("update should succeed"); + + assert_eq!( + filesystem.borrow().__remove_directory_async_calls(), + 1, + "removeDirectoryAsync should be called exactly once" + ); } diff --git a/crates/shirabe/tests/downloader/git_downloader_test.rs b/crates/shirabe/tests/downloader/git_downloader_test.rs index 07dec08..77a976e 100644 --- a/crates/shirabe/tests/downloader/git_downloader_test.rs +++ b/crates/shirabe/tests/downloader/git_downloader_test.rs @@ -9,6 +9,8 @@ use shirabe::config::Config; use shirabe::downloader::VcsDownloader; use shirabe::downloader::git_downloader::GitDownloader; use shirabe::io::IOInterface; +use shirabe::io::io_interface; +use shirabe::package::Mirror; use shirabe::package::handle::{CompletePackageHandle, PackageInterfaceHandle}; use shirabe::util::Git as GitUtil; use shirabe::util::ProcessExecutor; @@ -18,6 +20,7 @@ use shirabe_semver::VersionParser; use tempfile::TempDir; use crate::config_stub::ConfigStubBuilder; +use crate::io_mock::{Expectation, get_io_mock}; use crate::io_stub::IOStub; use crate::process_executor_mock::{cmd, cmd_full, get_process_executor_mock}; @@ -333,9 +336,11 @@ fn test_download_with_cache() { fs.remove_directory(&cache_path).ok(); } -#[ignore = "getSourceUrls returns a mirror list (['https://github.com/mirrors/composer', \ - 'https://github.com/composer/composer']) that a real CompletePackage cannot reproduce \ - from a single source_url; no PackageInterface mock with arbitrary getSourceUrls exists"] +#[ignore = "Source-side blocker: getSourceUrls() must list the mirror ahead of the \ + source url, which requires a preferred source mirror. A git source mirror is \ + processed through ComposerMirror::process_git_url, whose github regex lacks PCRE \ + delimiters and panics when compiled by the php-shim Preg. PHP mocks getSourceUrls \ + directly and never exercises that path."] #[test] fn test_download_uses_various_protocols_and_sets_push_url_for_github() { let working_dir = set_up(); @@ -344,14 +349,106 @@ fn test_download_uses_various_protocols_and_sets_push_url_for_github() { todo!() } -#[ignore = "pushUrlProvider configures getSourceUrls to a list a real CompletePackage cannot \ - reproduce; no PackageInterface mock with arbitrary getSourceUrls exists"] +#[serial] #[test] fn test_download_and_set_push_url_use_custom_various_protocols_for_github() { - let working_dir = set_up(); - let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + // ref: pushUrlProvider — (github-protocols, fetch url, push url). + let cases: Vec<(Vec<&str>, &str, &str)> = vec![ + ( + vec!["ssh"], + "git@github.com:composer/composer", + "git@github.com:composer/composer.git", + ), + ( + vec!["https", "ssh", "git"], + "https://github.com/composer/composer", + "git@github.com:composer/composer.git", + ), + ( + vec!["https"], + "https://github.com/composer/composer", + "https://github.com/composer/composer.git", + ), + ]; + + for (protocols, url, push_url) in cases { + let working_dir = set_up(); + let _tear_down = TearDown::new(working_dir.path().to_path_buf()); + + let package = get_package( + "composer/composer", + "1.0.0", + Some("ref"), + Some("https://github.com/composer/composer"), + ); + + let expected_path = working_dir + .path() + .join("composerPath") + .to_string_lossy() + .into_owned(); + + let (process, _guard) = get_process_executor_mock( + vec![ + cmd(vec![ + "git", + "clone", + "--no-checkout", + "--", + url, + &expected_path, + ]), + cmd(vec!["git", "remote", "add", "composer", "--", url]), + cmd(["git", "fetch", "composer"]), + cmd(vec!["git", "remote", "set-url", "origin", "--", url]), + cmd(vec!["git", "remote", "set-url", "composer", "--", url]), + cmd(vec![ + "git", "remote", "set-url", "--push", "origin", "--", push_url, + ]), + cmd(["git", "branch", "-r"]), + cmd(["git", "checkout", "ref", "--"]), + cmd(["git", "reset", "--hard", "ref", "--"]), + ], + true, + Default::default(), + ); + + let mut config = Config::new(false, None); + let mut top: IndexMap = IndexMap::new(); + let mut section: IndexMap = IndexMap::new(); + section.insert( + "github-protocols".to_string(), + PhpMixed::List( + protocols + .iter() + .map(|p| PhpMixed::String(p.to_string())) + .collect(), + ), + ); + top.insert("config".to_string(), PhpMixed::Array(section)); + config.merge(&top, Config::SOURCE_UNKNOWN); + + let mut downloader = get_downloader_mock(None, Some(config), process, None); + + run(async { + downloader + .download(package.clone(), &expected_path, None) + .await + .unwrap(); + downloader + .prepare("install", package.clone(), &expected_path, None) + .await + .unwrap(); + downloader + .install(package.clone(), &expected_path) + .await + .unwrap(); + downloader + .cleanup("install", package, &expected_path, None) + .await + .unwrap(); + }); + } } #[serial] @@ -589,18 +686,95 @@ fn test_update_with_new_repo_url() { }); } -#[ignore = "getSourceUrls returns a multi-URL list a real CompletePackage cannot reproduce; \ - no PackageInterface mock with arbitrary getSourceUrls exists"] +#[ignore = "Blocked by a Config bug: Config::get(\"github-protocols\") does not drop the \ + insecure \"git\" protocol under secure-http. array_search_mixed returns the \ + matched index as PhpMixed::Int, but config.rs reads it via as_string (which is \ + Some only for String), so the removal is skipped and get() returns \ + [https, ssh, git] instead of [https, ssh]. The downloader then attempts a third \ + git:// fetch (absent from the process mock) and the error message reads \ + \"via https, ssh, git protocols\", failing the assertion. PHP's new Config() \ + reduces it to two protocols."] +#[serial] #[test] fn test_update_throws_runtime_exception_if_git_command_fails() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let url = "https://github.com/composer/composer"; + let package = get_package("composer/composer", "1.0.0", Some("ref"), Some(url)); + + let (process, _guard) = get_process_executor_mock( + vec![ + cmd(["git", "show-ref", "--head", "-d"]), + cmd(["git", "status", "--porcelain", "--untracked-files=no"]), + // commit not yet in so we try to fetch + cmd_full( + ["git", "rev-parse", "--quiet", "--verify", "ref^{commit}"], + 1, + "", + "", + ), + // fail first fetch + cmd(["git", "remote", "-v"]), + cmd(vec!["git", "remote", "set-url", "composer", "--", url]), + cmd_full(["git", "fetch", "composer"], 1, "", ""), + // fail second fetch + cmd(vec![ + "git", + "remote", + "set-url", + "composer", + "--", + "git@github.com:composer/composer", + ]), + cmd_full(["git", "fetch", "composer"], 1, "", ""), + cmd(["git", "--version"]), + ], + true, + Default::default(), + ); + + let mut fs = Filesystem::new(None); + fs.ensure_directory_exists(&format!("{}/.git", working_dir.path().to_string_lossy())) + .unwrap(); + let working_dir_str = working_dir.path().to_string_lossy().into_owned(); + + let config = Config::new(false, None); + + let mut downloader = get_downloader_mock(None, Some(config), process, None); + + let result = run(async { + downloader + .download(package.clone(), &working_dir_str, Some(package.clone())) + .await?; + downloader + .prepare( + "update", + package.clone(), + &working_dir_str, + Some(package.clone()), + ) + .await?; + downloader + .update(package.clone(), package.clone(), &working_dir_str) + .await?; + downloader + .cleanup("update", package.clone(), &working_dir_str, Some(package)) + .await + }); + + let e = result.expect_err("failing git fetch should throw"); + assert!(e.to_string().contains( + "Failed to clone https://github.com/composer/composer via https, ssh protocols, aborting." + )); + assert!(e.to_string().contains("git@github.com:composer/composer")); } -#[ignore = "getSourceUrls returns a multi-URL list (['/' , github]) a real CompletePackage cannot \ - reproduce; no PackageInterface mock with arbitrary getSourceUrls exists"] +#[ignore = "Source-side blocker: getSourceUrls() == ['/', github] requires a source \ + mirror so the list has two entries. A git source mirror is processed through \ + ComposerMirror::process_git_url, whose github regex lacks PCRE delimiters and \ + panics when compiled by the php-shim Preg. PHP mocks getSourceUrls directly and \ + never exercises that path."] #[test] fn test_update_doesnt_throws_runtime_exception_if_git_command_fails_at_first_but_is_able_to_recover() { @@ -610,24 +784,142 @@ fn test_update_doesnt_throws_runtime_exception_if_git_command_fails_at_first_but todo!() } -#[ignore = "getSourceUrls returns a multi-URL list (['/foo/bar', github]) a real CompletePackage \ - cannot reproduce; no PackageInterface mock with arbitrary getSourceUrls exists"] +#[serial] #[test] fn test_downgrade_shows_appropriate_message() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let url = "https://github.com/composer/composer"; + + let old_package = get_package("composer/composer", "1.2.0", Some("ref"), Some("/foo/bar")); + old_package.set_source_mirrors(Some(vec![Mirror { + url: url.to_string(), + preferred: false, + }])); + let new_package = get_package("composer/composer", "1.0.0", Some("ref"), Some(url)); + + let (process, _guard) = get_process_executor_mock(vec![], false, Default::default()); + + let (io_mock, _io_guard) = get_io_mock(io_interface::NORMAL).unwrap(); + io_mock + .borrow_mut() + .expects(vec![Expectation::text_regex("{Downgrading .*}")], false) + .unwrap(); + let io = io_mock.clone() as Rc>; + + let mut fs = Filesystem::new(None); + fs.ensure_directory_exists(&format!("{}/.git", working_dir.path().to_string_lossy())) + .unwrap(); + let working_dir_str = working_dir.path().to_string_lossy().into_owned(); + + let mut downloader = get_downloader_mock(Some(io), None, process, None); + + run(async { + downloader + .download( + new_package.clone(), + &working_dir_str, + Some(old_package.clone()), + ) + .await + .unwrap(); + downloader + .prepare( + "update", + new_package.clone(), + &working_dir_str, + Some(old_package.clone()), + ) + .await + .unwrap(); + downloader + .update(old_package.clone(), new_package.clone(), &working_dir_str) + .await + .unwrap(); + downloader + .cleanup("update", new_package, &working_dir_str, Some(old_package)) + .await + .unwrap(); + }); } -#[ignore = "getSourceUrls returns a multi-URL list (['/foo/bar', github]) a real CompletePackage \ - cannot reproduce; no PackageInterface mock with arbitrary getSourceUrls exists"] +#[serial] #[test] fn test_not_using_downgrading_with_references() { let working_dir = set_up(); let _tear_down = TearDown::new(working_dir.path().to_path_buf()); - let _ = &working_dir; - todo!() + + let url = "https://github.com/composer/composer"; + + // dev versions: getVersion() is the (non-normalized) branch name. + let old_package = CompletePackageHandle::new( + "composer/composer".to_string(), + "dev-ref".to_string(), + "dev-ref".to_string(), + ); + old_package.__set_source_type(Some("git".to_string())); + old_package.set_source_reference(Some("ref".to_string())); + old_package.set_source_url(Some("/foo/bar".to_string())); + old_package.set_source_mirrors(Some(vec![Mirror { + url: url.to_string(), + preferred: false, + }])); + let old_package: PackageInterfaceHandle = old_package.into(); + + let new_package = CompletePackageHandle::new( + "composer/composer".to_string(), + "dev-ref2".to_string(), + "dev-ref2".to_string(), + ); + new_package.__set_source_type(Some("git".to_string())); + new_package.set_source_reference(Some("ref".to_string())); + new_package.set_source_url(Some(url.to_string())); + let new_package: PackageInterfaceHandle = new_package.into(); + + let (process, _guard) = get_process_executor_mock(vec![], false, Default::default()); + + let (io_mock, _io_guard) = get_io_mock(io_interface::NORMAL).unwrap(); + io_mock + .borrow_mut() + .expects(vec![Expectation::text_regex("{Upgrading .*}")], false) + .unwrap(); + let io = io_mock.clone() as Rc>; + + let mut fs = Filesystem::new(None); + fs.ensure_directory_exists(&format!("{}/.git", working_dir.path().to_string_lossy())) + .unwrap(); + let working_dir_str = working_dir.path().to_string_lossy().into_owned(); + + let mut downloader = get_downloader_mock(Some(io), None, process, None); + + run(async { + downloader + .download( + new_package.clone(), + &working_dir_str, + Some(old_package.clone()), + ) + .await + .unwrap(); + downloader + .prepare( + "update", + new_package.clone(), + &working_dir_str, + Some(old_package.clone()), + ) + .await + .unwrap(); + downloader + .update(old_package.clone(), new_package.clone(), &working_dir_str) + .await + .unwrap(); + downloader + .cleanup("update", new_package, &working_dir_str, Some(old_package)) + .await + .unwrap(); + }); } #[ignore = "PHP mocks Filesystem::removeDirectoryAsync (asserting it is called once with the \ diff --git a/crates/shirabe/tests/downloader/perforce_downloader_test.rs b/crates/shirabe/tests/downloader/perforce_downloader_test.rs index 63f1de4..f23888a 100644 --- a/crates/shirabe/tests/downloader/perforce_downloader_test.rs +++ b/crates/shirabe/tests/downloader/perforce_downloader_test.rs @@ -1,44 +1,193 @@ //! ref: composer/tests/Composer/Test/Downloader/PerforceDownloaderTest.php +use std::cell::RefCell; +use std::rc::Rc; + +use indexmap::IndexMap; +use shirabe::config::Config; +use shirabe::downloader::VcsDownloader; +use shirabe::downloader::perforce_downloader::PerforceDownloader; +use shirabe::io::IOInterface; +use shirabe::io::io_interface::NORMAL; +use shirabe::package::handle::{CompletePackageHandle, PackageInterfaceHandle}; +use shirabe::util::filesystem::Filesystem; +use shirabe::util::process_executor::MockHandler; +use shirabe_php_shim::PhpMixed; +use shirabe_semver::VersionParser; use tempfile::TempDir; -fn set_up() -> TempDir { - let test_path = TempDir::new().unwrap(); - // repoConfig/config/io/processExecutor/repository/package/downloader rely on - // ProcessExecutorMock and PHPUnit mocks of the repository and Package, which are not - // ported. - let () = todo!(); - #[allow(unreachable_code)] - test_path +use crate::io_mock::{Expectation, get_io_mock}; +use crate::io_stub::IOStub; +use crate::process_executor_mock::get_process_executor_mock; + +// A getMockBuilder('Composer\Util\Perforce') stand-in: the seam trait extracted from the +// concrete `Perforce` struct, mocked so the downloader's workflow can be verified. +mockall::mock! { + #[derive(Debug)] + pub Perforce {} + impl shirabe::util::PerforceInterface for Perforce { + fn initialize_path(&mut self, path: &str); + fn set_stream(&mut self, stream: &str); + fn p4_login(&mut self) -> anyhow::Result<()>; + fn check_stream(&mut self) -> bool; + fn write_p4_client_spec(&mut self) -> anyhow::Result<()>; + fn connect_client(&mut self) -> anyhow::Result<()>; + fn sync_code_base(&mut self, source_reference: Option) -> anyhow::Result<()>; + fn cleanup_client_spec(&mut self); + fn get_commit_logs(&mut self, from_reference: &str, to_reference: &str) -> Option; + fn get_file_content(&mut self, file: &str, identifier: &str) -> Option; + fn get_branches(&mut self) -> IndexMap; + fn get_tags(&mut self) -> IndexMap; + fn get_user(&self) -> Option; + fn get_composer_information( + &mut self, + identifier: &str, + ) -> anyhow::Result>>; + } +} + +fn run(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(future) } -// These mock Perforce, the repository config and a Package to drive PerforceDownloader's -// initialization and install paths; mocking is not available here. +/// ref: PerforceDownloaderTest::getConfig (seeds `home` with the temp dir) +fn get_config(test_path: &std::path::Path) -> Config { + let mut config = Config::new(true, None); + let mut top: IndexMap = IndexMap::new(); + let mut section: IndexMap = IndexMap::new(); + section.insert( + "home".to_string(), + PhpMixed::String(test_path.to_string_lossy().into_owned()), + ); + top.insert("config".to_string(), PhpMixed::Array(section)); + config.merge(&top, Config::SOURCE_UNKNOWN); + config +} + +/// ref: PerforceDownloaderTest::getMockPackageInterface. A real CompletePackage stands in for +/// the PHPUnit PackageInterface mock; the source reference is returned by getSourceReference. +fn make_package(source_reference: Option<&str>) -> PackageInterfaceHandle { + let norm_version = VersionParser.normalize("1.0.0", None).unwrap(); + let package = + CompletePackageHandle::new("test/pkg".to_string(), norm_version, "1.0.0".to_string()); + package.set_source_reference(source_reference.map(|s| s.to_string())); + package.into() +} -#[ignore = "requires PHPUnit getMockBuilder mocks of IOInterface/PackageInterface/VcsRepository and ProcessExecutorMock via unported set_up()"] #[test] fn test_init_perforce_instantiates_a_new_perforce_object() { - let _test_path = set_up(); - todo!() + // @doesNotPerformAssertions: only verifies init_perforce instantiates a Perforce without + // error. PHP attaches a VcsRepository whose getRepoConfig seeds the config, but in the + // current port VcsRepository implements ConfigurableRepositoryInterface only (not + // RepositoryInterface), so it cannot be held in a RepositoryInterfaceHandle. The package + // is therefore built without a repository, yielding an empty repo config. + let test_path = TempDir::new().unwrap(); + let io: Rc> = Rc::new(RefCell::new(IOStub::new())); + let config = Rc::new(RefCell::new(get_config(test_path.path()))); + let package = make_package(None); + + let (process, _process_guard) = + get_process_executor_mock(vec![], false, MockHandler::default()); + let fs = Rc::new(RefCell::new(Filesystem::new(None))); + let mut downloader = PerforceDownloader::new(io, config, process, fs); + + downloader.init_perforce( + package, + test_path.path().to_string_lossy().into_owned(), + "SOURCE_REF".to_string(), + ); } -#[ignore = "requires PHPUnit getMockBuilder mock of Composer\\Util\\Perforce and expects()->never() verification, unavailable in the Rust port"] #[test] fn test_init_perforce_does_nothing_if_perforce_already_set() { - let _test_path = set_up(); - todo!() + let test_path = TempDir::new().unwrap(); + let io: Rc> = Rc::new(RefCell::new(IOStub::new())); + let config = Rc::new(RefCell::new(get_config(test_path.path()))); + let (process, _process_guard) = + get_process_executor_mock(vec![], false, MockHandler::default()); + let fs = Rc::new(RefCell::new(Filesystem::new(None))); + let mut downloader = PerforceDownloader::new(io, config, process, fs); + + // The already-set perforce only sees initializePath; the repository's getRepoConfig is + // never consulted (the early return happens before reaching it). + let mut perforce = MockPerforce::new(); + perforce.expect_initialize_path().times(1).returning(|_| ()); + downloader.set_perforce(Box::new(perforce)); + + let package = make_package(None); + downloader.init_perforce( + package, + test_path.path().to_string_lossy().into_owned(), + "SOURCE_REF".to_string(), + ); } -#[ignore = "requires PHPUnit getMockBuilder mock of Composer\\Util\\Perforce with expects()->once()->method() expectation verification, unavailable in the Rust port"] #[test] fn test_do_install_with_tag() { - let _test_path = set_up(); - todo!() + do_install_workflow("SOURCE_REF@123", Some("123".to_string())); } -#[ignore = "requires PHPUnit getMockBuilder mock of Composer\\Util\\Perforce with expects()->once()->method() expectation verification, unavailable in the Rust port"] #[test] fn test_do_install_with_no_tag() { - let _test_path = set_up(); - todo!() + do_install_workflow("SOURCE_REF", None); +} + +// Shared body for testDoInstallWithTag / testDoInstallWithNoTag: enforce the install workflow +// against a mocked Perforce, asserting each step is invoked exactly once. +fn do_install_workflow(source_ref: &'static str, expected_label: Option) { + let test_path = TempDir::new().unwrap(); + let test_path_str = test_path.path().to_string_lossy().into_owned(); + + let (io_mock, _io_guard) = get_io_mock(NORMAL).unwrap(); + io_mock + .borrow_mut() + .expects( + vec![Expectation::text_regex(format!("Cloning {}", source_ref))], + false, + ) + .unwrap(); + let io: Rc> = io_mock.clone(); + let config = Rc::new(RefCell::new(get_config(test_path.path()))); + let (process, _process_guard) = + get_process_executor_mock(vec![], false, MockHandler::default()); + let fs = Rc::new(RefCell::new(Filesystem::new(None))); + let mut downloader = PerforceDownloader::new(io, config, process, fs); + + let mut perforce = MockPerforce::new(); + let expected_path = test_path_str.clone(); + perforce + .expect_initialize_path() + .times(1) + .withf(move |path: &str| path == expected_path) + .returning(|_| ()); + perforce + .expect_set_stream() + .times(1) + .withf(move |stream: &str| stream == source_ref) + .returning(|_| ()); + perforce.expect_p4_login().times(1).returning(|| Ok(())); + perforce + .expect_write_p4_client_spec() + .times(1) + .returning(|| Ok(())); + perforce + .expect_connect_client() + .times(1) + .returning(|| Ok(())); + perforce + .expect_sync_code_base() + .times(1) + .withf(move |reference: &Option| *reference == expected_label) + .returning(|_| Ok(())); + perforce + .expect_cleanup_client_spec() + .times(1) + .returning(|| ()); + downloader.set_perforce(Box::new(perforce)); + + let package = make_package(Some(source_ref)); + run(downloader.do_install(package, &test_path_str, "url")).unwrap(); } diff --git a/crates/shirabe/tests/installer/library_installer_test.rs b/crates/shirabe/tests/installer/library_installer_test.rs index 0796efe..42730fe 100644 --- a/crates/shirabe/tests/installer/library_installer_test.rs +++ b/crates/shirabe/tests/installer/library_installer_test.rs @@ -323,8 +323,67 @@ fn test_get_install_path_with_target_dir() { tear_down(&mut setup); } +/// Records the calls a `BinaryInstaller` double receives, standing in for the PHPUnit mock that +/// asserts `removeBinaries` is never called and `installBinaries` is called once. +#[derive(Debug, Default)] +struct BinaryInstallerCalls { + install_binaries: Vec<(shirabe::package::PackageInterfaceHandle, String, bool)>, + remove_binaries: Vec, +} + +#[derive(Debug)] +struct RecordingBinaryInstaller { + calls: Rc>, +} + +impl shirabe::installer::BinaryInstallerInterface for RecordingBinaryInstaller { + fn install_binaries( + &mut self, + package: shirabe::package::PackageInterfaceHandle, + install_path: &str, + warn_on_overwrite: bool, + ) { + self.calls.borrow_mut().install_binaries.push(( + package, + install_path.to_string(), + warn_on_overwrite, + )); + } + + fn remove_binaries(&mut self, package: shirabe::package::PackageInterfaceHandle) { + self.calls.borrow_mut().remove_binaries.push(package); + } +} + #[test] -#[ignore = "requires PHPUnit mock of BinaryInstaller (expects(never)->removeBinaries, expects(once)->installBinaries) injected via LibraryInstaller's binaryInstaller argument"] fn test_ensure_binaries_installed() { - todo!() + let mut setup = set_up(); + let calls = Rc::new(RefCell::new(BinaryInstallerCalls::default())); + let mut library = LibraryInstaller::new( + setup.io.clone(), + setup.composer.clone(), + Some("library".to_string()), + None, + None, + ); + library.__set_binary_installer(Box::new(RecordingBinaryInstaller { + calls: calls.clone(), + })); + let package = get_package("foo/bar", "1.0.0"); + let expected_path = library.get_install_path(package.clone()).unwrap(); + + library.ensure_binaries_presence(package.clone()); + + let recorded = calls.borrow(); + // PHP asserts removeBinaries is never called. + assert!(recorded.remove_binaries.is_empty()); + // PHP asserts installBinaries is called once with ($package, getInstallPath, false). + assert_eq!(recorded.install_binaries.len(), 1); + let (recorded_package, recorded_path, warn_on_overwrite) = &recorded.install_binaries[0]; + assert!(Rc::ptr_eq(recorded_package.as_rc(), package.as_rc())); + assert_eq!(recorded_path, &expected_path); + assert!(!warn_on_overwrite); + drop(recorded); + + tear_down(&mut setup); } diff --git a/crates/shirabe/tests/package/loader/root_package_loader_test.rs b/crates/shirabe/tests/package/loader/root_package_loader_test.rs index 23c8beb..7fa970a 100644 --- a/crates/shirabe/tests/package/loader/root_package_loader_test.rs +++ b/crates/shirabe/tests/package/loader/root_package_loader_test.rs @@ -4,21 +4,28 @@ // ProcessExecutor / VersionGuesser or require constraints whose parsing goes through a // look-around regex the regex crate cannot compile. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use indexmap::IndexMap; +use serial_test::serial; use shirabe::config::Config; use shirabe::io::IOInterface; use shirabe::io::null_io::NullIO; +use shirabe::package::RootPackage; use shirabe::package::loader::RootPackageLoader; -use shirabe::package::version::{VersionGuesser, VersionParser}; +use shirabe::package::version::{ + VersionData, VersionGuesser, VersionGuesserInterface, VersionParser, +}; use shirabe::package::{STABILITY_ALPHA, STABILITY_DEV, STABILITY_RC}; use shirabe::repository::RepositoryManager; +use shirabe::util::Git as GitUtil; use shirabe::util::http_downloader::HttpDownloader; -use shirabe::util::process_executor::ProcessExecutor; +use shirabe::util::process_executor::{MockExpectation, MockHandler, ProcessExecutor}; use shirabe_php_shim::PhpMixed; +use crate::process_executor_mock::{cmd, cmd_full, get_process_executor_mock}; + fn null_io() -> Rc> { Rc::new(RefCell::new(NullIO::new())) } @@ -35,6 +42,73 @@ fn http_downloader( ))) } +// `$config = new Config; $config->merge(['repositories' => ['packagist' => false]]);` +fn make_config() -> Rc> { + let config = Rc::new(RefCell::new(Config::new(true, None))); + let mut repositories: IndexMap = IndexMap::new(); + repositories.insert("packagist".to_string(), PhpMixed::Bool(false)); + let mut merge: IndexMap = IndexMap::new(); + merge.insert("repositories".to_string(), PhpMixed::Array(repositories)); + config.borrow_mut().merge(&merge, "test"); + config +} + +// Stands in for `getMockBuilder('Composer\Repository\RepositoryManager')->disableOriginalConstructor()`. +// The loader only stores it and feeds it default repositories, so a real instance suffices. +fn make_manager( + io: &Rc>, + config: &Rc>, +) -> Rc> { + Rc::new(RefCell::new(RepositoryManager::new( + io.clone(), + config.clone(), + http_downloader(io, config), + None, + None, + ))) +} + +fn require_map(entries: &[(&str, &str)]) -> PhpMixed { + let mut m: IndexMap = IndexMap::new(); + for (k, v) in entries { + m.insert(k.to_string(), PhpMixed::String(v.to_string())); + } + PhpMixed::Array(m) +} + +// Resets the cached git `version` static on drop so a seeded value does not leak into other +// tests in this binary (VersionGuesserTest seeds/resets the same static). +struct GitVersionGuard; + +impl Drop for GitVersionGuard { + fn drop(&mut self) { + GitUtil::__reset_version(); + } +} + +// A test double for the concrete VersionGuesser, supplied through the VersionGuesserInterface seam. +#[derive(Debug)] +struct VersionGuesserMock { + version_data: VersionData, + guess_version_calls: Rc>, +} + +impl VersionGuesserInterface for VersionGuesserMock { + fn guess_version( + &mut self, + _package_config: &IndexMap, + _path: &str, + ) -> anyhow::Result> { + self.guess_version_calls + .set(self.guess_version_calls.get() + 1); + Ok(Some(self.version_data.clone())) + } + + fn get_root_version_from_env(&self) -> anyhow::Result { + unreachable!("COMPOSER_ROOT_VERSION is not set in this test") + } +} + #[test] #[ignore = "process_executor.enable_async() drives the async stream path, which calls stream_set_blocking (fcntl(2) todo!() in shirabe-php-shim::stream)"] fn test_stability_flags_parsing() { @@ -70,7 +144,8 @@ fn test_stability_flags_parsing() { Some(io.clone()), ); - let mut loader = RootPackageLoader::new(manager, config.clone(), None, Some(guesser), None); + let mut loader = + RootPackageLoader::new(manager, config.clone(), None, Some(Box::new(guesser)), None); let mut data = IndexMap::new(); data.insert( @@ -136,25 +211,158 @@ fn test_stability_flags_parsing() { } #[test] -#[ignore = "requires getProcessExecutorMock with expects(['return' => 1]); no ProcessExecutorMock mocking infrastructure exists"] +#[serial] fn test_no_version_is_visible_in_pretty_version() { - todo!() + GitUtil::__reset_version(); + let _git_guard = GitVersionGuard; + + let io = null_io(); + let config = make_config(); + let manager = make_manager(&io, &config); + + let (process, _guard) = get_process_executor_mock( + vec![], + false, + MockHandler { + r#return: 1, + ..Default::default() + }, + ); + let guesser = VersionGuesser::new(config.clone(), process, VersionParser::new(), None); + + let mut loader = + RootPackageLoader::new(manager, config.clone(), None, Some(Box::new(guesser)), None); + + let package = loader + .load(IndexMap::new(), "Composer\\Package\\RootPackage", None) + .unwrap(); + let package = package.as_root().unwrap(); + + assert_eq!("1.0.0.0", package.get_version()); + assert_eq!( + RootPackage::DEFAULT_PRETTY_VERSION, + package.get_pretty_version() + ); } #[test] -#[ignore = "requires getMockBuilder VersionGuesser mock with guessVersion expectation; no VersionGuesser mocking infrastructure exists"] +#[serial] fn test_pretty_version_for_root_package_in_version_branch() { - todo!() + // see #6845 + let io = null_io(); + let config = make_config(); + let manager = make_manager(&io, &config); + + let guess_version_calls = Rc::new(Cell::new(0u32)); + let version_guesser = VersionGuesserMock { + version_data: VersionData { + version: Some("3.0.9999999.9999999-dev".to_string()), + commit: Some("aabbccddee".to_string()), + pretty_version: Some("3.0-dev".to_string()), + feature_version: None, + feature_pretty_version: None, + }, + guess_version_calls: guess_version_calls.clone(), + }; + + let mut loader = RootPackageLoader::new( + manager, + config.clone(), + None, + Some(Box::new(version_guesser)), + None, + ); + + let package = loader + .load(IndexMap::new(), "Composer\\Package\\RootPackage", None) + .unwrap(); + + assert!(guess_version_calls.get() >= 1); + assert_eq!("3.0-dev", package.as_root().unwrap().get_pretty_version()); } #[test] -#[ignore = "requires getProcessExecutorMock with expects() git command expectations; no ProcessExecutorMock mocking infrastructure exists"] +#[ignore = "feature-branch guessing calls ProcessExecutor::execute_async, whose mock path is todo!()"] +#[serial] fn test_feature_branch_pretty_version() { - todo!() + // proc_open() is always available; the PHP markTestSkipped guard does not apply here. + GitUtil::__set_version(Some("2.52.0".to_string())); + let _git_guard = GitVersionGuard; + + let io = null_io(); + let config = make_config(); + let manager = make_manager(&io, &config); + + let expectations: Vec = vec![ + cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + "* latest-production 38137d2f6c70e775e137b2d8a7a7d3eaebf7c7e5 Commit message\n master 4f6ed96b0bc363d2aa4404c3412de1c011f67c66 Commit message\n", + "", + ), + cmd(["git", "rev-list", "master..latest-production"]), + ]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + let guesser = VersionGuesser::new(config.clone(), process, VersionParser::new(), None); + + let mut loader = + RootPackageLoader::new(manager, config.clone(), None, Some(Box::new(guesser)), None); + + let mut data = IndexMap::new(); + data.insert( + "require".to_string(), + require_map(&[("foo/bar", "self.version")]), + ); + + let package = loader + .load(data, "Composer\\Package\\RootPackage", None) + .unwrap(); + + assert_eq!( + "dev-master", + package.as_root().unwrap().get_pretty_version() + ); } #[test] -#[ignore = "requires getProcessExecutorMock with expects() git command expectations; no ProcessExecutorMock mocking infrastructure exists"] +#[serial] fn test_non_feature_branch_pretty_version() { - todo!() + // proc_open() is always available; the PHP markTestSkipped guard does not apply here. + GitUtil::__set_version(Some("2.52.0".to_string())); + let _git_guard = GitVersionGuard; + + let io = null_io(); + let config = make_config(); + let manager = make_manager(&io, &config); + + let expectations: Vec = vec![cmd_full( + ["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], + 0, + "* latest-production 38137d2f6c70e775e137b2d8a7a7d3eaebf7c7e5 Commit message\n master 4f6ed96b0bc363d2aa4404c3412de1c011f67c66 Commit message\n", + "", + )]; + let (process, _guard) = get_process_executor_mock(expectations, true, MockHandler::default()); + let guesser = VersionGuesser::new(config.clone(), process, VersionParser::new(), None); + + let mut loader = + RootPackageLoader::new(manager, config.clone(), None, Some(Box::new(guesser)), None); + + let mut data = IndexMap::new(); + data.insert( + "require".to_string(), + require_map(&[("foo/bar", "self.version")]), + ); + data.insert( + "non-feature-branches".to_string(), + PhpMixed::List(vec![PhpMixed::String("latest-.*".to_string())]), + ); + + let package = loader + .load(data, "Composer\\Package\\RootPackage", None) + .unwrap(); + + assert_eq!( + "dev-latest-production", + package.as_root().unwrap().get_pretty_version() + ); } diff --git a/crates/shirabe/tests/package/version/version_selector_test.rs b/crates/shirabe/tests/package/version/version_selector_test.rs index fa288cd..f48a6ce 100644 --- a/crates/shirabe/tests/package/version/version_selector_test.rs +++ b/crates/shirabe/tests/package/version/version_selector_test.rs @@ -1,77 +1,546 @@ //! ref: composer/tests/Composer/Test/Package/Version/VersionSelectorTest.php -// VersionSelector ranks candidate packages whose versions/constraints are parsed through a -// look-around regex the regex crate cannot compile; the setup also mocks a repository. +use std::cell::RefCell; +use std::rc::Rc; + +use indexmap::IndexMap; +use shirabe::filter::platform_requirement_filter::PlatformRequirementFilterFactory; +use shirabe::io::BufferIO; +use shirabe::io::IOInterface; +use shirabe::package::BasePackageHandle; +use shirabe::package::CompleteAliasPackageHandle; +use shirabe::package::CompletePackageHandle; +use shirabe::package::Link; +use shirabe::package::PackageInterfaceHandle; +use shirabe::package::version::VersionSelector; +use shirabe::package::version::version_parser::VersionParser; +use shirabe::repository::PlatformRepository; +use shirabe::repository::RepositorySetInterface; +use shirabe_php_shim::PhpMixed; +use shirabe_semver::constraint::AnyConstraint; + +use shirabe_external_packages::symfony::console::output::output_interface; + +use crate::test_case::get_package; + +mockall::mock! { + RepositorySet {} + impl RepositorySetInterface for RepositorySet { + fn find_packages( + &self, + name: &str, + constraint: Option, + flags: i64, + ) -> anyhow::Result>; + } +} + +// `RepositorySetInterface` requires `Debug`; mockall does not generate it. +impl std::fmt::Debug for MockRepositorySet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("MockRepositorySet") + } +} + +fn into_seam(mock: MockRepositorySet) -> Rc> { + Rc::new(RefCell::new(mock)) +} + +/// Mirrors PHPUnit `assertSame($expected, $best)`: object identity, not value equality. +fn assert_same( + best: &Option, + expected: &PackageInterfaceHandle, + msg: &str, +) { + let best = best + .as_ref() + .unwrap_or_else(|| panic!("{msg}: expected Some(_), got None")); + assert!(best.ptr_eq(expected), "{msg}"); +} + +fn require_link(package_name: &str, target: &str, pretty_constraint: &str) -> Link { + let parser = VersionParser::new(); + Link::new( + package_name.to_string(), + target.to_string(), + parser.parse_constraints(pretty_constraint).unwrap(), + Some(Link::TYPE_REQUIRE.to_string()), + pretty_constraint.to_string(), + ) +} + +fn find_best( + version_selector: &mut VersionSelector, + package_name: &str, + preferred_stability: &str, + platform_requirement_filter: Option< + std::rc::Rc< + dyn shirabe::filter::platform_requirement_filter::PlatformRequirementFilterInterface, + >, + >, + io: Option>>, +) -> Option { + version_selector + .find_best_candidate( + package_name, + None, + preferred_stability, + platform_requirement_filter, + 0, + io, + PhpMixed::Bool(true), + ) + .unwrap() +} + #[test] -#[ignore = "requires mocking RepositorySet::find_packages to return fixed package objects; RepositorySet is a concrete struct with no injectable/overridable find_packages"] fn test_latest_version_is_returned() { - todo!() + let package_name = "foo/bar"; + + let package1 = get_package("foo/bar", "1.2.1"); + let package2 = get_package("foo/bar", "1.2.2"); + let package3 = get_package("foo/bar", "1.2.0"); + let packages = vec![package1.clone(), package2.clone(), package3.clone()]; + + let mut repository_set = MockRepositorySet::new(); + repository_set + .expect_find_packages() + .times(1) + .returning_st(move |_, _, _| Ok(packages.clone())); + + let mut version_selector = VersionSelector::new(into_seam(repository_set), None).unwrap(); + let best = find_best(&mut version_selector, package_name, "stable", None, None); + + // 1.2.2 should be returned because it's the latest of the returned versions + assert_same(&best, &package2, "Latest version should be 1.2.2"); } #[test] -#[ignore = "requires mocking RepositorySet::find_packages to return fixed package objects; RepositorySet is a concrete struct with no injectable/overridable find_packages"] +#[ignore = "PlatformRepository initialization calls shirabe_php_shim::runtime::constant() which is still todo!(); unrelated to the RepositorySet seam"] fn test_latest_version_is_returned_that_matches_php_requirements() { - todo!() + let package_name = "foo/bar"; + + let mut overrides: IndexMap = IndexMap::new(); + overrides.insert("php".to_string(), PhpMixed::String("5.5.0".to_string())); + let mut platform = PlatformRepository::new(vec![], overrides).unwrap(); + + let package0 = get_package("foo/bar", "0.9.0"); + package0.__set_requires(IndexMap::from([( + "php".to_string(), + require_link(package_name, "php", ">=5.6"), + )])); + let package1 = get_package("foo/bar", "1.0.0"); + package1.__set_requires(IndexMap::from([( + "php".to_string(), + require_link(package_name, "php", ">=5.4"), + )])); + let package2 = get_package("foo/bar", "2.0.0"); + package2.__set_requires(IndexMap::from([( + "php".to_string(), + require_link(package_name, "php", ">=5.6"), + )])); + let package3 = get_package("foo/bar", "2.1.0"); + package3.__set_requires(IndexMap::from([( + "php".to_string(), + require_link(package_name, "php", ">=5.6"), + )])); + let packages = vec![ + package0.clone(), + package1.clone(), + package2.clone(), + package3.clone(), + ]; + + let mut repository_set = MockRepositorySet::new(); + repository_set + .expect_find_packages() + .times(3) + .returning_st(move |_, _, _| Ok(packages.clone())); + + let mut version_selector = + VersionSelector::new(into_seam(repository_set), Some(&mut platform)).unwrap(); + + let io = Rc::new(RefCell::new( + BufferIO::new(String::new(), output_interface::VERBOSITY_NORMAL, None).unwrap(), + )); + let io_dyn: Rc> = io.clone(); + let best = find_best( + &mut version_selector, + package_name, + "stable", + None, + Some(io_dyn), + ); + assert_same( + &best, + &package1, + "Latest version supporting php 5.5 should be returned (1.0.0)", + ); + assert_eq!( + "Cannot use foo/bar's latest version 2.1.0 as it requires php >=5.6 which is not satisfied by your platform.\n", + io.borrow().get_output() + ); + + let io = Rc::new(RefCell::new( + BufferIO::new(String::new(), output_interface::VERBOSITY_VERBOSE, None).unwrap(), + )); + let io_dyn: Rc> = io.clone(); + let best = find_best( + &mut version_selector, + package_name, + "stable", + None, + Some(io_dyn), + ); + assert_same( + &best, + &package1, + "Latest version supporting php 5.5 should be returned (1.0.0)", + ); + assert_eq!( + "Cannot use foo/bar's latest version 2.1.0 as it requires php >=5.6 which is not satisfied by your platform.\n\ + Cannot use foo/bar 2.0.0 as it requires php >=5.6 which is not satisfied by your platform.\n", + io.borrow().get_output() + ); + + let best = find_best( + &mut version_selector, + package_name, + "stable", + Some(PlatformRequirementFilterFactory::ignore_all()), + None, + ); + assert_same( + &best, + &package3, + "Latest version should be returned when ignoring platform reqs (2.1.0)", + ); } #[test] -#[ignore = "requires mocking RepositorySet::find_packages to return fixed package objects; RepositorySet is a concrete struct with no injectable/overridable find_packages"] +#[ignore = "PlatformRepository initialization calls shirabe_php_shim::runtime::constant() which is still todo!(); unrelated to the RepositorySet seam"] fn test_latest_version_is_returned_that_matches_ext_requirements() { - todo!() + let package_name = "foo/bar"; + + let mut overrides: IndexMap = IndexMap::new(); + overrides.insert("ext-zip".to_string(), PhpMixed::String("5.3.0".to_string())); + let mut platform = PlatformRepository::new(vec![], overrides).unwrap(); + + let package1 = get_package("foo/bar", "1.0.0"); + package1.__set_requires(IndexMap::from([( + "ext-zip".to_string(), + require_link(package_name, "ext-zip", "^5.2"), + )])); + let package2 = get_package("foo/bar", "2.0.0"); + package2.__set_requires(IndexMap::from([( + "ext-zip".to_string(), + require_link(package_name, "ext-zip", "^5.4"), + )])); + let packages = vec![package1.clone(), package2.clone()]; + + let mut repository_set = MockRepositorySet::new(); + repository_set + .expect_find_packages() + .times(2) + .returning_st(move |_, _, _| Ok(packages.clone())); + + let mut version_selector = + VersionSelector::new(into_seam(repository_set), Some(&mut platform)).unwrap(); + + let best = find_best(&mut version_selector, package_name, "stable", None, None); + assert_same( + &best, + &package1, + "Latest version supporting ext-zip 5.3.0 should be returned (1.0.0)", + ); + let best = find_best( + &mut version_selector, + package_name, + "stable", + Some(PlatformRequirementFilterFactory::ignore_all()), + None, + ); + assert_same( + &best, + &package2, + "Latest version should be returned when ignoring platform reqs (2.0.0)", + ); } #[test] -#[ignore = "requires mocking RepositorySet::find_packages to return fixed package objects; RepositorySet is a concrete struct with no injectable/overridable find_packages"] +#[ignore = "PlatformRepository initialization calls shirabe_php_shim::runtime::constant() which is still todo!(); unrelated to the RepositorySet seam"] fn test_latest_version_is_returned_that_matches_platform_ext() { - todo!() + let package_name = "foo/bar"; + + let mut platform = PlatformRepository::new(vec![], IndexMap::new()).unwrap(); + + let package1 = get_package("foo/bar", "1.0.0"); + let package2 = get_package("foo/bar", "2.0.0"); + package2.__set_requires(IndexMap::from([( + "ext-barfoo".to_string(), + require_link(package_name, "ext-barfoo", "*"), + )])); + let packages = vec![package1.clone(), package2.clone()]; + + let mut repository_set = MockRepositorySet::new(); + repository_set + .expect_find_packages() + .times(2) + .returning_st(move |_, _, _| Ok(packages.clone())); + + let mut version_selector = + VersionSelector::new(into_seam(repository_set), Some(&mut platform)).unwrap(); + + let best = find_best(&mut version_selector, package_name, "stable", None, None); + assert_same( + &best, + &package1, + "Latest version not requiring ext-barfoo should be returned (1.0.0)", + ); + let best = find_best( + &mut version_selector, + package_name, + "stable", + Some(PlatformRequirementFilterFactory::ignore_all()), + None, + ); + assert_same( + &best, + &package2, + "Latest version should be returned when ignoring platform reqs (2.0.0)", + ); } #[test] -#[ignore = "requires mocking RepositorySet::find_packages to return fixed package objects; RepositorySet is a concrete struct with no injectable/overridable find_packages"] +#[ignore = "PlatformRepository initialization calls shirabe_php_shim::runtime::constant() which is still todo!(); unrelated to the RepositorySet seam"] fn test_latest_version_is_returned_that_matches_composer_requirements() { - todo!() + let package_name = "foo/bar"; + + let mut overrides: IndexMap = IndexMap::new(); + overrides.insert( + "composer-runtime-api".to_string(), + PhpMixed::String("1.0.0".to_string()), + ); + let mut platform = PlatformRepository::new(vec![], overrides).unwrap(); + + let package1 = get_package("foo/bar", "1.0.0"); + package1.__set_requires(IndexMap::from([( + "composer-runtime-api".to_string(), + require_link(package_name, "composer-runtime-api", "^1.0"), + )])); + let package2 = get_package("foo/bar", "1.1.0"); + package2.__set_requires(IndexMap::from([( + "composer-runtime-api".to_string(), + require_link(package_name, "composer-runtime-api", "^2.0"), + )])); + let packages = vec![package1.clone(), package2.clone()]; + + let mut repository_set = MockRepositorySet::new(); + repository_set + .expect_find_packages() + .times(2) + .returning_st(move |_, _, _| Ok(packages.clone())); + + let mut version_selector = + VersionSelector::new(into_seam(repository_set), Some(&mut platform)).unwrap(); + + let best = find_best(&mut version_selector, package_name, "stable", None, None); + assert_same( + &best, + &package1, + "Latest version supporting composer 1 should be returned (1.0.0)", + ); + let best = find_best( + &mut version_selector, + package_name, + "stable", + Some(PlatformRequirementFilterFactory::ignore_all()), + None, + ); + assert_same( + &best, + &package2, + "Latest version should be returned when ignoring platform reqs (1.1.0)", + ); } #[test] -#[ignore = "requires mocking RepositorySet::find_packages to return fixed package objects; RepositorySet is a concrete struct with no injectable/overridable find_packages"] fn test_most_stable_version_is_returned() { - todo!() + let package_name = "foo/bar"; + + let package1 = get_package("foo/bar", "1.0.0"); + let package2 = get_package("foo/bar", "1.1.0-beta"); + let packages = vec![package1.clone(), package2.clone()]; + + let mut repository_set = MockRepositorySet::new(); + repository_set + .expect_find_packages() + .times(1) + .returning_st(move |_, _, _| Ok(packages.clone())); + + let mut version_selector = VersionSelector::new(into_seam(repository_set), None).unwrap(); + let best = find_best(&mut version_selector, package_name, "stable", None, None); + + assert_same( + &best, + &package1, + "Latest most stable version should be returned (1.0.0)", + ); } #[test] -#[ignore = "requires mocking RepositorySet::find_packages with willReturnOnConsecutiveCalls; RepositorySet is a concrete struct with no injectable/overridable find_packages"] fn test_most_stable_version_is_returned_regardless_of_order() { - todo!() + let package_name = "foo/bar"; + + let package1 = get_package("foo/bar", "2.x-dev"); + let package2 = get_package("foo/bar", "2.0.0-beta3"); + let packages = vec![package1.clone(), package2.clone()]; + let reversed: Vec = packages.iter().rev().cloned().collect(); + + let mut repository_set = MockRepositorySet::new(); + let mut seq = mockall::Sequence::new(); + repository_set + .expect_find_packages() + .times(1) + .in_sequence(&mut seq) + .returning_st(move |_, _, _| Ok(packages.clone())); + repository_set + .expect_find_packages() + .times(1) + .in_sequence(&mut seq) + .returning_st(move |_, _, _| Ok(reversed.clone())); + + let mut version_selector = VersionSelector::new(into_seam(repository_set), None).unwrap(); + let best = find_best(&mut version_selector, package_name, "stable", None, None); + assert_same( + &best, + &package2, + "Expecting 2.0.0-beta3, cause beta is more stable than dev", + ); + + let best = find_best(&mut version_selector, package_name, "stable", None, None); + assert_same( + &best, + &package2, + "Expecting 2.0.0-beta3, cause beta is more stable than dev", + ); } #[test] -#[ignore = "requires mocking RepositorySet::find_packages to return fixed package objects; RepositorySet is a concrete struct with no injectable/overridable find_packages"] fn test_highest_version_is_returned() { - todo!() + let package_name = "foo/bar"; + + let package1 = get_package("foo/bar", "1.0.0"); + let package2 = get_package("foo/bar", "1.1.0-beta"); + let packages = vec![package1.clone(), package2.clone()]; + + let mut repository_set = MockRepositorySet::new(); + repository_set + .expect_find_packages() + .times(1) + .returning_st(move |_, _, _| Ok(packages.clone())); + + let mut version_selector = VersionSelector::new(into_seam(repository_set), None).unwrap(); + let best = find_best(&mut version_selector, package_name, "dev", None, None); + + assert_same( + &best, + &package2, + "Latest version should be returned (1.1.0-beta)", + ); } #[test] -#[ignore = "requires mocking RepositorySet::find_packages to return fixed package objects; RepositorySet is a concrete struct with no injectable/overridable find_packages"] fn test_highest_version_matching_stability_is_returned() { - todo!() + let package_name = "foo/bar"; + + let package1 = get_package("foo/bar", "1.0.0"); + let package2 = get_package("foo/bar", "1.1.0-beta"); + let package3 = get_package("foo/bar", "1.2.0-alpha"); + let packages = vec![package1.clone(), package2.clone(), package3.clone()]; + + let mut repository_set = MockRepositorySet::new(); + repository_set + .expect_find_packages() + .times(1) + .returning_st(move |_, _, _| Ok(packages.clone())); + + let mut version_selector = VersionSelector::new(into_seam(repository_set), None).unwrap(); + let best = find_best(&mut version_selector, package_name, "beta", None, None); + + assert_same( + &best, + &package2, + "Latest version should be returned (1.1.0-beta)", + ); } #[test] -#[ignore = "requires mocking RepositorySet::find_packages to return fixed package objects; RepositorySet is a concrete struct with no injectable/overridable find_packages"] fn test_most_stable_unstable_version_is_returned() { - todo!() + let package_name = "foo/bar"; + + let package2 = get_package("foo/bar", "1.1.0-beta"); + let package3 = get_package("foo/bar", "1.2.0-alpha"); + let packages = vec![package2.clone(), package3.clone()]; + + let mut repository_set = MockRepositorySet::new(); + repository_set + .expect_find_packages() + .times(1) + .returning_st(move |_, _, _| Ok(packages.clone())); + + let mut version_selector = VersionSelector::new(into_seam(repository_set), None).unwrap(); + let best = find_best(&mut version_selector, package_name, "stable", None, None); + + assert_same( + &best, + &package2, + "Latest version should be returned (1.1.0-beta)", + ); } #[test] -#[ignore = "requires mocking RepositorySet::find_packages to return fixed package objects; RepositorySet is a concrete struct with no injectable/overridable find_packages"] fn test_default_branch_alias_is_never_returned() { - todo!() + let package_name = "foo/bar"; + + let package = get_package("foo/bar", "1.1.0-beta"); + let package2 = get_package("foo/bar", "dev-main"); + let package2_complete = CompletePackageHandle::from_rc_unchecked(package2.as_rc().clone()); + let package2_alias: PackageInterfaceHandle = CompleteAliasPackageHandle::new( + package2_complete, + VersionParser::DEFAULT_BRANCH_ALIAS.to_string(), + VersionParser::DEFAULT_BRANCH_ALIAS.to_string(), + ) + .into(); + let packages = vec![package.clone(), package2_alias.clone()]; + + let mut repository_set = MockRepositorySet::new(); + repository_set + .expect_find_packages() + .times(1) + .returning_st(move |_, _, _| Ok(packages.clone())); + + let mut version_selector = VersionSelector::new(into_seam(repository_set), None).unwrap(); + let best = find_best(&mut version_selector, package_name, "dev", None, None); + + assert_same( + &best, + &package2, + "Latest version should be returned (dev-main)", + ); } #[test] -#[ignore = "requires mocking RepositorySet::find_packages to return an empty list; RepositorySet is a concrete struct with no injectable/overridable find_packages"] fn test_false_returned_on_no_packages() { - todo!() + let mut repository_set = MockRepositorySet::new(); + repository_set + .expect_find_packages() + .times(1) + .returning_st(|_, _, _| Ok(vec![])); + + let mut version_selector = VersionSelector::new(into_seam(repository_set), None).unwrap(); + let best = find_best(&mut version_selector, "foobaz", "stable", None, None); + assert!(best.is_none(), "No versions are available returns false"); } #[test] diff --git a/crates/shirabe/tests/platform/hhvm_detector_test.rs b/crates/shirabe/tests/platform/hhvm_detector_test.rs index 71da314..f1ec656 100644 --- a/crates/shirabe/tests/platform/hhvm_detector_test.rs +++ b/crates/shirabe/tests/platform/hhvm_detector_test.rs @@ -1,6 +1,7 @@ //! ref: composer/tests/Composer/Test/Platform/HhvmDetectorTest.php use shirabe::platform::hhvm_detector::HhvmDetector; +use shirabe::platform::hhvm_detector::HhvmDetectorInterface; use shirabe::util::Platform; use shirabe::util::ProcessExecutor; use shirabe_external_packages::symfony::process::ExecutableFinder; diff --git a/crates/shirabe/tests/repository/filesystem_repository_test.rs b/crates/shirabe/tests/repository/filesystem_repository_test.rs index 1914927..0d0e621 100644 --- a/crates/shirabe/tests/repository/filesystem_repository_test.rs +++ b/crates/shirabe/tests/repository/filesystem_repository_test.rs @@ -1,12 +1,23 @@ //! ref: composer/tests/Composer/Test/Repository/FilesystemRepositoryTest.php +use std::cell::RefCell; +use std::rc::Rc; + use indexmap::IndexMap; +use shirabe::dependency_resolver::operation::OperationInterface; use shirabe::installed_versions::InstalledVersions; +use shirabe::installer::{InstallationManagerInterface, InstallerInterface}; +use shirabe::io::IOInterface; use shirabe::json::json_file::JsonFile; +use shirabe::package::PackageInterfaceHandle; +use shirabe::repository::InstalledRepositoryInterface; use shirabe::repository::RepositoryInterface; use shirabe::repository::filesystem_repository::FilesystemRepository; +use shirabe::util::filesystem::Filesystem; use shirabe_php_shim::PhpMixed; +use crate::test_case::get_package; + /// PHP mocks JsonFile::read()/exists(); without a mocking framework the canned read value is /// materialized as a real temp file whose decoded JSON reproduces the mock return value exactly. fn create_temp_json_file(contents: &str) -> String { @@ -78,14 +89,114 @@ fn test_unexistent_repository_file() { assert_eq!(packages.len(), 0); } +/// Stub for `InstallationManagerInterface` whose `getInstallPath` returns a fixed path, mirroring +/// the PHPUnit mock built with `disableOriginalConstructor()`. Only `get_install_path` is reached by +/// `FilesystemRepository::write`; the call counter backs PHP's `expects($this->exactly(2))`. +#[derive(Debug)] +struct InstallPathStub { + calls: Rc>, + fixed_path: String, +} + +impl InstallationManagerInterface for InstallPathStub { + fn add_installer(&mut self, _installer: Box) { + unimplemented!() + } + fn remove_installer(&mut self, _installer: &dyn InstallerInterface) { + unimplemented!() + } + fn disable_plugins(&mut self) { + unimplemented!() + } + fn is_package_installed( + &mut self, + _repo: &dyn InstalledRepositoryInterface, + _package: PackageInterfaceHandle, + ) -> anyhow::Result { + unimplemented!() + } + fn ensure_binaries_presence(&mut self, _package: PackageInterfaceHandle) { + unimplemented!() + } + fn execute( + &mut self, + _repo: &mut dyn InstalledRepositoryInterface, + _operations: Vec>, + _dev_mode: bool, + _run_scripts: bool, + _download_only: bool, + ) -> anyhow::Result<()> { + unimplemented!() + } + fn get_install_path(&mut self, _package: PackageInterfaceHandle) -> Option { + *self.calls.borrow_mut() += 1; + Some(self.fixed_path.clone()) + } + fn set_output_progress(&mut self, _output_progress: bool) { + unimplemented!() + } + fn notify_installs(&mut self, _io: Rc>) { + unimplemented!() + } +} + #[test] -#[ignore = "requires mocking InstallationManager::get_install_path; write() takes a concrete InstallationManager with no trait/seam to stub the canned per-package paths the PHP test relies on"] fn test_repository_write() { - todo!() + // PHP mocks JsonFile::write/read/getPath; here a real JsonFile under a temp repo dir is written + // and read back. write() never reads the file (it dumps the in-memory packages), so the mocked + // read()/exists() return values are irrelevant to the result. + let base = std::fs::canonicalize(std::env::temp_dir()).unwrap(); + let repo_dir = format!( + "{}/shirabe_repo_write_test_{}_{}", + base.display(), + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let mut fs = Filesystem::new(None); + fs.remove_directory(&repo_dir).ok(); + + let json_path = format!("{}/vendor/composer/installed.json", repo_dir); + let json = JsonFile::new(json_path.clone(), None, None).unwrap(); + let mut repository = FilesystemRepository::new(json, false, None, None).unwrap(); + + let calls = Rc::new(RefCell::new(0i64)); + let mut im = InstallPathStub { + calls: calls.clone(), + fixed_path: format!("{}/vendor/woop/woop", repo_dir), + }; + + repository.set_dev_package_names(vec!["mypkg2".to_string()]); + repository + .add_package(get_package("mypkg2", "1.2.3")) + .unwrap(); + repository + .add_package(get_package("mypkg", "0.1.10")) + .unwrap(); + repository.write(true, &mut im).unwrap(); + + // PHP asserts getInstallPath is called exactly twice (once per installed package). + assert_eq!(*calls.borrow(), 2); + + let written = std::fs::read_to_string(&json_path).unwrap(); + let actual: serde_json::Value = serde_json::from_str(&written).unwrap(); + let expected = serde_json::json!({ + "packages": [ + {"name": "mypkg", "type": "library", "version": "0.1.10", "version_normalized": "0.1.10.0", "install-path": "../woop/woop"}, + {"name": "mypkg2", "type": "library", "version": "1.2.3", "version_normalized": "1.2.3.0", "install-path": "../woop/woop"}, + ], + "dev": true, + "dev-package-names": ["mypkg2"], + }); + assert_eq!(actual, expected); + + fs.remove_directory(&repo_dir).ok(); } #[test] -#[ignore = "requires mocking InstallationManager::get_install_path (concrete method, no stub seam) plus missing test helpers get_root_package and configure_links"] +#[ignore = "needs get_root_package + configure_links test helpers (not present in tests/common; project_test_port_link_setters describes them via ArrayLoader::load_packages but this branch lacks them) plus exact byte-match of the generated installed.php fixture under a chdir"] fn test_repository_writes_installed_php() { todo!() } diff --git a/crates/shirabe/tests/repository/platform_repository_test.rs b/crates/shirabe/tests/repository/platform_repository_test.rs index c85af4b..7d2a328 100644 --- a/crates/shirabe/tests/repository/platform_repository_test.rs +++ b/crates/shirabe/tests/repository/platform_repository_test.rs @@ -1,38 +1,1854 @@ //! ref: composer/tests/Composer/Test/Repository/PlatformRepositoryTest.php -use shirabe::repository::PlatformRepository; +use indexmap::IndexMap; +use mockall::predicate::eq; +use shirabe::package::{BasePackageHandle, Link}; +use shirabe::platform::{HhvmDetectorInterface, RuntimeInterface}; +use shirabe::repository::{ + FindPackageConstraint, PlatformRepository, RepositoryInterface, SEARCH_NAME, +}; +use shirabe_php_shim::PhpMixed; +use shirabe_semver::constraint::SimpleConstraint; + +// The Runtime/HhvmDetector seams are concrete structs in PHP; the tests mock them +// directly. +mockall::mock! { + pub Runtime {} + impl RuntimeInterface for Runtime { + fn has_constant(&self, constant_name: &str, class: Option) -> bool; + fn get_constant(&self, constant_name: &str, class: Option) -> PhpMixed; + fn invoke(&self, callable: PhpMixed, arguments: Vec) -> PhpMixed; + fn has_class(&self, class: &str) -> bool; + fn construct(&self, class: &str, arguments: Vec) -> anyhow::Result; + fn get_extensions(&self) -> Vec; + fn get_extension_version(&self, extension: &str) -> String; + fn get_extension_info(&self, extension: &str) -> anyhow::Result; + } +} + +mockall::mock! { + pub HhvmDetector {} + impl HhvmDetectorInterface for HhvmDetector { + fn reset(&self); + fn get_version(&mut self) -> Option; + } +} + +// The seam traits require `Debug` (so `PlatformRepository` can derive it); mockall does +// not generate it for mocks. +impl std::fmt::Debug for MockRuntime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("MockRuntime") + } +} + +impl std::fmt::Debug for MockHhvmDetector { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("MockHhvmDetector") + } +} + +/// PHP: ltrim($class.'::'.$constant, ':') +fn constant_key(constant_name: &str, class: Option<&str>) -> String { + format!("{}::{}", class.unwrap_or(""), constant_name) + .trim_start_matches(':') + .to_string() +} + +// All five tests below are fully ported but kept `#[ignore]`: every code path through +// `PlatformRepository::initialize()` reaches `XdebugHandler::get_skipped_version()`, +// which is an unrelated `todo!()` stub in `shirabe-external-packages`, so the tests +// panic before any assertion (Phase D: visible failure is acceptable, and the category +// brief permits leaving confidently-panicking tests ignored). The seam/mocks are in +// place so they will execute once that stub lands. -// These probe runtime/extension/library versions and assert the synthesized platform -// packages; the detection mocks ProcessExecutor/Runtime/HhvmDetector and the package -// versions are parsed through a look-around regex. #[test] -#[ignore = "requires PHPUnit getMockBuilder mock of Composer\\Platform\\HhvmDetector (willReturn getVersion); Runtime/HhvmDetector are concrete structs with no mock/override mechanism"] +#[ignore = "seam + HhvmDetector mock ported; execution panics at the unrelated \ + XdebugHandler::get_skipped_version() todo!() during initialize(), and the \ + default (real) Runtime also relies on PHP-runtime shim todo!()s"] fn test_hhvm_package() { - todo!() + let mut hhvm_detector = MockHhvmDetector::new(); + hhvm_detector + .expect_get_version() + .times(..) + .returning(|| Some("2.1.0".to_string())); + + let mut platform_repository = + PlatformRepository::new4(vec![], IndexMap::new(), None, Some(Box::new(hhvm_detector))) + .unwrap(); + + let hhvm = platform_repository + .find_package("hhvm", FindPackageConstraint::String("*".to_string())) + .unwrap(); + assert!(hhvm.is_some(), "hhvm found"); + + assert_eq!("2.1.0", hhvm.unwrap().get_pretty_version()); +} + +fn php_flavor_test_cases() -> Vec<( + IndexMap, + Vec<(&'static str, &'static str)>, + Vec<(PhpMixed, Vec, PhpMixed)>, +)> { + let ubuntu = "7.2.31-1+ubuntu16.04.1+deb.sury.org+1"; + let s = |v: &str| PhpMixed::String(v.to_string()); + + vec![ + ( + IndexMap::from([("PHP_VERSION".to_string(), s("7.1.33"))]), + vec![("php", "7.1.33")], + vec![], + ), + ( + IndexMap::from([ + ("PHP_VERSION".to_string(), s(ubuntu)), + ("PHP_DEBUG".to_string(), PhpMixed::Bool(true)), + ]), + vec![("php", "7.2.31"), ("php-debug", "7.2.31")], + vec![], + ), + ( + IndexMap::from([ + ("PHP_VERSION".to_string(), s(ubuntu)), + ("PHP_ZTS".to_string(), PhpMixed::Bool(true)), + ]), + vec![("php", "7.2.31"), ("php-zts", "7.2.31")], + vec![], + ), + ( + IndexMap::from([ + ("PHP_VERSION".to_string(), s(ubuntu)), + ("PHP_INT_SIZE".to_string(), PhpMixed::Int(8)), + ]), + vec![("php", "7.2.31"), ("php-64bit", "7.2.31")], + vec![], + ), + ( + IndexMap::from([ + ("PHP_VERSION".to_string(), s(ubuntu)), + ("AF_INET6".to_string(), PhpMixed::Int(30)), + ]), + vec![("php", "7.2.31"), ("php-ipv6", "7.2.31")], + vec![], + ), + ( + IndexMap::from([("PHP_VERSION".to_string(), s(ubuntu))]), + vec![("php", "7.2.31"), ("php-ipv6", "7.2.31")], + vec![(s("inet_pton"), vec![s("::")], s(""))], + ), + ( + IndexMap::from([("PHP_VERSION".to_string(), s(ubuntu))]), + vec![("php", "7.2.31")], + vec![(s("inet_pton"), vec![s("::")], PhpMixed::Bool(false))], + ), + ] } #[test] -#[ignore = "requires PHPUnit getMockBuilder mock of Composer\\Platform\\Runtime (willReturnCallback/willReturnMap for hasConstant/getConstant/invoke/getExtensions); no mocking framework and Runtime is a concrete struct"] +#[ignore = "seam + Runtime mock ported (hasConstant/getConstant/invoke/getExtensions); \ + execution panics at the unrelated XdebugHandler::get_skipped_version() todo!()"] fn test_php_version() { - todo!() + for (constants, packages, functions) in php_flavor_test_cases() { + let constants_has = constants.clone(); + let constants_get = constants.clone(); + + let mut runtime = MockRuntime::new(); + runtime + .expect_get_extensions() + .times(..) + .returning(Vec::new); + runtime + .expect_has_constant() + .times(..) + .returning(move |constant, class| { + constants_has.contains_key(&constant_key(constant, class.as_deref())) + }); + runtime + .expect_get_constant() + .times(..) + .returning(move |constant, class| { + constants_get + .get(&constant_key(constant, class.as_deref())) + .cloned() + .unwrap_or(PhpMixed::Null) + }); + runtime + .expect_invoke() + .times(..) + .returning(move |callable, arguments| { + for (c, a, ret) in &functions { + if *c == callable && *a == arguments { + return ret.clone(); + } + } + PhpMixed::Null + }); + + let mut repository = + PlatformRepository::new4(vec![], IndexMap::new(), Some(Box::new(runtime)), None) + .unwrap(); + + for (package_name, version) in packages { + let package = repository + .find_package(package_name, FindPackageConstraint::String("*".to_string())) + .unwrap(); + assert!( + package.is_some(), + "Expected to find package \"{}\"", + package_name + ); + assert_eq!( + version, + package.unwrap().get_pretty_version(), + "Expected package \"{}\" version to be {}", + package_name, + version + ); + } + } } #[test] -#[ignore = "requires PHPUnit getMockBuilder mock of Composer\\Platform\\Runtime (expects(once)/with/willReturn for invoke, willReturnCallback for getConstant); no mocking framework"] +#[ignore = "seam + Runtime mock ported (invoke once + getConstant callback); \ + execution panics at the unrelated XdebugHandler::get_skipped_version() todo!()"] fn test_inet_pton_regression() { - todo!() + let mut runtime = MockRuntime::new(); + // PHP: ->expects(self::once())->method('invoke')->with('inet_pton', ['::'])->willReturn(false). + runtime + .expect_invoke() + .with( + eq(PhpMixed::String("inet_pton".to_string())), + eq(vec![PhpMixed::String("::".to_string())]), + ) + .times(1) + .returning(|_callable, _arguments| PhpMixed::Bool(false)); + // suppressing PHP_ZTS & AF_INET6 + runtime + .expect_has_constant() + .times(..) + .returning(|_, _| false); + + let constants: IndexMap = IndexMap::from([ + ( + "PHP_VERSION".to_string(), + PhpMixed::String("7.0.0".to_string()), + ), + ("PHP_DEBUG".to_string(), PhpMixed::Bool(false)), + ]); + runtime + .expect_get_constant() + .times(..) + .returning(move |constant, class| { + constants + .get(&constant_key(constant, class.as_deref())) + .cloned() + .unwrap_or(PhpMixed::Null) + }); + runtime + .expect_get_extensions() + .times(..) + .returning(Vec::new); + + let mut repository = + PlatformRepository::new4(vec![], IndexMap::new(), Some(Box::new(runtime)), None).unwrap(); + let package = repository + .find_package("php-ipv6", FindPackageConstraint::String("*".to_string())) + .unwrap(); + assert!(package.is_none()); +} + +enum Exp { + Version(&'static str), + Missing, + Full( + &'static str, + &'static [&'static str], + &'static [&'static str], + ), +} + +impl Exp { + fn parts( + &self, + ) -> ( + Option<&'static str>, + &'static [&'static str], + &'static [&'static str], + ) { + match self { + Exp::Version(v) => (Some(*v), &[], &[]), + Exp::Missing => (None, &[], &[]), + Exp::Full(v, replaces, provides) => (Some(*v), *replaces, *provides), + } + } +} + +#[derive(Clone)] +struct ClassDef { + class: &'static str, + construct: Option<(Vec, PhpMixed)>, +} + +struct LibCase { + extensions: Vec<&'static str>, + info: Option<&'static str>, + expectations: Vec<(&'static str, Exp)>, + functions: Vec<(PhpMixed, Vec, PhpMixed)>, + constants: Vec<(&'static str, Option<&'static str>, PhpMixed)>, + class_definitions: Vec, +} + +// Port of PlatformRepositoryTest::provideLibraryTestCases (all 59 datasets). The intl and +// imagick cases reference the PHP ResourceBundleStub/ImagickStub objects (modelled as +// PhpMixed::Object holding the version they would expose); their runtime read paths are +// still `TODO(plugin)` stubs, but the data is ported faithfully. +fn library_test_cases() -> Vec { + let s = |v: &str| PhpMixed::String(v.to_string()); + let curl = |v: &str| -> (PhpMixed, Vec, PhpMixed) { + ( + s("curl_version"), + vec![], + PhpMixed::Array(IndexMap::from([("version".to_string(), s(v))])), + ) + }; + + vec![ + LibCase { + extensions: vec!["amqp"], + info: Some( + " + +amqp + +Version => 1.9.4 +Revision => release +Compiled => Nov 19 2019 @ 08:44:26 +AMQP protocol version => 0-9-1 +librabbitmq version => 0.9.0 +Default max channels per connection => 256 +Default max frame size => 131072 +Default heartbeats interval => 0", + ), + expectations: vec![ + ("lib-amqp-protocol", Exp::Version("0.9.1")), + ("lib-amqp-librabbitmq", Exp::Version("0.9.0")), + ], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["bz2"], + info: Some( + " +bz2 + +BZip2 Support => Enabled +Stream Wrapper support => compress.bzip2:// +Stream Filter support => bzip2.decompress, bzip2.compress +BZip2 Version => 1.0.5, 6-Sept-2010", + ), + expectations: vec![("lib-bz2", Exp::Version("1.0.5"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["curl"], + info: Some( + " +curl + +cURL support => enabled +cURL Information => 7.38.0 +Age => 3 +Features +AsynchDNS => Yes +CharConv => No +Debug => No +GSS-Negotiate => No +IDN => Yes +IPv6 => Yes +krb4 => No +Largefile => Yes +libz => Yes +NTLM => Yes +NTLMWB => Yes +SPNEGO => Yes +SSL => Yes +SSPI => No +TLS-SRP => Yes +HTTP2 => No +GSSAPI => Yes +Protocols => dict, file, ftp, ftps, gopher, http, https, imap, imaps, ldap, ldaps, pop3, pop3s, rtmp, rtsp, scp, sftp, smtp, smtps, telnet, tftp +Host => x86_64-pc-linux-gnu +SSL Version => OpenSSL/1.0.1t +ZLib Version => 1.2.8 +libSSH Version => libssh2/1.4.3 + +Directive => Local Value => Master Value +curl.cainfo => no value => no value", + ), + expectations: vec![ + ("lib-curl", Exp::Version("2.0.0")), + ("lib-curl-openssl", Exp::Version("1.0.1.20")), + ("lib-curl-zlib", Exp::Version("1.2.8")), + ("lib-curl-libssh2", Exp::Version("1.4.3")), + ], + functions: vec![curl("2.0.0")], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["curl"], + info: Some( + " +curl + +cURL support => enabled +cURL Information => 7.38.0 +Age => 3 +Features +AsynchDNS => Yes +CharConv => No +Debug => No +GSS-Negotiate => No +IDN => Yes +IPv6 => Yes +krb4 => No +Largefile => Yes +libz => Yes +NTLM => Yes +NTLMWB => Yes +SPNEGO => Yes +SSL => Yes +SSPI => No +TLS-SRP => Yes +HTTP2 => No +GSSAPI => Yes +Protocols => dict, file, ftp, ftps, gopher, http, https, imap, imaps, ldap, ldaps, pop3, pop3s, rtmp, rtsp, scp, sftp, smtp, smtps, telnet, tftp +Host => x86_64-pc-linux-gnu +SSL Version => OpenSSL/1.0.1t-fips +ZLib Version => 1.2.8 +libSSH Version => libssh2/1.4.3 + +Directive => Local Value => Master Value +curl.cainfo => no value => no value", + ), + expectations: vec![ + ("lib-curl", Exp::Version("2.0.0")), + ( + "lib-curl-openssl-fips", + Exp::Full("1.0.1.20", &[], &["lib-curl-openssl"]), + ), + ("lib-curl-zlib", Exp::Version("1.2.8")), + ("lib-curl-libssh2", Exp::Version("1.4.3")), + ], + functions: vec![curl("2.0.0")], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["curl"], + info: Some( + " +curl + +cURL support => enabled +cURL Information => 7.22.0 +Age => 3 +Features +AsynchDNS => No +CharConv => No +Debug => No +GSS-Negotiate => Yes +IDN => Yes +IPv6 => Yes +krb4 => No +Largefile => Yes +libz => Yes +NTLM => Yes +NTLMWB => Yes +SPNEGO => No +SSL => Yes +SSPI => No +TLS-SRP => Yes +Protocols => dict, file, ftp, ftps, gopher, http, https, imap, imaps, ldap, pop3, pop3s, rtmp, rtsp, smtp, smtps, telnet, tftp +Host => x86_64-pc-linux-gnu +SSL Version => GnuTLS/2.12.14 +ZLib Version => 1.2.3.4", + ), + expectations: vec![ + ("lib-curl", Exp::Version("7.22.0")), + ("lib-curl-zlib", Exp::Version("1.2.3.4")), + ( + "lib-curl-gnutls", + Exp::Full("2.12.14", &["lib-curl-openssl"], &[]), + ), + ], + functions: vec![curl("7.22.0")], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["curl"], + info: Some( + " +curl + +cURL support => enabled +cURL Information => 7.24.0 +Age => 3 +Features +AsynchDNS => Yes +Debug => No +GSS-Negotiate => Yes +IDN => Yes +IPv6 => Yes +Largefile => Yes +NTLM => Yes +SPNEGO => No +SSL => Yes +SSPI => No +krb4 => No +libz => Yes +CharConv => No +Protocols => dict, file, ftp, ftps, gopher, http, https, imap, imaps, ldap, ldaps, pop3, pop3s, rtsp, scp, sftp, smtp, smtps, telnet, tftp +Host => x86_64-redhat-linux-gnu +SSL Version => NSS/3.13.3.0 +ZLib Version => 1.2.5 +libSSH Version => libssh2/1.4.1", + ), + expectations: vec![ + ("lib-curl", Exp::Version("7.24.0")), + ( + "lib-curl-nss", + Exp::Full("3.13.3.0", &["lib-curl-openssl"], &[]), + ), + ("lib-curl-zlib", Exp::Version("1.2.5")), + ("lib-curl-libssh2", Exp::Version("1.4.1")), + ], + functions: vec![curl("7.24.0")], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["curl"], + info: Some( + " +curl + +cURL support => enabled +cURL Information => 7.68.0 +Age => 5 +Features +AsynchDNS => Yes +CharConv => No +Debug => No +GSS-Negotiate => No +IDN => Yes +IPv6 => Yes +krb4 => No +Largefile => Yes +libz => Yes +NTLM => Yes +NTLMWB => Yes +SPNEGO => Yes +SSL => Yes +SSPI => No +TLS-SRP => Yes +HTTP2 => Yes +GSSAPI => Yes +KERBEROS5 => Yes +UNIX_SOCKETS => Yes +PSL => Yes +HTTPS_PROXY => Yes +MULTI_SSL => No +BROTLI => Yes +Protocols => dict, file, ftp, ftps, gopher, http, https, imap, imaps, ldap, ldaps, pop3, pop3s, rtmp, rtsp, scp, sftp, smb, smbs, smtp, smtps, telnet, tftp +Host => x86_64-pc-linux-gnu +SSL Version => OpenSSL/1.1.1g +ZLib Version => 1.2.11 +libSSH Version => libssh/0.9.3/openssl/zlib", + ), + expectations: vec![ + ("lib-curl", Exp::Version("7.68.0")), + ("lib-curl-openssl", Exp::Version("1.1.1.7")), + ("lib-curl-zlib", Exp::Version("1.2.11")), + ("lib-curl-libssh", Exp::Version("0.9.3")), + ], + functions: vec![curl("7.68.0")], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["curl"], + info: Some( + " +curl + +cURL support => enabled +cURL Information => 8.1.2 +Age => 10 +Features +AsynchDNS => Yes +CharConv => No +Debug => No +GSS-Negotiate => No +IDN => Yes +IPv6 => Yes +krb4 => No +Largefile => Yes +libz => Yes +NTLM => Yes +NTLMWB => Yes +SPNEGO => Yes +SSL => Yes +SSPI => No +TLS-SRP => Yes +HTTP2 => Yes +GSSAPI => Yes +KERBEROS5 => Yes +UNIX_SOCKETS => Yes +PSL => No +HTTPS_PROXY => Yes +MULTI_SSL => Yes +BROTLI => Yes +ALTSVC => Yes +HTTP3 => No +UNICODE => No +ZSTD => Yes +HSTS => Yes +GSASL => No +Protocols => dict, file, ftp, ftps, gopher, gophers, http, https, imap, imaps, ldap, ldaps, mqtt, pop3, pop3s, rtmp, rtmpe, rtmps, rtmpt, rtmpte, rtmpts, rtsp, scp, sftp, smb, smbs, smtp, smtps, telnet, tftp +Host => aarch64-apple-darwin22.4.0 +SSL Version => (SecureTransport) OpenSSL/3.1.1 +ZLib Version => 1.2.11 +libSSH Version => libssh2/1.11.0", + ), + expectations: vec![ + ("lib-curl", Exp::Version("8.1.2")), + ( + "lib-curl-securetransport", + Exp::Full("3.1.1", &["lib-curl-openssl"], &[]), + ), + ("lib-curl-zlib", Exp::Version("1.2.11")), + ("lib-curl-libssh2", Exp::Version("1.11.0")), + ], + functions: vec![curl("8.1.2")], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["curl"], + info: Some( + " +curl + +cURL support => enabled +cURL Information => 8.1.2 +Age => 10 +Features +AsynchDNS => Yes +CharConv => No +Debug => No +GSS-Negotiate => No +IDN => No +IPv6 => Yes +krb4 => No +Largefile => Yes +libz => Yes +NTLM => Yes +NTLMWB => Yes +SPNEGO => Yes +SSL => Yes +SSPI => No +TLS-SRP => No +HTTP2 => Yes +GSSAPI => Yes +KERBEROS5 => Yes +UNIX_SOCKETS => Yes +PSL => No +HTTPS_PROXY => Yes +MULTI_SSL => Yes +BROTLI => No +ALTSVC => Yes +HTTP3 => No +UNICODE => No +ZSTD => No +HSTS => Yes +GSASL => No +Protocols => dict, file, ftp, ftps, gopher, gophers, http, https, imap, imaps, ldap, ldaps, mqtt, pop3, pop3s, rtsp, smb, smbs, smtp, smtps, telnet, tftp +Host => x86_64-apple-darwin20.0 +SSL Version => (SecureTransport) LibreSSL/2.8.3 +ZLib Version => 1.2.11", + ), + expectations: vec![ + ("lib-curl", Exp::Version("8.1.2")), + ( + "lib-curl-securetransport", + Exp::Full("2.8.3", &["lib-curl-libressl"], &[]), + ), + ("lib-curl-zlib", Exp::Version("1.2.11")), + ], + functions: vec![curl("8.1.2")], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["date"], + info: Some( + " +date + +date/time support => enabled +timelib version => 2018.03 +\"Olson\" Timezone Database Version => 2020.1 +Timezone Database => external +Default timezone => Europe/Berlin", + ), + expectations: vec![ + ("lib-date-timelib", Exp::Version("2018.03")), + ("lib-date-zoneinfo", Exp::Version("2020.1")), + ], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["date"], + info: Some( + " +date + +date/time support => enabled +\"Olson\" Timezone Database Version => 2013.2 +Timezone Database => internal +Default timezone => Europe/Amsterdam", + ), + expectations: vec![ + ("lib-date-zoneinfo", Exp::Version("2013.2")), + ("lib-date-timelib", Exp::Missing), + ], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["date", "timezonedb"], + info: Some( + " +date + +date/time support => enabled +\"Olson\" Timezone Database Version => 2020.1 +Timezone Database => internal +Default timezone => UTC", + ), + expectations: vec![("lib-date-zoneinfo", Exp::Version("2020.1"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["date", "timezonedb"], + info: Some( + " +date + +date/time support => enabled +\"Olson\" Timezone Database Version => 2020.1 +Timezone Database => external +Default timezone => UTC", + ), + expectations: vec![( + "lib-timezonedb-zoneinfo", + Exp::Full("2020.1", &["lib-date-zoneinfo"], &[]), + )], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["date"], + info: Some( + " + + +date/time support => enabled +timelib version => 2018.03 +\"Olson\" Timezone Database Version => 0.system +Timezone Database => internal +Default timezone => Europe/Berlin + +Directive => Local Value => Master Value +date.timezone => no value => no value +date.default_latitude => 31.7667 => 31.7667 +date.default_longitude => 35.2333 => 35.2333 +date.sunset_zenith => 90.583333 => 90.583333 +date.sunrise_zenith => 90.583333 => 90.583333", + ), + expectations: vec![ + ("lib-date-zoneinfo", Exp::Version("0")), + ("lib-date-timelib", Exp::Version("2018.03")), + ], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["fileinfo"], + info: Some( + " +fileinfo + +fileinfo support => enabled +libmagic => 537", + ), + expectations: vec![("lib-fileinfo-libmagic", Exp::Version("537"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["gd"], + info: Some( + " +gd + +GD Support => enabled +GD Version => bundled (2.1.0 compatible) +FreeType Support => enabled +FreeType Linkage => with freetype +FreeType Version => 2.10.0 +GIF Read Support => enabled +GIF Create Support => enabled +JPEG Support => enabled +libJPEG Version => 9 compatible +PNG Support => enabled +libPNG Version => 1.6.34 +WBMP Support => enabled +XBM Support => enabled +WebP Support => enabled + +Directive => Local Value => Master Value +gd.jpeg_ignore_warning => 1 => 1", + ), + expectations: vec![ + ("lib-gd", Exp::Version("1.2.3")), + ("lib-gd-freetype", Exp::Version("2.10.0")), + ("lib-gd-libjpeg", Exp::Version("9.0")), + ("lib-gd-libpng", Exp::Version("1.6.34")), + ], + functions: vec![], + constants: vec![("GD_VERSION", None, s("1.2.3"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["gd"], + info: Some( + " +gd + +GD Support => enabled +GD Version => bundled (2.1.0 compatible) +FreeType Support => enabled +FreeType Linkage => with freetype +FreeType Version => 2.9.1 +GIF Read Support => enabled +GIF Create Support => enabled +JPEG Support => enabled +libJPEG Version => 6b +PNG Support => enabled +libPNG Version => 1.6.35 +WBMP Support => enabled +XBM Support => enabled +WebP Support => enabled + +Directive => Local Value => Master Value +gd.jpeg_ignore_warning => 1 => 1", + ), + expectations: vec![ + ("lib-gd", Exp::Version("1.2.3")), + ("lib-gd-freetype", Exp::Version("2.9.1")), + ("lib-gd-libjpeg", Exp::Version("6.2")), + ("lib-gd-libpng", Exp::Version("1.6.35")), + ], + functions: vec![], + constants: vec![("GD_VERSION", None, s("1.2.3"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["gd"], + info: Some( + " +gd + +GD Support => enabled +GD headers Version => 2.2.5 +GD library Version => 2.2.5 +FreeType Support => enabled +FreeType Linkage => with freetype +FreeType Version => 2.6.3 +GIF Read Support => enabled +GIF Create Support => enabled +JPEG Support => enabled +libJPEG Version => 6b +PNG Support => enabled +libPNG Version => 1.6.28 +WBMP Support => enabled +XPM Support => enabled +libXpm Version => 30411 +XBM Support => enabled +WebP Support => enabled + +Directive => Local Value => Master Value +gd.jpeg_ignore_warning => 1 => 1", + ), + expectations: vec![ + ("lib-gd", Exp::Version("2.2.5")), + ("lib-gd-freetype", Exp::Version("2.6.3")), + ("lib-gd-libjpeg", Exp::Version("6.2")), + ("lib-gd-libpng", Exp::Version("1.6.28")), + ("lib-gd-libxpm", Exp::Version("3.4.11")), + ], + functions: vec![], + constants: vec![("GD_VERSION", None, s("2.2.5"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["iconv"], + info: None, + expectations: vec![("lib-iconv", Exp::Version("1.2.4"))], + functions: vec![], + constants: vec![("ICONV_VERSION", None, s("1.2.4"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["gmp"], + info: None, + expectations: vec![("lib-gmp", Exp::Version("6.1.0"))], + functions: vec![], + constants: vec![("GMP_VERSION", None, s("6.1.0"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["intl"], + info: Some( + " +intl + +Internationalization support => enabled +ICU version => 57.1 +ICU Data version => 57.1 +ICU TZData version => 2016b +ICU Unicode version => 8.0 + +Directive => Local Value => Master Value +intl.default_locale => no value => no value +intl.error_level => 0 => 0 +intl.use_exceptions => 0 => 0", + ), + expectations: vec![ + ("lib-icu", Exp::Version("100")), + ("lib-icu-cldr", Exp::Version("32.0.1")), + ("lib-icu-unicode", Exp::Version("7.0.0")), + ("lib-icu-zoneinfo", Exp::Version("2016.2")), + ], + functions: vec![ + ( + PhpMixed::List(vec![s("ResourceBundle"), s("create")]), + vec![s("root"), s("ICUDATA"), PhpMixed::Bool(false)], + PhpMixed::Object(IndexMap::from([("Version".to_string(), s("32.0.1"))])), + ), + ( + PhpMixed::List(vec![s("IntlChar"), s("getUnicodeVersion")]), + vec![], + PhpMixed::List(vec![ + PhpMixed::Int(7), + PhpMixed::Int(0), + PhpMixed::Int(0), + PhpMixed::Int(0), + ]), + ), + ], + constants: vec![("INTL_ICU_VERSION", None, s("100"))], + class_definitions: vec![ + ClassDef { + class: "ResourceBundle", + construct: None, + }, + ClassDef { + class: "IntlChar", + construct: None, + }, + ], + }, + LibCase { + extensions: vec!["intl"], + info: Some( + " +intl + +Internationalization support => enabled +version => 1.1.0 +ICU version => 57.1 +ICU Data version => 57.1", + ), + expectations: vec![("lib-icu", Exp::Version("57.1"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["imagick"], + info: None, + expectations: vec![( + "lib-imagick-imagemagick", + Exp::Full("6.2.9", &["lib-imagick"], &[]), + )], + functions: vec![], + constants: vec![], + class_definitions: vec![ClassDef { + class: "Imagick", + construct: Some(( + vec![], + PhpMixed::Object(IndexMap::from([( + "versionString".to_string(), + s("ImageMagick 6.2.9 Q16 x86_64 2018-05-18 http://www.imagemagick.org"), + )])), + )), + }], + }, + LibCase { + extensions: vec!["imagick"], + info: None, + expectations: vec![( + "lib-imagick-imagemagick", + Exp::Full("7.0.8.34", &["lib-imagick"], &[]), + )], + functions: vec![], + constants: vec![], + class_definitions: vec![ClassDef { + class: "Imagick", + construct: Some(( + vec![], + PhpMixed::Object(IndexMap::from([( + "versionString".to_string(), + s("ImageMagick 7.0.8-34 Q16 x86_64 2019-03-23 https://imagemagick.org"), + )])), + )), + }], + }, + LibCase { + extensions: vec!["ldap"], + info: Some( + " +ldap + +LDAP Support => enabled +RCS Version => $Id: 5f1913de8e05a346da913956f81e0c0d8991c7cb $ +Total Links => 0/unlimited +API Version => 3001 +Vendor Name => OpenLDAP +Vendor Version => 20450 +SASL Support => Enabled + +Directive => Local Value => Master Value +ldap.max_links => Unlimited => Unlimited", + ), + expectations: vec![("lib-ldap-openldap", Exp::Version("2.4.50"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["libxml"], + info: None, + expectations: vec![("lib-libxml", Exp::Version("2.1.5"))], + functions: vec![], + constants: vec![("LIBXML_DOTTED_VERSION", None, s("2.1.5"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["libxml", "dom", "simplexml", "xml", "xmlreader", "xmlwriter"], + info: None, + expectations: vec![( + "lib-libxml", + Exp::Full( + "2.1.5", + &[], + &[ + "lib-dom-libxml", + "lib-simplexml-libxml", + "lib-xml-libxml", + "lib-xmlreader-libxml", + "lib-xmlwriter-libxml", + ], + ), + )], + functions: vec![], + constants: vec![("LIBXML_DOTTED_VERSION", None, s("2.1.5"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["mbstring"], + info: Some( + " +mbstring + +Multibyte Support => enabled +Multibyte string engine => libmbfl +HTTP input encoding translation => disabled +libmbfl version => 1.3.2 + +mbstring extension makes use of \"streamable kanji code filter and converter\", which is distributed under the GNU Lesser General Public License version 2.1. + +Multibyte (japanese) regex support => enabled +Multibyte regex (oniguruma) version => 6.1.3", + ), + expectations: vec![ + ("lib-mbstring-libmbfl", Exp::Version("1.3.2")), + ("lib-mbstring-oniguruma", Exp::Version("7.0.0")), + ], + functions: vec![], + constants: vec![("MB_ONIGURUMA_VERSION", None, s("7.0.0"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["mbstring"], + info: Some( + " +mbstring + +Multibyte Support => enabled +Multibyte string engine => libmbfl +HTTP input encoding translation => disabled +libmbfl version => 1.3.2 + +mbstring extension makes use of \"streamable kanji code filter and converter\", which is distributed under the GNU Lesser General Public License version 2.1. + +Multibyte (japanese) regex support => enabled +Multibyte regex (oniguruma) version => 6.1.3", + ), + expectations: vec![ + ("lib-mbstring-libmbfl", Exp::Version("1.3.2")), + ("lib-mbstring-oniguruma", Exp::Version("6.1.3")), + ], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["mbstring"], + info: Some( + " +mbstring + +Multibyte Support => enabled +Multibyte string engine => libmbfl +HTTP input encoding translation => disabled +libmbfl version => 1.3.2 +oniguruma version => 6.9.4 + +mbstring extension makes use of \"streamable kanji code filter and converter\", which is distributed under the GNU Lesser General Public License version 2.1. + +Multibyte (japanese) regex support => enabled +Multibyte regex (oniguruma) backtrack check => On", + ), + expectations: vec![ + ("lib-mbstring-libmbfl", Exp::Version("1.3.2")), + ("lib-mbstring-oniguruma", Exp::Version("6.9.4")), + ], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["memcached"], + info: Some( + " +memcached + +memcached support => enabled +Version => 3.1.5 +libmemcached version => 1.0.18 +SASL support => yes +Session support => yes +igbinary support => yes +json support => yes +msgpack support => yes", + ), + expectations: vec![("lib-memcached-libmemcached", Exp::Version("1.0.18"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["openssl"], + info: None, + expectations: vec![("lib-openssl", Exp::Version("1.1.1.7"))], + functions: vec![], + constants: vec![("OPENSSL_VERSION_TEXT", None, s("OpenSSL 1.1.1g 21 Apr 2020"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["openssl"], + info: None, + expectations: vec![("lib-openssl", Exp::Version("1.1.1.7"))], + functions: vec![], + constants: vec![( + "OPENSSL_VERSION_TEXT", + None, + s("OpenSSL 1.1.1g-freebsd 21 Apr 2020"), + )], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["openssl"], + info: None, + expectations: vec![("lib-openssl", Exp::Version("0.9.8.33"))], + functions: vec![], + constants: vec![("OPENSSL_VERSION_TEXT", None, s("OpenSSL 0.9.8zg 21 Apr 2020"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["openssl"], + info: None, + expectations: vec![("lib-openssl", Exp::Version("1.1.1.7-alpha1"))], + functions: vec![], + constants: vec![("OPENSSL_VERSION_TEXT", None, s("OpenSSL 1.1.1g-pre1 21 Apr 2020"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["openssl"], + info: None, + expectations: vec![("lib-openssl", Exp::Version("1.1.1.7-beta2"))], + functions: vec![], + constants: vec![( + "OPENSSL_VERSION_TEXT", + None, + s("OpenSSL 1.1.1g-beta2 21 Apr 2020"), + )], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["openssl"], + info: None, + expectations: vec![("lib-openssl", Exp::Version("1.1.1.7-alpha4"))], + functions: vec![], + constants: vec![( + "OPENSSL_VERSION_TEXT", + None, + s("OpenSSL 1.1.1g-alpha4 21 Apr 2020"), + )], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["openssl"], + info: None, + expectations: vec![("lib-openssl", Exp::Version("1.1.1.7-rc2"))], + functions: vec![], + constants: vec![("OPENSSL_VERSION_TEXT", None, s("OpenSSL 1.1.1g-rc2 21 Apr 2020"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["openssl"], + info: None, + expectations: vec![( + "lib-openssl-fips", + Exp::Full("1.1.1.7", &[], &["lib-openssl"]), + )], + functions: vec![], + constants: vec![("OPENSSL_VERSION_TEXT", None, s("OpenSSL 1.1.1g-fips 21 Apr 2020"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["openssl"], + info: None, + expectations: vec![("lib-openssl", Exp::Version("2.0.1.0"))], + functions: vec![], + constants: vec![("OPENSSL_VERSION_TEXT", None, s("LibreSSL 2.0.1"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["mysqlnd"], + info: Some( + " + mysqlnd + +mysqlnd => enabled +Version => mysqlnd 5.0.11-dev - 20150407 - $Id: 38fea24f2847fa7519001be390c98ae0acafe387 $ +Compression => supported +core SSL => supported +extended SSL => supported +Command buffer size => 4096 +Read buffer size => 32768 +Read timeout => 31536000 +Collecting statistics => Yes +Collecting memory statistics => Yes +Tracing => n/a +Loaded plugins => mysqlnd,debug_trace,auth_plugin_mysql_native_password,auth_plugin_mysql_clear_password,auth_plugin_sha256_password +API Extensions => pdo_mysql,mysqli", + ), + expectations: vec![("lib-mysqlnd-mysqlnd", Exp::Version("5.0.11-dev"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["pdo_mysql"], + info: Some( + " + pdo_mysql + +PDO Driver for MySQL => enabled +Client API version => mysqlnd 5.0.10-dev - 20150407 - $Id: 38fea24f2847fa7519001be390c98ae0acafe387 $ + +Directive => Local Value => Master Value +pdo_mysql.default_socket => /tmp/mysql.sock => /tmp/mysql.sock", + ), + expectations: vec![("lib-pdo_mysql-mysqlnd", Exp::Version("5.0.10-dev"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["mongodb"], + info: Some( + " + mongodb + +MongoDB support => enabled +MongoDB extension version => 1.6.1 +MongoDB extension stability => stable +libbson bundled version => 1.15.2 +libmongoc bundled version => 1.15.2 +libmongoc SSL => enabled +libmongoc SSL library => OpenSSL +libmongoc crypto => enabled +libmongoc crypto library => libcrypto +libmongoc crypto system profile => disabled +libmongoc SASL => disabled +libmongoc ICU => enabled +libmongoc compression => enabled +libmongoc compression snappy => disabled +libmongoc compression zlib => enabled + +Directive => Local Value => Master Value +mongodb.debug => no value => no value", + ), + expectations: vec![ + ("lib-mongodb-libmongoc", Exp::Version("1.15.2")), + ("lib-mongodb-libbson", Exp::Version("1.15.2")), + ], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["pcre"], + info: Some( + " +pcre + +PCRE (Perl Compatible Regular Expressions) Support => enabled +PCRE Library Version => 10.33 2019-04-16 +PCRE Unicode Version => 11.0.0 +PCRE JIT Support => enabled +PCRE JIT Target => x86 64bit (little endian + unaligned)", + ), + expectations: vec![ + ("lib-pcre", Exp::Version("10.33")), + ("lib-pcre-unicode", Exp::Version("11.0.0")), + ], + functions: vec![], + constants: vec![("PCRE_VERSION", None, s("10.33 2019-04-16"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["pcre"], + info: Some( + " +pcre + +PCRE (Perl Compatible Regular Expressions) Support => enabled +PCRE Library Version => 8.38 2015-11-23 + +Directive => Local Value => Master Value +pcre.backtrack_limit => 1000000 => 1000000 +pcre.recursion_limit => 100000 => 100000 + ", + ), + expectations: vec![("lib-pcre", Exp::Version("8.38"))], + functions: vec![], + constants: vec![("PCRE_VERSION", None, s("8.38 2015-11-23"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["pgsql"], + info: Some( + " +pgsql + +PostgreSQL Support => enabled +PostgreSQL(libpq) Version => 12.2 +PostgreSQL(libpq) => PostgreSQL 12.3 on x86_64-apple-darwin18.7.0, compiled by Apple clang version 11.0.0 (clang-1100.0.33.17), 64-bit +Multibyte character support => enabled +SSL support => enabled +Active Persistent Links => 0 +Active Links => 0 + +Directive => Local Value => Master Value +pgsql.allow_persistent => On => On +pgsql.max_persistent => Unlimited => Unlimited +pgsql.max_links => Unlimited => Unlimited +pgsql.auto_reset_persistent => Off => Off +pgsql.ignore_notice => Off => Off +pgsql.log_notice => Off => Off", + ), + expectations: vec![("lib-pgsql-libpq", Exp::Version("12.2"))], + functions: vec![], + constants: vec![("PGSQL_LIBPQ_VERSION", None, s("12.2"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["pdo_pgsql"], + info: Some( + " + pdo_pgsql + +PDO Driver for PostgreSQL => enabled +PostgreSQL(libpq) Version => 12.1 +Module version => 7.1.33 +Revision => $Id: 9c5f356c77143981d2e905e276e439501fe0f419 $", + ), + expectations: vec![("lib-pdo_pgsql-libpq", Exp::Version("12.1"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["pq"], + info: Some( + "pq + +PQ Support => enabled +Extension Version => 2.2.0 + +Used Library => Compiled => Linked +libpq => 14.3 (Ubuntu 14.3-1.pgdg22.04+1) => 15.0.2 + ", + ), + expectations: vec![("lib-pq-libpq", Exp::Version("15.0.2"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["rdkafka"], + info: None, + expectations: vec![("lib-rdkafka-librdkafka", Exp::Version("1.9.2"))], + functions: vec![], + constants: vec![("RD_KAFKA_VERSION", None, PhpMixed::Int(17367807))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["libsodium"], + info: None, + expectations: vec![("lib-libsodium", Exp::Version("1.0.17"))], + functions: vec![], + constants: vec![("SODIUM_LIBRARY_VERSION", None, s("1.0.17"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["sodium"], + info: None, + expectations: vec![("lib-libsodium", Exp::Version("1.0.15"))], + functions: vec![], + constants: vec![("SODIUM_LIBRARY_VERSION", None, s("1.0.15"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["pdo_sqlite"], + info: Some( + " +pdo_sqlite + +PDO Driver for SQLite 3.x => enabled +SQLite Library => 3.32.3 + ", + ), + expectations: vec![("lib-pdo_sqlite-sqlite", Exp::Version("3.32.3"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["sqlite3"], + info: Some( + " +sqlite3 + +SQLite3 support => enabled +SQLite3 module version => 7.1.33 +SQLite Library => 3.31.0 + +Directive => Local Value => Master Value +sqlite3.extension_dir => no value => no value +sqlite3.defensive => 1 => 1", + ), + expectations: vec![("lib-sqlite3-sqlite", Exp::Version("3.31.0"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["ssh2"], + info: Some( + " +ssh2 + +SSH2 support => enabled +extension version => 1.2 +libssh2 version => 1.8.0 +banner => SSH-2.0-libssh2_1.8.0", + ), + expectations: vec![("lib-ssh2-libssh2", Exp::Version("1.8.0"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["yaml"], + info: Some( + " + yaml + +LibYAML Support => enabled +Module Version => 2.0.2 +LibYAML Version => 0.2.2 + +Directive => Local Value => Master Value +yaml.decode_binary => 0 => 0 +yaml.decode_timestamp => 0 => 0 +yaml.decode_php => 0 => 0 +yaml.output_canonical => 0 => 0 +yaml.output_indent => 2 => 2 +yaml.output_width => 80 => 80", + ), + expectations: vec![("lib-yaml-libyaml", Exp::Version("0.2.2"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["xsl"], + info: Some( + " +xsl + +XSL => enabled +libxslt Version => 1.1.33 +libxslt compiled against libxml Version => 2.9.8 +EXSLT => enabled +libexslt Version => 1.1.29", + ), + expectations: vec![ + ("lib-libxslt", Exp::Full("1.1.29", &["lib-xsl"], &[])), + ("lib-libxslt-libxml", Exp::Version("2.9.8")), + ], + functions: vec![], + constants: vec![("LIBXSLT_DOTTED_VERSION", None, s("1.1.29"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["zip"], + info: None, + expectations: vec![("lib-zip-libzip", Exp::Full("1.5.0", &["lib-zip"], &[]))], + functions: vec![], + constants: vec![("LIBZIP_VERSION", Some("ZipArchive"), s("1.5.0"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["zlib"], + info: None, + expectations: vec![("lib-zlib", Exp::Version("1.2.10"))], + functions: vec![], + constants: vec![("ZLIB_VERSION", None, s("1.2.10"))], + class_definitions: vec![], + }, + LibCase { + extensions: vec!["zlib"], + info: Some( + " +zlib + +ZLib Support => enabled +Stream Wrapper => compress.zlib:// +Stream Filter => zlib.inflate, zlib.deflate +Compiled Version => 1.2.8 +Linked Version => 1.2.11", + ), + expectations: vec![("lib-zlib", Exp::Version("1.2.11"))], + functions: vec![], + constants: vec![], + class_definitions: vec![], + }, + ] +} + +fn assert_package_links( + context: &str, + expected_links: &[&str], + source_package: &BasePackageHandle, + links: IndexMap, +) { + assert_eq!( + expected_links.len(), + links.len(), + "{}: expected package count to match", + context + ); + + for link in links.values() { + assert_eq!(source_package.get_name(), link.get_source()); + assert!( + expected_links.contains(&link.get_target()), + "{}: package {} not in {:?}", + context, + link.get_target(), + expected_links + ); + let version_constraint = + SimpleConstraint::new("=".to_string(), source_package.get_version(), None); + assert!(link.get_constraint().matches(&version_constraint.into())); + } } #[test] -#[ignore = "requires PHPUnit getMockBuilder mock of Composer\\Platform\\Runtime (willReturnMap/willReturnCallback for getExtensions/getExtensionVersion/getExtensionInfo/invoke/hasConstant/getConstant/hasClass/construct) plus ResourceBundleStub/ImagickStub; no mocking framework"] +#[ignore = "all 59 provideLibraryTestCases datasets ported faithfully; execution still \ + panics at the unrelated XdebugHandler::get_skipped_version() todo!() (and a few \ + cases additionally rely on the ResourceBundle/Imagick stub paths), so it stays \ + ignored"] fn test_library_information() { - todo!() + let extension_version = "100.200.300"; + + for case in library_test_cases() { + let extensions: Vec = case.extensions.iter().map(|e| e.to_string()).collect(); + + let mut constants: Vec<(String, Option, PhpMixed)> = case + .constants + .iter() + .map(|(n, c, v)| (n.to_string(), c.map(|s| s.to_string()), v.clone())) + .collect(); + constants.push(( + "PHP_VERSION".to_string(), + None, + PhpMixed::String("7.1.0".to_string()), + )); + + let functions = case.functions.clone(); + let info = case.info.map(|s| s.to_string()); + + let exts_for_get = extensions.clone(); + let constants_has = constants.clone(); + let constants_get = constants.clone(); + + let mut runtime = MockRuntime::new(); + runtime + .expect_get_extensions() + .times(..) + .returning(move || exts_for_get.clone()); + runtime + .expect_get_extension_version() + .times(..) + .returning(move |_extension| extension_version.to_string()); + runtime + .expect_get_extension_info() + .times(..) + .returning(move |_extension| Ok(info.clone().unwrap_or_default())); + runtime + .expect_invoke() + .times(..) + .returning(move |callable, arguments| { + for (c, a, ret) in &functions { + if *c == callable && *a == arguments { + return ret.clone(); + } + } + PhpMixed::Null + }); + runtime + .expect_has_constant() + .times(..) + .returning(move |constant, class| { + constants_has + .iter() + .any(|(n, c, _)| n == constant && c.as_deref() == class.as_deref()) + }); + runtime + .expect_get_constant() + .times(..) + .returning(move |constant, class| { + constants_get + .iter() + .find(|(n, c, _)| n == constant && c.as_deref() == class.as_deref()) + .map(|(_, _, v)| v.clone()) + .unwrap_or(PhpMixed::Null) + }); + let class_definitions_has = case.class_definitions.clone(); + let class_definitions_construct = case.class_definitions.clone(); + runtime + .expect_has_class() + .times(..) + .returning(move |class| class_definitions_has.iter().any(|d| d.class == class)); + runtime + .expect_construct() + .times(..) + .returning(move |class, arguments| { + for d in &class_definitions_construct { + if d.class == class + && let Some((args, ret)) = &d.construct + && *args == arguments + { + return Ok(ret.clone()); + } + } + Ok(PhpMixed::Null) + }); + + let mut platform_repository = + PlatformRepository::new4(vec![], IndexMap::new(), Some(Box::new(runtime)), None) + .unwrap(); + + let libraries: Vec = platform_repository + .search("lib".to_string(), SEARCH_NAME, None) + .unwrap() + .into_iter() + .map(|package| package.name) + .filter(|name| name.starts_with("lib-")) + .collect(); + let expected_libraries: Vec<&str> = case + .expectations + .iter() + .filter(|(_, exp)| !matches!(exp, Exp::Missing)) + .map(|(name, _)| *name) + .collect(); + assert_eq!( + expected_libraries.len(), + libraries.len(), + "Expected: {:?}, got {:?}", + expected_libraries, + libraries + ); + + let mut all_expectations: Vec<(String, (Option<&str>, &[&str], &[&str]))> = case + .expectations + .iter() + .map(|(name, exp)| (name.to_string(), exp.parts())) + .collect(); + for extension in &extensions { + all_expectations.push(( + format!("ext-{}", extension), + (Some(extension_version), &[], &[]), + )); + } + + for (package_name, (expected_version, expected_replaces, expected_provides)) in + all_expectations + { + let package = platform_repository + .find_package( + &package_name, + FindPackageConstraint::String("*".to_string()), + ) + .unwrap(); + match expected_version { + None => assert!( + package.is_none(), + "Expected to not find package \"{}\"", + package_name + ), + Some(expected_version) => { + assert!( + package.is_some(), + "Expected to find package \"{}\"", + package_name + ); + let package = package.unwrap(); + assert_eq!( + expected_version, + package.get_pretty_version(), + "Expected version {} for {}", + expected_version, + package_name + ); + assert_package_links( + "replaces", + expected_replaces, + &package, + package.get_replaces(), + ); + assert_package_links( + "provides", + expected_provides, + &package, + package.get_provides(), + ); + } + } + } + } } #[test] -#[ignore = "requires PHPUnit getMockBuilder mock of Composer\\Platform\\Runtime (willReturnMap for getConstant/getExtensions); no mocking framework and Runtime is a concrete struct"] +#[ignore = "seam + Runtime mock ported (getConstant/getExtensions map); execution panics \ + at the unrelated XdebugHandler::get_skipped_version() todo!()"] fn test_composer_platform_version() { - todo!() + let constants: IndexMap = IndexMap::from([ + ( + "PHP_VERSION".to_string(), + PhpMixed::String("7.0.0".to_string()), + ), + ("PHP_DEBUG".to_string(), PhpMixed::Bool(false)), + ]); + + let mut runtime = MockRuntime::new(); + runtime + .expect_get_extensions() + .times(..) + .returning(Vec::new); + runtime + .expect_get_constant() + .times(..) + .returning(move |constant, class| { + constants + .get(&constant_key(constant, class.as_deref())) + .cloned() + .unwrap_or(PhpMixed::Null) + }); + // PHP only stubs getExtensions/getConstant; PHPUnit auto-returns null/false for the + // other probed methods. Mirror that so initialize() does not hit unset expectations. + runtime + .expect_has_constant() + .times(..) + .returning(|_, _| false); + runtime + .expect_invoke() + .times(..) + .returning(|_, _| PhpMixed::Null); + + let mut platform_repository = + PlatformRepository::new4(vec![], IndexMap::new(), Some(Box::new(runtime)), None).unwrap(); + + let package = platform_repository + .find_package( + "composer", + FindPackageConstraint::String(format!("={}", shirabe::composer::get_version())), + ) + .unwrap(); + assert!(package.is_some(), "Composer package exists"); } #[test] diff --git a/crates/shirabe/tests/repository/vcs/perforce_driver_test.rs b/crates/shirabe/tests/repository/vcs/perforce_driver_test.rs index 5e9471f..ef55c75 100644 --- a/crates/shirabe/tests/repository/vcs/perforce_driver_test.rs +++ b/crates/shirabe/tests/repository/vcs/perforce_driver_test.rs @@ -8,25 +8,48 @@ use shirabe::config::Config; use shirabe::io::IOInterface; use shirabe::io::null_io::NullIO; use shirabe::repository::vcs::PerforceDriver; +use shirabe::util::HttpDownloader; use shirabe::util::filesystem::Filesystem; +use shirabe::util::process_executor::MockHandler; use shirabe_php_shim::PhpMixed; use tempfile::TempDir; +use crate::process_executor_mock::{ProcessExecutorMockGuard, get_process_executor_mock}; + const TEST_URL: &str = "TEST_PERFORCE_URL"; const TEST_DEPOT: &str = "TEST_DEPOT_CONFIG"; const TEST_BRANCH: &str = "TEST_BRANCH_CONFIG"; +// A getMockBuilder('Composer\Util\Perforce') stand-in: the seam trait extracted from the +// concrete `Perforce` struct, injected via overrideDriverInternalPerforce's Rust equivalent. +mockall::mock! { + #[derive(Debug)] + pub Perforce {} + impl shirabe::util::PerforceInterface for Perforce { + fn initialize_path(&mut self, path: &str); + fn set_stream(&mut self, stream: &str); + fn p4_login(&mut self) -> anyhow::Result<()>; + fn check_stream(&mut self) -> bool; + fn write_p4_client_spec(&mut self) -> anyhow::Result<()>; + fn connect_client(&mut self) -> anyhow::Result<()>; + fn sync_code_base(&mut self, source_reference: Option) -> anyhow::Result<()>; + fn cleanup_client_spec(&mut self); + fn get_commit_logs(&mut self, from_reference: &str, to_reference: &str) -> Option; + fn get_file_content(&mut self, file: &str, identifier: &str) -> Option; + fn get_branches(&mut self) -> IndexMap; + fn get_tags(&mut self) -> IndexMap; + fn get_user(&self) -> Option; + fn get_composer_information( + &mut self, + identifier: &str, + ) -> anyhow::Result>>; + } +} + struct SetUp { test_path: TempDir, config: Config, repo_config: IndexMap, - // The IOInterface, ProcessExecutor, HttpDownloader and Perforce mocks, the - // driver instance and the reflection-based perforce override are not ported. - io: (), - process: (), - http_downloader: (), - perforce: (), - driver: (), } fn get_test_config(test_path: &std::path::Path) -> Config { @@ -57,25 +80,34 @@ fn set_up() -> SetUp { PhpMixed::String(TEST_BRANCH.to_string()), ); - let io = (); - let process = (); - let http_downloader = (); - let perforce = (); - // The driver construction and overrideDriverInternalPerforce (reflection) are not ported. - let driver = (); - SetUp { test_path, config, repo_config, - io, - process, - http_downloader, - perforce, - driver, } } +// ref: setUp builds the driver from a mock IO, an empty ProcessExecutorMock and a mock +// HttpDownloader. The empty, non-strict process mock lets any p4 query Perforce issues resolve +// with the default (exit 0) result. +fn make_driver( + config: Config, + repo_config: &IndexMap, +) -> (PerforceDriver, ProcessExecutorMockGuard) { + let io: Rc> = Rc::new(RefCell::new(NullIO::new())); + let config = Rc::new(RefCell::new(config)); + let http_downloader = Rc::new(RefCell::new(HttpDownloader::new( + io.clone(), + config.clone(), + IndexMap::new(), + false, + ))); + let (process, guard) = get_process_executor_mock(vec![], false, MockHandler::default()); + let driver = PerforceDriver::new(repo_config.clone(), io, config, http_downloader, process); + + (driver, guard) +} + fn tear_down(test_path: &std::path::Path) { // cleanup directory under test path let mut fs = Filesystem::new(None); @@ -106,146 +138,127 @@ fn test_supports_returns_false_no_deep_check() { assert!(!PerforceDriver::supports(io, config, "existing.url", false).unwrap()); } -// The remaining cases mock Perforce, the repository config and IO to drive initialization, -// composer-file detection and cleanup; mocking is not available here. #[test] fn test_initialize_captures_variables_from_repo_config() { let SetUp { test_path, config, repo_config, - io, - process, - http_downloader, - perforce, - driver, } = set_up(); let _tear_down = TearDown::new(test_path.path().to_path_buf()); - let _ = (&io, &process, &http_downloader, &perforce, &driver); - let io: Rc> = Rc::new(RefCell::new(NullIO::new())); - let config = Rc::new(RefCell::new(config)); - let http_downloader = Rc::new(RefCell::new(shirabe::util::HttpDownloader::new( - io.clone(), - config.clone(), - IndexMap::new(), - false, - ))); - // ref: setUp uses getProcessExecutorMock(); the empty, non-strict mock lets Perforce's p4 - // queries resolve with the default (exit 0) result so initialize() never shells out to p4. - let (process, _process_guard) = crate::process_executor_mock::get_process_executor_mock( - vec![], - false, - shirabe::util::process_executor::MockHandler::default(), - ); - - let mut driver = PerforceDriver::new(repo_config, io, config, http_downloader, process); + let (mut driver, _process_guard) = make_driver(config, &repo_config); driver.initialize().unwrap(); assert_eq!(TEST_URL, driver.get_url()); assert_eq!(TEST_DEPOT, driver.get_depot()); assert_eq!(TEST_BRANCH, driver.get_branch()); } -#[ignore = "needs a Perforce mock injected via reflection (overrideDriverInternalPerforce) to assert p4Login/checkStream/writeP4ClientSpec/connectClient are each called once; the perforce field is pub(crate) and no mock infra exists"] #[test] fn test_initialize_logs_in_and_connects_client() { let SetUp { test_path, config, repo_config, - io, - process, - http_downloader, - perforce, - driver, } = set_up(); let _tear_down = TearDown::new(test_path.path().to_path_buf()); - let _ = ( - &config, - &repo_config, - &io, - &process, - &http_downloader, - &perforce, - &driver, - ); - todo!() + + let (mut driver, _process_guard) = make_driver(config, &repo_config); + let mut perforce = MockPerforce::new(); + perforce.expect_p4_login().times(1).returning(|| Ok(())); + perforce.expect_check_stream().times(1).returning(|| false); + perforce + .expect_write_p4_client_spec() + .times(1) + .returning(|| Ok(())); + perforce + .expect_connect_client() + .times(1) + .returning(|| Ok(())); + driver.__override_perforce(Box::new(perforce)); + + driver.initialize().unwrap(); } -#[ignore = "needs a Perforce mock injected via reflection (overrideDriverInternalPerforce) to stub getComposerInformation; the perforce field is pub(crate) and no mock infra exists"] #[test] fn test_has_composer_file_returns_false_on_no_composer_file() { let SetUp { test_path, config, repo_config, - io, - process, - http_downloader, - perforce, - driver, } = set_up(); let _tear_down = TearDown::new(test_path.path().to_path_buf()); - let _ = ( - &config, - &repo_config, - &io, - &process, - &http_downloader, - &perforce, - &driver, - ); - todo!() + + let identifier = "TEST_IDENTIFIER"; + let formatted_depot_path = format!("//{}/{}", TEST_DEPOT, identifier); + + let (mut driver, _process_guard) = make_driver(config, &repo_config); + let mut perforce = MockPerforce::new(); + perforce.expect_p4_login().returning(|| Ok(())); + perforce.expect_check_stream().returning(|| false); + perforce.expect_write_p4_client_spec().returning(|| Ok(())); + perforce.expect_connect_client().returning(|| Ok(())); + perforce + .expect_get_composer_information() + .withf(move |id: &str| id == formatted_depot_path) + .returning(|_| Ok(Some(IndexMap::new()))); + driver.__override_perforce(Box::new(perforce)); + + driver.initialize().unwrap(); + let result = driver.has_composer_file(identifier); + assert!(!result); } -#[ignore = "needs a Perforce mock injected via reflection (overrideDriverInternalPerforce) to stub getComposerInformation; the perforce field is pub(crate) and no mock infra exists"] #[test] fn test_has_composer_file_returns_true_with_one_or_more_composer_files() { let SetUp { test_path, config, repo_config, - io, - process, - http_downloader, - perforce, - driver, } = set_up(); let _tear_down = TearDown::new(test_path.path().to_path_buf()); - let _ = ( - &config, - &repo_config, - &io, - &process, - &http_downloader, - &perforce, - &driver, - ); - todo!() + + let identifier = "TEST_IDENTIFIER"; + let formatted_depot_path = format!("//{}/{}", TEST_DEPOT, identifier); + + let (mut driver, _process_guard) = make_driver(config, &repo_config); + let mut perforce = MockPerforce::new(); + perforce.expect_p4_login().returning(|| Ok(())); + perforce.expect_check_stream().returning(|| false); + perforce.expect_write_p4_client_spec().returning(|| Ok(())); + perforce.expect_connect_client().returning(|| Ok(())); + perforce + .expect_get_composer_information() + .withf(move |id: &str| id == formatted_depot_path) + .returning(|_| { + // ref: returnValue(['']) — a non-empty list with one empty-string element. + let mut info: IndexMap = IndexMap::new(); + info.insert("0".to_string(), PhpMixed::String(String::new())); + Ok(Some(info)) + }); + driver.__override_perforce(Box::new(perforce)); + + driver.initialize().unwrap(); + let result = driver.has_composer_file(identifier); + assert!(result); } -#[ignore = "needs a Perforce mock injected via reflection (overrideDriverInternalPerforce) to assert cleanupClientSpec is called once; the perforce field is pub(crate) and no mock infra exists"] #[test] fn test_cleanup() { let SetUp { test_path, config, repo_config, - io, - process, - http_downloader, - perforce, - driver, } = set_up(); let _tear_down = TearDown::new(test_path.path().to_path_buf()); - let _ = ( - &config, - &repo_config, - &io, - &process, - &http_downloader, - &perforce, - &driver, - ); - todo!() + + let (mut driver, _process_guard) = make_driver(config, &repo_config); + let mut perforce = MockPerforce::new(); + perforce + .expect_cleanup_client_spec() + .times(1) + .returning(|| ()); + driver.__override_perforce(Box::new(perforce)); + + driver.cleanup().unwrap(); } diff --git a/crates/shirabe/tests/util/github_test.rs b/crates/shirabe/tests/util/github_test.rs index 5caf5c7..10b5dd7 100644 --- a/crates/shirabe/tests/util/github_test.rs +++ b/crates/shirabe/tests/util/github_test.rs @@ -1,17 +1,181 @@ //! ref: composer/tests/Composer/Test/Util/GitHubTest.php -// Both cases construct GitHub with a mocked IO/Config/JsonConfigSource and a mocked -// HttpDownloader to drive the username/password authentication flow. Mocking is not -// available, and a real HttpDownloader reaches curl_multi_init (todo!()). +use std::cell::RefCell; +use std::rc::Rc; + +use shirabe::config::{Config, ConfigSourceInterface}; +use shirabe::io::IOInterface; +use shirabe::io::io_interface; +use shirabe::util::GitHub; +use shirabe::util::http_downloader::{HttpDownloader, HttpDownloaderMockHandler}; +use shirabe_php_shim::PhpMixed; + +use crate::config_stub::ConfigStubBuilder; +use crate::http_downloader_mock::{expect_full, get_http_downloader_mock}; +use crate::io_mock::{Expectation, IOMock, get_io_mock}; + +const PASSWORD: &str = "password"; +const MESSAGE: &str = "mymessage"; +const ORIGIN: &str = "github.com"; + +// Records the config setting names a source has had removed, plus a fixed getName, +// mirroring GitHubTest's JsonConfigSource mocks (getName -> "auth.json", and the +// config source stubbing removeConfigSetting('github-oauth.')). +#[derive(Debug)] +struct ConfigSourceMock { + name: String, + removed: Rc>>, +} + +impl ConfigSourceMock { + fn new(name: &str) -> (Box, Rc>>) { + let removed = Rc::new(RefCell::new(Vec::new())); + ( + Box::new(Self { + name: name.to_string(), + removed: removed.clone(), + }), + removed, + ) + } +} + +impl ConfigSourceInterface for ConfigSourceMock { + fn add_repository( + &mut self, + _name: &str, + _config: PhpMixed, + _append: bool, + ) -> anyhow::Result<()> { + unreachable!() + } + fn insert_repository( + &mut self, + _name: &str, + _config: PhpMixed, + _reference_name: &str, + _offset: i64, + ) -> anyhow::Result<()> { + unreachable!() + } + fn set_repository_url(&mut self, _name: &str, _url: &str) -> anyhow::Result<()> { + unreachable!() + } + fn remove_repository(&mut self, _name: &str) -> anyhow::Result<()> { + unreachable!() + } + fn add_config_setting(&mut self, _name: &str, _value: PhpMixed) -> anyhow::Result<()> { + Ok(()) + } + fn remove_config_setting(&mut self, name: &str) -> anyhow::Result<()> { + self.removed.borrow_mut().push(name.to_string()); + Ok(()) + } + fn add_property(&mut self, _name: &str, _value: PhpMixed) -> anyhow::Result<()> { + unreachable!() + } + fn remove_property(&mut self, _name: &str) -> anyhow::Result<()> { + unreachable!() + } + fn add_link(&mut self, _type: &str, _name: &str, _value: &str) -> anyhow::Result<()> { + unreachable!() + } + fn remove_link(&mut self, _type: &str, _name: &str) -> anyhow::Result<()> { + unreachable!() + } + fn get_name(&self) -> String { + self.name.clone() + } +} + +fn build_github( + io_mock: &Rc>, + config: Rc>, + http_downloader: Rc>, +) -> GitHub { + let io: Rc> = io_mock.clone(); + GitHub::new(io, config, None, Some(http_downloader)).unwrap() +} + +// The PHP Config mock returns null for `get('github-expose-hostname')`, which is +// falsy and skips the `hostname` process call. A real Config defaults that key to +// true, so the stub seeds false to reproduce the mock's behaviour. +fn build_config() -> Rc> { + ConfigStubBuilder::new() + .with("github-expose-hostname", PhpMixed::Bool(false)) + .build_shared() +} #[test] -#[ignore = "requires getIOMock/getHttpDownloaderMock and getMockBuilder mocks of Config/JsonConfigSource with expects()/willReturn(); no mocking infrastructure exists"] fn test_username_password_authentication_flow() { - todo!() + let (io_mock, _io_guard) = get_io_mock(io_interface::NORMAL).unwrap(); + io_mock + .borrow_mut() + .expects( + vec![ + Expectation::text(MESSAGE), + Expectation::ask("Token (hidden): ", PASSWORD), + ], + false, + ) + .unwrap(); + + let (http_downloader, _http_guard) = get_http_downloader_mock( + vec![expect_full( + format!("https://api.{}/", ORIGIN), + None, + 200, + "{}", + vec![], + )], + true, + HttpDownloaderMockHandler::default(), + ); + + let config = build_config(); + let (auth_source, _) = ConfigSourceMock::new("auth.json"); + let (conf_source, conf_removed) = ConfigSourceMock::new("config.json"); + config.borrow_mut().set_auth_config_source(auth_source); + config.borrow_mut().set_config_source(conf_source); + + let mut github = build_github(&io_mock, config, http_downloader); + + assert!( + github + .authorize_oauth_interactively(ORIGIN, Some(MESSAGE)) + .unwrap() + ); + assert_eq!( + *conf_removed.borrow(), + vec![format!("github-oauth.{}", ORIGIN)] + ); } #[test] -#[ignore = "requires getIOMock/getHttpDownloaderMock and getMockBuilder mocks of Config/JsonConfigSource with expects()/willReturn(); no mocking infrastructure exists"] fn test_username_password_failure() { - todo!() + let (io_mock, _io_guard) = get_io_mock(io_interface::NORMAL).unwrap(); + io_mock + .borrow_mut() + .expects(vec![Expectation::ask("Token (hidden): ", PASSWORD)], false) + .unwrap(); + + let (http_downloader, _http_guard) = get_http_downloader_mock( + vec![expect_full( + format!("https://api.{}/", ORIGIN), + None, + 401, + "", + vec![], + )], + true, + HttpDownloaderMockHandler::default(), + ); + + let config = build_config(); + let (auth_source, _) = ConfigSourceMock::new("auth.json"); + config.borrow_mut().set_auth_config_source(auth_source); + + let mut github = build_github(&io_mock, config, http_downloader); + + assert!(!github.authorize_oauth_interactively(ORIGIN, None).unwrap()); } diff --git a/crates/shirabe/tests/util/http_downloader_test.rs b/crates/shirabe/tests/util/http_downloader_test.rs index 59807a9..078fd57 100644 --- a/crates/shirabe/tests/util/http_downloader_test.rs +++ b/crates/shirabe/tests/util/http_downloader_test.rs @@ -4,16 +4,59 @@ use std::cell::RefCell; use std::rc::Rc; use indexmap::IndexMap; +use shirabe::config::Config; +use shirabe::downloader::TransportException; use shirabe::io::IOInterface; use shirabe::io::buffer_io::BufferIO; +use shirabe::io::io_interface; +use shirabe::util::Platform; use shirabe::util::http_downloader::HttpDownloader; use shirabe_external_packages::symfony::console::output::output_interface::VERBOSITY_NORMAL; use shirabe_php_shim::{PHP_EOL, PhpMixed}; +use crate::config_stub::ConfigStubBuilder; +use crate::io_mock::{Expectation, get_io_mock}; + +// PHP performs a live HTTP get to assert the URL's user:pass is captured via +// setAuthentication. The credential capture happens in `add_job`, before any +// network I/O, so COMPOSER_DISABLE_NETWORK short-circuits the actual request +// (yielding a non-200 TransportException, as PHP's live 404 would) while the +// setAuthentication side effect still runs and is verified through the IOMock. #[test] -#[ignore = "asserts IOInterface mock ->expects()->method('setAuthentication')->with(...) and performs a live HTTP get; no mock infrastructure exists"] +#[serial_test::serial] fn test_capture_authentication_params_from_url() { - todo!() + let (io_mock, _io_guard) = get_io_mock(io_interface::NORMAL).unwrap(); + io_mock + .borrow_mut() + .expects( + vec![Expectation::auth( + "github.com", + "user", + Some("pass".to_string()), + )], + false, + ) + .unwrap(); + + // The PHP Config mock returns [] for github-domains/gitlab-domains. + let config: Rc> = ConfigStubBuilder::new() + .with("github-domains", PhpMixed::Array(IndexMap::new())) + .with("gitlab-domains", PhpMixed::Array(IndexMap::new())) + .build_shared(); + + let io: Rc> = io_mock.clone(); + + Platform::put_env("COMPOSER_DISABLE_NETWORK", "1"); + let mut fs = HttpDownloader::new(io, config, IndexMap::new(), false); + Platform::clear_env("COMPOSER_DISABLE_NETWORK"); + + if let Err(e) = fs.get( + "https://user:pass@github.com/composer/composer/404", + IndexMap::new(), + ) && let Some(te) = e.downcast_ref::() + { + assert_ne!(200, te.get_code()); + } } #[test] -- cgit v1.3.1