diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-06 02:51:52 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-06 02:51:52 +0900 |
| commit | 27e81d5e5ca0a89eb176a65b1d9f186658b16f50 (patch) | |
| tree | 3e43d18287eb1b4b64ffb7f77575de010245de79 /crates/shirabe/tests | |
| parent | 1df28c4f702045a33865d29d00ef430d8138579b (diff) | |
| download | php-shirabe-27e81d5e5ca0a89eb176a65b1d9f186658b16f50.tar.gz php-shirabe-27e81d5e5ca0a89eb176a65b1d9f186658b16f50.tar.zst php-shirabe-27e81d5e5ca0a89eb176a65b1d9f186658b16f50.zip | |
test(plugin): cover composer/installers beyond a fresh install
A single install leaves the installer contract half tested: it never reaches
`update`, never runs over an already-installed tree, and never reaches the
plugin's own `uninstall()` override — the one chaining onto the promise
`LibraryInstaller::uninstall` returns. Nor does it touch the configuration
surface real projects use, `installer-paths` and `installer-name`, which the
plugin reads back through the package proxy.
All three now compare command output as well as the resulting tree against
upstream Composer. Progress bar frames are dropped from that comparison:
Shirabe renders them differently for every install, including projects with
no plugin at all, so they say nothing about the plugin under test.
With those covered the plugin joins the verified list in the README.
Diffstat (limited to 'crates/shirabe/tests')
| -rw-r--r-- | crates/shirabe/tests/plugin/e2e_installers_test.rs | 198 |
1 files changed, 169 insertions, 29 deletions
diff --git a/crates/shirabe/tests/plugin/e2e_installers_test.rs b/crates/shirabe/tests/plugin/e2e_installers_test.rs index 36e99d77..67c51b5c 100644 --- a/crates/shirabe/tests/plugin/e2e_installers_test.rs +++ b/crates/shirabe/tests/plugin/e2e_installers_test.rs @@ -1,6 +1,6 @@ -//! composer/installers E2E compatibility check: upstream Composer and Shirabe each install a -//! project whose packages are placed by the plugin's `LibraryInstaller` subclass, and the -//! resulting project trees are compared. +//! composer/installers E2E compatibility check: upstream Composer and Shirabe each run the same +//! command sequences on a project whose packages are placed by the plugin's `LibraryInstaller` +//! subclass, and the resulting output and project trees are compared. //! //! The plugin is fetched by `fixtures/e2e-installers/fetch` into a git-ignored directory; the //! test skips itself while that directory, the PHP runtime or the Composer checkout is missing. @@ -20,8 +20,17 @@ fn plugin_fetched() -> bool { .is_file() } +/// The two implementations under comparison: upstream Composer first, Shirabe second. +fn implementations(composer_bin: &str) -> [(&str, Vec<&str>); 2] { + [ + ("php", vec![composer_bin]), + (env!("CARGO_BIN_EXE_shirabe"), vec![]), + ] +} + struct CommandRun { exit_code: i32, + stdout: Vec<String>, } fn run_command(work: &Path, program: &str, prefix_args: &[&str], args: &[&str]) -> CommandRun { @@ -38,9 +47,26 @@ fn run_command(work: &Path, program: &str, prefix_args: &[&str], args: &[&str]) .unwrap(); CommandRun { exit_code: output.status.code().unwrap_or(-1), + stdout: normalize_output(&String::from_utf8_lossy(&output.stdout), work), } } +/// Output lines the two implementations must agree on: the working directory (which the plugin +/// prints absolutely when it deletes a package) is replaced by a placeholder, and progress bar +/// frames are dropped — Shirabe renders those differently from upstream for every install, +/// including projects with no plugin at all, so they say nothing about this plugin. +fn normalize_output(stdout: &str, work: &Path) -> Vec<String> { + let work = work.to_str().unwrap(); + stdout + .lines() + .filter(|line| { + let trimmed = line.trim_start(); + !(trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains('[')) + }) + .map(|line| line.replace(work, "<work>")) + .collect() +} + /// Every file under `dir` as (relative path, contents), so two project trees compare as a whole. fn tree(dir: &Path) -> Vec<(String, String)> { let mut files = Vec::new(); @@ -67,13 +93,10 @@ fn collect(root: &Path, dir: &Path, files: &mut Vec<(String, String)>) { } } -/// Runs `install` in a fresh copy of the fixture and returns the run plus the resulting tree. -fn install(program: &str, prefix: &[&str]) -> (CommandRun, Vec<(String, String)>, TempDir) { - let work = TempDir::new().unwrap(); - copy_dir(&fixture_dir(), work.path()); - let run = run_command(work.path(), program, prefix, &["install"]); - let tree = tree(&work.path().join("project")); - (run, tree, work) +fn edit(path: &Path, from: &str, to: &str) { + let text = std::fs::read_to_string(path).unwrap(); + assert!(text.contains(from), "{path:?} does not contain {from:?}"); + std::fs::write(path, text.replace(from, to)).unwrap(); } #[test] @@ -87,27 +110,144 @@ fn test_composer_installers_matches_upstream_composer() { let _worker = lock_php_worker(); let composer_bin = composer_bin.to_str().unwrap().to_string(); - let (upstream, upstream_tree, _upstream_work) = install("php", &[composer_bin.as_str()]); - let (shirabe, shirabe_tree, _shirabe_work) = install(env!("CARGO_BIN_EXE_shirabe"), &[]); + let mut results = Vec::new(); + for (program, prefix) in &implementations(&composer_bin) { + let work = TempDir::new().unwrap(); + copy_dir(&fixture_dir(), work.path()); + let run = run_command(work.path(), program, prefix, &["install"]); + let tree = tree(&work.path().join("project")); + results.push((run, tree, work)); + } + let [upstream, shirabe] = <[_; 2]>::try_from(results).ok().unwrap(); - assert_eq!(0, upstream.exit_code, "upstream install must succeed"); - assert_eq!(upstream.exit_code, shirabe.exit_code); + assert_eq!(0, upstream.0.exit_code, "upstream install must succeed"); + assert_eq!(upstream.0.exit_code, shirabe.0.exit_code); + assert_eq!(upstream.0.stdout, shirabe.0.stdout); - // The paths alone carry the plugin's whole contribution: `acme/hello-module` and - // `acme/hello-theme` land under modules/ and themes/ rather than vendor/, which only the - // plugin-provided installer's getInstallPath can decide. - let paths = |files: &[(String, String)]| -> Vec<String> { - files - .iter() - .map(|(path, _)| path.clone()) - .filter(|path| !path.starts_with("vendor/composer/installers/")) - .collect() - }; + // `acme/hello-module` and `acme/hello-theme` land under modules/ and themes/ rather than + // vendor/, which only the plugin-provided installer's getInstallPath can decide. assert!( - paths(&shirabe_tree).contains(&"modules/hello-module/composer.json".to_string()), - "the plugin-provided installer must place the module outside vendor/: {:?}", - paths(&shirabe_tree) + shirabe + .1 + .iter() + .any(|(path, _)| path == "modules/hello-module/composer.json"), + "the plugin-provided installer must place the module outside vendor/" ); - assert_eq!(paths(&upstream_tree), paths(&shirabe_tree)); - assert_eq!(upstream_tree, shirabe_tree); + assert_eq!(upstream.1, shirabe.1); +} + +/// The rest of the installer contract: `update` reinstalls a package in place, a second `install` +/// runs over an already-installed tree, and `remove` reaches the plugin's own `uninstall()` +/// override — the one that chains onto the promise `LibraryInstaller::uninstall` returns. +#[test] +fn test_composer_installers_update_and_remove_match_upstream_composer() { + if !php_runtime_available() || !plugin_fetched() { + return; + } + let Some(composer_bin) = upstream_composer_bin() else { + return; + }; + let _worker = lock_php_worker(); + let composer_bin = composer_bin.to_str().unwrap().to_string(); + + let mut results = Vec::new(); + for (program, prefix) in &implementations(&composer_bin) { + let work = TempDir::new().unwrap(); + copy_dir(&fixture_dir(), work.path()); + let install = run_command(work.path(), program, prefix, &["install"]); + + let module = work.path().join("packages/hello-module"); + edit( + &module.join("composer.json"), + "\"version\": \"1.0.0\"", + "\"version\": \"1.1.0\"", + ); + std::fs::write(module.join("src/hello.txt"), "hello module payload v1.1\n").unwrap(); + edit( + &work.path().join("project/composer.json"), + "\"acme/hello-module\": \"1.0.0\"", + "\"acme/hello-module\": \"1.1.0\"", + ); + + let update = run_command( + work.path(), + program, + prefix, + &["update", "acme/hello-module"], + ); + let reinstall = run_command(work.path(), program, prefix, &["install"]); + let remove = run_command( + work.path(), + program, + prefix, + &["remove", "acme/hello-theme"], + ); + let tree = tree(&work.path().join("project")); + results.push((install, update, reinstall, remove, tree, work)); + } + let [upstream, shirabe] = <[_; 2]>::try_from(results).ok().unwrap(); + + for (label, upstream, shirabe) in [ + ("install", &upstream.0, &shirabe.0), + ("update", &upstream.1, &shirabe.1), + ("reinstall", &upstream.2, &shirabe.2), + ("remove", &upstream.3, &shirabe.3), + ] { + assert_eq!(0, upstream.exit_code, "upstream {label} must succeed"); + assert_eq!(upstream.exit_code, shirabe.exit_code, "{label} exit code"); + assert_eq!(upstream.stdout, shirabe.stdout, "{label} output"); + } + assert_eq!(upstream.4, shirabe.4); +} + +/// The plugin's configuration surface: `installer-paths` in the root package's extra (both the +/// `type:` and the package-name matcher, with `{$name}` templating) and `installer-name` in the +/// installed package's own extra. Both are read back through the package proxy. +#[test] +fn test_composer_installers_custom_paths_match_upstream_composer() { + if !php_runtime_available() || !plugin_fetched() { + return; + } + let Some(composer_bin) = upstream_composer_bin() else { + return; + }; + let _worker = lock_php_worker(); + let composer_bin = composer_bin.to_str().unwrap().to_string(); + + let mut results = Vec::new(); + for (program, prefix) in &implementations(&composer_bin) { + let work = TempDir::new().unwrap(); + copy_dir(&fixture_dir(), work.path()); + edit( + &work.path().join("project/composer.json"), + " \"require\": {", + " \"extra\": {\n \"installer-paths\": {\n \ + \"web/custom/{$name}/\": [\"type:drupal-theme\"],\n \ + \"web/mods/{$name}/\": [\"acme/hello-module\"]\n }\n },\n \"require\": {", + ); + edit( + &work.path().join("packages/hello-module/composer.json"), + " \"type\": \"drupal-module\",", + " \"type\": \"drupal-module\",\n \"extra\": {\"installer-name\": \"renamed-mod\"},", + ); + let run = run_command(work.path(), program, prefix, &["install"]); + let tree = tree(&work.path().join("project")); + results.push((run, tree, work)); + } + let [upstream, shirabe] = <[_; 2]>::try_from(results).ok().unwrap(); + + assert_eq!(0, upstream.0.exit_code, "upstream install must succeed"); + assert_eq!(upstream.0.exit_code, shirabe.0.exit_code); + assert_eq!(upstream.0.stdout, shirabe.0.stdout); + for expected in [ + "web/custom/hello-theme/composer.json", + "web/mods/renamed-mod/composer.json", + ] { + assert!( + shirabe.1.iter().any(|(path, _)| path == expected), + "expected {expected} in {:?}", + shirabe.1.iter().map(|(path, _)| path).collect::<Vec<_>>() + ); + } + assert_eq!(upstream.1, shirabe.1); } |
