diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-06-25 14:46:55 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-06-26 00:19:42 +0900 |
| commit | 9c6f614aa6ddb5d91340e34b0136be6a14712d63 (patch) | |
| tree | 35973a35532ada33fe05805995b2e5b5e7bee1bc /crates/shirabe | |
| parent | d0336078c5b63b174e7313d54d973a1832228928 (diff) | |
| download | php-shirabe-9c6f614aa6ddb5d91340e34b0136be6a14712d63.tar.gz php-shirabe-9c6f614aa6ddb5d91340e34b0136be6a14712d63.tar.zst php-shirabe-9c6f614aa6ddb5d91340e34b0136be6a14712d63.zip | |
feat(util): add ProcessExecutorMock test infra; port SymfonyStyle message methods
Add an internal mock hook to ProcessExecutor (None in production) so
tests can stub command execution without spawning processes, mirroring
Composer's ProcessExecutorMock subclass. Add get_process_executor_mock
helper and two verification tests. Implement SymfonyStyle's
message-handling methods.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe')
| -rw-r--r-- | crates/shirabe/src/util/process_executor.rs | 268 | ||||
| -rw-r--r-- | crates/shirabe/tests/common/process_executor_mock.rs | 101 | ||||
| -rw-r--r-- | crates/shirabe/tests/util/main.rs | 3 | ||||
| -rw-r--r-- | crates/shirabe/tests/util/process_executor_test.rs | 61 |
4 files changed, 429 insertions, 4 deletions
diff --git a/crates/shirabe/src/util/process_executor.rs b/crates/shirabe/src/util/process_executor.rs index 609fd70..fb486e3 100644 --- a/crates/shirabe/src/util/process_executor.rs +++ b/crates/shirabe/src/util/process_executor.rs @@ -12,10 +12,10 @@ use shirabe_external_packages::symfony::process::Process; use shirabe_external_packages::symfony::process::exception::ProcessSignaledException; use shirabe_external_packages::symfony::process::exception::RuntimeException as SymfonyProcessRuntimeException; use shirabe_php_shim::{ - LogicException, PhpMixed, RuntimeException, array_intersect, array_map, call_user_func, - defined, escapeshellarg, explode, implode, in_array, is_array, is_dir, is_numeric, is_string, - rtrim, sprintf, str_replace, strcspn, strlen, strpbrk, strtolower, strtr_array, substr_replace, - trim, usleep, + LogicException, PHP_EOL, PhpMixed, RuntimeException, array_intersect, array_map, + call_user_func, defined, escapeshellarg, explode, implode, in_array, is_array, is_dir, + is_numeric, is_string, rtrim, sprintf, str_replace, strcspn, strlen, strpbrk, strtolower, + strtr_array, substr_replace, trim, usleep, }; use crate::io::IOInterface; @@ -46,6 +46,77 @@ pub struct ProcessExecutor { id_gen: i64, /// @var bool allow_async: bool, + /// Test-only mock state. `None` in production; set via [`ProcessExecutor::__expects`] in tests. + /// Mirrors `composer/tests/Composer/Test/Mock/ProcessExecutorMock.php`. + mock: Option<ProcessExecutorMockState>, +} + +/// Test-only state for the ProcessExecutorMock behaviour (cf. +/// `composer/tests/Composer/Test/Mock/ProcessExecutorMock.php`). Held in +/// [`ProcessExecutor::mock`]; always `None` in production builds. +#[derive(Debug)] +pub struct ProcessExecutorMockState { + /// `null` until configured via `expects`; once set, an empty list means "no more calls". + pub expectations: Option<Vec<MockExpectation>>, + pub strict: bool, + pub default_handler: MockHandler, + pub log: Vec<String>, +} + +/// A single expected command (`array{cmd, return, stdout, stderr, callback}` in PHP). +pub struct MockExpectation { + /// `string|list<string>`: a `PhpMixed::String` or `PhpMixed::List`. + pub cmd: PhpMixed, + pub r#return: i64, + pub stdout: String, + pub stderr: String, + /// Optional `callable` fired when the expectation is consumed. Rare in Composer's suite. + pub callback: Option<Box<dyn FnMut()>>, +} + +impl std::fmt::Debug for MockExpectation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MockExpectation") + .field("cmd", &self.cmd) + .field("return", &self.r#return) + .field("stdout", &self.stdout) + .field("stderr", &self.stderr) + .field("callback", &self.callback.is_some()) + .finish() + } +} + +impl MockExpectation { + /// Builds an expectation from just a command (string or list), defaulting the rest, matching + /// PHP's handling of bare `string`/`list` entries in `expects`. + pub fn from_cmd(cmd: PhpMixed) -> Self { + Self { + cmd, + r#return: 0, + stdout: String::new(), + stderr: String::new(), + callback: None, + } + } +} + +/// The `{return, stdout, stderr}` default-handler triple used for unmatched commands in non-strict +/// mode (`$defaultHandler` in PHP). +#[derive(Debug, Clone)] +pub struct MockHandler { + pub r#return: i64, + pub stdout: String, + pub stderr: String, +} + +impl Default for MockHandler { + fn default() -> Self { + Self { + r#return: 0, + stdout: String::new(), + stderr: String::new(), + } + } } struct Job { @@ -105,6 +176,7 @@ impl ProcessExecutor { max_jobs: 10, id_gen: 0, allow_async: false, + mock: None, }; this.reset_max_jobs(); this @@ -297,6 +369,10 @@ impl ProcessExecutor { where O: IntoExecOutput<'o>, { + if self.mock.is_some() { + return self.mock_do_execute(command, cwd, output); + } + self.output_command_run(&command, cwd, false); self.capture_output = output.capture_output(); @@ -334,12 +410,196 @@ impl ProcessExecutor { .unwrap_or(0)) } + /// Mock replacement for `do_execute` when [`Self::mock`] is set (cf. + /// `ProcessExecutorMock::doExecute`). Logs the command, matches it against the head of the + /// expectation queue (exact `===`), pops on match (firing the optional callback), falls back to + /// the default handler in non-strict mode, or panics in strict mode. Emits stdout/stderr through + /// the output target and records `error_output`. + fn mock_do_execute<'o, O>( + &mut self, + command: PhpMixed, + cwd: Option<&str>, + mut output: O, + ) -> Result<i64> + where + O: IntoExecOutput<'o>, + { + let capture_output = output.capture_output(); + self.capture_output = capture_output; + self.error_output = String::new(); + + let command_string = if is_array(&command) { + match &command { + PhpMixed::List(l) => implode( + " ", + &l.iter() + .map(|v| v.as_string().unwrap_or("").to_string()) + .collect::<Vec<_>>(), + ), + PhpMixed::Array(m) => implode( + " ", + &m.values() + .map(|v| v.as_string().unwrap_or("").to_string()) + .collect::<Vec<_>>(), + ), + _ => String::new(), + } + } else { + command.as_string().unwrap_or("").to_string() + }; + + let mock = self.mock.as_mut().unwrap(); + mock.log.push(command_string.clone()); + + let matched = mock + .expectations + .as_ref() + .map(|exps| !exps.is_empty() && exps[0].cmd == command) + .unwrap_or(false); + + let (stdout, stderr, r#return); + if matched { + let mut expect = mock.expectations.as_mut().unwrap().remove(0); + stdout = expect.stdout.clone(); + stderr = expect.stderr.clone(); + r#return = expect.r#return; + if let Some(callback) = expect.callback.as_mut() { + callback(); + } + } else if !mock.strict { + stdout = mock.default_handler.stdout.clone(); + stderr = mock.default_handler.stderr.clone(); + r#return = mock.default_handler.r#return; + } else { + let expected = mock + .expectations + .as_ref() + .filter(|exps| !exps.is_empty()) + .map(|exps| format!("Expected {:?} at this point.", exps[0].cmd)) + .unwrap_or_else(|| "Expected no more calls at this point.".to_string()); + let received = mock.log[..mock.log.len().saturating_sub(1)].join(PHP_EOL); + panic!( + "Received unexpected command {:?} in \"{}\"{}{}{}Received calls:{}{}", + command, + cwd.unwrap_or(""), + PHP_EOL, + expected, + PHP_EOL, + PHP_EOL, + received + ); + } + + // Feed stdout/stderr through the output target, mirroring the PHP `$callback(...)` calls. + match output.to_callback() { + Ok(mut callback) => { + if !stdout.is_empty() { + callback(Process::OUT, &stdout); + } + if !stderr.is_empty() { + callback(Process::ERR, &stderr); + } + } + Err(mut out) => { + let mut io = self.io.clone(); + if !stdout.is_empty() { + Self::output_handler(capture_output, &mut io, Process::OUT, &stdout); + } + if !stderr.is_empty() { + Self::output_handler(capture_output, &mut io, Process::ERR, &stderr); + } + if capture_output { + out.write_back(stdout.clone()); + } + } + } + + self.error_output = stderr; + + Ok(r#return) + } + + /// For testing only. Configures the mock expectation queue (cf. `ProcessExecutorMock::expects`). + /// Activates the mock branch in `do_execute`/`execute_async`. + pub fn __expects( + &mut self, + expectations: Vec<MockExpectation>, + strict: bool, + default_handler: MockHandler, + ) { + self.mock = Some(ProcessExecutorMockState { + expectations: Some(expectations), + strict, + default_handler, + log: Vec::new(), + }); + } + + /// For testing only. Asserts all configured expectations were consumed (cf. + /// `ProcessExecutorMock::assertComplete`). Panics with the remaining/received commands otherwise. + pub fn __assert_complete(&self) { + let Some(mock) = self.mock.as_ref() else { + return; + }; + // Not configured to expect anything, so no need to react here. + let Some(expectations) = mock.expectations.as_ref() else { + return; + }; + + if !expectations.is_empty() { + let remaining: Vec<String> = expectations + .iter() + .map(|expect| { + if is_array(&expect.cmd) { + match &expect.cmd { + PhpMixed::List(l) => implode( + " ", + &l.iter() + .map(|v| v.as_string().unwrap_or("").to_string()) + .collect::<Vec<_>>(), + ), + PhpMixed::Array(m) => implode( + " ", + &m.values() + .map(|v| v.as_string().unwrap_or("").to_string()) + .collect::<Vec<_>>(), + ), + _ => String::new(), + } + } else { + expect.cmd.as_string().unwrap_or("").to_string() + } + }) + .collect(); + panic!( + "There are still {} expected process calls which have not been consumed:{}{}{}{}Received calls:{}{}", + expectations.len(), + PHP_EOL, + remaining.join(PHP_EOL), + PHP_EOL, + PHP_EOL, + PHP_EOL, + mock.log.join(PHP_EOL) + ); + } + } + /// starts a process on the commandline in async mode pub async fn execute_async<C>(&mut self, command: C, cwd: Option<&str>) -> Result<Process> where C: IntoExecCommand, { let command = command.into_exec_command(); + if self.mock.is_some() { + // PHP resolves the promise with a Process mock whose getOutput/isSuccessful/getExitCode + // reflect the doExecute result. We consume the expectation/log via mock_do_execute, but + // cannot fabricate a Process mock here: Process has no test seam in the external-packages + // crate (out of scope to modify). No portable test currently exercises the async mock + // path, so leave it unimplemented rather than returning a misleading Process. + let mut captured = PhpMixed::String(String::new()); + let _result = self.mock_do_execute(command, cwd, &mut captured)?; + todo!("ProcessExecutorMock async path needs a Process mock seam in external-packages"); + } if !self.allow_async { return Err(LogicException { message: "You must use the ProcessExecutor instance which is part of a Composer\\Loop instance to be able to run async processes".to_string(), diff --git a/crates/shirabe/tests/common/process_executor_mock.rs b/crates/shirabe/tests/common/process_executor_mock.rs new file mode 100644 index 0000000..b81c8bb --- /dev/null +++ b/crates/shirabe/tests/common/process_executor_mock.rs @@ -0,0 +1,101 @@ +//! ref: composer/tests/Composer/Test/Mock/ProcessExecutorMock.php + +use std::cell::RefCell; +use std::rc::Rc; + +use shirabe::util::process_executor::{MockExpectation, MockHandler, ProcessExecutor}; +use shirabe_php_shim::PhpMixed; + +// A command expectation as written in the PHP tests: either a bare command +// (`'git command'` / `['git', '--version']`) or the full +// `{cmd, return?, stdout?, stderr?}` associative form. +pub fn cmd(command: impl IntoMockCmd) -> MockExpectation { + MockExpectation::from_cmd(command.into_mock_cmd()) +} + +pub fn cmd_full( + command: impl IntoMockCmd, + r#return: i64, + stdout: impl Into<String>, + stderr: impl Into<String>, +) -> MockExpectation { + MockExpectation { + cmd: command.into_mock_cmd(), + r#return, + stdout: stdout.into(), + stderr: stderr.into(), + callback: None, + } +} + +// Accepts the same command shapes the tests use: a string command or a list of +// string args. Comparison against the executed command is exact (PHP `===`), so +// the form here must match the form the code under test passes to `execute`. +pub trait IntoMockCmd { + fn into_mock_cmd(self) -> PhpMixed; +} + +impl IntoMockCmd for &str { + fn into_mock_cmd(self) -> PhpMixed { + PhpMixed::String(self.to_string()) + } +} + +impl IntoMockCmd for String { + fn into_mock_cmd(self) -> PhpMixed { + PhpMixed::String(self) + } +} + +impl IntoMockCmd for Vec<&str> { + fn into_mock_cmd(self) -> PhpMixed { + PhpMixed::List( + self.into_iter() + .map(|s| PhpMixed::String(s.to_string())) + .collect(), + ) + } +} + +impl IntoMockCmd for Vec<String> { + fn into_mock_cmd(self) -> PhpMixed { + PhpMixed::List(self.into_iter().map(PhpMixed::String).collect()) + } +} + +impl<const N: usize> IntoMockCmd for [&str; N] { + fn into_mock_cmd(self) -> PhpMixed { + PhpMixed::List( + self.iter() + .map(|s| PhpMixed::String(s.to_string())) + .collect(), + ) + } +} + +pub struct ProcessExecutorMockGuard(Rc<RefCell<ProcessExecutor>>); + +impl Drop for ProcessExecutorMockGuard { + fn drop(&mut self) { + // Avoid aborting on a double panic when a test assertion is already unwinding. + if std::thread::panicking() { + return; + } + self.0.borrow().__assert_complete(); + } +} + +// For testing only. Mirrors TestCase::getProcessExecutorMock: returns a shared +// ProcessExecutor handle configured with the given expectations, plus a guard +// that runs `__assert_complete` when it drops at the end of the test scope. +pub fn get_process_executor_mock( + expectations: Vec<MockExpectation>, + strict: bool, + default_handler: MockHandler, +) -> (Rc<RefCell<ProcessExecutor>>, ProcessExecutorMockGuard) { + let process = Rc::new(RefCell::new(ProcessExecutor::new(None))); + process + .borrow_mut() + .__expects(expectations, strict, default_handler); + (process.clone(), ProcessExecutorMockGuard(process)) +} diff --git a/crates/shirabe/tests/util/main.rs b/crates/shirabe/tests/util/main.rs index e779fe0..bf23c6b 100644 --- a/crates/shirabe/tests/util/main.rs +++ b/crates/shirabe/tests/util/main.rs @@ -1,3 +1,6 @@ +#[path = "../common/process_executor_mock.rs"] +mod process_executor_mock; + mod auth_helper_test; mod bitbucket_test; mod config_validator_test; diff --git a/crates/shirabe/tests/util/process_executor_test.rs b/crates/shirabe/tests/util/process_executor_test.rs index ba3177f..342c7ff 100644 --- a/crates/shirabe/tests/util/process_executor_test.rs +++ b/crates/shirabe/tests/util/process_executor_test.rs @@ -204,3 +204,64 @@ fn test_escape_argument() { assert_eq!(unix, ProcessExecutor::escape(argument)); } } + +// Exercises the ProcessExecutorMock infrastructure (cf. +// composer/tests/Composer/Test/Mock/ProcessExecutorMock.php): expectations are matched in order, +// stdout is captured back, stderr surfaces via get_error_output, and assert_complete (via the +// guard) verifies the queue was fully consumed. +#[test] +fn test_mock_consumes_expectations_in_order() { + use crate::process_executor_mock::{cmd_full, get_process_executor_mock}; + use shirabe::util::process_executor::MockHandler; + + let (process, _guard) = get_process_executor_mock( + vec![ + cmd_full("git command", 1, "out one", ""), + cmd_full(["git", "--version"], 0, "git version 2.0.0", "warn"), + ], + true, + MockHandler::default(), + ); + + let mut output = String::new(); + let rc = process + .borrow_mut() + .execute("git command", &mut output, None) + .unwrap(); + assert_eq!(1, rc); + assert_eq!("out one", output); + + let mut output2 = String::new(); + let rc2 = process + .borrow_mut() + .execute(&["git", "--version"], &mut output2, None) + .unwrap(); + assert_eq!(0, rc2); + assert_eq!("git version 2.0.0", output2); + assert_eq!("warn", process.borrow().get_error_output()); +} + +// Non-strict mode falls back to the default handler for unexpected commands. +#[test] +fn test_mock_default_handler_for_unexpected_command() { + use crate::process_executor_mock::get_process_executor_mock; + use shirabe::util::process_executor::MockHandler; + + let (process, _guard) = get_process_executor_mock( + vec![], + false, + MockHandler { + r#return: 7, + stdout: "fallback".to_string(), + stderr: String::new(), + }, + ); + + let mut output = String::new(); + let rc = process + .borrow_mut() + .execute("anything goes", &mut output, None) + .unwrap(); + assert_eq!(7, rc); + assert_eq!("fallback", output); +} |
