aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe/tests/command
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-06-23 03:49:00 +0900
committernsfisis <nsfisis@gmail.com>2026-06-23 03:49:29 +0900
commit8fe3390d064303b86133a1d2983144a4818a7121 (patch)
tree3d7979e8fcce3db28ec9edc9b1ce16a593026454 /crates/shirabe/tests/command
parentc644aa4df0dfcd58e3f0a1372611921678ed6c6d (diff)
downloadphp-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')
-rw-r--r--crates/shirabe/tests/command/init_command_test.rs452
-rw-r--r--crates/shirabe/tests/command/main.rs3
2 files changed, 434 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();
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;