diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-10 23:47:33 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-10 23:47:33 +0900 |
| commit | b65e338d4cf06afb8237512e49a51e354277196d (patch) | |
| tree | 5099c6bd950ff59acd5be15ff224b02d05563834 /crates/shirabe/tests/plugin | |
| parent | 3f80f3c725245c769f05b6d3317f086e226fae50 (diff) | |
| download | php-shirabe-b65e338d4cf06afb8237512e49a51e354277196d.tar.gz php-shirabe-b65e338d4cf06afb8237512e49a51e354277196d.tar.zst php-shirabe-b65e338d4cf06afb8237512e49a51e354277196d.zip | |
feat(console): import scripts Command classes as application commands
`Application::do_run` registers a `composer.json` script whose value names a
`Symfony\Component\Console\Command\Command` subclass as a live command. The
class checks and `new $dummy($script)` need a real PHP runtime, so they run in
the worker: the command object lives there and this side keeps a metadata
mirror for `list`/`help`, forwarding a run to the worker-side console
application it is added to. The name and description fixups are applied to the
worker-side object, so both sides carry the same values.
Loading the Composer PHP runtime into the worker is gated on the Rust-side
`ClassLoader`s resolving the class to a file, keeping that load out of every
run whose scripts are plain shell commands.
The worker-side console application handoff now accepts commands registered
after it was published, since the scripts scan runs after plugin commands are
collected. Whether it was published is tracked per application: the handoff is
process-wide, so a second application must replace it rather than extend it.
`shirabe_php_shim::is_subclass_of` has no callers left.
Diffstat (limited to 'crates/shirabe/tests/plugin')
6 files changed, 247 insertions, 0 deletions
diff --git a/crates/shirabe/tests/plugin/e2e_script_command_test.rs b/crates/shirabe/tests/plugin/e2e_script_command_test.rs new file mode 100644 index 00000000..019c830d --- /dev/null +++ b/crates/shirabe/tests/plugin/e2e_script_command_test.rs @@ -0,0 +1,157 @@ +//! Script-provided command E2E compatibility check: a `composer.json` script naming a +//! `Symfony\Component\Console\Command\Command` subclass is imported as an application command, +//! and `list`, `help`, executions and the mismatched-name warning are compared between upstream +//! Composer and Shirabe. +//! +//! The whole fixture is Shirabe-authored (`fixtures/e2e-script-command/`), so nothing has to be +//! fetched; the test skips only while the PHP runtime or the Composer checkout is missing. + +use crate::e2e_extension_installer_test::{copy_dir, upstream_composer_bin}; +use crate::plugin_installer_test::{lock_php_worker, php_runtime_available}; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/plugin/fixtures/e2e-script-command") +} + +struct CommandRun { + exit_code: i32, + stdout: String, + stderr: String, +} + +/// One composer-CLI invocation inside the prepared project. +fn run_command(work: &Path, program: &str, prefix_args: &[&str], args: &[&str]) -> CommandRun { + let output = std::process::Command::new(program) + .args(prefix_args) + .args(args) + .current_dir(work) + .env("COMPOSER_HOME", work.join("home")) + .env("COMPOSER_CACHE_DIR", work.join("cache")) + .env("COMPOSER_NO_INTERACTION", "1") + // Rendering width must not depend on the invoking terminal. + .env("COLUMNS", "120") + .env("LINES", "30") + .output() + .unwrap(); + CommandRun { + exit_code: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + } +} + +fn lines_starting_with<'a>(text: &'a str, prefix: &str) -> Vec<&'a str> { + text.lines() + .map(str::trim_end) + .filter(|line| line.trim_start().starts_with(prefix)) + .collect() +} + +#[test] +fn test_script_command_class_import_matches_upstream_composer() { + if !php_runtime_available() { + 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 implementations: [(&str, Vec<&str>); 2] = [ + ("php", vec![composer_bin.as_str()]), + (env!("CARGO_BIN_EXE_shirabe"), vec![]), + ]; + + let mut results: Vec<[CommandRun; 4]> = Vec::new(); + for (program, prefix) in &implementations { + let work = TempDir::new().unwrap(); + copy_dir(&fixture_dir(), work.path()); + let install = run_command(work.path(), program, prefix, &["install"]); + assert_eq!(0, install.exit_code, "{program}: install must succeed"); + + let greet = run_command(work.path(), program, prefix, &["greet", "World", "--shout"]); + let renamed = run_command(work.path(), program, prefix, &["renamed"]); + let help = run_command(work.path(), program, prefix, &["help", "greet"]); + let list = run_command(work.path(), program, prefix, &["list"]); + results.push([greet, renamed, help, list]); + } + + let [upstream, shirabe] = <[_; 2]>::try_from(results).ok().unwrap(); + let [u_greet, u_renamed, u_help, u_list] = upstream; + let [s_greet, s_renamed, s_help, s_list] = shirabe; + + // The imported command runs with its own definition bound: the argument, the shorthand + // option, the name the constructor took from composer.json and the hosting application. + assert_eq!(0, u_greet.exit_code, "upstream greet must succeed"); + assert_eq!(u_greet.exit_code, s_greet.exit_code); + assert_eq!( + lines_starting_with(&u_greet.stdout, "greet:"), + lines_starting_with(&s_greet.stdout, "greet:") + ); + assert_eq!( + vec![ + "greet: HELLO WORLD", + "greet: name=greet", + "greet: app=Composer\\Console\\Application", + ], + lines_starting_with(&s_greet.stdout, "greet:") + ); + + // A class whose configure() sets a different name is renamed to the script name, and an + // empty description is filled in from scripts-descriptions. + assert_eq!(0, u_renamed.exit_code, "upstream renamed must succeed"); + assert_eq!(u_renamed.exit_code, s_renamed.exit_code); + assert_eq!( + lines_starting_with(&u_renamed.stdout, "renamed:"), + lines_starting_with(&s_renamed.stdout, "renamed:") + ); + assert_eq!( + vec![ + "renamed: name=renamed", + "renamed: description=Description taken from composer.json", + ], + lines_starting_with(&s_renamed.stdout, "renamed:") + ); + let mismatch_warning = + "The script named renamed in composer.json has a mismatched name in its class definition."; + assert!( + u_renamed.stderr.contains(mismatch_warning), + "upstream must warn about the mismatched name: {}", + u_renamed.stderr + ); + assert!( + s_renamed.stderr.contains(mismatch_warning), + "shirabe must warn about the mismatched name: {}", + s_renamed.stderr + ); + + // A class extending SingleCommandApplication is still imported, with a warning. + let single_warning = "The script named single extends SingleCommandApplication which is not compatible with Composer 2.9+"; + assert!( + u_list.stderr.contains(single_warning), + "upstream must warn about SingleCommandApplication: {}", + u_list.stderr + ); + assert!( + s_list.stderr.contains(single_warning), + "shirabe must warn about SingleCommandApplication: {}", + s_list.stderr + ); + + assert_eq!(0, u_help.exit_code, "upstream help greet must succeed"); + assert_eq!(u_help.exit_code, s_help.exit_code); + assert_eq!(u_help.stdout, s_help.stdout, "help greet output differs"); + + // The imported commands are listed with the descriptions the class and composer.json give + // them, next to the plain shell script that stays a ScriptAliasCommand. + assert_eq!(0, u_list.exit_code); + assert_eq!(u_list.exit_code, s_list.exit_code); + assert_eq!(u_list.stdout, s_list.stdout, "list output differs"); + assert_eq!( + vec![" greet Greets someone from a script-provided command."], + lines_starting_with(&s_list.stdout, "greet") + ); +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-script-command/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/composer.json new file mode 100644 index 00000000..2eeff25b --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/composer.json @@ -0,0 +1,23 @@ +{ + "name": "shirabe/e2e-script-command", + "description": "E2E fixture project: composer.json scripts naming Command classes, run against upstream Composer and Shirabe.", + "autoload": { + "psr-4": { + "ShirabeTest\\ScriptCommand\\": "src/" + } + }, + "scripts": { + "greet": "ShirabeTest\\ScriptCommand\\GreetCommand", + "renamed": "ShirabeTest\\ScriptCommand\\MismatchedNameCommand", + "single": "ShirabeTest\\ScriptCommand\\SingleAppCommand", + "plain": "echo plain-script" + }, + "scripts-descriptions": { + "renamed": "Description taken from composer.json" + }, + "repositories": [ + { + "packagist.org": false + } + ] +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/GreetCommand.php b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/GreetCommand.php new file mode 100644 index 00000000..e797486b --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/GreetCommand.php @@ -0,0 +1,34 @@ +<?php + +namespace ShirabeTest\ScriptCommand; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class GreetCommand extends Command +{ + protected function configure(): void + { + $this + ->setDescription('Greets someone from a script-provided command.') + ->setHelp('The <info>greet</info> command exercises a Command class named by a composer.json script.') + ->addArgument('who', InputArgument::REQUIRED, 'Who to greet') + ->addOption('shout', 's', InputOption::VALUE_NONE, 'Uppercase the greeting'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $message = 'Hello ' . $input->getArgument('who'); + if ($input->getOption('shout')) { + $message = strtoupper($message); + } + $output->writeln('greet: ' . $message); + $output->writeln('greet: name=' . $this->getName()); + $output->writeln('greet: app=' . get_class($this->getApplication())); + + return 0; + } +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/MismatchedNameCommand.php b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/MismatchedNameCommand.php new file mode 100644 index 00000000..f3f6b05f --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/MismatchedNameCommand.php @@ -0,0 +1,23 @@ +<?php + +namespace ShirabeTest\ScriptCommand; + +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class MismatchedNameCommand extends Command +{ + protected function configure(): void + { + $this->setName('not-the-script-name'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln('renamed: name=' . $this->getName()); + $output->writeln('renamed: description=' . $this->getDescription()); + + return 0; + } +} diff --git a/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/SingleAppCommand.php b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/SingleAppCommand.php new file mode 100644 index 00000000..89e17fe9 --- /dev/null +++ b/crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/SingleAppCommand.php @@ -0,0 +1,9 @@ +<?php + +namespace ShirabeTest\ScriptCommand; + +use Symfony\Component\Console\SingleCommandApplication; + +class SingleAppCommand extends SingleCommandApplication +{ +} diff --git a/crates/shirabe/tests/plugin/main.rs b/crates/shirabe/tests/plugin/main.rs index f1237731..8c0b394d 100644 --- a/crates/shirabe/tests/plugin/main.rs +++ b/crates/shirabe/tests/plugin/main.rs @@ -10,6 +10,7 @@ mod e2e_installer_test; mod e2e_installers_test; mod e2e_normalize_test; mod e2e_package_event_test; +mod e2e_script_command_test; mod plugin_installer_test; mod subscriber_test; mod value_round_trip_test; |
