diff options
Diffstat (limited to 'crates')
13 files changed, 785 insertions, 64 deletions
diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs b/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs index f2c2a00..9eb8fff 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/question_helper.rs @@ -66,12 +66,11 @@ impl QuestionHelper { return Ok(Ok(self.get_default_answer(question))); } - // TODO(phase-b): `$input instanceof StreamableInputInterface` cannot be - // expressed as a trait-object-to-trait-object downcast, and no concrete - // streamable input type is wired up yet. The stream is left unset so the - // helper falls back to STDIN. Revisit once StreamableInputInterface has a - // concrete implementor and the PhpResource/PhpMixed split is resolved. - let _ = &mut self.input_stream; + if let Some(streamable) = input.as_streamable() + && let Some(stream) = streamable.get_stream() + { + self.input_stream = Some(stream); + } let result: anyhow::Result<Result<PhpMixed, MissingInputException>> = (|| { if question.get_validator().is_none() { @@ -344,10 +343,7 @@ impl QuestionHelper { input_stream: &shirabe_php_shim::PhpResource, autocomplete: &dyn Fn(&str) -> Vec<PhpMixed>, ) -> String { - // TODO(phase-b): Cursor takes Option<PhpMixed>, but the input stream is a - // PhpResource and PhpMixed has no resource variant. Defaulting to STDIN - // until the PhpResource/PhpMixed stream representation is unified. - let cursor = Cursor::new(Rc::clone(&output), None); + let cursor = Cursor::new(Rc::clone(&output), Some(input_stream.clone())); let mut full_choice = String::new(); let mut ret = String::new(); diff --git a/crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs b/crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs index d7ddafc..0784c7b 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/argv_input.rs @@ -619,14 +619,18 @@ impl InputInterface for ArgvInput { fn set_interactive(&mut self, interactive: bool) { self.inner.set_interactive(interactive) } + + fn as_streamable(&self) -> Option<&dyn StreamableInputInterface> { + Some(self) + } } impl StreamableInputInterface for ArgvInput { - fn set_stream(&mut self, stream: PhpMixed) { + fn set_stream(&mut self, stream: shirabe_php_shim::PhpResource) { self.inner.set_stream(stream) } - fn get_stream(&self) -> Option<PhpMixed> { + fn get_stream(&self) -> Option<shirabe_php_shim::PhpResource> { self.inner.get_stream() } } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs b/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs index 3fc9abb..327650b 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/array_input.rs @@ -5,6 +5,7 @@ use crate::symfony::console::exception::invalid_option_exception::InvalidOptionE use crate::symfony::console::input::input::Input; use crate::symfony::console::input::input_definition::InputDefinition; use crate::symfony::console::input::input_interface::InputInterface; +use crate::symfony::console::input::streamable_input_interface::StreamableInputInterface; use indexmap::IndexMap; use shirabe_php_shim::PhpMixed; @@ -373,6 +374,20 @@ impl InputInterface for ArrayInput { fn set_interactive(&mut self, interactive: bool) { self.inner.set_interactive(interactive) } + + fn as_streamable(&self) -> Option<&dyn StreamableInputInterface> { + Some(self) + } +} + +impl StreamableInputInterface for ArrayInput { + fn set_stream(&mut self, stream: shirabe_php_shim::PhpResource) { + self.inner.set_stream(stream) + } + + fn get_stream(&self) -> Option<shirabe_php_shim::PhpResource> { + self.inner.get_stream() + } } /// PHP `(array) $values` cast: a string becomes a single-element array. diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input.rs b/crates/shirabe-external-packages/src/symfony/console/input/input.rs index d13ab6c..d365f8d 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/input.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/input.rs @@ -4,7 +4,7 @@ use crate::symfony::console::exception::invalid_argument_exception::InvalidArgum use crate::symfony::console::exception::runtime_exception::RuntimeException; use crate::symfony::console::input::input_definition::InputDefinition; use indexmap::IndexMap; -use shirabe_php_shim::PhpMixed; +use shirabe_php_shim::{PhpMixed, PhpResource}; /// Input is the base class for all concrete Input classes. /// @@ -16,7 +16,7 @@ use shirabe_php_shim::PhpMixed; #[derive(Debug, Clone)] pub struct Input { pub(crate) definition: InputDefinition, - pub(crate) stream: PhpMixed, + pub(crate) stream: Option<PhpResource>, pub(crate) options: IndexMap<String, PhpMixed>, pub(crate) arguments: IndexMap<String, PhpMixed>, pub(crate) interactive: bool, @@ -26,7 +26,7 @@ impl Input { pub fn new(definition: Option<InputDefinition>) -> anyhow::Result<Self> { let mut input = Input { definition: InputDefinition::new(vec![])?, - stream: PhpMixed::Null, + stream: None, options: IndexMap::new(), arguments: IndexMap::new(), interactive: true, @@ -238,14 +238,11 @@ impl Input { } } - pub fn set_stream(&mut self, stream: PhpMixed) { - self.stream = stream; + pub fn set_stream(&mut self, stream: PhpResource) { + self.stream = Some(stream); } - pub fn get_stream(&self) -> Option<PhpMixed> { - match &self.stream { - PhpMixed::Null => None, - other => Some(other.clone()), - } + pub fn get_stream(&self) -> Option<PhpResource> { + self.stream.clone() } } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs b/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs index c115df0..ccb5a98 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/input_interface.rs @@ -1,6 +1,7 @@ //! ref: composer/vendor/symfony/console/Input/InputInterface.php use crate::symfony::console::input::input_definition::InputDefinition; +use crate::symfony::console::input::streamable_input_interface::StreamableInputInterface; use shirabe_php_shim::PhpMixed; pub trait InputInterface: std::fmt::Debug + shirabe_php_shim::AsAny { @@ -41,4 +42,10 @@ pub trait InputInterface: std::fmt::Debug + shirabe_php_shim::AsAny { fn is_interactive(&self) -> bool; fn set_interactive(&mut self, interactive: bool); + + /// Models PHP's `$input instanceof StreamableInputInterface` check. Streamable inputs override + /// this to return `Some(self)`; everything else falls back to `None`. + fn as_streamable(&self) -> Option<&dyn StreamableInputInterface> { + None + } } diff --git a/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs b/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs index f687e0e..d4bdbfd 100644 --- a/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs +++ b/crates/shirabe-external-packages/src/symfony/console/input/streamable_input_interface.rs @@ -1,10 +1,10 @@ //! ref: composer/vendor/symfony/console/Input/StreamableInputInterface.php use crate::symfony::console::input::input_interface::InputInterface; -use shirabe_php_shim::PhpMixed; +use shirabe_php_shim::PhpResource; pub trait StreamableInputInterface: InputInterface { - fn set_stream(&mut self, stream: PhpMixed); + fn set_stream(&mut self, stream: PhpResource); - fn get_stream(&self) -> Option<PhpMixed>; + fn get_stream(&self) -> Option<PhpResource>; } diff --git a/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs b/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs index 0808fef..dd6840d 100644 --- a/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs +++ b/crates/shirabe-external-packages/src/symfony/console/output/console_output.rs @@ -110,6 +110,13 @@ impl ConsoleOutput { shirabe_php_shim::stripos(&shirabe_php_shim::implode(";", &checks), "OS400").is_some() } + /// For testing only. Overwrites the inner `StreamOutput`'s private `stream` field, mirroring + /// what `Symfony\Component\Console\Tester\TesterTrait::initOutput` does via reflection on the + /// parent `StreamOutput::$stream` property of a `ConsoleOutput`. + pub fn __set_stream(&mut self, stream: shirabe_php_shim::PhpResource) { + self.inner.__set_stream(stream); + } + fn open_output_stream() -> shirabe_php_shim::PhpResource { if !Self::has_stdout_support() { return shirabe_php_shim::php_fopen_resource("php://output", "w"); diff --git a/crates/shirabe-external-packages/src/symfony/console/output/stream_output.rs b/crates/shirabe-external-packages/src/symfony/console/output/stream_output.rs index 569b348..fa63557 100644 --- a/crates/shirabe-external-packages/src/symfony/console/output/stream_output.rs +++ b/crates/shirabe-external-packages/src/symfony/console/output/stream_output.rs @@ -66,6 +66,13 @@ impl StreamOutput { &self.stream } + /// For testing only. Overwrites the private `stream` field, mirroring what + /// `Symfony\Component\Console\Tester\TesterTrait::initOutput` does via reflection on the + /// `StreamOutput::$stream` property. + pub fn __set_stream(&mut self, stream: shirabe_php_shim::PhpResource) { + self.stream = stream; + } + /// Returns true if the stream supports colorization. /// /// Colorization is disabled if not supported by the stream: diff --git a/crates/shirabe/src/command/init_command.rs b/crates/shirabe/src/command/init_command.rs index a04836d..68b4b69 100644 --- a/crates/shirabe/src/command/init_command.rs +++ b/crates/shirabe/src/command/init_command.rs @@ -623,28 +623,25 @@ impl Command for InitCommand { } ), // PHP: function ($value) use ($self, $author) { ... $author = $self->parseAuthorString($value); ... } - // TODO(phase-c): IOInterface::ask_and_validate takes a `Box<dyn Fn> + 'static` - // validator, so it cannot borrow &self to call self.parse_author_string; and - // ask_and_validate itself is a deferred QuestionHelper todo!() (see console_io). The - // validator body below therefore stays a placeholder. (parse_author_string and - // is_valid_email are stateless, so a future fix can make them associated functions and - // call them from the closure once the helper interaction is modelled.) + // The validator is a `'static` closure and cannot borrow `&self`, but + // parse_author_string is stateless (it only delegates to the stateless + // is_valid_email), so a fresh InitCommand stands in for `$self` here. Box::new(move |value: PhpMixed| -> anyhow::Result<PhpMixed> { - let value_str = value.as_string().unwrap_or("").to_string(); - if value_str == "n" || value_str == "no" { + let value_str = value.as_string().map(|s| s.to_string()); + if value_str.as_deref() == Some("n") || value_str.as_deref() == Some("no") { return Ok(PhpMixed::Null); } - let value_or_default = if value_str.is_empty() { - author_for_validate.clone() - } else { - value_str + // PHP: $value = $value ?: $author + let value = match &value_str { + Some(s) if !s.is_empty() => s.clone(), + _ => author_for_validate.clone(), }; - // PHP: $author = $self->parseAuthorString($value); return $author['email'] === null - // ? $author['name'] : sprintf('%s <%s>', $author['name'], $author['email']); - // TODO(phase-c): see the closure note above — cannot reach parse_author_string from - // this 'static validator yet. - let _ = value_or_default; - Ok(PhpMixed::Null) + let parsed = InitCommand::new().parse_author_string(&value)?; + let name = parsed.get("name").cloned().flatten().unwrap_or_default(); + match parsed.get("email").cloned().flatten() { + None => Ok(PhpMixed::String(name)), + Some(email) => Ok(PhpMixed::String(format!("{} <{}>", name, email))), + } }), None, PhpMixed::String(author_default), diff --git a/crates/shirabe/src/console/application.rs b/crates/shirabe/src/console/application.rs index 8112e18..1a661df 100644 --- a/crates/shirabe/src/console/application.rs +++ b/crates/shirabe/src/console/application.rs @@ -2585,6 +2585,10 @@ impl ApplicationHandle { outcome } + + pub fn set_catch_exceptions(&self, boolean: bool) { + self.0.borrow_mut().set_catch_exceptions(boolean); + } } impl ApplicationHandle { diff --git a/crates/shirabe/tests/command/init_command_test.rs b/crates/shirabe/tests/command/init_command_test.rs index 6b118f3..1c2fca6 100644 --- a/crates/shirabe/tests/command/init_command_test.rs +++ b/crates/shirabe/tests/command/init_command_test.rs @@ -1,12 +1,10 @@ //! ref: composer/tests/Composer/Test/Command/InitCommandTest.php -// The run cases (testRunCommand, testRunCommandInvalid, testRunGuessNameFromDirSanitizesDir, -// testInteractiveRun) drive the full command via ApplicationTester / initTempComposer, which -// does not exist here; they remain reason'd-ignore. The unit-style cases call the helper -// methods directly via `__`-prefixed test-only wrappers. - +use crate::test_case::{RunOptions, get_application_tester, init_temp_composer}; +use serial_test::serial; use shirabe::command::init_command::InitCommand; -use shirabe_php_shim::{PhpMixed, server_set}; +use shirabe::json::JsonFile; +use shirabe_php_shim::{PhpMixed, server_set, server_unset}; use tempfile::TempDir; fn set_up() { @@ -14,6 +12,45 @@ fn set_up() { server_set("COMPOSER_DEFAULT_EMAIL", "john@example.com".to_string()); } +/// const DEFAULT_AUTHORS in PHP. +fn default_authors() -> serde_json::Value { + serde_json::json!({ "name": "John Smith", "email": "john@example.com" }) +} + +/// Reads CWD's `composer.json` like PHP's `(new JsonFile(...))->read()`, projected onto a +/// `serde_json::Value` so the comparison ignores object key order (matching PHPUnit's `assertEquals` +/// on arrays) while staying order-sensitive for lists. +fn read_composer_json(dir: &std::path::Path) -> serde_json::Value { + let mut file = JsonFile::new( + dir.join("composer.json").to_string_lossy().to_string(), + None, + None, + ) + .unwrap(); + serde_json::to_value(file.read().unwrap()).unwrap() +} + +/// `['command' => 'init', '--no-interaction' => true] + $arguments`. +fn non_interactive_input(arguments: Vec<(PhpMixed, PhpMixed)>) -> Vec<(PhpMixed, PhpMixed)> { + let mut input = vec![ + (PhpMixed::from("command"), PhpMixed::from("init")), + (PhpMixed::from("--no-interaction"), PhpMixed::Bool(true)), + ]; + input.extend(arguments); + input +} + +fn opt(name: &str, value: &str) -> (PhpMixed, PhpMixed) { + (PhpMixed::from(name), PhpMixed::from(value)) +} + +fn opt_list(name: &str, values: &[&str]) -> (PhpMixed, PhpMixed) { + ( + PhpMixed::from(name), + PhpMixed::List(values.iter().map(|v| PhpMixed::from(*v)).collect()), + ) +} + /// @return iterable<string, array{0: string, 1: string|null, 2: string}> fn valid_author_string_provider() -> Vec<(&'static str, Option<&'static str>, &'static str)> { vec![ @@ -54,7 +91,6 @@ fn valid_author_string_provider() -> Vec<(&'static str, Option<&'static str>, &' ] } -#[ignore] #[test] fn test_parse_valid_author_string() { set_up(); @@ -73,7 +109,6 @@ fn test_parse_valid_author_string() { } } -#[ignore] #[test] fn test_parse_empty_author_string() { set_up(); @@ -83,7 +118,6 @@ fn test_parse_empty_author_string() { assert!(result.is_err()); } -#[ignore] #[test] fn test_parse_author_string_with_invalid_email() { set_up(); @@ -123,39 +157,416 @@ fn test_namespace_from_missing_package_name() { assert_eq!(None, namespace); } -#[ignore = "needs TestCase::init_temp_composer and get_application_tester (ApplicationTester) infrastructure, not implemented"] +/// @return iterable<string, array{0: array<string, mixed>, 1: array<string, mixed>}> +/// +/// Empty `require` is `{}` (PHP's `new \stdClass`), not `[]`, to match what the command writes: +/// shirabe's `json_decode` keeps the empty object/array distinction that PHP collapses. +fn run_data_provider() -> Vec<(serde_json::Value, Vec<(PhpMixed, PhpMixed)>)> { + vec![ + // name argument + ( + serde_json::json!({ + "name": "test/pkg", + "authors": [default_authors()], + "require": {}, + }), + vec![opt("--name", "test/pkg")], + ), + // name and author arguments + ( + serde_json::json!({ + "name": "test/pkg", + "require": {}, + "authors": [{ "name": "Mr. Test", "email": "test@example.org" }], + }), + vec![ + opt("--name", "test/pkg"), + opt("--author", "Mr. Test <test@example.org>"), + ], + ), + // name and author arguments without email + ( + serde_json::json!({ + "name": "test/pkg", + "require": {}, + "authors": [{ "name": "Mr. Test" }], + }), + vec![opt("--name", "test/pkg"), opt("--author", "Mr. Test")], + ), + // single repository argument + ( + serde_json::json!({ + "name": "test/pkg", + "authors": [default_authors()], + "require": {}, + "repositories": [{ "type": "vcs", "url": "http://packages.example.com" }], + }), + vec![ + opt("--name", "test/pkg"), + opt_list( + "--repository", + &["{\"type\":\"vcs\",\"url\":\"http://packages.example.com\"}"], + ), + ], + ), + // multiple repository arguments + ( + serde_json::json!({ + "name": "test/pkg", + "authors": [default_authors()], + "require": {}, + "repositories": [ + { "type": "vcs", "url": "http://vcs.example.com" }, + { "type": "composer", "url": "http://composer.example.com" }, + { + "type": "composer", + "url": "http://composer2.example.com", + "options": { "ssl": { "verify_peer": "true" } }, + }, + ], + }), + vec![ + opt("--name", "test/pkg"), + opt_list( + "--repository", + &[ + "{\"type\":\"vcs\",\"url\":\"http://vcs.example.com\"}", + "{\"type\":\"composer\",\"url\":\"http://composer.example.com\"}", + "{\"type\":\"composer\",\"url\":\"http://composer2.example.com\",\"options\":{\"ssl\":{\"verify_peer\":\"true\"}}}", + ], + ), + ], + ), + // stability argument + ( + serde_json::json!({ + "name": "test/pkg", + "authors": [default_authors()], + "require": {}, + "minimum-stability": "dev", + }), + vec![opt("--name", "test/pkg"), opt("--stability", "dev")], + ), + // require one argument + ( + serde_json::json!({ + "name": "test/pkg", + "authors": [default_authors()], + "require": { "first/pkg": "1.0.0" }, + }), + vec![ + opt("--name", "test/pkg"), + opt_list("--require", &["first/pkg:1.0.0"]), + ], + ), + // require multiple arguments + ( + serde_json::json!({ + "name": "test/pkg", + "authors": [default_authors()], + "require": { "first/pkg": "1.0.0", "second/pkg": "^3.4" }, + }), + vec![ + opt("--name", "test/pkg"), + opt_list("--require", &["first/pkg:1.0.0", "second/pkg:^3.4"]), + ], + ), + // require-dev one argument + ( + serde_json::json!({ + "name": "test/pkg", + "authors": [default_authors()], + "require": {}, + "require-dev": { "first/pkg": "1.0.0" }, + }), + vec![ + opt("--name", "test/pkg"), + opt_list("--require-dev", &["first/pkg:1.0.0"]), + ], + ), + // require-dev multiple arguments + ( + serde_json::json!({ + "name": "test/pkg", + "authors": [default_authors()], + "require": {}, + "require-dev": { "first/pkg": "1.0.0", "second/pkg": "^3.4" }, + }), + vec![ + opt("--name", "test/pkg"), + opt_list("--require-dev", &["first/pkg:1.0.0", "second/pkg:^3.4"]), + ], + ), + // autoload argument + ( + serde_json::json!({ + "name": "test/pkg", + "authors": [default_authors()], + "require": {}, + "autoload": { "psr-4": { "Test\\Pkg\\": "testMapping/" } }, + }), + vec![opt("--name", "test/pkg"), opt("--autoload", "testMapping/")], + ), + // homepage argument + ( + serde_json::json!({ + "name": "test/pkg", + "authors": [default_authors()], + "require": {}, + "homepage": "https://example.org/", + }), + vec![ + opt("--name", "test/pkg"), + opt("--homepage", "https://example.org/"), + ], + ), + // description argument + ( + serde_json::json!({ + "name": "test/pkg", + "authors": [default_authors()], + "require": {}, + "description": "My first example package", + }), + vec![ + opt("--name", "test/pkg"), + opt("--description", "My first example package"), + ], + ), + // type argument + ( + serde_json::json!({ + "name": "test/pkg", + "authors": [default_authors()], + "require": {}, + "type": "project", + }), + vec![opt("--name", "test/pkg"), opt("--type", "project")], + ), + // license argument + ( + serde_json::json!({ + "name": "test/pkg", + "authors": [default_authors()], + "require": {}, + "license": "MIT", + }), + vec![opt("--name", "test/pkg"), opt("--license", "MIT")], + ), + ] +} + #[test] +#[serial] +#[ignore = "drives InitCommand, which calls get_git_config -> ProcessExecutor::run_process; \ + that path is not ported (shim is_callable and Process::start/run are todo!())"] fn test_run_command() { set_up(); - todo!() + for (expected, arguments) in run_data_provider() { + let tear_down = init_temp_composer(None, None, None, true); + let dir = tear_down.working_dir(); + std::fs::remove_file(dir.join("composer.json")).unwrap(); + std::fs::remove_file(dir.join("auth.json")).unwrap(); + + let mut app_tester = get_application_tester(); + app_tester + .run(non_interactive_input(arguments), RunOptions::default()) + .unwrap(); + + assert_eq!(0, app_tester.get_status_code()); + + assert_eq!(expected, read_composer_json(&dir)); + } +} + +/// Either the run throws (optionally carrying a message), or it returns exit code 1 and writes a +/// message matching the regex to stderr. +enum InvalidExpectation { + Throws(Option<&'static str>), + StderrMatches(&'static str), +} + +/// @return iterable<string, array{0: class-string<\Throwable>|null, 1: string|null, 2: array<string, mixed>}> +fn run_invalid_data_provider() -> Vec<(InvalidExpectation, Vec<(PhpMixed, PhpMixed)>)> { + vec![ + // invalid name argument + ( + InvalidExpectation::Throws(None), + vec![opt("--name", "test")], + ), + // invalid author argument + ( + InvalidExpectation::Throws(None), + vec![ + opt("--name", "test/pkg"), + opt("--author", "Mr. Test <test>"), + ], + ), + // invalid stability argument + ( + InvalidExpectation::StderrMatches( + r"minimum-stability\s+:\s+Does not have a value in the enumeration", + ), + vec![opt("--name", "test/pkg"), opt("--stability", "bogus")], + ), + // invalid require argument + ( + InvalidExpectation::Throws(Some( + "Option first is missing a version constraint, use e.g. first:^1.0", + )), + vec![opt("--name", "test/pkg"), opt_list("--require", &["first"])], + ), + // invalid require-dev argument + ( + InvalidExpectation::Throws(Some( + "Option first is missing a version constraint, use e.g. first:^1.0", + )), + vec![ + opt("--name", "test/pkg"), + opt_list("--require-dev", &["first"]), + ], + ), + // invalid homepage argument + ( + InvalidExpectation::StderrMatches(r"homepage\s*:\s*Invalid URL format"), + vec![opt("--name", "test/pkg"), opt("--homepage", "not-a-url")], + ), + ] } -#[ignore = "needs TestCase::init_temp_composer and get_application_tester (ApplicationTester) infrastructure, not implemented"] #[test] +#[serial] +#[ignore = "drives InitCommand, which calls get_git_config -> ProcessExecutor::run_process; \ + that path is not ported (shim is_callable and Process::start/run are todo!())"] fn test_run_command_invalid() { set_up(); - todo!() + for (expectation, arguments) in run_invalid_data_provider() { + let tear_down = init_temp_composer(None, None, None, true); + let dir = tear_down.working_dir(); + std::fs::remove_file(dir.join("composer.json")).unwrap(); + std::fs::remove_file(dir.join("auth.json")).unwrap(); + + let mut app_tester = get_application_tester(); + let options = RunOptions { + capture_stderr_separately: true, + ..Default::default() + }; + let result = app_tester.run(non_interactive_input(arguments), options); + + match expectation { + InvalidExpectation::Throws(message) => { + let error = result.expect_err("expected the command to surface an exception"); + if let Some(message) = message { + let rendered = format!("{:#}", error); + assert!( + rendered.contains(message), + "error {:?} did not contain {:?}", + rendered, + message + ); + } + } + InvalidExpectation::StderrMatches(pattern) => { + result.unwrap(); + assert_eq!(1, app_tester.get_status_code()); + let regex = regex::Regex::new(pattern).unwrap(); + let stderr = app_tester.get_error_output(); + assert!( + regex.is_match(&stderr), + "stderr {:?} did not match {:?}", + stderr, + pattern + ); + } + } + } } -#[ignore = "needs TestCase::init_temp_composer and get_application_tester (ApplicationTester) infrastructure, not implemented"] #[test] +#[serial] +#[ignore = "drives InitCommand, which calls get_git_config -> ProcessExecutor::run_process; \ + that path is not ported (shim is_callable and Process::start/run are todo!())"] fn test_run_guess_name_from_dir_sanitizes_dir() { set_up(); - todo!() + let tear_down = init_temp_composer(None, None, None, true); + + let dir_name = "_foo_--bar__baz.--..qux__"; + std::fs::create_dir(dir_name).unwrap(); + std::env::set_current_dir(dir_name).unwrap(); + + server_set("COMPOSER_DEFAULT_VENDOR", ".vendorName".to_string()); + + let mut app_tester = get_application_tester(); + let result = app_tester.run(non_interactive_input(vec![]), RunOptions::default()); + + server_unset("COMPOSER_DEFAULT_VENDOR"); + result.unwrap(); + + assert_eq!(0, app_tester.get_status_code()); + + let expected = serde_json::json!({ + "name": "vendor-name/foo-bar_baz.qux", + "authors": [default_authors()], + "require": {}, + }); + assert_eq!( + expected, + read_composer_json(&std::env::current_dir().unwrap()) + ); + + drop(tear_down); } -#[ignore = "needs TestCase::init_temp_composer and get_application_tester (ApplicationTester with set_inputs) infrastructure, not implemented"] #[test] +#[serial] +#[ignore = "drives InitCommand, which calls get_git_config -> ProcessExecutor::run_process; \ + that path is not ported (shim is_callable and Process::start/run are todo!())"] fn test_interactive_run() { set_up(); - todo!() + let tear_down = init_temp_composer(None, None, None, true); + let dir = tear_down.working_dir(); + std::fs::remove_file(dir.join("composer.json")).unwrap(); + std::fs::remove_file(dir.join("auth.json")).unwrap(); + + let mut app_tester = get_application_tester(); + app_tester.set_inputs(vec![ + "vendor/pkg".to_string(), // Pkg name + "my description".to_string(), // Description + "Mr. Test <test@example.org>".to_string(), // Author + "stable".to_string(), // Minimum stability + "library".to_string(), // Type + "AGPL-3.0-only".to_string(), // License + "no".to_string(), // Define dependencies + "no".to_string(), // Define dev dependencies + "n".to_string(), // Add PSR-4 autoload mapping + "".to_string(), // Confirm generation + ]); + + app_tester + .run( + vec![(PhpMixed::from("command"), PhpMixed::from("init"))], + RunOptions::default(), + ) + .unwrap(); + + assert_eq!(0, app_tester.get_status_code()); + + let expected = serde_json::json!({ + "name": "vendor/pkg", + "description": "my description", + "type": "library", + "license": "AGPL-3.0-only", + "authors": [{ "name": "Mr. Test", "email": "test@example.org" }], + "minimum-stability": "stable", + "require": {}, + }); + assert_eq!(expected, read_composer_json(&dir)); } -#[ignore] #[test] fn test_format_authors() { set_up(); @@ -185,8 +596,9 @@ fn test_format_authors() { assert_eq!(expected, authors[0]); } -#[ignore] #[test] +#[ignore = "calls get_git_config -> ProcessExecutor::run_process, which is not ported \ + (shim is_callable and Process::start/run are todo!())"] fn test_get_git_config() { set_up(); @@ -196,7 +608,6 @@ fn test_get_git_config() { assert!(git_config.contains_key("user.email")); } -#[ignore] #[test] fn test_add_vendor_ignore() { set_up(); @@ -212,7 +623,6 @@ fn test_add_vendor_ignore() { assert!(content.contains("/vendor/")); } -#[ignore] #[test] fn test_has_vendor_ignore() { set_up(); diff --git a/crates/shirabe/tests/command/main.rs b/crates/shirabe/tests/command/main.rs index 287fb27..78345fe 100644 --- a/crates/shirabe/tests/command/main.rs +++ b/crates/shirabe/tests/command/main.rs @@ -1,3 +1,6 @@ +#[path = "../common/test_case.rs"] +mod test_case; + mod about_command_test; mod archive_command_test; mod audit_command_test; diff --git a/crates/shirabe/tests/common/test_case.rs b/crates/shirabe/tests/common/test_case.rs index 71c49a9..4902448 100644 --- a/crates/shirabe/tests/common/test_case.rs +++ b/crates/shirabe/tests/common/test_case.rs @@ -4,11 +4,25 @@ //! `#[path = "../common/test_case.rs"] mod test_case;`. #![allow(dead_code)] +use shirabe::console::application::ApplicationHandle; use shirabe::package::handle::{ CompleteAliasPackageHandle, CompletePackageHandle, PackageInterfaceHandle, }; +use shirabe::util::platform::Platform; +use shirabe_external_packages::symfony::console::input::array_input::ArrayInput; +use shirabe_external_packages::symfony::console::input::input_interface::InputInterface; +use shirabe_external_packages::symfony::console::input::streamable_input_interface::StreamableInputInterface; +use shirabe_external_packages::symfony::console::output::console_output::ConsoleOutput; +use shirabe_external_packages::symfony::console::output::console_output_interface::ConsoleOutputInterface; +use shirabe_external_packages::symfony::console::output::output_interface::OutputInterface; +use shirabe_external_packages::symfony::console::output::stream_output::StreamOutput; +use shirabe_php_shim::{PhpMixed, PhpResource}; use shirabe_semver::constraint::{AnyConstraint, SimpleConstraint}; use shirabe_semver::version_parser::VersionParser; +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; +use tempfile::TempDir; /// ref: TestCase::getPackage (default class CompletePackage) pub fn get_package(name: &str, version: &str) -> PackageInterfaceHandle { @@ -35,3 +49,263 @@ pub fn get_version_constraint(operator: &str, version: &str) -> AnyConstraint { Some(format!("{} {}", operator, version)), )) } + +/// ref: TestCase::initTempComposer plus the running TearDown. +/// +/// Creates a fresh temp dir, chdir()s into it, points `COMPOSER_HOME` at it, and writes +/// `composer.json`/`auth.json` (and `composer.lock` when given). The returned guard restores the +/// previous cwd / `COMPOSER_HOME` and removes the temp tree on drop, mirroring PHPUnit's `tearDown`. +pub struct TearDown { + temp_dir: TempDir, + prev_cwd: PathBuf, + prev_composer_home: Option<String>, +} + +impl TearDown { + /// The temp directory created by `init_temp_composer`. Equivalent to the `$dir` returned by PHP. + pub fn working_dir(&self) -> PathBuf { + self.temp_dir.path().to_path_buf() + } +} + +impl Drop for TearDown { + fn drop(&mut self) { + // Restore the cwd before the TempDir field is dropped so the tree (possibly the cwd itself) + // can be removed cleanly, even on a panicking test. + let _ = std::env::set_current_dir(&self.prev_cwd); + match &self.prev_composer_home { + Some(value) => Platform::put_env("COMPOSER_HOME", value), + None => Platform::clear_env("COMPOSER_HOME"), + } + } +} + +/// ref: TestCase::initTempComposer +pub fn init_temp_composer( + composer_json: Option<&serde_json::Value>, + auth_json: Option<&serde_json::Value>, + composer_lock: Option<&serde_json::Value>, + setup_repositories: bool, +) -> TearDown { + let temp_dir = TempDir::new().unwrap(); + let dir = temp_dir.path().to_path_buf(); + + let prev_cwd = std::env::current_dir().unwrap(); + let prev_composer_home = Platform::get_env("COMPOSER_HOME"); + + Platform::put_env("COMPOSER_HOME", &format!("{}/composer-home", dir.display())); + Platform::put_env("COMPOSER_DISABLE_XDEBUG_WARN", "1"); + + let mut composer_json = composer_json + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let auth_json = auth_json.cloned().unwrap_or_else(|| serde_json::json!({})); + + if setup_repositories && let Some(repositories) = composer_json.get("repositories").cloned() { + let packagist_false = serde_json::json!({ "packagist.org": false }); + let already_present = match &repositories { + serde_json::Value::Object(map) => map.contains_key("packagist.org"), + serde_json::Value::Array(list) => list.iter().any(|r| r == &packagist_false), + _ => false, + }; + if !already_present { + match composer_json + .get_mut("repositories") + .and_then(|r| r.as_array_mut()) + { + Some(list) => list.push(packagist_false), + None => { + if let Some(map) = composer_json + .get_mut("repositories") + .and_then(|r| r.as_object_mut()) + { + map.insert("packagist.org".to_string(), serde_json::Value::Bool(false)); + } + } + } + } + } + + std::env::set_current_dir(&dir).unwrap(); + std::fs::write( + dir.join("composer.json"), + serde_json::to_string_pretty(&composer_json).unwrap(), + ) + .unwrap(); + std::fs::write( + dir.join("auth.json"), + serde_json::to_string_pretty(&auth_json).unwrap(), + ) + .unwrap(); + if let Some(composer_lock) = composer_lock { + std::fs::write( + dir.join("composer.lock"), + serde_json::to_string_pretty(composer_lock).unwrap(), + ) + .unwrap(); + } + + TearDown { + temp_dir, + prev_cwd, + prev_composer_home, + } +} + +/// ref: TestCase::getApplicationTester +pub fn get_application_tester() -> ApplicationTester { + let application = ApplicationHandle::new("Composer".to_string(), "".to_string()).unwrap(); + application.set_catch_exceptions(false); + ApplicationTester::new(application) +} + +/// ref: Symfony\Component\Console\Tester\ApplicationTester::run options. +/// +/// Lives in the test harness (not `shirabe_external_packages`) because the tester drives shirabe's +/// own `ApplicationHandle`, which the external-packages crate cannot depend on. +#[derive(Debug, Default)] +pub struct RunOptions { + pub interactive: Option<bool>, + pub decorated: Option<bool>, + pub verbosity: Option<i64>, + pub capture_stderr_separately: bool, +} + +/// ref: Symfony\Component\Console\Tester\ApplicationTester (with TesterTrait inlined). +/// +/// The shared `TesterTrait` logic is inlined here; revisit common extraction when `CommandTester` +/// is ported. +pub struct ApplicationTester { + application: ApplicationHandle, + inputs: Vec<String>, + status_code: Option<i32>, + output: Option<Rc<RefCell<dyn OutputInterface>>>, + /// Handles retained before injection so `get_display`/`get_error_output` can read the memory + /// streams without relying on a `get_stream()` accessor across the ConsoleOutput composition gap. + output_stream: Option<PhpResource>, + error_stream: Option<PhpResource>, + capture_streams_independently: bool, +} + +impl ApplicationTester { + pub fn new(application: ApplicationHandle) -> Self { + Self { + application, + inputs: Vec::new(), + status_code: None, + output: None, + output_stream: None, + error_stream: None, + capture_streams_independently: false, + } + } + + pub fn set_inputs(&mut self, inputs: Vec<String>) -> &mut Self { + self.inputs = inputs; + self + } + + pub fn run( + &mut self, + input: Vec<(PhpMixed, PhpMixed)>, + options: RunOptions, + ) -> anyhow::Result<i32> { + let mut array_input = ArrayInput::new(input, None)?; + if let Some(interactive) = options.interactive { + array_input.set_interactive(interactive); + } + if !self.inputs.is_empty() { + array_input.set_stream(Self::create_stream(&self.inputs)); + } + + self.init_output(&options); + + let input: Rc<RefCell<dyn InputInterface>> = Rc::new(RefCell::new(array_input)); + let output = self.output.clone().expect("init_output initializes output"); + + let status_code = self.application.run(Some(input), Some(output))?; + self.status_code = Some(status_code); + + Ok(status_code) + } + + fn init_output(&mut self, options: &RunOptions) { + self.capture_streams_independently = options.capture_stderr_separately; + + if !self.capture_streams_independently { + let stream = shirabe_php_shim::php_fopen_resource("php://memory", "w"); + self.output_stream = Some(stream.clone()); + self.error_stream = None; + + let output = StreamOutput::new(stream, None, None, None) + .unwrap() + .expect("php://memory is a valid stream"); + if let Some(decorated) = options.decorated { + output.set_decorated(decorated); + } + if let Some(verbosity) = options.verbosity { + output.set_verbosity(verbosity); + } + self.output = Some(Rc::new(RefCell::new(output))); + } else { + let stdout = shirabe_php_shim::php_fopen_resource("php://memory", "w"); + let stderr = shirabe_php_shim::php_fopen_resource("php://memory", "w"); + self.output_stream = Some(stdout.clone()); + self.error_stream = Some(stderr.clone()); + + let mut output = + ConsoleOutput::new(options.verbosity, options.decorated, None).unwrap(); + + let error_output = StreamOutput::new(stderr, None, None, None) + .unwrap() + .expect("php://memory is a valid stream"); + error_output.set_formatter(output.get_formatter()); + error_output.set_verbosity(output.get_verbosity()); + error_output.set_decorated(output.is_decorated()); + + output.set_error_output(Rc::new(RefCell::new(error_output))); + output.__set_stream(stdout); + + self.output = Some(Rc::new(RefCell::new(output))); + } + } + + fn create_stream(inputs: &[String]) -> PhpResource { + let stream = shirabe_php_shim::php_fopen_resource("php://memory", "r+"); + for input in inputs { + shirabe_php_shim::fwrite_resource( + &stream, + &format!("{}{}", input, shirabe_php_shim::PHP_EOL), + ); + } + shirabe_php_shim::rewind(&stream); + stream + } + + pub fn get_status_code(&self) -> i32 { + self.status_code + .expect("status code not initialized; did you run() before requesting it?") + } + + pub fn get_display(&self) -> String { + let stream = self + .output_stream + .as_ref() + .expect("output not initialized; did you run() before requesting the display?"); + shirabe_php_shim::rewind(stream); + shirabe_php_shim::stream_get_contents(stream).unwrap_or_default() + } + + pub fn get_error_output(&self) -> String { + assert!( + self.capture_streams_independently, + "The error output is not available when the tester is run without \"capture_stderr_separately\" option set." + ); + let stream = self + .error_stream + .as_ref() + .expect("error output not initialized; did you run() before requesting it?"); + shirabe_php_shim::rewind(stream); + shirabe_php_shim::stream_get_contents(stream).unwrap_or_default() + } +} |
