diff options
Diffstat (limited to 'crates/shirabe/tests/command/update_command_test.rs')
| -rw-r--r-- | crates/shirabe/tests/command/update_command_test.rs | 624 |
1 files changed, 610 insertions, 14 deletions
diff --git a/crates/shirabe/tests/command/update_command_test.rs b/crates/shirabe/tests/command/update_command_test.rs index 95b6962..6cc36bd 100644 --- a/crates/shirabe/tests/command/update_command_test.rs +++ b/crates/shirabe/tests/command/update_command_test.rs @@ -1,43 +1,639 @@ //! ref: composer/tests/Composer/Test/Command/UpdateCommandTest.php +use crate::test_case::{ + RunOptions, create_composer_lock, create_installed_json, get_application_tester, get_package, + init_temp_composer, +}; +use serial_test::serial; +use shirabe::package::Link; +use shirabe_php_shim::PhpMixed; +use shirabe_semver::constraint::{AnyConstraint, MatchAllConstraint}; + +fn input(pairs: Vec<(&str, PhpMixed)>) -> Vec<(PhpMixed, PhpMixed)> { + pairs + .into_iter() + .map(|(k, v)| (PhpMixed::from(k), v)) + .collect() +} + +/// Minimal port of PHPUnit's `assertStringMatchesFormat` covering the placeholders these fixtures +/// use: `%d` (integer), `%f` (float), `%s` (non-newline run), `%a` (any run incl. newlines). +fn assert_string_matches_format(expected: &str, actual: &str) { + let mut pattern = String::from("(?s)^"); + let mut chars = expected.chars().peekable(); + while let Some(c) = chars.next() { + if c == '%' + && let Some(&next) = chars.peek() + { + match next { + 'd' => { + chars.next(); + pattern.push_str(r"[0-9]+"); + continue; + } + 'f' => { + chars.next(); + pattern.push_str(r"[+-]?[0-9]*\.?[0-9]+([eE][+-]?[0-9]+)?"); + continue; + } + 's' => { + chars.next(); + pattern.push_str(r"[^\r\n]+"); + continue; + } + 'a' => { + chars.next(); + pattern.push_str(r".+"); + continue; + } + _ => {} + } + } + pattern.push_str(®ex::escape(&c.to_string())); + } + pattern.push('$'); + let re = regex::Regex::new(&pattern).unwrap(); + assert!( + re.is_match(actual), + "string did not match format.\n--- expected format ---\n{expected}\n--- actual ---\n{actual}" + ); +} + +fn root_dep_and_transitive_dep() -> serde_json::Value { + serde_json::json!({ + "repositories": { "packages": { "type": "package", "package": [ + { "name": "root/req", "version": "1.0.0", "require": { "dep/pkg": "^1" } }, + { "name": "dep/pkg", "version": "1.0.0", "replace": { "replaced/pkg": "1.0.0" } }, + { "name": "dep/pkg", "version": "1.0.1", "replace": { "replaced/pkg": "1.0.1" } }, + { "name": "dep/pkg", "version": "1.0.2", "replace": { "replaced/pkg": "1.0.2" } }, + ] } }, + "require": { "root/req": "1.*" }, + }) +} + +/// ref: UpdateCommandTest::provideUpdates +fn provide_updates() -> Vec<( + &'static str, + serde_json::Value, + Vec<(&'static str, PhpMixed)>, + &'static str, + bool, +)> { + vec![ + ( + "simple update", + root_dep_and_transitive_dep(), + vec![], + "Loading composer repositories with package information +Updating dependencies +Lock file operations: 2 installs, 0 updates, 0 removals + - Locking dep/pkg (1.0.2) + - Locking root/req (1.0.0) +Installing dependencies from lock file (including require-dev) +Package operations: 2 installs, 0 updates, 0 removals + - Installing dep/pkg (1.0.2) + - Installing root/req (1.0.0)", + false, + ), + ( + "simple update with very verbose output", + root_dep_and_transitive_dep(), + vec![("-vv", PhpMixed::from(true))], + "Loading composer repositories with package information +Updating dependencies +Dependency resolution completed in %f seconds +Analyzed %d packages to resolve dependencies +Analyzed %d rules to resolve dependencies +Lock file operations: 2 installs, 0 updates, 0 removals +Installs: dep/pkg:1.0.2, root/req:1.0.0 + - Locking dep/pkg (1.0.2) from package repo (defining 4 packages) + - Locking root/req (1.0.0) from package repo (defining 4 packages) +Installing dependencies from lock file (including require-dev) +Package operations: 2 installs, 0 updates, 0 removals +Installs: dep/pkg:1.0.2, root/req:1.0.0 + - Installing dep/pkg (1.0.2) + - Installing root/req (1.0.0)", + false, + ), + ( + "update with temporary constraint + --no-install", + root_dep_and_transitive_dep(), + vec![ + ("--with", PhpMixed::List(vec![PhpMixed::from("dep/pkg:1.0.0")])), + ("--no-install", PhpMixed::from(true)), + ], + "Loading composer repositories with package information +Updating dependencies +Lock file operations: 2 installs, 0 updates, 0 removals + - Locking dep/pkg (1.0.0) + - Locking root/req (1.0.0)", + false, + ), + ( + "update with temporary constraint failing resolution", + root_dep_and_transitive_dep(), + vec![("--with", PhpMixed::List(vec![PhpMixed::from("dep/pkg:^2")]))], + "Loading composer repositories with package information +Updating dependencies +Your requirements could not be resolved to an installable set of packages. + + Problem 1 + - Root composer.json requires root/req 1.* -> satisfiable by root/req[1.0.0]. + - root/req 1.0.0 requires dep/pkg ^1 -> found dep/pkg[1.0.0, 1.0.1, 1.0.2] but it conflicts with your temporary update constraint (dep/pkg:^2).", + false, + ), + ( + "update with temporary constraint failing resolution on root package", + root_dep_and_transitive_dep(), + vec![("--with", PhpMixed::List(vec![PhpMixed::from("root/req:^2")]))], + "The temporary constraint \"^2\" for \"root/req\" must be a subset of the constraint in your composer.json (1.*) +Run `composer require root/req` or `composer require root/req:^2` instead to replace the constraint", + false, + ), + ( + "update & bump", + root_dep_and_transitive_dep(), + vec![("--bump-after-update", PhpMixed::from(true))], + "Loading composer repositories with package information +Updating dependencies +Lock file operations: 2 installs, 0 updates, 0 removals + - Locking dep/pkg (1.0.2) + - Locking root/req (1.0.0) +Installing dependencies from lock file (including require-dev) +Package operations: 2 installs, 0 updates, 0 removals + - Installing dep/pkg (1.0.2) + - Installing root/req (1.0.0) +Bumping dependencies +<warning>Warning: Bumping dependency constraints is not recommended for libraries as it will narrow down your dependencies and may cause problems for your users.</warning> +<warning>If your package is not a library, you can explicitly specify the \"type\" by using \"composer config type project\".</warning> +<warning>Alternatively you can use --bump-after-update=dev to only bump dependencies within \"require-dev\".</warning> +No requirements to update in ./composer.json.", + true, + ), + ( + "update & bump with lock", + root_dep_and_transitive_dep(), + vec![ + ("--bump-after-update", PhpMixed::from(true)), + ("--lock", PhpMixed::from(true)), + ], + "Loading composer repositories with package information +Updating dependencies +Nothing to modify in lock file +Installing dependencies from lock file (including require-dev) +Nothing to install, update or remove", + true, + ), + ( + "update & bump dev only", + root_dep_and_transitive_dep(), + vec![("--bump-after-update", PhpMixed::from("dev"))], + "Loading composer repositories with package information +Updating dependencies +Lock file operations: 2 installs, 0 updates, 0 removals + - Locking dep/pkg (1.0.2) + - Locking root/req (1.0.0) +Installing dependencies from lock file (including require-dev) +Package operations: 2 installs, 0 updates, 0 removals + - Installing dep/pkg (1.0.2) + - Installing root/req (1.0.0) +Bumping dependencies +No requirements to update in ./composer.json.", + true, + ), + ( + "update & dump with failing update", + root_dep_and_transitive_dep(), + vec![ + ("--with", PhpMixed::List(vec![PhpMixed::from("dep/pkg:^2")])), + ("--bump-after-update", PhpMixed::from(true)), + ], + "Loading composer repositories with package information +Updating dependencies +Your requirements could not be resolved to an installable set of packages. + + Problem 1 + - Root composer.json requires root/req 1.* -> satisfiable by root/req[1.0.0]. + - root/req 1.0.0 requires dep/pkg ^1 -> found dep/pkg[1.0.0, 1.0.1, 1.0.2] but it conflicts with your temporary update constraint (dep/pkg:^2).", + false, + ), + ( + "update with replaced name filter fails to resolve", + root_dep_and_transitive_dep(), + vec![("--with", PhpMixed::List(vec![PhpMixed::from("replaced/pkg:^2")]))], + "Loading composer repositories with package information +Updating dependencies +Your requirements could not be resolved to an installable set of packages. + + Problem 1 + - Root composer.json requires root/req 1.* -> satisfiable by root/req[1.0.0]. + - root/req 1.0.0 requires dep/pkg ^1 -> found dep/pkg[1.0.0, 1.0.1, 1.0.2] but it conflicts with your temporary update constraint (replaced/pkg:^2).", + false, + ), + ] +} + #[test] -#[ignore = "requires ApplicationTester and TestCase::initTempComposer/createComposerLock harness (not implemented)"] +#[serial] +#[ignore = "UpdateCommand::configure sets an empty InputDefinition (Phase-C: InputArgument/InputOption modeling pending), so the command rejects its own options, failing with e.g. \"The \\\"--dry-run\\\" option does not exist.\""] fn test_update() { - todo!() + for (label, composer_json, command, expected, create_lock) in provide_updates() { + let _tear_down = init_temp_composer(Some(&composer_json), None, None, true); + + if create_lock { + create_composer_lock(&[], &[]); + } + + let mut app_tester = get_application_tester(); + let mut args = vec![ + ("command", PhpMixed::from("update")), + ("--dry-run", PhpMixed::from(true)), + ("--no-audit", PhpMixed::from(true)), + ]; + args.extend(command); + app_tester.run(input(args), RunOptions::default()).unwrap(); + + assert_string_matches_format(expected.trim(), app_tester.get_display().trim()); + let _ = label; + } } #[test] -#[ignore = "requires ApplicationTester and TestCase::initTempComposer/createComposerLock harness (not implemented)"] +#[serial] +#[ignore = "UpdateCommand::configure sets an empty InputDefinition (Phase-C: InputArgument/InputOption modeling pending), so the command rejects its own options, failing with e.g. \"The \\\"--dry-run\\\" option does not exist.\""] fn test_update_with_patch_only() { - todo!() + let composer_json = serde_json::json!({ + "repositories": { "packages": { "type": "package", "package": [ + { "name": "root/req", "version": "1.0.0" }, + { "name": "root/req", "version": "1.0.1" }, + { "name": "root/req", "version": "1.1.0" }, + { "name": "root/req2", "version": "1.0.0" }, + { "name": "root/req2", "version": "1.0.1" }, + { "name": "root/req2", "version": "1.1.0" }, + { "name": "root/req3", "version": "1.0.0" }, + { "name": "root/req3", "version": "1.0.1" }, + { "name": "root/req3", "version": "1.1.0" }, + ] } }, + "require": { "root/req": "1.*", "root/req2": "1.*", "root/req3": "1.*" }, + }); + let _tear_down = init_temp_composer(Some(&composer_json), None, None, true); + + let package = get_package("root/req", "1.0.0"); + let package2 = get_package("root/req2", "1.0.0"); + let package3 = get_package("root/req3", "1.0.0"); + create_composer_lock(&[package, package2, package3], &[]); + + let mut app_tester = get_application_tester(); + // root/req fails because of incompatible --with requirement + app_tester + .run( + input(vec![ + ("command", PhpMixed::from("update")), + ("--dry-run", PhpMixed::from(true)), + ("--no-audit", PhpMixed::from(true)), + ("--no-install", PhpMixed::from(true)), + ("--patch-only", PhpMixed::from(true)), + ( + "--with", + PhpMixed::List(vec![PhpMixed::from("root/req:^1.1")]), + ), + ]), + RunOptions::default(), + ) + .unwrap(); + + assert_string_matches_format( + "Loading composer repositories with package information +Updating dependencies +Your requirements could not be resolved to an installable set of packages. + + Problem 1 + - Root composer.json requires root/req 1.*, found root/req[1.0.0, 1.0.1, 1.1.0] but it conflicts with your temporary update constraint (root/req:[[>= 1.1.0.0-dev < 2.0.0.0-dev] [>= 1.0.0.0-dev < 1.1.0.0-dev]]).", + app_tester.get_display().trim(), + ); + + let mut app_tester = get_application_tester(); + // root/req upgrades to 1.0.1 as that is compatible with the --with requirement now + // root/req2 upgrades to 1.0.1 only due to --patch-only + // root/req3 does not update as it is not in the allowlist + app_tester + .run( + input(vec![ + ("command", PhpMixed::from("update")), + ("--dry-run", PhpMixed::from(true)), + ("--no-audit", PhpMixed::from(true)), + ("--no-install", PhpMixed::from(true)), + ("--patch-only", PhpMixed::from(true)), + ( + "--with", + PhpMixed::List(vec![PhpMixed::from("root/req:^1.0.1")]), + ), + ( + "packages", + PhpMixed::List(vec![ + PhpMixed::from("root/req"), + PhpMixed::from("root/req2"), + ]), + ), + ]), + RunOptions::default(), + ) + .unwrap(); + + assert_string_matches_format( + "Loading composer repositories with package information +Updating dependencies +Lock file operations: 0 installs, 2 updates, 0 removals + - Upgrading root/req (1.0.0 => 1.0.1) + - Upgrading root/req2 (1.0.0 => 1.0.1)", + app_tester.get_display().trim(), + ); } #[test] -#[ignore = "requires ApplicationTester and TestCase::initTempComposer/createComposerLock harness (not implemented)"] +#[serial] +#[ignore = "UpdateCommand::configure sets an empty InputDefinition (Phase-C: InputArgument/InputOption modeling pending), so the command rejects its own options, failing with e.g. \"The \\\"--dry-run\\\" option does not exist.\""] fn test_interactive_mode_throws_if_no_package_to_update() { - todo!() + let composer_json = serde_json::json!({ + "repositories": { "packages": { "type": "package", "package": [ + { "name": "root/req", "version": "1.0.0" }, + ] } }, + "require": { "root/req": "1.*" }, + }); + let _tear_down = init_temp_composer(Some(&composer_json), None, None, true); + create_composer_lock(&[get_package("root/req", "1.0.0")], &[]); + + let mut app_tester = get_application_tester(); + app_tester.set_inputs(vec!["".to_string()]); + let err = app_tester + .run( + input(vec![ + ("command", PhpMixed::from("update")), + ("--interactive", PhpMixed::from(true)), + ]), + RunOptions::default(), + ) + .expect_err("expected an exception when there is no package to update"); + assert!( + err.to_string() + .contains("Could not find any package with new versions available"), + "got: {err}" + ); } #[test] -#[ignore = "requires ApplicationTester and TestCase::initTempComposer/createComposerLock harness (not implemented)"] +#[serial] +#[ignore = "UpdateCommand::configure sets an empty InputDefinition (Phase-C: InputArgument/InputOption modeling pending), so the command rejects its own options, failing with e.g. \"The \\\"--dry-run\\\" option does not exist.\""] fn test_interactive_mode_throws_if_no_package_entered() { - todo!() + let composer_json = serde_json::json!({ + "repositories": { "packages": { "type": "package", "package": [ + { "name": "root/req", "version": "1.0.0" }, + { "name": "root/req", "version": "1.0.1" }, + ] } }, + "require": { "root/req": "1.*" }, + }); + let _tear_down = init_temp_composer(Some(&composer_json), None, None, true); + create_composer_lock(&[get_package("root/req", "1.0.0")], &[]); + + let mut app_tester = get_application_tester(); + app_tester.set_inputs(vec!["".to_string()]); + let err = app_tester + .run( + input(vec![ + ("command", PhpMixed::from("update")), + ("--interactive", PhpMixed::from(true)), + ]), + RunOptions::default(), + ) + .expect_err("expected an exception when no package name is entered"); + assert!( + err.to_string() + .contains(r#"No package named "" is installed."#), + "got: {err}" + ); +} + +/// ref: UpdateCommandTest::provideInteractiveUpdates +fn provide_interactive_updates() -> Vec<(Vec<(&'static str, &'static str)>, &'static str)> { + vec![ + ( + vec![("dep/pkg", "1.0.1")], + "Lock file operations: 1 install, 1 update, 0 removals + - Locking another-dep/pkg (1.0.2) + - Upgrading dep/pkg (1.0.1 => 1.0.2) +Installing dependencies from lock file (including require-dev) +Package operations: 1 install, 1 update, 0 removals + - Upgrading dep/pkg (1.0.1 => 1.0.2) + - Installing another-dep/pkg (1.0.2)", + ), + ( + vec![("dep/pkg", "1.0.1"), ("another-dep/pkg", "1.0.2")], + "Lock file operations: 0 installs, 1 update, 0 removals + - Upgrading dep/pkg (1.0.1 => 1.0.2) +Installing dependencies from lock file (including require-dev) +Package operations: 0 installs, 1 update, 0 removals + - Upgrading dep/pkg (1.0.1 => 1.0.2)", + ), + ] } #[test] -#[ignore = "requires ApplicationTester and TestCase::initTempComposer/createComposerLock/createInstalledJson harness (not implemented)"] +#[serial] +#[ignore = "UpdateCommand::configure sets an empty InputDefinition (Phase-C: InputArgument/InputOption modeling pending), so the command rejects its own options, failing with e.g. \"The \\\"--dry-run\\\" option does not exist.\""] fn test_interactive_tmp() { - todo!() + for (package_names, expected) in provide_interactive_updates() { + let composer_json = serde_json::json!({ + "repositories": { "packages": { "type": "package", "package": [ + { "name": "root/req", "version": "1.0.0", "require": { "dep/pkg": "^1" } }, + { "name": "dep/pkg", "version": "1.0.0" }, + { "name": "dep/pkg", "version": "1.0.1" }, + { "name": "dep/pkg", "version": "1.0.2" }, + { "name": "another-dep/pkg", "version": "1.0.2" }, + ] } }, + "require": { "root/req": "1.*" }, + }); + let _tear_down = init_temp_composer(Some(&composer_json), None, None, true); + + let root_package = get_package("root/req", "1.0.0"); + let mut packages = vec![root_package.clone()]; + for (pkg, ver) in &package_names { + packages.push(get_package(pkg, ver)); + } + + let mut requires = indexmap::IndexMap::new(); + requires.insert( + "dep/pkg".to_string(), + Link::new( + "root/req".to_string(), + "dep/pkg".to_string(), + AnyConstraint::MatchAll(MatchAllConstraint::new(None)), + Some(Link::TYPE_REQUIRE.to_string()), + "^1".to_string(), + ), + ); + requires.insert( + "another-dep/pkg".to_string(), + Link::new( + "root/req".to_string(), + "another-dep/pkg".to_string(), + AnyConstraint::MatchAll(MatchAllConstraint::new(None)), + Some(Link::TYPE_REQUIRE.to_string()), + "^1".to_string(), + ), + ); + root_package.__set_requires(requires); + + create_composer_lock(&packages, &[]); + create_installed_json(&packages, &[], true); + + let mut app_tester = get_application_tester(); + let mut inputs: Vec<String> = package_names + .iter() + .map(|(pkg, _)| pkg.to_string()) + .collect(); + inputs.push("".to_string()); + inputs.push("yes".to_string()); + app_tester.set_inputs(inputs); + app_tester + .run( + input(vec![ + ("command", PhpMixed::from("update")), + ("--interactive", PhpMixed::from(true)), + ("--no-audit", PhpMixed::from(true)), + ("--dry-run", PhpMixed::from(true)), + ]), + RunOptions { + interactive: Some(true), + ..RunOptions::default() + }, + ) + .unwrap(); + + assert!( + app_tester.get_display().trim().ends_with(expected.trim()), + "expected display to end with:\n{expected}\n--- got ---\n{}", + app_tester.get_display() + ); + } } #[test] -#[ignore = "requires ApplicationTester and TestCase::initTempComposer harness (not implemented)"] +#[serial] +#[ignore = "UpdateCommand::configure sets an empty InputDefinition (Phase-C: InputArgument/InputOption modeling pending), so the command rejects its own options, failing with e.g. \"The \\\"--dry-run\\\" option does not exist.\""] fn test_no_security_blocking_allows_insecure_packages() { - todo!() + let composer_json = serde_json::json!({ + "repositories": { "packages": { + "type": "package", + "package": [ + { "name": "vulnerable/pkg", "version": "1.0.0" }, + { "name": "vulnerable/pkg", "version": "1.1.0" }, + ], + "security-advisories": { + "vulnerable/pkg": [ + { + "advisoryId": "PKSA-test-001", + "packageName": "vulnerable/pkg", + "remoteId": "CVE-2024-1234", + "title": "Test Security Vulnerability", + "link": "https://example.com/advisory", + "cve": "CVE-2024-1234", + "affectedVersions": ">=1.1.0,<2.0.0", + "source": "test", + "reportedAt": "2024-01-01 00:00:00", + "composerRepository": "Package Repository", + "severity": "high", + "sources": [ { "name": "test", "remoteId": "CVE-2024-1234" } ], + }, + ], + }, + } }, + "require": { "vulnerable/pkg": "^1.0" }, + }); + let _tear_down = init_temp_composer(Some(&composer_json), None, None, true); + + // Test 1: Without --no-security-blocking, the vulnerable version 1.1.0 should be filtered out + let mut app_tester = get_application_tester(); + app_tester + .run( + input(vec![ + ("command", PhpMixed::from("update")), + ("--dry-run", PhpMixed::from(true)), + ("--no-audit", PhpMixed::from(true)), + ("--no-install", PhpMixed::from(true)), + ]), + RunOptions::default(), + ) + .unwrap(); + let display = app_tester.get_display(); + assert!( + display.contains("Locking vulnerable/pkg (1.0.0)"), + "got: {display}" + ); + assert!( + !display.contains("Locking vulnerable/pkg (1.1.0)"), + "got: {display}" + ); + + // Test 2: With --no-security-blocking, the vulnerable version 1.1.0 should be allowed + let mut app_tester = get_application_tester(); + app_tester + .run( + input(vec![ + ("command", PhpMixed::from("update")), + ("--dry-run", PhpMixed::from(true)), + ("--no-audit", PhpMixed::from(true)), + ("--no-install", PhpMixed::from(true)), + ("--no-security-blocking", PhpMixed::from(true)), + ]), + RunOptions::default(), + ) + .unwrap(); + let display = app_tester.get_display(); + assert!( + display.contains("Locking vulnerable/pkg (1.1.0)"), + "got: {display}" + ); + assert!( + !display.contains("Locking vulnerable/pkg (1.0.0)"), + "got: {display}" + ); } #[test] -#[ignore = "requires ApplicationTester and TestCase::initTempComposer harness (not implemented)"] +#[serial] +#[ignore = "UpdateCommand::configure sets an empty InputDefinition (Phase-C: InputArgument/InputOption modeling pending), so the command rejects its own options, failing with e.g. \"The \\\"--dry-run\\\" option does not exist.\""] fn test_bump_after_update_without_lockfile() { - todo!() + let composer_json = serde_json::json!({ + "repositories": { "packages": { "type": "package", "package": [ + { "name": "root/a", "version": "1.0.0" }, + { "name": "root/a", "version": "1.1.0" }, + ] } }, + "require-dev": { "root/a": "^1.0.0" }, + "config": { "lock": false }, + }); + let _tear_down = init_temp_composer(Some(&composer_json), None, None, true); + + let mut app_tester = get_application_tester(); + app_tester + .run( + input(vec![ + ("command", PhpMixed::from("update")), + ("--dry-run", PhpMixed::from(true)), + ("--no-audit", PhpMixed::from(true)), + ("--bump-after-update", PhpMixed::from("dev")), + ]), + RunOptions::default(), + ) + .unwrap(); + + assert_string_matches_format( + "Loading composer repositories with package information +Updating dependencies +Package operations: 1 install, 0 updates, 0 removals + - Installing root/a (1.1.0) +Bumping dependencies +./composer.json would be updated with: + - require-dev.root/a: ^1.1.0", + app_tester.get_display().trim(), + ); } |
