aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-06-25 14:46:55 +0900
committernsfisis <nsfisis@gmail.com>2026-06-26 00:19:42 +0900
commit9c6f614aa6ddb5d91340e34b0136be6a14712d63 (patch)
tree35973a35532ada33fe05805995b2e5b5e7bee1bc
parentd0336078c5b63b174e7313d54d973a1832228928 (diff)
downloadphp-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>
-rw-r--r--crates/shirabe-external-packages/src/symfony/console/style/symfony_style.rs40
-rw-r--r--crates/shirabe/src/util/process_executor.rs268
-rw-r--r--crates/shirabe/tests/common/process_executor_mock.rs101
-rw-r--r--crates/shirabe/tests/util/main.rs3
-rw-r--r--crates/shirabe/tests/util/process_executor_test.rs61
5 files changed, 457 insertions, 16 deletions
diff --git a/crates/shirabe-external-packages/src/symfony/console/style/symfony_style.rs b/crates/shirabe-external-packages/src/symfony/console/style/symfony_style.rs
index d7b2ef6..a5508bf 100644
--- a/crates/shirabe-external-packages/src/symfony/console/style/symfony_style.rs
+++ b/crates/shirabe-external-packages/src/symfony/console/style/symfony_style.rs
@@ -91,8 +91,11 @@ impl SymfonyStyle {
escape: bool,
) {
let messages: Vec<PhpMixed> = if shirabe_php_shim::is_array(&messages) {
- // array_values($messages)
- todo!()
+ match messages {
+ PhpMixed::Array(entries) => entries.into_values().collect(),
+ PhpMixed::List(items) => items,
+ _ => unreachable!("value is an array past the is_array guard"),
+ }
} else {
vec![messages]
};
@@ -207,8 +210,8 @@ impl SymfonyStyle {
_iterable: Vec<PhpMixed>,
_max: Option<i64>,
) -> Vec<PhpMixed> {
- // yield from $this->createProgressBar()->iterate($iterable, $max);
- // $this->newLine(2);
+ // TODO(phase-c/d): PHP uses `yield from`; porting the generator semantics of
+ // ProgressBar::iterate() requires a streaming design not yet in place.
todo!()
}
@@ -436,6 +439,8 @@ impl SymfonyStyle {
self.output.borrow().get_formatter()
}
+ // TODO(phase-c/d): downcasting `dyn OutputInterface` to `dyn ConsoleOutputInterface`
+ // is not expressible with the current trait design (same as `output_style.rs`).
fn is_console_output_interface(_output: &Rc<RefCell<dyn OutputInterface>>) -> bool {
todo!()
}
@@ -446,12 +451,14 @@ impl SymfonyStyle {
todo!()
}
+ // TODO(phase-c/d): `PhpMixed` cannot carry a `TableSeparator` object, so the
+ // `$value instanceof TableSeparator` check has no faithful representation yet.
fn is_table_separator(_value: &PhpMixed) -> bool {
todo!()
}
- fn php_string(_value: &PhpMixed) -> String {
- todo!()
+ fn php_string(value: &PhpMixed) -> String {
+ shirabe_php_shim::strval(value)
}
/// Bridges the `StyleInterface` validator (which yields `anyhow::Error`) to the
@@ -481,8 +488,11 @@ impl SymfonyStyle {
let messages: Vec<PhpMixed> = if !shirabe_php_shim::is_iterable(&messages) {
vec![messages]
} else {
- // iterate over the iterable
- todo!()
+ match messages {
+ PhpMixed::Array(entries) => entries.into_values().collect(),
+ PhpMixed::List(items) => items,
+ _ => unreachable!("value is iterable past the is_iterable guard"),
+ }
};
for message in messages {
@@ -497,8 +507,11 @@ impl SymfonyStyle {
let messages: Vec<PhpMixed> = if !shirabe_php_shim::is_iterable(&messages) {
vec![messages]
} else {
- // iterate over the iterable
- todo!()
+ match messages {
+ PhpMixed::Array(entries) => entries.into_values().collect(),
+ PhpMixed::List(items) => items,
+ _ => unreachable!("value is iterable past the is_iterable guard"),
+ }
};
for message in messages {
@@ -581,8 +594,11 @@ impl StyleInterface for SymfonyStyle {
self.auto_prepend_text();
let messages: Vec<PhpMixed> = if shirabe_php_shim::is_array(&message) {
- // array_values($message)
- todo!()
+ match message {
+ PhpMixed::Array(entries) => entries.into_values().collect(),
+ PhpMixed::List(items) => items,
+ _ => unreachable!("value is an array past the is_array guard"),
+ }
} else {
vec![message]
};
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);
+}