diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-06-27 17:21:00 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-06-27 17:26:28 +0900 |
| commit | e98823e599eb375b30037cc714710e3309d927d1 (patch) | |
| tree | e1689ec164086798fc46aa6a58d1e914cf4873b2 /crates/shirabe/src | |
| parent | 20f620bdd0b5764ed2e9812dc0772f907b0d6f29 (diff) | |
| download | php-shirabe-e98823e599eb375b30037cc714710e3309d927d1.tar.gz php-shirabe-e98823e599eb375b30037cc714710e3309d927d1.tar.zst php-shirabe-e98823e599eb375b30037cc714710e3309d927d1.zip | |
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) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe/src')
21 files changed, 549 insertions, 127 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<String, Vec<AnySecurityAdvisory>>, #[serde(rename = "ignored-advisories", skip_serializing_if = "Option::is_none")] ignored_advisories: Option<&'a IndexMap<String, Vec<AnySecurityAdvisory>>>, @@ -31,9 +32,46 @@ struct AuditJsonReport<'a> { skip_serializing_if = "Option::is_none" )] unreachable_repositories: Option<&'a Vec<String>>, + #[serde(serialize_with = "serialize_abandoned_field")] abandoned: IndexMap<String, Option<String>>, } +/// 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<S, V>(map: &IndexMap<String, V>, serializer: S) -> Result<S::Ok, S::Error> +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<S>( + map: &&IndexMap<String, Vec<AnySecurityAdvisory>>, + serializer: S, +) -> Result<S::Ok, S::Error> +where + S: serde::Serializer, +{ + serialize_php_array(*map, serializer) +} + +fn serialize_abandoned_field<S>( + map: &IndexMap<String, Option<String>>, + serializer: S, +) -> Result<S::Ok, S::Error> +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<std::cell::RefCell<Filesystem>>, read_only: bool, + /// Test-only seam. Always `None` in production; configured via [`Cache::__set_mock`]. + mock: Option<CacheMock>, +} + +/// 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<GcFinderMock>, + /// When `Some`, `gc_is_necessary` returns it verbatim. + pub gc_is_necessary: Option<bool>, + /// When `Some`, `gc` records each `(ttl, max_size)` call here and skips its real body. + pub gc_calls: Option<Vec<(i64, i64)>>, +} + +/// 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<std::path::PathBuf>, + /// Entries yielded by `get_finder().sort_by_accessed_time().get_iterator()`. + pub by_accessed_time: Vec<std::path::PathBuf>, } /// @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<std::path::PathBuf> { + 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<std::path::PathBuf> { + 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<ArchiveCommandTestHooks>, +} + +/// 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<i64>, + archive_calls: Vec<ArchiveCallRecord>, +} + +/// For testing only: the scalar arguments captured from a stubbed `archive` call. +#[derive(Debug, Clone)] +pub struct ArchiveCallRecord { + pub package_name: Option<String>, + pub version: Option<String>, + pub format: String, + pub dest: String, + pub file_name: Option<String>, + 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<RefCell<dyn InputInterface>>, output: Rc<RefCell<dyn OutputInterface>>, ) -> 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<ArchiveCallRecord> { + 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<i64> { + 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<PackageInterfaceHandle>, + ) -> Result<Vec<String>> { + 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<Perforce>, + pub(crate) perforce: Option<Box<dyn PerforceInterface>>, } 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<String, PhpMixed> { repository.get_repo_config().clone() } - pub fn set_perforce(&mut self, perforce: Perforce) { + pub fn set_perforce(&mut self, perforce: Box<dyn PerforceInterface>) { 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<std::cell::RefCell<dyn IOInterface>>, ) -> 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<std::cell::RefCell<dyn IOInterface>>, pub(crate) r#type: Option<String>, pub(crate) filesystem: std::rc::Rc<std::cell::RefCell<Filesystem>>, - pub(crate) binary_installer: BinaryInstaller, + pub(crate) binary_installer: Box<dyn BinaryInstallerInterface>, } 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<dyn BinaryInstallerInterface> = 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<dyn BinaryInstallerInterface>) { + 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<std::cell::RefCell<RepositoryManager>>, config: std::rc::Rc<std::cell::RefCell<Config>>, - version_guesser: VersionGuesser, + version_guesser: Box<dyn VersionGuesserInterface>, io: Option<std::rc::Rc<std::cell::RefCell<dyn IOInterface>>>, } @@ -32,19 +33,19 @@ impl RootPackageLoader { manager: std::rc::Rc<std::cell::RefCell<RepositoryManager>>, config: std::rc::Rc<std::cell::RefCell<Config>>, parser: Option<VersionParser>, - version_guesser: Option<VersionGuesser>, + version_guesser: Option<Box<dyn VersionGuesserInterface>>, io: Option<std::rc::Rc<std::cell::RefCell<dyn IOInterface>>>, ) -> 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<String, PhpMixed>, + path: &str, + ) -> Result<Option<VersionData>>; + + fn get_root_version_from_env(&self) -> Result<String>; +} + /// 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<String, PhpMixed>, + path: &str, + ) -> Result<Option<VersionData>> { + VersionGuesser::guess_version(self, package_config, path) + } + + fn get_root_version_from_env(&self) -> Result<String> { + VersionGuesser::get_root_version_from_env(self) + } +} + #[derive(Debug)] pub struct FeatureVersionResult { pub version: Option<String>, 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<std::cell::RefCell<RepositorySet>>, + repository_set: std::rc::Rc<std::cell::RefCell<dyn RepositorySetInterface>>, platform_constraints: IndexMap<String, Vec<AnyConstraint>>, parser: Option<VersionParser>, } impl VersionSelector { pub fn new( - repository_set: std::rc::Rc<std::cell::RefCell<RepositorySet>>, + repository_set: std::rc::Rc<std::cell::RefCell<dyn RepositorySetInterface>>, platform_repo: Option<&mut crate::repository::PlatformRepository>, ) -> anyhow::Result<Self> { let mut platform_constraints: IndexMap<String, Vec<AnyConstraint>> = 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<Option<Option<String>>> = 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<String>; +} + #[derive(Debug)] pub struct HhvmDetector { executable_finder: Option<ExecutableFinder>, @@ -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<String> { + fn get_version(&mut self) -> Option<String> { 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<String>) -> bool; + fn get_constant(&self, constant_name: &str, class: Option<String>) -> 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>) -> PhpMixed; + fn has_class(&self, class: &str) -> bool; + fn construct(&self, class: &str, arguments: Vec<PhpMixed>) -> Result<PhpMixed>; + fn get_extensions(&self) -> Vec<String>; + fn get_extension_version(&self, extension: &str) -> String; + fn get_extension_info(&self, extension: &str) -> Result<String>; +} + #[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<String>) -> 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<String>) -> 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<dyn Fn(Vec<PhpMixed>) -> PhpMixed>, - arguments: Vec<PhpMixed>, - ) -> PhpMixed { - callable(arguments) + fn invoke(&self, callable: PhpMixed, arguments: Vec<PhpMixed>) -> 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<PhpMixed>) -> Result<PhpMixed> { + fn construct(&self, class: &str, arguments: Vec<PhpMixed>) -> Result<PhpMixed> { if arguments.is_empty() { Ok(instantiate_class(class, vec![])) } else { @@ -50,20 +62,26 @@ impl Runtime { } } - pub fn get_extensions(&self) -> Vec<String> { + fn get_extensions(&self) -> Vec<String> { 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<String> { + fn get_extension_info(&self, extension: &str) -> Result<String> { // 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<String> = 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<String, PhpMixed> = 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<string, string> $installPaths fn generate_installed_versions( &mut self, - installation_manager: &InstallationManager, + installation_manager: &dyn InstallationManagerInterface, install_paths: &IndexMap<String, Option<String>>, 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<VersionParser>, pub(crate) overrides: IndexMap<String, PlatformOverride>, pub(crate) disabled_packages: IndexMap<String, CompletePackageInterfaceHandle>, - pub(crate) runtime: Runtime, - pub(crate) hhvm_detector: HhvmDetector, + pub(crate) runtime: Box<dyn RuntimeInterface>, + pub(crate) hhvm_detector: Box<dyn HhvmDetectorInterface>, } impl PlatformRepository { @@ -65,11 +67,12 @@ impl PlatformRepository { pub fn new4( packages: Vec<PackageInterfaceHandle>, overrides: IndexMap<String, PhpMixed>, - runtime: Option<Runtime>, - hhvm_detector: Option<HhvmDetector>, + runtime: Option<Box<dyn RuntimeInterface>>, + hhvm_detector: Option<Box<dyn HhvmDetectorInterface>>, ) -> anyhow::Result<Self> { - let runtime = runtime.unwrap_or(Runtime); - let hhvm_detector = hhvm_detector.unwrap_or_else(|| HhvmDetector::new(None, None)); + let runtime: Box<dyn RuntimeInterface> = runtime.unwrap_or_else(|| Box::new(Runtime)); + let hhvm_detector: Box<dyn HhvmDetectorInterface> = + hhvm_detector.unwrap_or_else(|| Box::new(HhvmDetector::new(None, None))); let mut overrides_map: IndexMap<String, PlatformOverride> = 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::<PhpMixed, anyhow::Error>(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<AnyConstraint>, + flags: i64, + ) -> anyhow::Result<Vec<BasePackageHandle>>; +} + +impl RepositorySetInterface for RepositorySet { + fn find_packages( + &self, + name: &str, + constraint: Option<AnyConstraint>, + flags: i64, + ) -> anyhow::Result<Vec<BasePackageHandle>> { + RepositorySet::find_packages(self, name, constraint, flags) + } +} + #[derive(Debug)] pub struct SecurityAdvisoriesResult { pub advisories: IndexMap<String, Vec<AnySecurityAdvisory>>, 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<Perforce>, + pub(crate) perforce: Option<Box<dyn PerforceInterface>>, } 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<dyn PerforceInterface>) { + 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<std::rc::Rc<std::cell::RefCell<ProcessExecutor>>>, + /// Test-only seam. Always `None` in production; configured via [`Filesystem::__set_mock`]. + mock: Option<FilesystemMock>, +} + +/// 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<bool>, + 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<std::rc::Rc<std::cell::RefCell<ProcessExecutor>>>) -> 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<Path>) -> anyhow::Result<bool> { 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<bool> { + 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<String> = 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::<crate::downloader::TransportException>() - .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<String>) -> Result<()>; + fn cleanup_client_spec(&mut self); + fn get_commit_logs(&mut self, from_reference: &str, to_reference: &str) -> Option<String>; + fn get_file_content(&mut self, file: &str, identifier: &str) -> Option<String>; + fn get_branches(&mut self) -> IndexMap<String, String>; + fn get_tags(&mut self) -> IndexMap<String, String>; + fn get_user(&self) -> Option<String>; + fn get_composer_information( + &mut self, + identifier: &str, + ) -> Result<Option<IndexMap<String, PhpMixed>>>; +} + +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<String>) -> 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<String> { + Perforce::get_commit_logs(self, from_reference, to_reference) + } + + fn get_file_content(&mut self, file: &str, identifier: &str) -> Option<String> { + Perforce::get_file_content(self, file, identifier) + } + + fn get_branches(&mut self) -> IndexMap<String, String> { + Perforce::get_branches(self) + } + + fn get_tags(&mut self) -> IndexMap<String, String> { + Perforce::get_tags(self) + } + + fn get_user(&self) -> Option<String> { + Perforce::get_user(self) + } + + fn get_composer_information( + &mut self, + identifier: &str, + ) -> Result<Option<IndexMap<String, PhpMixed>>> { + Perforce::get_composer_information(self, identifier) + } +} |
