diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-06-23 03:49:00 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-06-23 03:49:29 +0900 |
| commit | 8fe3390d064303b86133a1d2983144a4818a7121 (patch) | |
| tree | 3d7979e8fcce3db28ec9edc9b1ce16a593026454 /crates/shirabe/tests/command/init_command_test.rs | |
| parent | c644aa4df0dfcd58e3f0a1372611921678ed6c6d (diff) | |
| download | php-shirabe-8fe3390d064303b86133a1d2983144a4818a7121.tar.gz php-shirabe-8fe3390d064303b86133a1d2983144a4818a7121.tar.zst php-shirabe-8fe3390d064303b86133a1d2983144a4818a7121.zip | |
test(command): port InitCommandTest
Port the pure-method cases (parse/namespace/formatAuthors/git/vendor-ignore)
and build the ApplicationTester / initTempComposer harness the run cases need.
Supporting production changes:
- carry the streamable input stream as PhpResource (not PhpMixed) and add
InputInterface::as_streamable so QuestionHelper reads the injected stream
- add StreamOutput/ConsoleOutput __set_stream test helpers and
ApplicationHandle::set_catch_exceptions for the tester
- implement the interact() author validator via parse_author_string
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe/tests/command/init_command_test.rs')
| -rw-r--r-- | crates/shirabe/tests/command/init_command_test.rs | 452 |
1 files changed, 431 insertions, 21 deletions
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(); |
