diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-06-28 22:22:01 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-06-28 22:22:01 +0900 |
| commit | 1e72ee3ddaf581753e30b39dfc33b48d44a25ef7 (patch) | |
| tree | 65f92b778f853bf25b6d20080b369f9b91773c52 /crates/shirabe | |
| parent | 4728dbcddc1de54a2c8f2e90c8a1f80098485e22 (diff) | |
| download | php-shirabe-1e72ee3ddaf581753e30b39dfc33b48d44a25ef7.tar.gz php-shirabe-1e72ee3ddaf581753e30b39dfc33b48d44a25ef7.tar.zst php-shirabe-1e72ee3ddaf581753e30b39dfc33b48d44a25ef7.zip | |
test(installer): port InstallerTest unit and integration harness
Port composer/tests/Composer/Test/InstallerTest.php. testInstaller (the
provideInstaller cases) is fully ported and passes; the three integration
tests port doTestIntegration in full (the .test fixture loader, FactoryMock,
the in-process console Application with install/update commands, and the
PHPUnit assertStringMatchesFormat matcher) and remain #[ignore]'d since the
install pipeline is not yet executable end-to-end.
Add test-only `__`-seams to the concrete types the test depends on, since
their consumers (e.g. Locker takes the concrete InstallationManager) and the
subclass-style mocks have no trait to mock: InstallationManager (recording
mock + as_any), Factory (__create_mock), VersionGuesser, and
InstalledFilesystemRepository. The production path (mock: false) is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe')
| -rw-r--r-- | crates/shirabe/src/command/archive_command.rs | 2 | ||||
| -rw-r--r-- | crates/shirabe/src/factory.rs | 124 | ||||
| -rw-r--r-- | crates/shirabe/src/installer/installation_manager.rs | 148 | ||||
| -rw-r--r-- | crates/shirabe/src/package/version/version_guesser.rs | 24 | ||||
| -rw-r--r-- | crates/shirabe/src/repository/installed_filesystem_repository.rs | 26 | ||||
| -rw-r--r-- | crates/shirabe/tests/installer_test.rs | 1207 |
6 files changed, 1509 insertions, 22 deletions
diff --git a/crates/shirabe/src/command/archive_command.rs b/crates/shirabe/src/command/archive_command.rs index 91e8036..ce1d496 100644 --- a/crates/shirabe/src/command/archive_command.rs +++ b/crates/shirabe/src/command/archive_command.rs @@ -288,7 +288,7 @@ impl ArchiveCommand { composer_archive_manager_ref = composer_archive_manager.borrow_mut(); &mut *composer_archive_manager_ref } else { - let factory = Factory; + let factory = Factory::default(); let process = std::rc::Rc::new(std::cell::RefCell::new(ProcessExecutor::new(None))); let http_downloader = std::rc::Rc::new(std::cell::RefCell::new( Factory::create_http_downloader(io.clone(), config, indexmap::IndexMap::new())?, diff --git a/crates/shirabe/src/factory.rs b/crates/shirabe/src/factory.rs index b3d478c..dcbdf5d 100644 --- a/crates/shirabe/src/factory.rs +++ b/crates/shirabe/src/factory.rs @@ -85,7 +85,12 @@ impl DisablePlugins { } /// Creates a configured instance of composer. -pub struct Factory; +#[derive(Default)] +pub struct Factory { + /// For testing only: when true, the overridable `create_*`/`add_*` hooks behave like + /// `Composer\Test\Mock\FactoryMock`. `false` in production. + mock: bool, +} impl Factory { fn get_home_dir() -> anyhow::Result<String> { @@ -501,7 +506,11 @@ impl Factory { } // Load config and override with local config/auth config - let mut config = Self::create_config(Some(io.clone()), Some(&cwd))?; + let mut config = if self.mock { + Self::__create_config_mock(Some(&cwd))? + } else { + Self::create_config(Some(io.clone()), Some(&cwd))? + }; let is_global = local_config_source != Config::SOURCE_UNKNOWN && realpath(&config.get_str("home")?) == realpath(&dirname(&local_config_source)); config.merge(&local_config_data, &local_config_source); @@ -653,12 +662,22 @@ impl Factory { // load package let parser = VersionParser::new(); - let guesser = VersionGuesser::new( - config.clone(), - process.clone(), - parser.clone(), - Some(io.clone()), - ); + // FactoryMock::loadRootPackage swaps in a VersionGuesserMock (guessVersion returns null). + let guesser = if self.mock { + VersionGuesser::__new_mock( + config.clone(), + process.clone(), + parser.clone(), + Some(io.clone()), + ) + } else { + VersionGuesser::new( + config.clone(), + process.clone(), + parser.clone(), + Some(io.clone()), + ) + }; let mut loader = self.load_root_package( rm.clone(), config.clone(), @@ -890,7 +909,7 @@ impl Factory { disable_plugins: DisablePlugins, disable_scripts: bool, ) -> Option<PartialComposerHandle> { - let factory = Self; + let factory = Self::default(); let config = Self::create_config(Some(io.clone()), None).ok()?; factory.create_global_composer(io, &config, disable_plugins, disable_scripts, true) @@ -904,6 +923,15 @@ impl Factory { root_package: RootPackageInterfaceHandle, process: Option<&std::rc::Rc<std::cell::RefCell<ProcessExecutor>>>, ) { + // FactoryMock::addLocalRepository installs a bare InstalledArrayRepository instead. + if self.mock { + rm.set_local_repository(crate::repository::RepositoryInterfaceHandle::new( + crate::repository::InstalledArrayRepository::new() + .expect("InstalledArrayRepository::new should not fail"), + )); + return; + } + let fs = process .map(|p| std::rc::Rc::new(std::cell::RefCell::new(Filesystem::new(Some(p.clone()))))); @@ -1210,6 +1238,10 @@ impl Factory { io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, event_dispatcher: Option<std::rc::Rc<std::cell::RefCell<EventDispatcher>>>, ) -> InstallationManager { + // FactoryMock::createInstallationManager returns a recording InstallationManagerMock. + if self.mock { + return InstallationManager::__new_mock(r#loop, io, event_dispatcher); + } InstallationManager::new(r#loop, io, event_dispatcher) } @@ -1220,6 +1252,11 @@ impl Factory { io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, process: Option<&std::rc::Rc<std::cell::RefCell<ProcessExecutor>>>, ) { + // FactoryMock::createDefaultInstallers is a noop (no installers registered). + if self.mock { + return; + } + let fs = std::rc::Rc::new(std::cell::RefCell::new(Filesystem::new( process.map(std::rc::Rc::clone), ))); @@ -1307,7 +1344,7 @@ impl Factory { disable_plugins: DisablePlugins, disable_scripts: bool, ) -> anyhow::Result<ComposerHandle> { - let factory = Self; + let factory = Self::default(); // for BC reasons, if a config is passed in either as array or a path that is not the default composer.json path // we disable local plugins as they really should not be loaded from CWD @@ -1338,6 +1375,73 @@ impl Factory { }) } + /// For testing only: equivalent of `Composer\Test\Mock\FactoryMock::create`. Builds the Composer + /// the same way as `create`, but with the FactoryMock overrides enabled (see the `self.mock` + /// branches in `create_composer`/`add_local_repository`/`create_installation_manager`/ + /// `create_default_installers`). + pub fn __create_mock( + io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, + config: Option<LocalConfigInput>, + disable_plugins: DisablePlugins, + disable_scripts: bool, + ) -> anyhow::Result<ComposerHandle> { + let factory = Self { mock: true }; + + let default_composer_file = Self::get_composer_file()?; + let config_is_default = matches!( + config.as_ref(), + Some(LocalConfigInput::Path(p)) if *p == default_composer_file + ); + let disable_plugins = if config.is_some() + && !config_is_default + && matches!(disable_plugins, DisablePlugins::None) + { + DisablePlugins::Local + } else { + disable_plugins + }; + + let composer = + factory.create_composer(io, config, disable_plugins, None, true, disable_scripts)?; + composer.as_full().ok_or_else(|| { + anyhow::anyhow!(RuntimeException { + message: "Composer expected with fullLoad=true".to_string(), + code: 0, + }) + }) + } + + /// For testing only: equivalent of `FactoryMock::createConfig`. Builds a `Config` with a unique + /// temp home and packagist disabled, without loading the global config/auth files. + fn __create_config_mock(cwd: Option<&str>) -> anyhow::Result<Config> { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + + let mut config = Config::new(true, cwd.map(|s| s.to_string())); + + let home = std::env::temp_dir().join(format!( + "shirabe-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::create_dir_all(&home); + + let mut config_section: IndexMap<String, PhpMixed> = IndexMap::new(); + config_section.insert( + "home".to_string(), + PhpMixed::String(home.to_string_lossy().into_owned()), + ); + let mut repositories: IndexMap<String, PhpMixed> = IndexMap::new(); + repositories.insert("packagist".to_string(), PhpMixed::Bool(false)); + + let mut merge: IndexMap<String, PhpMixed> = IndexMap::new(); + merge.insert("config".to_string(), PhpMixed::Array(config_section)); + merge.insert("repositories".to_string(), PhpMixed::Array(repositories)); + config.merge(&merge, Config::SOURCE_UNKNOWN); + + Ok(config) + } + /// If you are calling this in a plugin, you probably should instead use `$composer->getLoop()->getHttpDownloader()` pub fn create_http_downloader( io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, diff --git a/crates/shirabe/src/installer/installation_manager.rs b/crates/shirabe/src/installer/installation_manager.rs index a2cc228..ea6f668 100644 --- a/crates/shirabe/src/installer/installation_manager.rs +++ b/crates/shirabe/src/installer/installation_manager.rs @@ -40,6 +40,19 @@ pub struct InstallationManager { io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, event_dispatcher: Option<std::rc::Rc<std::cell::RefCell<EventDispatcher>>>, output_progress: bool, + /// For testing only: present iff this manager behaves like + /// `Composer\Test\Mock\InstallationManagerMock`, recording operations instead of executing + /// them. `None` in production. + mock: Option<InstallationManagerMockState>, +} + +/// For testing only: recorded operations for the `InstallationManagerMock` behavior. +#[derive(Debug, Default)] +struct InstallationManagerMockState { + installed: Vec<PackageInterfaceHandle>, + updated: Vec<(PackageInterfaceHandle, PackageInterfaceHandle)>, + uninstalled: Vec<PackageInterfaceHandle>, + trace: Vec<String>, } impl InstallationManager { @@ -56,9 +69,55 @@ impl InstallationManager { io, event_dispatcher, output_progress: false, + mock: None, } } + /// For testing only: builds a manager that records operations instead of executing them, + /// mirroring `Composer\Test\Mock\InstallationManagerMock`. + pub fn __new_mock( + loop_: std::rc::Rc<std::cell::RefCell<Loop>>, + io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>, + event_dispatcher: Option<std::rc::Rc<std::cell::RefCell<EventDispatcher>>>, + ) -> Self { + Self { + mock: Some(InstallationManagerMockState::default()), + ..Self::new(loop_, io, event_dispatcher) + } + } + + /// For testing only: the trace of stringified operations recorded by the mock. + pub fn __get_trace(&self) -> Vec<String> { + self.mock + .as_ref() + .map(|m| m.trace.clone()) + .unwrap_or_default() + } + + /// For testing only: packages passed to install (and markAliasInstalled) operations. + pub fn __get_installed_packages(&self) -> Vec<PackageInterfaceHandle> { + self.mock + .as_ref() + .map(|m| m.installed.clone()) + .unwrap_or_default() + } + + /// For testing only: (initial, target) package pairs passed to update operations. + pub fn __get_updated_packages(&self) -> Vec<(PackageInterfaceHandle, PackageInterfaceHandle)> { + self.mock + .as_ref() + .map(|m| m.updated.clone()) + .unwrap_or_default() + } + + /// For testing only: packages passed to uninstall (and markAliasUninstalled) operations. + pub fn __get_uninstalled_packages(&self) -> Vec<PackageInterfaceHandle> { + self.mock + .as_ref() + .map(|m| m.uninstalled.clone()) + .unwrap_or_default() + } + pub fn reset(&mut self) { self.notifiable_packages = IndexMap::new(); FileDownloader::reset_download_metadata(); @@ -126,6 +185,11 @@ impl InstallationManager { repo: &dyn InstalledRepositoryInterface, package: PackageInterfaceHandle, ) -> Result<bool> { + // For testing only (ref InstallationManagerMock::isPackageInstalled). + if self.mock.is_some() { + return Ok(repo.has_package(package)); + } + if let Some(alias) = package.as_alias() { let alias_of: PackageInterfaceHandle = alias.get_alias_of().into(); return Ok( @@ -165,6 +229,70 @@ impl InstallationManager { run_scripts: bool, download_only: bool, ) -> Result<()> { + // For testing only: the mock records each operation and mutates the repo directly, + // skipping the download step (ref InstallationManagerMock::execute). The alias operations' + // repo mutation is inlined (rather than calling mark_alias_*) so `self.mock` can stay + // borrowed across the loop without also borrowing `&self`. + if let Some(mock) = self.mock.as_mut() { + let _ = (dev_mode, run_scripts, download_only); + for operation in operations { + let trace = shirabe_php_shim::strip_tags(&operation.to_string()); + match operation.get_operation_type().as_str() { + "install" => { + let op = operation.as_install_operation().expect("install operation"); + let package = op.get_package(); + mock.installed.push(package.clone()); + mock.trace.push(trace); + repo.add_package(PackageInterfaceHandle::dup(&package)); + } + "update" => { + let op = operation.as_update_operation().expect("update operation"); + let initial = op.get_initial_package().clone(); + let target = op.get_target_package().clone(); + mock.updated.push((initial.clone(), target.clone())); + mock.trace.push(trace); + repo.remove_package(initial); + if !repo.has_package(target.clone()) { + repo.add_package(PackageInterfaceHandle::dup(&target)); + } + } + "uninstall" => { + let op = operation + .as_uninstall_operation() + .expect("uninstall operation"); + let package = op.get_package(); + mock.uninstalled.push(package.clone()); + mock.trace.push(trace); + repo.remove_package(package); + } + "markAliasInstalled" => { + let op = operation + .as_any() + .downcast_ref::<MarkAliasInstalledOperation>() + .expect("markAliasInstalled operation"); + let package = op.get_package(); + mock.installed.push(package.clone().into()); + mock.trace.push(trace); + if !repo.has_package(package.clone().into()) { + repo.add_package(PackageInterfaceHandle::dup(&package.into())); + } + } + "markAliasUninstalled" => { + let op = operation + .as_any() + .downcast_ref::<MarkAliasUninstalledOperation>() + .expect("markAliasUninstalled operation"); + let package = op.get_package(); + mock.uninstalled.push(package.clone().into()); + mock.trace.push(trace); + repo.remove_package(package.into()); + } + other => panic!("unknown operation type: {}", other), + } + } + return Ok(()); + } + // @var array<callable(): ?PromiseInterface<void|null>> $cleanupPromises let mut cleanup_promises: IndexMap< i64, @@ -623,6 +751,11 @@ impl InstallationManager { /// Returns the installation path of a package pub fn get_install_path(&mut self, package: PackageInterfaceHandle) -> Option<String> { + // For testing only (ref InstallationManagerMock::getInstallPath). + if self.mock.is_some() { + return Some(format!("vendor/{}", package.get_name())); + } + let installer = self.get_installer(&package.get_type()).ok()?; installer.get_install_path(package) @@ -633,6 +766,11 @@ impl InstallationManager { } pub fn notify_installs(&mut self, _io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>) { + // For testing only (ref InstallationManagerMock::notifyInstalls is a noop). + if self.mock.is_some() { + return; + } + // TODO(phase-c-promise): PHP collects every http_downloader.add() promise and runs them via // Loop::wait; the single-threaded sync bridge block_on's each notification serially instead. let result: Result<()> = (|| -> Result<()> { @@ -792,6 +930,12 @@ impl InstallationManager { // plugins may swap in a replacement. The interface captures the methods reached through Composer's // accessor and through the `&mut dyn InstallationManagerInterface` references fed from it. pub trait InstallationManagerInterface: std::fmt::Debug { + /// For testing only: lets a test recover the concrete manager (e.g. the recording mock) from a + /// trait object returned by `Composer::get_installation_manager`. + fn as_any(&self) -> &dyn std::any::Any { + unimplemented!("as_any is only implemented for the concrete InstallationManager") + } + fn add_installer(&mut self, installer: Box<dyn InstallerInterface>); fn remove_installer(&mut self, installer: &dyn InstallerInterface); fn disable_plugins(&mut self); @@ -815,6 +959,10 @@ pub trait InstallationManagerInterface: std::fmt::Debug { } impl InstallationManagerInterface for InstallationManager { + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn add_installer(&mut self, installer: Box<dyn InstallerInterface>) { self.add_installer(installer); } diff --git a/crates/shirabe/src/package/version/version_guesser.rs b/crates/shirabe/src/package/version/version_guesser.rs index 8e8cc32..6fba7c4 100644 --- a/crates/shirabe/src/package/version/version_guesser.rs +++ b/crates/shirabe/src/package/version/version_guesser.rs @@ -49,6 +49,10 @@ pub struct VersionGuesser { /// @var IOInterface|null io: Option<std::rc::Rc<std::cell::RefCell<dyn IOInterface>>>, + + /// For testing only: when true, `guess_version` always returns `None`, mirroring + /// `Composer\Test\Mock\VersionGuesserMock`. `false` in production. + mock: bool, } /// PHP: @phpstan-type Version array{version, commit, pretty_version, feature_version?, feature_pretty_version?} @@ -73,6 +77,21 @@ impl VersionGuesser { process, version_parser, io, + mock: false, + } + } + + /// For testing only: builds a guesser whose `guess_version` always returns `None`, mirroring + /// `Composer\Test\Mock\VersionGuesserMock`. + pub fn __new_mock( + config: std::rc::Rc<std::cell::RefCell<Config>>, + process: std::rc::Rc<std::cell::RefCell<ProcessExecutor>>, + version_parser: VersionParser, + io: Option<std::rc::Rc<std::cell::RefCell<dyn IOInterface>>>, + ) -> Self { + Self { + mock: true, + ..Self::new(config, process, version_parser, io) } } @@ -85,6 +104,11 @@ impl VersionGuesser { package_config: &IndexMap<String, PhpMixed>, path: &str, ) -> Result<Option<VersionData>> { + // For testing only (ref VersionGuesserMock::guessVersion returns null). + if self.mock { + return Ok(None); + } + if !function_exists("proc_open") { return Ok(None); } diff --git a/crates/shirabe/src/repository/installed_filesystem_repository.rs b/crates/shirabe/src/repository/installed_filesystem_repository.rs index 3cd21eb..8ce07e4 100644 --- a/crates/shirabe/src/repository/installed_filesystem_repository.rs +++ b/crates/shirabe/src/repository/installed_filesystem_repository.rs @@ -19,6 +19,9 @@ use shirabe_semver::constraint::AnyConstraint; #[derive(Debug)] pub struct InstalledFilesystemRepository { inner: FilesystemRepository, + /// For testing only: when true, `reload` and `write` are no-ops, mirroring + /// `Composer\Test\Mock\InstalledFilesystemRepositoryMock`. `false` in production. + mock: bool, } impl InstalledFilesystemRepository { @@ -35,6 +38,21 @@ impl InstalledFilesystemRepository { root_package, filesystem, )?, + mock: false, + }) + } + + /// For testing only: builds a repository whose `reload`/`write` are no-ops, mirroring + /// `Composer\Test\Mock\InstalledFilesystemRepositoryMock`. + pub fn __new_mock( + repository_file: JsonFile, + dump_versions: bool, + root_package: Option<RootPackageInterfaceHandle>, + filesystem: Option<std::rc::Rc<std::cell::RefCell<Filesystem>>>, + ) -> Result<Self> { + Ok(Self { + mock: true, + ..Self::new(repository_file, dump_versions, root_package, filesystem)? }) } @@ -59,6 +77,10 @@ impl WritableRepositoryInterface for InstalledFilesystemRepository { dev_mode: bool, installation_manager: &mut crate::installer::InstallationManager, ) -> anyhow::Result<()> { + // For testing only (ref InstalledFilesystemRepositoryMock::write is a noop). + if self.mock { + return Ok(()); + } self.inner.write(dev_mode, installation_manager) } @@ -75,6 +97,10 @@ impl WritableRepositoryInterface for InstalledFilesystemRepository { } fn reload(&mut self) -> anyhow::Result<()> { + // For testing only (ref InstalledFilesystemRepositoryMock::reload is a noop). + if self.mock { + return Ok(()); + } self.inner.reload() } diff --git a/crates/shirabe/tests/installer_test.rs b/crates/shirabe/tests/installer_test.rs index e2d4939..a89209b 100644 --- a/crates/shirabe/tests/installer_test.rs +++ b/crates/shirabe/tests/installer_test.rs @@ -1,10 +1,60 @@ //! ref: composer/tests/Composer/Test/InstallerTest.php -// These are large end-to-end installer integration cases driven by fixtures and a mocked -// Composer/IO/repositories; the full install pipeline (and constraint parsing through a -// look-around regex) is not ported. +#[path = "common/config_stub.rs"] +mod config_stub; +#[path = "common/test_case.rs"] +mod test_case; +use config_stub::ConfigStubBuilder; +use test_case::{get_package, get_version_constraint}; + +use indexmap::IndexMap; +use std::cell::RefCell; +use std::rc::Rc; + +use shirabe::advisory::{AuditConfig, Auditor}; +use shirabe::autoload::{AutoloadGeneratorInterface, ClassLoader}; +use shirabe::config::Config; +use shirabe::console::application::ApplicationHandle; +use shirabe::dependency_resolver::{Transaction, UpdateAllowTransitiveDeps}; +use shirabe::downloader::{DownloadManagerInterface, DownloaderInterface}; +use shirabe::event_dispatcher::{Callable, EventDispatcherInterface, EventInterface}; +use shirabe::factory::{DisablePlugins, Factory, LocalConfigInput}; +use shirabe::filter::platform_requirement_filter::{ + PlatformRequirementFilterFactory, PlatformRequirementFilterInterface, +}; +use shirabe::installer::{InstallationManager, Installer}; +use shirabe::io::IOInterface; +use shirabe::io::buffer_io::BufferIO; +use shirabe::json::JsonFile; +use shirabe::package::dumper::ArrayDumper; +use shirabe::package::{ + Link, Locker, LockerInterface, PackageInterfaceHandle, RootPackageHandle, + RootPackageInterfaceHandle, +}; +use shirabe::repository::{ + ArrayRepository, InstalledArrayRepository, InstalledRepositoryInterface, + RepositoryInterfaceHandle, RepositoryManager, RepositoryManagerInterface, +}; +use shirabe::util::http_downloader::HttpDownloader; +use shirabe::util::r#loop::Loop; use shirabe::util::platform::Platform; +use shirabe::util::process_executor::ProcessExecutor; +use shirabe_class_map_generator::class_map::ClassMap; +use shirabe_external_packages::composer::pcre::preg::Preg; +use shirabe_external_packages::symfony::console::command::command::Command as SymfonyCommand; +use shirabe_external_packages::symfony::console::command::command::CommandData; +use shirabe_external_packages::symfony::console::input::input_argument::InputArgument; +use shirabe_external_packages::symfony::console::input::input_interface::InputInterface; +use shirabe_external_packages::symfony::console::input::input_option::InputOption; +use shirabe_external_packages::symfony::console::input::string_input::StringInput; +use shirabe_external_packages::symfony::console::output::output_interface::{ + OutputInterface, VERBOSITY_NORMAL, +}; +use shirabe_external_packages::symfony::console::output::stream_output::StreamOutput; +use shirabe_php_shim::{PREG_SPLIT_DELIM_CAPTURE, PhpMixed}; +use shirabe_semver::VersionParser; +use shirabe_semver::constraint::AnyConstraint; // The chdir back to prevCwd (cwd management) and removeDirectory of tempComposerHome (a // path produced by the unported install pipeline) are not ported; only the env clears are. @@ -21,30 +71,1165 @@ impl Drop for TearDown { } } +// PHP mocks `Composer\Downloader\DownloadManager` with getMockBuilder; PHPUnit mocks are permissive +// (every method returns null), so the Rust equivalent is a no-op stub over the trait seam. +#[derive(Debug)] +struct StubDownloadManager; + +#[async_trait::async_trait(?Send)] +impl DownloadManagerInterface for StubDownloadManager { + fn set_prefer_source(&mut self, _prefer_source: bool) {} + fn set_prefer_dist(&mut self, _prefer_dist: bool) {} + fn get_downloader_for_package( + &self, + _package: PackageInterfaceHandle, + ) -> anyhow::Result<Option<Rc<RefCell<dyn DownloaderInterface>>>> { + Ok(None) + } + async fn download( + &self, + _package: PackageInterfaceHandle, + _target_dir: &str, + _prev_package: Option<PackageInterfaceHandle>, + ) -> anyhow::Result<Option<PhpMixed>> { + Ok(None) + } + async fn prepare( + &self, + _type: &str, + _package: PackageInterfaceHandle, + _target_dir: &str, + _prev_package: Option<PackageInterfaceHandle>, + ) -> anyhow::Result<Option<PhpMixed>> { + Ok(None) + } + async fn install( + &self, + _package: PackageInterfaceHandle, + _target_dir: &str, + ) -> anyhow::Result<Option<PhpMixed>> { + Ok(None) + } + async fn update( + &self, + _initial: PackageInterfaceHandle, + _target: PackageInterfaceHandle, + _target_dir: &str, + ) -> anyhow::Result<Option<PhpMixed>> { + Ok(None) + } + async fn remove( + &self, + _package: PackageInterfaceHandle, + _target_dir: &str, + ) -> anyhow::Result<Option<PhpMixed>> { + Ok(None) + } + async fn cleanup( + &self, + _type: &str, + _package: PackageInterfaceHandle, + _target_dir: &str, + _prev_package: Option<PackageInterfaceHandle>, + ) -> anyhow::Result<Option<PhpMixed>> { + Ok(None) + } +} + +// PHP mocks `Composer\EventDispatcher\EventDispatcher` with disableOriginalConstructor()->getMock(); +// a permissive no-op stub mirrors the PHPUnit mock. +#[derive(Debug)] +struct StubEventDispatcher; + +impl EventDispatcherInterface for StubEventDispatcher { + fn dispatch( + &mut self, + _event_name: Option<&str>, + _event: Option<&mut dyn EventInterface>, + ) -> anyhow::Result<i64> { + Ok(0) + } + fn dispatch_script( + &mut self, + _event_name: &str, + _dev_mode: bool, + _additional_args: Vec<String>, + _flags: IndexMap<String, PhpMixed>, + ) -> anyhow::Result<i64> { + Ok(0) + } + fn dispatch_installer_event( + &mut self, + _event_name: &str, + _dev_mode: bool, + _execute_operations: bool, + _transaction: Transaction, + ) -> anyhow::Result<i64> { + 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 + } +} + +// PHP mocks `Composer\Autoload\AutoloadGenerator` with disableOriginalConstructor()->getMock(); +// a permissive no-op stub mirrors the PHPUnit mock. +#[derive(Debug)] +struct StubAutoloadGenerator; + +impl AutoloadGeneratorInterface for StubAutoloadGenerator { + fn set_dev_mode(&mut self, _dev_mode: bool) {} + fn set_class_map_authoritative(&mut self, _class_map_authoritative: bool) {} + fn set_apcu(&mut self, _apcu: bool, _apcu_prefix: Option<String>) {} + fn set_run_scripts(&mut self, _run_scripts: bool) {} + fn set_dry_run(&mut self, _dry_run: bool) {} + fn set_platform_requirement_filter( + &mut self, + _platform_requirement_filter: Rc<dyn PlatformRequirementFilterInterface>, + ) { + } + #[allow(clippy::too_many_arguments)] + fn dump( + &mut self, + _config: &Config, + _local_repo: &mut dyn InstalledRepositoryInterface, + _root_package: RootPackageInterfaceHandle, + _installation_manager: &mut dyn shirabe::installer::InstallationManagerInterface, + _target_dir: &str, + _scan_psr_packages: bool, + _suffix: Option<String>, + _locker: Option<&mut dyn LockerInterface>, + _strict_ambiguous: bool, + ) -> anyhow::Result<ClassMap> { + Ok(ClassMap::new()) + } + fn build_package_map( + &self, + _installation_manager: &mut dyn shirabe::installer::InstallationManagerInterface, + _root_package: RootPackageInterfaceHandle, + _packages: Vec<PackageInterfaceHandle>, + ) -> anyhow::Result<Vec<(PackageInterfaceHandle, Option<String>)>> { + Ok(vec![]) + } + fn parse_autoloads( + &self, + _package_map: Vec<(PackageInterfaceHandle, Option<String>)>, + _root_package: RootPackageInterfaceHandle, + _filtered_dev_packages: PhpMixed, + ) -> IndexMap<String, PhpMixed> { + IndexMap::new() + } + fn create_loader( + &self, + _autoloads: &IndexMap<String, PhpMixed>, + _vendor_dir: Option<String>, + ) -> ClassLoader { + unimplemented!("create_loader is not reached by the installer test path") + } +} + +/// ref: TestCase::getPackage with class `Composer\Package\RootPackage`. +fn root_package(name: &str, version: &str) -> RootPackageHandle { + let normalized = VersionParser.normalize(version, None).unwrap(); + RootPackageHandle::new(name.to_string(), normalized, version.to_string()) +} + +/// ref: `new Link($source, $target, $constraint, $type, $constraint->getPrettyString())`. +fn link(source: &str, target: &str, constraint: AnyConstraint, r#type: &str) -> Link { + let pretty = constraint.get_pretty_string(); + Link::new( + source.to_string(), + target.to_string(), + constraint, + Some(r#type.to_string()), + pretty, + ) +} + +/// One row of `provideInstaller`. +struct InstallerCase { + root_package: RootPackageHandle, + repositories: Vec<RepositoryInterfaceHandle>, + expected_install: Vec<PackageInterfaceHandle>, + expected_update: Vec<(PackageInterfaceHandle, PackageInterfaceHandle)>, + expected_uninstall: Vec<PackageInterfaceHandle>, +} + +/// ref: InstallerTest::provideInstaller +fn provide_installer() -> Vec<InstallerCase> { + let mut cases = vec![]; + + // when A requires B and B requires A, and A is a non-published root package + // the install of B should succeed + let a = root_package("A", "1.0.0"); + a.set_requires(IndexMap::from([( + "b".to_string(), + link( + "A", + "B", + get_version_constraint("=", "1.0.0"), + Link::TYPE_REQUIRE, + ), + )])); + let b = get_package("B", "1.0.0"); + b.as_complete_package() + .unwrap() + .__set_requires(IndexMap::from([( + "a".to_string(), + link( + "B", + "A", + get_version_constraint("=", "1.0.0"), + Link::TYPE_REQUIRE, + ), + )])); + + cases.push(InstallerCase { + root_package: a, + repositories: vec![RepositoryInterfaceHandle::new( + ArrayRepository::new(vec![b.clone()]).unwrap(), + )], + expected_install: vec![b], + expected_update: vec![], + expected_uninstall: vec![], + }); + + // #480: when A requires B and B requires A, and A is a published root package + // only B should be installed, as A is the root + let a = root_package("A", "1.0.0"); + a.set_requires(IndexMap::from([( + "b".to_string(), + link( + "A", + "B", + get_version_constraint("=", "1.0.0"), + Link::TYPE_REQUIRE, + ), + )])); + let b = get_package("B", "1.0.0"); + b.as_complete_package() + .unwrap() + .__set_requires(IndexMap::from([( + "a".to_string(), + link( + "B", + "A", + get_version_constraint("=", "1.0.0"), + Link::TYPE_REQUIRE, + ), + )])); + + cases.push(InstallerCase { + root_package: a.clone(), + repositories: vec![RepositoryInterfaceHandle::new( + ArrayRepository::new(vec![a.into(), b.clone()]).unwrap(), + )], + expected_install: vec![b], + expected_update: vec![], + expected_uninstall: vec![], + }); + + // TODO why are there not more cases with uninstall/update? + cases +} + +/// ref: InstallerTest::makePackagesComparable +fn make_packages_comparable( + packages: &[PackageInterfaceHandle], +) -> Vec<IndexMap<String, PhpMixed>> { + let dumper = ArrayDumper::new(); + packages.iter().map(|p| dumper.dump(p.clone())).collect() +} + #[test] -#[ignore = "requires PHPUnit getMockBuilder mocks of DownloadManager/Config/EventDispatcher/HttpDownloader/JsonFile/AutoloadGenerator plus an unported InstallationManagerMock and the provideInstaller data provider; no mocking infrastructure exists"] fn test_installer() { let _tear_down = TearDown; - todo!() + + for case in provide_installer() { + let io_buffer = Rc::new(RefCell::new( + BufferIO::new(String::new(), VERBOSITY_NORMAL, None).unwrap(), + )); + let io: Rc<RefCell<dyn IOInterface>> = io_buffer.clone(); + + let config = ConfigStubBuilder::new() + .with("vendor-dir", PhpMixed::String("foo".to_string())) + .with("lock", PhpMixed::Bool(true)) + .with("notify-on-install", PhpMixed::Bool(true)) + .build_shared(); + + let download_manager: Rc<RefCell<dyn DownloadManagerInterface>> = + Rc::new(RefCell::new(StubDownloadManager)); + + let http_downloader = Rc::new(RefCell::new(HttpDownloader::__new_mock( + io.clone(), + config.clone(), + ))); + + let mut repository_manager = RepositoryManager::new( + io.clone(), + config.clone(), + http_downloader.clone(), + None, + None, + ); + repository_manager.set_local_repository(RepositoryInterfaceHandle::new( + InstalledArrayRepository::new().unwrap(), + )); + for repository in &case.repositories { + repository_manager.add_repository(repository.clone()); + } + let repository_manager: Rc<RefCell<dyn RepositoryManagerInterface>> = + Rc::new(RefCell::new(repository_manager)); + + let r#loop = Rc::new(RefCell::new(Loop::new(http_downloader.clone(), None))); + let installation_manager: Rc<RefCell<InstallationManager>> = Rc::new(RefCell::new( + InstallationManager::__new_mock(r#loop, io.clone(), None), + )); + + // emulate a writable lock file: a real JsonFile over a fresh temp path (initially absent, so + // the installer falls back to an update; PHP uses an in-memory JsonFile mock instead). + let lock_dir = tempfile::TempDir::new().unwrap(); + let lock_path = lock_dir.path().join("composer.lock"); + let lock_json = + JsonFile::new(lock_path.to_string_lossy().into_owned(), None, None).unwrap(); + let process = Rc::new(RefCell::new(ProcessExecutor::new(Some(io.clone())))); + let locker: Rc<RefCell<dyn LockerInterface>> = Rc::new(RefCell::new(Locker::new( + io.clone(), + lock_json, + installation_manager.clone(), + "{}", + process, + ))); + + let autoload_generator: Rc<RefCell<dyn AutoloadGeneratorInterface>> = + Rc::new(RefCell::new(StubAutoloadGenerator)); + + let root_package: RootPackageInterfaceHandle = + RootPackageInterfaceHandle::dup(&case.root_package.clone().into()); + let mut installer = Installer::new( + io.clone(), + config.clone(), + root_package, + download_manager, + repository_manager, + locker, + installation_manager.clone(), + Rc::new(RefCell::new(StubEventDispatcher)), + autoload_generator, + ); + installer.set_audit_config( + AuditConfig::from_config(&mut config.borrow_mut(), false, Auditor::FORMAT_SUMMARY) + .unwrap(), + ); + let result = installer.run().unwrap(); + + let output = io_buffer.borrow().get_output().replace('\r', ""); + assert_eq!(0, result, "{}", output); + + let installed = installation_manager.borrow().__get_installed_packages(); + assert_eq!( + make_packages_comparable(&case.expected_install), + make_packages_comparable(&installed), + "{}", + output + ); + + let updated = installation_manager.borrow().__get_updated_packages(); + assert_eq!(case.expected_update, updated); + + let uninstalled = installation_manager.borrow().__get_uninstalled_packages(); + assert_eq!(case.expected_uninstall, uninstalled); + } +} + +/// ref: PHPUnit assertStringMatchesFormat's StringMatchesFormatDescription::createPatternFromFormat. +fn create_pattern_from_format(format: &str) -> String { + let escaped = regex::escape(format); + let bytes = escaped.as_bytes(); + let mut out = String::from("(?s)^"); + let mut i = 0; + while i < bytes.len() { + // regex::escape turns "%" into "%" (it is not special) so the format codes survive intact. + if bytes[i] == b'%' && i + 1 < bytes.len() { + let replacement: Option<&str> = match bytes[i + 1] { + b'%' => Some("%"), + b'e' => Some("\\/"), + b's' => Some("[^\\r\\n]+"), + b'S' => Some("[^\\r\\n]*"), + b'a' => Some(".+"), + b'A' => Some(".*"), + b'w' => Some("\\s*"), + b'i' => Some("[+-]?\\d+"), + b'd' => Some("\\d+"), + b'x' => Some("[0-9a-fA-F]+"), + b'f' => Some("[+-]?\\.?\\d+\\.?\\d*(?:[Ee][+-]?\\d+)?"), + b'c' => Some("."), + _ => None, + }; + if let Some(replacement) = replacement { + out.push_str(replacement); + i += 2; + continue; + } + } + out.push(escaped[i..].chars().next().unwrap()); + i += escaped[i..].chars().next().unwrap().len_utf8(); + } + out.push('$'); + out +} + +/// ref: PHPUnit self::assertStringMatchesFormat. +fn assert_string_matches_format(format: &str, subject: &str, context: &str) { + let pattern = create_pattern_from_format(format); + let re = regex::Regex::new(&pattern) + .unwrap_or_else(|e| panic!("invalid format pattern {}: {}", pattern, e)); + assert!( + re.is_match(subject), + "output does not match format.\n--- format ---\n{}\n--- output ---\n{}\n--- context ---\n{}", + format, + subject, + context + ); +} + +#[derive(Debug, Clone)] +enum ExpectLock { + /// No EXPECT-LOCK section (`[]` in PHP); the lock is not asserted. + Unset, + /// EXPECT-LOCK is the literal string "false"; the lock must never be written. + Never, + /// EXPECT-LOCK holds an expected lock JSON. + Json(serde_json::Value), +} + +#[derive(Debug, Clone)] +enum ExpectResult { + ExitCode(i64), + /// EXPECT-EXCEPTION: the class-string of an expected exception. + Exception(String), +} + +#[derive(Debug, Clone)] +struct IntegrationCase { + file: String, + message: String, + condition: Option<String>, + composer: serde_json::Value, + lock: Option<serde_json::Value>, + installed: Option<serde_json::Value>, + run: String, + expect_lock: ExpectLock, + expect_installed: Option<serde_json::Value>, + expect_output: Option<String>, + expect_output_optimized: Option<String>, + expect: String, + expect_result: ExpectResult, +} + +fn fixtures_dir(path: &str) -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../composer/tests/Composer/Test/Fixtures") + .join(path) + .canonicalize() + .unwrap() +} + +/// ref: InstallerTest::readTestFile +fn read_test_file( + file: &std::path::Path, + fixtures_dir: &std::path::Path, +) -> IndexMap<String, String> { + let contents = std::fs::read_to_string(file).unwrap(); + let tokens = Preg::split4( + r"#(?:^|\n*)--([A-Z-]+)--\n#", + &contents, + -1, + PREG_SPLIT_DELIM_CAPTURE, + ); + + let section_info: [(&str, bool); 13] = [ + ("TEST", true), + ("CONDITION", false), + ("COMPOSER", true), + ("LOCK", false), + ("INSTALLED", false), + ("RUN", true), + ("EXPECT-LOCK", false), + ("EXPECT-INSTALLED", false), + ("EXPECT-OUTPUT", false), + ("EXPECT-OUTPUT-OPTIMIZED", false), + ("EXPECT-EXIT-CODE", false), + ("EXPECT-EXCEPTION", false), + ("EXPECT", true), + ]; + let known: indexmap::IndexSet<&str> = section_info.iter().map(|(k, _)| *k).collect(); + + let mut section: Option<String> = None; + let mut data: IndexMap<String, String> = IndexMap::new(); + for token in tokens { + if section.is_none() && token.is_empty() { + continue; // skip leading blank + } + if section.is_none() { + assert!( + known.contains(token.as_str()), + "The test file \"{}\" must not contain a section named \"{}\".", + file.display(), + token + ); + section = Some(token); + continue; + } + let sec = section.take().unwrap(); + data.insert(sec, token); + } + + for (sec, required) in section_info { + if required { + assert!( + data.contains_key(sec), + "The test file \"{}\" must have a section named \"{}\".", + file.display(), + sec + ); + } + } + let _ = fixtures_dir; + data +} + +fn collect_test_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) { + for entry in std::fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.is_dir() { + collect_test_files(&path, out); + } else if path.extension().and_then(|e| e.to_str()) == Some("test") { + out.push(path); + } + } +} + +/// ref: InstallerTest::loadIntegrationTests +fn load_integration_tests(path: &str) -> Vec<IntegrationCase> { + let dir = fixtures_dir(path); + let mut files = Vec::new(); + collect_test_files(&dir, &mut files); + files.sort(); + + let mut tests = Vec::new(); + for file in files { + let test_data = read_test_file(&file, &dir); + + // skip 64bit related tests on 32bit (usize is 64-bit here, so this never triggers). + if test_data + .get("EXPECT-OUTPUT") + .map(|s| s.contains("php-64bit")) + .unwrap_or(false) + && (usize::BITS == 32) + { + continue; + } + + let message = test_data["TEST"].clone(); + let condition = test_data + .get("CONDITION") + .filter(|s| !s.is_empty()) + .cloned(); + let mut composer: serde_json::Value = serde_json::from_str(&test_data["COMPOSER"]).unwrap(); + + if let Some(repositories) = composer.get_mut("repositories") { + let fixtures_str = dir.to_string_lossy().replace('\\', "/"); + let rewrite = |repo: &mut serde_json::Value| { + if repo.get("type").and_then(|t| t.as_str()) != Some("composer") { + return; + } + if let Some(url) = repo.get("url").and_then(|u| u.as_str()) + && Preg::is_match(r"{^file://[^/]}", url) + { + let new_url = format!("file://{}/{}", fixtures_str, &url[7..]); + repo["url"] = serde_json::Value::String(new_url); + } + }; + match repositories { + serde_json::Value::Array(list) => list.iter_mut().for_each(rewrite), + serde_json::Value::Object(map) => map.values_mut().for_each(rewrite), + _ => {} + } + } + + let lock = test_data.get("LOCK").filter(|s| !s.is_empty()).map(|s| { + let mut lock: serde_json::Value = serde_json::from_str(s).unwrap(); + if lock.get("hash").is_none() { + let encoded = JsonFile::encode_with_options( + &composer, + shirabe::json::JsonEncodeOptions::none(), + ); + let hash = format!("{:x}", md5::compute(encoded.as_bytes())); + lock["hash"] = serde_json::Value::String(hash); + } + lock + }); + + let installed = test_data + .get("INSTALLED") + .filter(|s| !s.is_empty()) + .map(|s| serde_json::from_str(s).unwrap()); + + let run = test_data["RUN"].clone(); + + let expect_lock = match test_data.get("EXPECT-LOCK").filter(|s| !s.is_empty()) { + None => ExpectLock::Unset, + Some(s) if s == "false" => ExpectLock::Never, + Some(s) => ExpectLock::Json(serde_json::from_str(s).unwrap()), + }; + + let expect_installed = test_data + .get("EXPECT-INSTALLED") + .filter(|s| !s.is_empty()) + .map(|s| serde_json::from_str(s).unwrap()); + + let expect_output = test_data.get("EXPECT-OUTPUT").cloned(); + let expect_output_optimized = test_data.get("EXPECT-OUTPUT-OPTIMIZED").cloned(); + let expect = test_data["EXPECT"].clone(); + + let expect_result = + if let Some(exc) = test_data.get("EXPECT-EXCEPTION").filter(|s| !s.is_empty()) { + assert!( + test_data + .get("EXPECT-EXIT-CODE") + .filter(|s| !s.is_empty()) + .is_none(), + "EXPECT-EXCEPTION and EXPECT-EXIT-CODE are mutually exclusive" + ); + ExpectResult::Exception(exc.clone()) + } else if let Some(code) = test_data.get("EXPECT-EXIT-CODE").filter(|s| !s.is_empty()) { + ExpectResult::ExitCode(code.trim().parse().unwrap()) + } else { + ExpectResult::ExitCode(0) + }; + + tests.push(IntegrationCase { + file: file + .strip_prefix(&dir) + .unwrap() + .to_string_lossy() + .into_owned(), + message, + condition, + composer, + lock, + installed, + run, + expect_lock, + expect_installed, + expect_output, + expect_output_optimized, + expect, + expect_result, + }); + } + + tests +} + +/// ref: the inline `eval($condition)` in doTestIntegration, ported for the known fixture conditions. +fn evaluate_condition(condition: &str) -> bool { + match condition.trim() { + // putenv() returns true on success, so these conditions always run the test (with the env set). + "putenv('COMPOSER_FUND=1')" => { + Platform::put_env("COMPOSER_FUND", "1"); + true + } + "putenv('COMPOSER_FUND=0')" => { + Platform::put_env("COMPOSER_FUND", "0"); + true + } + // HHVM is never defined under the Rust port. + "!defined('HHVM_VERSION')" => true, + // TODO(phase-d): unported CONDITION expression (PHP eval has no Rust equivalent). + other => panic!("// TODO(phase-d): unported CONDITION: {}", other), + } +} + +fn opt_bool(input: &dyn InputInterface, name: &str) -> bool { + input + .get_option(name) + .ok() + .and_then(|m| m.as_bool()) + .unwrap_or(false) +} + +/// ref: `$ignorePlatformReqs = true === getOption('ignore-platform-reqs') ?: (getOption('ignore-platform-req') ?: false)`. +fn ignore_platform_reqs_value(input: &dyn InputInterface) -> PhpMixed { + if opt_bool(input, "ignore-platform-reqs") { + return PhpMixed::Bool(true); + } + let list = input + .get_option("ignore-platform-req") + .unwrap_or(PhpMixed::Bool(false)); + match &list { + PhpMixed::List(items) if !items.is_empty() => list, + PhpMixed::Array(map) if !map.is_empty() => list, + _ => PhpMixed::Bool(false), + } +} + +fn write_json(path: &std::path::Path, value: &serde_json::Value) { + std::fs::write(path, serde_json::to_string_pretty(value).unwrap()).unwrap(); +} + +/// ref: InstallerTest::doTestIntegration +fn do_test_integration(case: &IntegrationCase, expect_output: Option<&str>) { + if let Some(condition) = &case.condition + && !evaluate_condition(condition) + { + return; // markTestSkipped + } + + let io_buffer = Rc::new(RefCell::new( + BufferIO::new(String::new(), VERBOSITY_NORMAL, None).unwrap(), + )); + let io: Rc<RefCell<dyn IOInterface>> = io_buffer.clone(); + + let is_exception = matches!(case.expect_result, ExpectResult::Exception(_)); + + // Create Composer mock object according to configuration (FactoryMock::create). + let composer_str = serde_json::to_string(&case.composer).unwrap(); + let composer_data = JsonFile::parse_json(Some(&composer_str), None) + .unwrap() + .as_array() + .cloned() + .unwrap_or_default(); + let composer = Factory::__create_mock( + io.clone(), + Some(LocalConfigInput::Data(composer_data)), + DisablePlugins::None, + false, + ) + .unwrap(); + + // installed.json mock: a real JsonFile over a temp file holding $installed, wrapped in the + // no-op InstalledFilesystemRepositoryMock. + let installed_dir = tempfile::TempDir::new().unwrap(); + let installed_path = installed_dir.path().join("installed.json"); + write_json( + &installed_path, + case.installed.as_ref().unwrap_or(&serde_json::json!([])), + ); + let installed_json = + JsonFile::new(installed_path.to_string_lossy().into_owned(), None, None).unwrap(); + let local_repo = shirabe::repository::InstalledFilesystemRepository::__new_mock( + installed_json, + false, + None, + None, + ) + .unwrap(); + let repository_manager = composer.borrow().get_repository_manager(); + repository_manager + .borrow_mut() + .set_local_repository(RepositoryInterfaceHandle::new(local_repo)); + + // emulate a writable lock file: a real composer.lock over a temp path. + let lock_dir = tempfile::TempDir::new().unwrap(); + let lock_path = lock_dir.path().join("composer.lock"); + if let Some(lock) = &case.lock { + write_json(&lock_path, lock); + } + let lock_before = std::fs::read_to_string(&lock_path).ok(); + let lock_json = JsonFile::new(lock_path.to_string_lossy().into_owned(), None, None).unwrap(); + + // The Locker needs a concrete InstallationManager; build a fresh recording mock just for it. The + // asserted trace comes from the composer's own installation manager (read via as_any below). + let process = Rc::new(RefCell::new(ProcessExecutor::new(Some(io.clone())))); + let locker_loop = composer.borrow().get_loop(); + let locker_im = Rc::new(RefCell::new(InstallationManager::__new_mock( + locker_loop, + io.clone(), + None, + ))); + let contents = serde_json::to_string(&case.composer).unwrap(); + let locker = Locker::new(io.clone(), lock_json, locker_im, &contents, process); + composer + .borrow_mut() + .set_locker(Rc::new(RefCell::new(locker))); + + composer + .borrow_mut() + .set_autoload_generator(Rc::new(RefCell::new(StubAutoloadGenerator))); + composer + .borrow_mut() + .set_event_dispatcher(Rc::new(RefCell::new(StubEventDispatcher))); + + let installer = Rc::new(RefCell::new(Installer::create( + io.clone(), + &composer.upcast(), + ))); + + // Application with inline install/update commands (setCode closures). + let application = ApplicationHandle::new("Composer".to_string(), "".to_string()).unwrap(); + application.set_catch_exceptions(false); + + let run_result: Rc<RefCell<Option<anyhow::Result<i64>>>> = Rc::new(RefCell::new(None)); + + let install = Rc::new(RefCell::new(CommandData::new(Some("install".to_string())))); + { + let install_ref = install.borrow(); + install_ref + .add_option( + "ignore-platform-reqs", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "", + PhpMixed::Null, + ) + .unwrap(); + install_ref + .add_option( + "ignore-platform-req", + PhpMixed::Null, + Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), + "", + PhpMixed::Null, + ) + .unwrap(); + install_ref + .add_option( + "no-dev", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "", + PhpMixed::Null, + ) + .unwrap(); + install_ref + .add_option( + "dry-run", + PhpMixed::Null, + Some(InputOption::VALUE_NONE), + "", + PhpMixed::Null, + ) + .unwrap(); + let installer_cl = installer.clone(); + let composer_cl = composer.clone(); + let run_result_cl = run_result.clone(); + install_ref.set_code(Box::new(move |input, _output| { + let ignore = ignore_platform_reqs_value(input); + let mut inst = installer_cl.borrow_mut(); + inst.set_dev_mode(!opt_bool(input, "no-dev")) + .set_dry_run(opt_bool(input, "dry-run")) + .set_platform_requirement_filter( + PlatformRequirementFilterFactory::from_bool_or_list(ignore).unwrap(), + ) + .set_audit_config( + AuditConfig::from_config( + &mut composer_cl.borrow().get_config().borrow_mut(), + false, + Auditor::FORMAT_SUMMARY, + ) + .unwrap(), + ); + let r = inst.run(); + let code = match &r { + Ok(c) => *c, + Err(_) => 1, + }; + *run_result_cl.borrow_mut() = Some(r); + PhpMixed::Int(code) + })); + } + application + .add(install.clone() as Rc<RefCell<dyn SymfonyCommand>>) + .unwrap(); + + let update = Rc::new(RefCell::new(CommandData::new(Some("update".to_string())))); + { + let update_ref = update.borrow(); + for (name, mode) in [ + ("ignore-platform-reqs", InputOption::VALUE_NONE), + ("no-dev", InputOption::VALUE_NONE), + ("no-install", InputOption::VALUE_NONE), + ("dry-run", InputOption::VALUE_NONE), + ("lock", InputOption::VALUE_NONE), + ("with-all-dependencies", InputOption::VALUE_NONE), + ("with-dependencies", InputOption::VALUE_NONE), + ("minimal-changes", InputOption::VALUE_NONE), + ("prefer-stable", InputOption::VALUE_NONE), + ("prefer-lowest", InputOption::VALUE_NONE), + ] { + update_ref + .add_option(name, PhpMixed::Null, Some(mode), "", PhpMixed::Null) + .unwrap(); + } + update_ref + .add_option( + "ignore-platform-req", + PhpMixed::Null, + Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY), + "", + PhpMixed::Null, + ) + .unwrap(); + update_ref + .add_argument( + "packages", + Some(InputArgument::IS_ARRAY | InputArgument::OPTIONAL), + "", + PhpMixed::Null, + ) + .unwrap(); + let installer_cl = installer.clone(); + let composer_cl = composer.clone(); + let run_result_cl = run_result.clone(); + update_ref.set_code(Box::new(move |input, _output| { + let packages: Vec<String> = + match input.get_argument("packages").unwrap_or(PhpMixed::Null) { + PhpMixed::List(items) => items + .into_iter() + .filter_map(|v| v.as_string().map(|s| s.to_string())) + .collect(), + _ => vec![], + }; + let filtered: Vec<String> = packages + .iter() + .filter(|p| !["lock", "nothing", "mirrors"].contains(&p.as_str())) + .cloned() + .collect(); + let update_mirrors = opt_bool(input, "lock") || filtered.len() != packages.len(); + + let update_allow_transitive = if opt_bool(input, "with-all-dependencies") { + UpdateAllowTransitiveDeps::UpdateListedWithTransitiveDeps + } else if opt_bool(input, "with-dependencies") { + UpdateAllowTransitiveDeps::UpdateListedWithTransitiveDepsNoRootRequire + } else { + UpdateAllowTransitiveDeps::UpdateOnlyListed + }; + + let ignore = ignore_platform_reqs_value(input); + + let mut inst = installer_cl.borrow_mut(); + inst.set_dev_mode(!opt_bool(input, "no-dev")) + .set_update(true) + .set_install(!opt_bool(input, "no-install")) + .set_dry_run(opt_bool(input, "dry-run")) + .set_update_mirrors(update_mirrors) + .set_update_allow_list(filtered) + .set_update_allow_transitive_dependencies(update_allow_transitive) + .unwrap() + .set_prefer_stable(opt_bool(input, "prefer-stable")) + .set_prefer_lowest(opt_bool(input, "prefer-lowest")) + .set_platform_requirement_filter( + PlatformRequirementFilterFactory::from_bool_or_list(ignore).unwrap(), + ) + .set_audit_config( + AuditConfig::from_config( + &mut composer_cl.borrow().get_config().borrow_mut(), + false, + Auditor::FORMAT_SUMMARY, + ) + .unwrap(), + ) + .set_minimal_update(opt_bool(input, "minimal-changes")); + let r = inst.run(); + let code = match &r { + Ok(c) => *c, + Err(_) => 1, + }; + *run_result_cl.borrow_mut() = Some(r); + PhpMixed::Int(code) + })); + } + application + .add(update.clone() as Rc<RefCell<dyn SymfonyCommand>>) + .unwrap(); + + assert!( + Preg::is_match(r"{^(install|update)\b}", &case.run), + "The run command only supports install and update" + ); + + let app_output_stream = shirabe_php_shim::php_fopen_resource("php://memory", "w+"); + let app_output = StreamOutput::new(app_output_stream.clone(), None, None, None) + .unwrap() + .expect("php://memory is a valid stream"); + let mut string_input = StringInput::new(&format!("{} -vvv", case.run)).unwrap(); + string_input.set_interactive(false); + let input: Rc<RefCell<dyn InputInterface>> = Rc::new(RefCell::new(string_input)); + let output: Rc<RefCell<dyn OutputInterface>> = Rc::new(RefCell::new(app_output)); + + let app_run = application.run(Some(input), Some(output)); + + let output_string = io_buffer.borrow().get_output().replace('\r', ""); + + // Shouldn't check output and results if an exception was expected by this point. + if is_exception { + let ExpectResult::Exception(_) = &case.expect_result else { + unreachable!() + }; + let normalized = case.expect.replace('\n', shirabe_php_shim::PHP_EOL); + let normalized = normalized.trim_end(); + let err = match run_result.borrow().as_ref() { + Some(Err(e)) => format!("{}", e), + _ => app_run + .as_ref() + .err() + .map(|e| format!("{}", e)) + .unwrap_or_default(), + }; + assert!( + err.contains(normalized), + "expected exception message containing:\n{}\n--- got ---\n{}", + normalized, + err + ); + return; + } + + let result = match run_result.borrow().as_ref() { + Some(Ok(c)) => *c, + Some(Err(e)) => panic!("installer run failed: {}\n{}", e, output_string), + None => app_run.unwrap_or(-1) as i64, + }; + + let ExpectResult::ExitCode(expect_result) = &case.expect_result else { + unreachable!() + }; + shirabe_php_shim::rewind(&app_output_stream); + let app_output_contents = + shirabe_php_shim::stream_get_contents(&app_output_stream).unwrap_or_default(); + assert_eq!( + *expect_result, result, + "{}{}", + output_string, app_output_contents + ); + + if let ExpectLock::Json(expect_lock) = &case.expect_lock { + let actual = std::fs::read_to_string(&lock_path).unwrap(); + let mut actual_lock: serde_json::Value = serde_json::from_str(&actual).unwrap(); + if let Some(obj) = actual_lock.as_object_mut() { + for k in ["hash", "content-hash", "_readme", "plugin-api-version"] { + obj.remove(k); + } + } + let mut expect_lock = expect_lock.clone(); + // PHP turns the empty-array sentinel into stdClass; serde compares {} vs [] strictly, so + // normalize the known object-valued keys to {} when they are empty. + if let Some(obj) = expect_lock.as_object_mut() { + for k in ["stability-flags", "platform", "platform-dev"] { + if obj.get(k) == Some(&serde_json::json!([])) { + obj.insert(k.to_string(), serde_json::json!({})); + } + } + } + assert_eq!(expect_lock, actual_lock); + } else if let ExpectLock::Never = &case.expect_lock { + let lock_after = std::fs::read_to_string(&lock_path).ok(); + assert_eq!(lock_before, lock_after, "lock file must not be written"); + } + + if let Some(expect_installed) = &case.expect_installed { + let dumper = ArrayDumper::new(); + let local_repo = repository_manager.borrow().get_local_repository(); + let mut actual_installed: Vec<IndexMap<String, PhpMixed>> = local_repo + .get_canonical_packages() + .unwrap() + .into_iter() + .map(|package| { + let mut dumped = dumper.dump(package); + dumped.shift_remove("version_normalized"); + dumped + }) + .collect(); + actual_installed.sort_by( + |a: &IndexMap<String, PhpMixed>, b: &IndexMap<String, PhpMixed>| { + let an = a + .get("name") + .and_then(|m| m.as_string()) + .map(|s| s.to_string()) + .unwrap_or_default(); + let bn = b + .get("name") + .and_then(|m| m.as_string()) + .map(|s| s.to_string()) + .unwrap_or_default(); + an.cmp(&bn) + }, + ); + // Faithful comparison would dump expect_installed through the same shape; we compare the + // serialized forms so the assertion still fails loudly on divergence. + let actual_json = serde_json::to_value( + actual_installed + .iter() + .map(php_mixed_map_to_json) + .collect::<Vec<_>>(), + ) + .unwrap(); + assert_eq!(expect_installed, &actual_json); + } + + // trace from the composer's recording InstallationManager. + let im_handle = composer.borrow().get_installation_manager(); + let im_ref = im_handle.borrow(); + let trace = im_ref + .as_any() + .downcast_ref::<InstallationManager>() + .expect("composer installation manager is the recording mock") + .__get_trace(); + assert_eq!(case.expect.trim_end(), trace.join("\n")); + + if let Some(expect_output) = expect_output + && !expect_output.is_empty() + { + let output = Preg::replace(r"{^ - .*?\.ini$}m", "__inilist__", &output_string); + let output = Preg::replace(r"{(__inilist__\r?\n)+}", "__inilist__\n", &output); + assert_string_matches_format(expect_output.trim_end(), output.trim_end(), &output_string); + } +} + +fn php_mixed_map_to_json(map: &IndexMap<String, PhpMixed>) -> serde_json::Value { + serde_json::to_value(map).unwrap_or(serde_json::Value::Null) } #[test] -#[ignore = "delegates to unported do_test_integration which needs FactoryMock, InstalledFilesystemRepositoryMock, the loadIntegrationTests .test-fixture loader and a symfony console Application; none exist in the Rust port"] +#[ignore = "ported; exercises the full install pipeline which is not yet executable end-to-end (execute_batch / repository / autoload stubs), so cases are expected to fail at runtime"] fn test_slow_integration() { let _tear_down = TearDown; - todo!() + for case in load_integration_tests("installer-slow/") { + Platform::clear_env("COMPOSER_FUND"); + Platform::put_env("COMPOSER_POOL_OPTIMIZER", "0"); + let expect_output = case.expect_output.clone(); + do_test_integration(&case, expect_output.as_deref()); + } } #[test] -#[ignore = "delegates to unported do_test_integration which needs FactoryMock, InstalledFilesystemRepositoryMock, the loadIntegrationTests .test-fixture loader and a symfony console Application; none exist in the Rust port"] +#[ignore = "ported; exercises the full install pipeline which is not yet executable end-to-end (execute_batch / repository / autoload stubs), so cases are expected to fail at runtime"] fn test_integration_with_pool_optimizer() { let _tear_down = TearDown; - todo!() + for case in load_integration_tests("installer/") { + Platform::clear_env("COMPOSER_FUND"); + Platform::put_env("COMPOSER_POOL_OPTIMIZER", "1"); + let expect_output = case + .expect_output_optimized + .clone() + .filter(|s| !s.is_empty()) + .or_else(|| case.expect_output.clone()); + do_test_integration(&case, expect_output.as_deref()); + } } #[test] -#[ignore = "delegates to unported do_test_integration which needs FactoryMock, InstalledFilesystemRepositoryMock, the loadIntegrationTests .test-fixture loader and a symfony console Application; none exist in the Rust port"] +#[ignore = "ported; exercises the full install pipeline which is not yet executable end-to-end (execute_batch / repository / autoload stubs), so cases are expected to fail at runtime"] fn test_integration_with_raw_pool() { let _tear_down = TearDown; - todo!() + for case in load_integration_tests("installer/") { + Platform::clear_env("COMPOSER_FUND"); + Platform::put_env("COMPOSER_POOL_OPTIMIZER", "0"); + let expect_output = case.expect_output.clone(); + do_test_integration(&case, expect_output.as_deref()); + } } |
