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. --- crates/shirabe/src/console/application.rs | 239 +++++++++++++++------ crates/shirabe/src/plugin/php_plugin_proxy.rs | 129 +++++++++-- .../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 + 8 files changed, 536 insertions(+), 79 deletions(-) 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') diff --git a/crates/shirabe/src/console/application.rs b/crates/shirabe/src/console/application.rs index 897408c0..c0cd072b 100644 --- a/crates/shirabe/src/console/application.rs +++ b/crates/shirabe/src/console/application.rs @@ -38,6 +38,7 @@ use crate::composer; use crate::composer::PartialComposerHandle; use crate::console::GithubActionError; use crate::downloader::TransportException; +use crate::event_dispatcher::EventDispatcher; use crate::event_dispatcher::ScriptExecutionException; use crate::exception::NoSslException; use crate::factory::Factory; @@ -60,9 +61,9 @@ use shirabe_php_shim::{ bin2hex, chdir, date_default_timezone_get, date_default_timezone_set, defined, dirname, disk_free_space, extension_loaded, file_exists, file_get_contents, file_put_contents, function_exists, getcwd, getmypid, glob, ini_set, is_array, is_dir, is_file, is_string, - is_subclass_of, json_decode, memory_get_peak_usage, memory_get_usage, microtime, php_regex, - php_uname, posix_getuid, random_bytes, realpath, restore_error_handler, round, str_contains, - str_replace, strpos, strtoupper, sys_get_temp_dir, time, unlink, + json_decode, memory_get_peak_usage, memory_get_usage, microtime, php_regex, php_uname, + posix_getuid, random_bytes, realpath, restore_error_handler, round, str_contains, str_replace, + strpos, strtoupper, sys_get_temp_dir, time, unlink, }; use shirabe_seld_json_lint::ParsingException; use shirabe_symfony_console::application::Application as BaseApplication; @@ -122,6 +123,10 @@ pub struct Application { pub(crate) composer: Option, pub(crate) io: std::rc::Rc>, has_plugin_commands: bool, + /// Whether this application published the worker-side console application handoff. The + /// handoff is process-wide, so a second application in the same process must replace it + /// rather than extend the one its predecessor left behind. + published_worker_console_context: bool, disable_plugins_by_default: bool, disable_scripts_by_default: bool, /// Store the initial working directory at startup time @@ -188,6 +193,7 @@ impl Application { composer: None, io, has_plugin_commands: false, + published_worker_console_context: false, disable_plugins_by_default: false, disable_scripts_by_default: false, initial_working_directory, @@ -631,45 +637,70 @@ impl Application { } if !commands.is_empty() { - // Publish the handoff the worker-side console application boots from when one - // of these commands actually runs: the shared object graph, plus metadata - // mirrors of the built-in commands (plugin commands are not registered yet, so - // this snapshot is exactly the Rust-implemented set). - let mut seen: Vec<*const ()> = Vec::new(); - let mut rust_commands: Vec = Vec::new(); - for command in self.commands.values() { - let ptr = std::rc::Rc::as_ptr(command) as *const (); - if seen.contains(&ptr) { - continue; - } - seen.push(ptr); - let command = command.borrow(); - let Some(name) = command.get_name() else { - continue; - }; - rust_commands.push(crate::plugin::RustCommandMetadata { - name, - description: command.get_description(), - aliases: command.get_aliases(), - hidden: command.is_hidden(), - }); - } - crate::plugin::publish_console_application_context( - &composer, - &self.io, - self.get_initial_working_directory(), - self.disable_plugins_by_default, - self.disable_scripts_by_default, - rust_commands, + self.register_worker_console_commands( + Some(&composer), crate::plugin::take_pending_plugin_command_handles(), - ); - register_worker_reverse_application(self.me.clone()); + )?; } } Ok(commands) } + /// Makes worker-hosted commands runnable through the worker-side console application: they + /// join the handoff it boots from when one of them actually runs. This application's first + /// call publishes that handoff, carrying the shared object graph plus metadata mirrors of + /// the commands registered so far — all Rust-implemented at that point, since a worker-hosted + /// command is only registered after being handed to this method. + /// + /// TODO(plugin): a Rust-implemented command registered *after* this first call is missing + /// from that mirror, so the worker cannot `find()` it. `Application::do_run` hits this when + /// one `scripts` entry names a Command class and a later entry falls back to + /// `ScriptAliasCommand`: the alias command is registered after the class command published + /// the handoff. Fixing it needs a metadata half of `extend_console_application_commands` + /// that also builds a `\Shirabe\RustCommandStub` in an already-booted worker application. + fn register_worker_console_commands( + &mut self, + composer: Option<&crate::composer::ComposerHandle>, + handles: Vec, + ) -> anyhow::Result<()> { + if self.published_worker_console_context { + return crate::plugin::extend_console_application_commands(handles); + } + self.published_worker_console_context = true; + + let mut seen: Vec<*const ()> = Vec::new(); + let mut rust_commands: Vec = Vec::new(); + for command in self.commands.values() { + let ptr = std::rc::Rc::as_ptr(command) as *const (); + if seen.contains(&ptr) { + continue; + } + seen.push(ptr); + let command = command.borrow(); + let Some(name) = command.get_name() else { + continue; + }; + rust_commands.push(crate::plugin::RustCommandMetadata { + name, + description: command.get_description(), + aliases: command.get_aliases(), + hidden: command.is_hidden(), + }); + } + crate::plugin::publish_console_application_context( + composer, + &self.io, + self.get_initial_working_directory(), + self.disable_plugins_by_default, + self.disable_scripts_by_default, + rust_commands, + handles, + ); + register_worker_reverse_application(self.me.clone()); + Ok(()) + } + /// Get the working directory at startup time pub fn get_initial_working_directory(&self) -> Option { self.initial_working_directory.clone() @@ -2389,8 +2420,8 @@ impl ApplicationHandle { let composer_opt = application.borrow_mut().get_composer(false, None, None)?; - if let Some(composer) = composer_opt { - let composer = crate::composer::composer_full(&composer); + if let Some(ref composer_handle) = composer_opt { + let composer = crate::composer::composer_full(composer_handle); let root_package = composer.get_package(); let generator = composer.get_autoload_generator().clone(); let generator = generator.borrow(); @@ -2421,34 +2452,118 @@ impl ApplicationHandle { // if the command is not an array of commands, and points to a valid SymfonyCommand subclass, import its details directly let dummy_str = dummy.as_string().unwrap_or("").to_string(); + // The class lives in the PHP worker, which cannot even declare + // a subclass of Symfony's Command before the Composer PHP + // runtime is loaded there. That load is skipped for a class the + // Rust-side ClassLoaders cannot resolve to a file, which is + // every plain shell-command script. + // + // TODO(php-runtime): the file lookup is narrower than PHP's + // `class_exists`, which is also true for a class already + // declared in the process. A script naming one of those (say + // `Composer\Command\AboutCommand`) is imported as a command by + // PHP but falls through to ScriptAliasCommand here. + let is_command_class = is_string(dummy) + && crate::plugin::find_file_in_registered_loaders(&dummy_str) + .is_some() + && { + EventDispatcher::ensure_composer_php_runtime()?; + crate::plugin::php_class_query( + "class_exists", + vec![shirabe_php_rpc::PluginValue::string( + dummy_str.clone(), + )], + )? && crate::plugin::php_class_query( + "is_subclass_of", + vec![ + shirabe_php_rpc::PluginValue::string( + dummy_str.clone(), + ), + shirabe_php_rpc::PluginValue::string( + "Symfony\\Component\\Console\\Command\\Command", + ), + shirabe_php_rpc::PluginValue::Bool(true), + ], + )? + }; let cmd: std::rc::Rc> = - if is_string(dummy) - && shirabe_php_shim::class_exists(&dummy_str) - && is_subclass_of( - &PhpMixed::String(dummy_str.clone()), - "Symfony\\Component\\Console\\Command\\Command", - true, - ) - { - if is_subclass_of( - &PhpMixed::String(dummy_str.clone()), - "Symfony\\Component\\Console\\SingleCommandApplication", - true, - ) { + if is_command_class { + if crate::plugin::php_class_query( + "is_subclass_of", + vec![ + shirabe_php_rpc::PluginValue::string( + dummy_str.clone(), + ), + shirabe_php_rpc::PluginValue::string( + "Symfony\\Component\\Console\\SingleCommandApplication", + ), + shirabe_php_rpc::PluginValue::Bool(true), + ], + )? { io.write_error(&format!("The script named {} extends SingleCommandApplication which is not compatible with Composer 2.9+, make sure you extend Symfony\\Component\\Console\\Command instead.", script)); } - // TODO(plugin): `new $dummy($script)` instantiates the - // user's PHP command class in-process and registers the - // live object on this Application. The worker-side - // console application and PhpCommandProxy exist now, - // but this arm is not wired to them: the class checks - // above use the shim class_exists, which never - // recognizes user classes, so the arm stays - // unreachable until the checks and the instantiation - // go through the worker. - todo!( - "plugin: import a user Command class as a live application command" - ); + + let handle = crate::plugin::new_php_object( + &dummy_str, + vec![shirabe_php_rpc::PluginValue::string( + script.clone(), + )], + )?; + + // makes sure the command is find()'able by the name defined in composer.json, and the name isn't overridden in its configure() + let cmd_name = crate::plugin::call_php_entity_method( + &handle, + "getName", + vec![], + )?; + if let shirabe_php_rpc::PluginValue::String(ref bytes) = + cmd_name + && !bytes.is_empty() + && bytes.as_slice() != script.as_bytes() + { + io.write_error(&format!("The script named {} in composer.json has a mismatched name in its class definition. For consistency, either use the same name, or do not define one inside the class.", script)); + // override it with the defined script name + crate::plugin::call_php_entity_method( + &handle, + "setName", + vec![shirabe_php_rpc::PluginValue::string( + script.clone(), + )], + )?; + } + + let cmd_description = + crate::plugin::call_php_entity_method( + &handle, + "getDescription", + vec![], + )?; + if matches!( + cmd_description, + shirabe_php_rpc::PluginValue::String(ref bytes) + if bytes.is_empty() + ) { + crate::plugin::call_php_entity_method( + &handle, + "setDescription", + vec![shirabe_php_rpc::PluginValue::string( + description, + )], + )?; + } + + let cmd = + crate::plugin::PhpCommandProxy::new_script_command( + handle, + )?; + application.borrow_mut().register_worker_console_commands( + composer_opt + .as_ref() + .and_then(|c| c.as_full()) + .as_ref(), + crate::plugin::take_pending_plugin_command_handles(), + )?; + std::rc::Rc::new(std::cell::RefCell::new(cmd)) } else { // fallback to usual aliasing behavior std::rc::Rc::new(std::cell::RefCell::new( diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs index e4384f1c..4edc76ce 100644 --- a/crates/shirabe/src/plugin/php_plugin_proxy.rs +++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs @@ -30,7 +30,7 @@ use crate::repository::{ use indexmap::IndexMap; use shirabe_php_rpc::{ PhpObjHandle, PhpThrow, PluginValue, RustMethodDispatcher, RustObjHandle, - call_function_with_dispatcher, call_php_method, release_php_handle, + call_function_with_dispatcher, call_php_method, new_object, release_php_handle, }; use shirabe_php_shim::PhpMixed; use shirabe_symfony_console::command::Command; @@ -2362,6 +2362,58 @@ pub fn io_handle_value( Ok(rust_handle_value(register_io_entity(io), class)) } +/// Runs a boolean class query (`class_exists`, `is_subclass_of`, ...) in the worker with the +/// script autoloader active, so a class named by `composer.json` resolves through the Rust-side +/// [`ClassLoader`]s. +pub(crate) fn php_class_query(function: &str, args: Vec) -> anyhow::Result { + crate::event_dispatcher::EventDispatcher::ensure_script_autoloader()?; + let value = unwrap_php_result(call_function_with_dispatcher( + function, + args, + Some(&mut PluginRpcDispatcher::default()), + ))?; + match value { + PluginValue::Bool(value) => Ok(value), + other => Err(anyhow::anyhow!( + "PHP class query `{function}` did not return a bool: {other:?}" + )), + } +} + +/// Instantiates `new $class(...$ctor_args)` in the worker, with the script autoloader active. +pub(crate) fn new_php_object( + class: &str, + ctor_args: Vec, +) -> anyhow::Result { + crate::event_dispatcher::EventDispatcher::ensure_script_autoloader()?; + let value = unwrap_php_result(new_object( + class, + ctor_args, + Some(&mut PluginRpcDispatcher::default()), + ))?; + match value { + PluginValue::PhpHandle(handle) => Ok(handle), + other => Err(shirabe_php_shim::RuntimeException::new(format!( + "`new {class}` returned an unsupported shape over RPC: {other:?}" + )) + .into()), + } +} + +/// Calls `$obj->$method(...$args)` on a worker-side entity. +pub(crate) fn call_php_entity_method( + handle: &PhpObjHandle, + method: &str, + args: Vec, +) -> anyhow::Result { + unwrap_php_result(call_php_method( + handle.phandle, + method, + args, + Some(&mut PluginRpcDispatcher::default()), + )) +} + /// `is_a($obj, $class)` evaluated in the worker: the child's own class table answers, so /// parent classes are covered (a `PhpObjHandle`'s `implements` lists interfaces only). pub(crate) fn php_is_a(handle: &PhpObjHandle, class: &str) -> anyhow::Result { @@ -2742,7 +2794,7 @@ impl Drop for PhpCommandProviderProxy { } /// Metadata row for one Rust-implemented command, mirrored into the worker as a -/// `\Shirabe\RustCommandStub` so a plugin-provided command can `find()` and invoke built-in +/// `\Shirabe\RustCommandStub` so a worker-hosted command can `find()` and invoke built-in /// commands (their execution crosses back into this process). #[derive(Debug)] pub(crate) struct RustCommandMetadata { @@ -2776,25 +2828,25 @@ impl RustCommandMetadata { /// Handoff state for the worker-side console application (the `Composer\Console\Application` /// defined under the RPC crate's `php/runtime/`): assembled by -/// `Application::get_plugin_commands` once the full command set is known, booted in the worker -/// the first time a plugin-provided command actually runs. +/// `Application::register_worker_console_commands` as worker-hosted commands are registered, +/// booted in the worker the first time one of them actually runs. #[derive(Debug)] pub(crate) struct PhpConsoleApplicationContext { - composer: ComposerHandle, + composer: Option, io: std::rc::Rc>, initial_working_directory: Option, disable_plugins_by_default: bool, disable_scripts_by_default: bool, rust_commands: Vec, - /// Clones of the plugin command handles; ownership (and release) stays with the + /// Clones of the worker-hosted command handles; ownership (and release) stays with the /// `PhpCommandProxy` instances holding the originals. - plugin_commands: Vec, + plugin_commands: std::cell::RefCell>, app: std::cell::RefCell>, } thread_local! { - /// Handles of the `PhpCommandProxy` instances built while `Application::get_plugin_commands` - /// collects providers; drained into the context it publishes. + /// Handles of the `PhpCommandProxy` instances built since the last drain; drained into the + /// console application context by `Application::register_worker_console_commands`. static PENDING_COMMAND_HANDLES: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; @@ -2813,7 +2865,7 @@ pub(crate) fn take_pending_plugin_command_handles() -> Vec { } pub(crate) fn publish_console_application_context( - composer: &ComposerHandle, + composer: Option<&ComposerHandle>, io: &std::rc::Rc>, initial_working_directory: Option, disable_plugins_by_default: bool, @@ -2822,18 +2874,36 @@ pub(crate) fn publish_console_application_context( plugin_commands: Vec, ) { let context = std::rc::Rc::new(PhpConsoleApplicationContext { - composer: composer.clone(), + composer: composer.cloned(), io: io.clone(), initial_working_directory, disable_plugins_by_default, disable_scripts_by_default, rust_commands, - plugin_commands, + plugin_commands: std::cell::RefCell::new(plugin_commands), app: std::cell::RefCell::new(None), }); CONSOLE_APP_CONTEXT.with(|slot| *slot.borrow_mut() = Some(context)); } +/// Adds command entities to the published handoff, for commands registered after it was +/// published. A worker-side application that already booted gets them right away, so the two +/// sides keep the same command set. +pub(crate) fn extend_console_application_commands( + handles: Vec, +) -> anyhow::Result<()> { + let context = CONSOLE_APP_CONTEXT + .with(|slot| slot.borrow().clone()) + .expect("only an application that published the handoff extends it"); + for handle in handles { + if let Some(app) = context.app.borrow().as_ref() { + call_php_entity_method(app, "add", vec![PluginValue::PhpHandle(handle.clone())])?; + } + context.plugin_commands.borrow_mut().push(handle); + } + Ok(()) +} + impl PhpConsoleApplicationContext { /// Boots the worker-side application on first use and returns its handle. fn booted_app(&self) -> anyhow::Result { @@ -2841,7 +2911,13 @@ impl PhpConsoleApplicationContext { return Ok(app.clone()); } let mut config: IndexMap, PluginValue> = IndexMap::new(); - config.insert(b"composer".to_vec(), composer_handle_value(&self.composer)); + config.insert( + b"composer".to_vec(), + match &self.composer { + Some(composer) => composer_handle_value(composer), + None => PluginValue::Null, + }, + ); config.insert(b"io".to_vec(), io_handle_value(&self.io)?); config.insert( b"initialWorkingDirectory".to_vec(), @@ -2871,6 +2947,7 @@ impl PhpConsoleApplicationContext { b"pluginCommands".to_vec(), PluginValue::List( self.plugin_commands + .borrow() .iter() .cloned() .map(PluginValue::PhpHandle) @@ -2919,6 +2996,28 @@ pub struct PhpCommandProxy { impl PhpCommandProxy { pub(crate) fn new(handle: PhpObjHandle) -> anyhow::Result { + let proxy_command = match Self::call_metadata_getter(&handle, "isProxyCommand")? { + PluginValue::Bool(proxy_command) => proxy_command, + other => return Err(Self::unsupported_shape(&handle, "isProxyCommand", &other)), + }; + Self::build(handle, proxy_command) + } + + /// A command class named by a `composer.json` script only has to extend Symfony's `Command`, + /// so `isProxyCommand()` is asked for only when it also extends Composer's `BaseCommand`. + pub(crate) fn new_script_command(handle: PhpObjHandle) -> anyhow::Result { + let proxy_command = if php_is_a(&handle, "Composer\\Command\\BaseCommand")? { + match Self::call_metadata_getter(&handle, "isProxyCommand")? { + PluginValue::Bool(proxy_command) => proxy_command, + other => return Err(Self::unsupported_shape(&handle, "isProxyCommand", &other)), + } + } else { + false + }; + Self::build(handle, proxy_command) + } + + fn build(handle: PhpObjHandle, proxy_command: bool) -> anyhow::Result { let data = crate::command::BaseCommandData::new(None); let name = Self::call_metadata_getter(&handle, "getName")?; match name { @@ -2958,10 +3057,6 @@ impl PhpCommandProxy { } other => return Err(Self::unsupported_shape(&handle, "isHidden", &other)), } - let proxy_command = match Self::call_metadata_getter(&handle, "isProxyCommand")? { - PluginValue::Bool(proxy_command) => proxy_command, - other => return Err(Self::unsupported_shape(&handle, "isProxyCommand", &other)), - }; Self::read_back_definition(&handle, &data)?; PENDING_COMMAND_HANDLES.with(|handles| handles.borrow_mut().push(handle.clone())); Ok(Self { 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 @@ +