From b65e338d4cf06afb8237512e49a51e354277196d Mon Sep 17 00:00:00 2001 From: nsfisis Date: Mon, 10 Aug 2026 23:47:33 +0900 Subject: 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. --- .../tests/plugin/e2e_script_command_test.rs | 157 +++++++++++++++++++++ .../fixtures/e2e-script-command/composer.json | 23 +++ .../e2e-script-command/src/GreetCommand.php | 34 +++++ .../src/MismatchedNameCommand.php | 23 +++ .../e2e-script-command/src/SingleAppCommand.php | 9 ++ crates/shirabe/tests/plugin/main.rs | 1 + 6 files changed, 247 insertions(+) create mode 100644 crates/shirabe/tests/plugin/e2e_script_command_test.rs create mode 100644 crates/shirabe/tests/plugin/fixtures/e2e-script-command/composer.json create mode 100644 crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/GreetCommand.php create mode 100644 crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/MismatchedNameCommand.php create mode 100644 crates/shirabe/tests/plugin/fixtures/e2e-script-command/src/SingleAppCommand.php (limited to 'crates/shirabe/tests') 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 @@ +setDescription('Greets someone from a script-provided command.') + ->setHelp('The greet 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 @@ +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 @@ +