diff options
Diffstat (limited to 'crates/shirabe/tests/plugin/e2e_extension_installer_test.rs')
| -rw-r--r-- | crates/shirabe/tests/plugin/e2e_extension_installer_test.rs | 163 |
1 files changed, 163 insertions, 0 deletions
diff --git a/crates/shirabe/tests/plugin/e2e_extension_installer_test.rs b/crates/shirabe/tests/plugin/e2e_extension_installer_test.rs new file mode 100644 index 00000000..c78af3cd --- /dev/null +++ b/crates/shirabe/tests/plugin/e2e_extension_installer_test.rs @@ -0,0 +1,163 @@ +//! Real-plugin E2E compatibility check: upstream Composer and Shirabe each +//! run `install` on a pristine copy of the pinned phpstan/extension-installer fixture project +//! at the same filesystem path, and every produced artifact (composer.lock and the whole +//! vendor tree, including the plugin-generated GeneratedConfig.php) must match byte for byte. +//! +//! Prerequisites: the PHP runtime, the Composer checkout, and the plugin under test in +//! `fixtures/e2e/ext/` — run `fixtures/e2e/fetch` once to populate it. The test skips while +//! any of these is missing. Test runs themselves are offline: the fixture project resolves +//! everything from local repositories. + +use crate::plugin_installer_test::{lock_php_worker, php_runtime_available}; +use indexmap::IndexMap; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/plugin/fixtures/e2e") +} + +/// The upstream Composer checkout used as the comparison oracle (and as the PHP runtime of +/// the worker). Absent checkout means the oracle cannot run; the test returns early, +/// following the convention of the non-mock tests in `shirabe-php-rpc`. +fn upstream_composer_bin() -> Option<PathBuf> { + let root = match std::env::var("SHIRABE_COMPOSER_PHP_DIR") { + Ok(dir) => PathBuf::from(dir), + Err(_) => Path::new(env!("CARGO_MANIFEST_DIR")).join("../../composer"), + }; + let bin = root.join("bin/composer"); + if bin.is_file() && root.join("vendor/autoload.php").is_file() { + Some(bin) + } else { + None + } +} + +fn copy_dir(from: &Path, to: &Path) { + std::fs::create_dir_all(to).unwrap(); + for entry in std::fs::read_dir(from).unwrap() { + let entry = entry.unwrap(); + let target = to.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_dir(&entry.path(), &target); + } else { + std::fs::copy(entry.path(), &target).unwrap(); + } + } +} + +/// Every file under `dir` as relative path => contents. +fn snapshot_tree(dir: &Path) -> IndexMap<String, Vec<u8>> { + let mut files = IndexMap::new(); + fn walk(root: &Path, dir: &Path, files: &mut IndexMap<String, Vec<u8>>) { + for entry in std::fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_dir() { + walk(root, &entry.path(), files); + } else { + let relative = entry + .path() + .strip_prefix(root) + .unwrap() + .to_str() + .unwrap() + .to_string(); + files.insert(relative, std::fs::read(entry.path()).unwrap()); + } + } + } + walk(dir, dir, &mut files); + files +} + +struct InstallRun { + exit_code: i32, + /// The plugin's own IO lines, order preserved (progress rendering differs between the two + /// implementations and is not compared). + plugin_output: Vec<String>, + artifacts: IndexMap<String, Vec<u8>>, +} + +fn run_install(work: &Path, program: &str, args: &[&str]) -> InstallRun { + let project = work.join("project"); + copy_dir(&fixture_dir(), work); + let output = std::process::Command::new(program) + .args(args) + .arg("install") + .current_dir(&project) + .env("COMPOSER_HOME", work.join("home")) + .env("COMPOSER_CACHE_DIR", work.join("cache")) + .env("COMPOSER_NO_INTERACTION", "1") + .output() + .unwrap(); + let mut artifacts = snapshot_tree(&project.join("vendor")); + artifacts.insert( + "composer.lock".to_string(), + std::fs::read(project.join("composer.lock")).unwrap_or_default(), + ); + // Directory walk order is filesystem-dependent; a canonical order keys the comparison. + artifacts.sort_keys(); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let plugin_output = text + .lines() + .filter(|line| line.starts_with("phpstan/extension-installer:") || line.starts_with("> ")) + .map(str::to_string) + .collect(); + // The next run reuses the same path so absolute paths embedded in the artifacts + // (GeneratedConfig.php's install_path) compare byte for byte. + std::fs::remove_dir_all(&project).unwrap(); + InstallRun { + exit_code: output.status.code().unwrap_or(-1), + plugin_output, + artifacts, + } +} + +#[test] +fn test_extension_installer_install_matches_upstream_composer() { + if !php_runtime_available() { + return; + } + let Some(composer_bin) = upstream_composer_bin() else { + return; + }; + // Populated by `fixtures/e2e/fetch` (network, one-off). + if !fixture_dir() + .join("ext/phpstan-extension-installer-1.4.3/src/Plugin.php") + .is_file() + { + return; + } + let _worker = lock_php_worker(); + + let work = TempDir::new().unwrap(); + let composer_bin = composer_bin.to_str().unwrap().to_string(); + let upstream = run_install(work.path(), "php", &[&composer_bin]); + let shirabe = run_install(work.path(), env!("CARGO_BIN_EXE_shirabe"), &[]); + + assert_eq!(0, upstream.exit_code, "upstream Composer must succeed"); + assert_eq!(upstream.exit_code, shirabe.exit_code); + assert_eq!(upstream.plugin_output, shirabe.plugin_output); + assert_eq!( + vec![ + "phpstan/extension-installer: Extensions installed", + "> acme/extension: installed", + "> acme/phpstan-tools: not supported", + ], + upstream.plugin_output + ); + + let upstream_files: Vec<&String> = upstream.artifacts.keys().collect(); + let shirabe_files: Vec<&String> = shirabe.artifacts.keys().collect(); + assert_eq!(upstream_files, shirabe_files); + for (path, contents) in &upstream.artifacts { + assert_eq!( + contents, &shirabe.artifacts[path], + "artifact `{path}` differs between upstream Composer and Shirabe" + ); + } +} |
