diff --git a/.changeset/changesets/cheerfully-punctual-mudsucker.md b/.changeset/changesets/cheerfully-punctual-mudsucker.md new file mode 100644 index 0000000..bf9167d --- /dev/null +++ b/.changeset/changesets/cheerfully-punctual-mudsucker.md @@ -0,0 +1,4 @@ +--- +changeset-test-helpers: major +--- +Changed virtual terminal size to 1200x400 diff --git a/.changeset/changesets/exquisitely-dauntless-filly.md b/.changeset/changesets/exquisitely-dauntless-filly.md new file mode 100644 index 0000000..3d1ba8d --- /dev/null +++ b/.changeset/changesets/exquisitely-dauntless-filly.md @@ -0,0 +1,5 @@ +--- +category: fixed +cargo-changeset: patch +--- +Pressing ESC now aborts interactive mode instead of selecting the default diff --git a/.changeset/changesets/repeatedly-pioneering-ladybug.md b/.changeset/changesets/repeatedly-pioneering-ladybug.md new file mode 100644 index 0000000..9f3377c --- /dev/null +++ b/.changeset/changesets/repeatedly-pioneering-ladybug.md @@ -0,0 +1,5 @@ +--- +category: added +changeset-test-helpers: minor +--- +Added helpers for sending Ctrl+C to the virtual terminal. Added helper for toggling items in interactive mode. diff --git a/crates/cargo-changeset/src/commands/init.rs b/crates/cargo-changeset/src/commands/init.rs index 75f4d21..baa9b9e 100644 --- a/crates/cargo-changeset/src/commands/init.rs +++ b/crates/cargo-changeset/src/commands/init.rs @@ -36,7 +36,14 @@ pub(crate) fn run(args: InitArgs, start_path: &Path) -> Result<()> { } else { None }; - let input = build_init_input(&args, provider, context)?; + let input = match build_init_input(&args, provider, context) { + Ok(input) => input, + Err(crate::error::CliError::Operation(changeset_operations::OperationError::Cancelled)) => { + println!("Cancelled."); + return Ok(()); + } + Err(e) => return Err(e), + }; let config = build_config_from_input(&input, context); diff --git a/crates/cargo-changeset/src/interaction/mod.rs b/crates/cargo-changeset/src/interaction/mod.rs index de575a8..fa163b5 100644 --- a/crates/cargo-changeset/src/interaction/mod.rs +++ b/crates/cargo-changeset/src/interaction/mod.rs @@ -12,12 +12,12 @@ use changeset_git::DEFAULT_BASE_BRANCH; use changeset_manifest::{ ChangelogLocation, ComparisonLinks, NoneBumpBehavior, ZeroVersionBehavior, }; -use changeset_operations::Result; use changeset_operations::traits::{ BumpSelection, CategorySelection, ChangelogSettingsInput, DescriptionInput, FilteringSettingsInput, GitSettingsInput, InitInteractionProvider, InteractionProvider, PackageSelection, ProjectContext, VersionSettingsInput, }; +use changeset_operations::{OperationError, Result}; use crate::commands::{cli_error_to_operation_error, dialoguer_to_operation_error}; use crate::environment::is_interactive; @@ -163,16 +163,21 @@ impl InitInteractionProvider for TerminalInitInteractionProvider { .interact_opt() .map_err(dialoguer_to_operation_error)?; - if configure != Some(true) { - return Ok(None); + match configure { + Some(true) => {} + Some(false) => return Ok(None), + None => return Err(OperationError::Cancelled), } let commit = select_bool("Create git commits on release?", true) - .map_err(cli_error_to_operation_error)?; + .map_err(cli_error_to_operation_error)? + .ok_or(OperationError::Cancelled)?; let tags = select_bool("Create git tags on release?", true) - .map_err(cli_error_to_operation_error)?; + .map_err(cli_error_to_operation_error)? + .ok_or(OperationError::Cancelled)?; let keep_changesets = select_bool("Keep changeset files after release?", false) - .map_err(cli_error_to_operation_error)?; + .map_err(cli_error_to_operation_error)? + .ok_or(OperationError::Cancelled)?; let tag_format = select_tag_format(context.is_single_package).map_err(cli_error_to_operation_error)?; let base_branch = prompt_base_branch().map_err(cli_error_to_operation_error)?; @@ -180,7 +185,8 @@ impl InitInteractionProvider for TerminalInitInteractionProvider { let (commit_title_template, changes_in_body) = if commit { let template = prompt_commit_title_template().map_err(cli_error_to_operation_error)?; let body = select_bool("Include version details in commit body?", true) - .map_err(cli_error_to_operation_error)?; + .map_err(cli_error_to_operation_error)? + .ok_or(OperationError::Cancelled)?; (Some(template), Some(body)) } else { (None, None) @@ -211,8 +217,10 @@ impl InitInteractionProvider for TerminalInitInteractionProvider { .interact_opt() .map_err(dialoguer_to_operation_error)?; - if configure != Some(true) { - return Ok(None); + match configure { + Some(true) => {} + Some(false) => return Ok(None), + None => return Err(OperationError::Cancelled), } let changelog = if context.is_single_package { @@ -262,8 +270,10 @@ impl InitInteractionProvider for TerminalInitInteractionProvider { .interact_opt() .map_err(dialoguer_to_operation_error)?; - if configure != Some(true) { - return Ok(None); + match configure { + Some(true) => {} + Some(false) => return Ok(None), + None => return Err(OperationError::Cancelled), } let zero_version_behavior = @@ -298,8 +308,10 @@ impl InitInteractionProvider for TerminalInitInteractionProvider { .interact_opt() .map_err(dialoguer_to_operation_error)?; - if configure != Some(true) { - return Ok(None); + match configure { + Some(true) => {} + Some(false) => return Ok(None), + None => return Err(OperationError::Cancelled), } let ignored_files = prompt_ignored_files_loop().map_err(cli_error_to_operation_error)?; @@ -466,33 +478,35 @@ fn prompt_base_branch() -> CliResult { .interact_text()?) } -fn select_bool(prompt: &str, default: bool) -> CliResult { +fn select_bool(prompt: &str, default: bool) -> CliResult> { Ok(Confirm::new() .with_prompt(prompt) .default(default) - .interact()?) + .interact_opt()?) } fn select_tag_format(is_single_package: bool) -> CliResult { let default_idx: usize = if is_single_package { 0 } else { 1 }; let selection = select_variant::("Select tag format", default_idx)?; - Ok(selection.map(Into::into).unwrap_or(if is_single_package { - changeset_manifest::TagFormat::VersionOnly - } else { - changeset_manifest::TagFormat::CratePrefixed - })) + Ok(selection + .ok_or(CliError::Operation(OperationError::Cancelled))? + .into()) } fn select_changelog_location() -> CliResult { let selection = select_variant::("Select changelog location", 0)?; - Ok(selection.map_or_else(ChangelogLocation::default, Into::into)) + Ok(selection + .ok_or(CliError::Operation(OperationError::Cancelled))? + .into()) } fn select_comparison_links() -> CliResult { let selection = select_variant::("Select comparison links mode", 0)?; - Ok(selection.map_or_else(ComparisonLinks::default, Into::into)) + Ok(selection + .ok_or(CliError::Operation(OperationError::Cancelled))? + .into()) } fn select_zero_version_behavior() -> CliResult { @@ -500,13 +514,17 @@ fn select_zero_version_behavior() -> CliResult { "Select zero version (0.x.y) behavior", 0, )?; - Ok(selection.map_or_else(ZeroVersionBehavior::default, Into::into)) + Ok(selection + .ok_or(CliError::Operation(OperationError::Cancelled))? + .into()) } fn select_none_bump_behavior() -> CliResult { let selection = select_variant::("Select none bump behavior", 0)?; - Ok(selection.map_or_else(NoneBumpBehavior::default, Into::into)) + Ok(selection + .ok_or(CliError::Operation(OperationError::Cancelled))? + .into()) } fn prompt_commit_title_template() -> CliResult { diff --git a/crates/cargo-changeset/tests/add_command.rs b/crates/cargo-changeset/tests/add_command.rs index a400c3c..952eefe 100644 --- a/crates/cargo-changeset/tests/add_command.rs +++ b/crates/cargo-changeset/tests/add_command.rs @@ -486,6 +486,7 @@ mod interactive { use std::os::unix::fs::PermissionsExt; use changeset_test_helpers::terminal_session::TerminalSession; + use indoc::indoc; use super::*; @@ -520,23 +521,44 @@ MOCK_EDITOR_EOF } #[test] - fn interactive_selection_shows_prompt() { + fn interactive_package_selection_renders_menu() { let workspace = create_virtual_workspace(); let mut session = spawn_add_in_workspace(&workspace); - session.wait_for("Select packages"); + session.wait_for("Select packages to include in changeset"); + session.assert_screen( + "package multi-select menu", + indoc! {" + Select packages to include in changeset: + > [ ] crate-a (0.1.0) + [ ] crate-b (0.2.0)"}, + ); + session.cancel(); session.wait_for_exit(); } #[test] - fn interactive_shows_crate_names() { + fn interactive_cancel_at_package_selection() { let workspace = create_virtual_workspace(); let mut session = spawn_add_in_workspace(&workspace); - session.wait_for("crate-a"); + session.wait_for("Select packages to include in changeset"); + session.assert_screen( + "package menu before cancel", + indoc! {" + Select packages to include in changeset: + > [ ] crate-a (0.1.0) + [ ] crate-b (0.2.0)"}, + ); session.cancel(); session.wait_for_exit(); + + let changeset_dir = workspace.path().join(".changeset/changesets"); + assert!( + !changeset_dir.exists(), + "no changeset directory should be created on cancel" + ); } #[test] @@ -544,57 +566,264 @@ MOCK_EDITOR_EOF let workspace = create_virtual_workspace(); let mut session = spawn_add_in_workspace(&workspace); - session.wait_for("Select packages"); - session.send_raw("\n"); + session.wait_for("Select packages to include in changeset"); + session.assert_screen( + "package menu before empty confirm", + indoc! {" + Select packages to include in changeset: + > [ ] crate-a (0.1.0) + [ ] crate-b (0.2.0)"}, + ); + session.confirm(); session.wait_for_exit(); + + let changeset_dir = workspace.path().join(".changeset/changesets"); + assert!( + !changeset_dir.exists(), + "no changeset directory should be created on empty selection" + ); } #[test] - fn interactive_cancellation_exits_cleanly() { + fn interactive_cancel_at_bump_type() { let workspace = create_virtual_workspace(); let mut session = spawn_add_in_workspace(&workspace); - session.wait_for("Select packages"); + session.wait_for("Select packages to include in changeset"); + session.toggle_item(0); + session.confirm(); + session.wait_for("Select bump type for 'crate-a'"); + session.assert_screen( + "bump type menu before cancel", + indoc! {" + Select packages to include in changeset: crate-a (0.1.0) + Select bump type for 'crate-a': + > patch - Bug fixes (backwards compatible) + minor - New features (backwards compatible) + major - Breaking changes + none - No version bump (internal changes only)"}, + ); session.cancel(); session.wait_for_exit(); + + let changeset_dir = workspace.path().join(".changeset/changesets"); + assert!( + !changeset_dir.exists(), + "no changeset directory should be created on cancel" + ); } #[test] - fn interactive_select_package_and_bump_type() { + fn interactive_cancel_at_category() { let workspace = create_virtual_workspace(); let mut session = spawn_add_in_workspace(&workspace); - session.wait_for("Select packages"); - session.send_raw(" "); - session.send_raw("\n"); + session.wait_for("Select packages to include in changeset"); + session.toggle_item(0); + session.confirm(); + session.wait_for("Select bump type for 'crate-a'"); + session.confirm(); + session.wait_for("Select change category"); + session.assert_screen( + "category menu before cancel", + indoc! {" + Select packages to include in changeset: crate-a (0.1.0) + Select bump type for 'crate-a': patch - Bug fixes (backwards compatible) + Select change category: + > changed - General changes (default) + added - New features + fixed - Bug fixes + deprecated - Deprecated features + removed - Removed features + security - Security fixes"}, + ); + session.cancel(); + session.wait_for_exit(); + + let changeset_dir = workspace.path().join(".changeset/changesets"); + assert!( + !changeset_dir.exists(), + "no changeset directory should be created on cancel" + ); + } + + #[test] + fn interactive_cancel_at_description() { + let workspace = create_single_crate_workspace(); + let mut session = spawn_add_in_workspace(&workspace); + + session.wait_for("Select bump type for 'test-crate'"); + session.assert_screen( + "bump type menu", + indoc! {" + Using package: test-crate (1.0.0) + Select bump type for 'test-crate': + > patch - Bug fixes (backwards compatible) + minor - New features (backwards compatible) + major - Breaking changes + none - No version bump (internal changes only)"}, + ); + session.confirm(); + session.wait_for("Select change category"); + session.assert_screen( + "category menu", + indoc! {" + Using package: test-crate (1.0.0) + Select bump type for 'test-crate': patch - Bug fixes (backwards compatible) + Select change category: + > changed - General changes (default) + added - New features + fixed - Bug fixes + deprecated - Deprecated features + removed - Removed features + security - Security fixes"}, + ); + session.confirm(); + session.wait_for("Enter description (press Enter 3 times to finish):"); + session.assert_screen( + "description prompt before ctrl-c", + indoc! {" + Using package: test-crate (1.0.0) + Select bump type for 'test-crate': patch - Bug fixes (backwards compatible) + Select change category: changed - General changes (default) + + Enter description (press Enter 3 times to finish):"}, + ); + session.ctrl_c(); + session.wait_for_exit(); - session.wait_for("bump type"); - session.send_raw("\n"); + let changeset_dir = workspace.path().join(".changeset/changesets"); + assert!( + !changeset_dir.exists(), + "no changeset directory should be created on Ctrl+C at description" + ); + } + + #[test] + fn interactive_bump_type_menu_rendering() { + let workspace = create_virtual_workspace(); + let mut session = spawn_add_in_workspace(&workspace); + + session.wait_for("Select packages to include in changeset"); + session.assert_screen( + "package menu before toggle", + indoc! {" + Select packages to include in changeset: + > [ ] crate-a (0.1.0) + [ ] crate-b (0.2.0)"}, + ); + session.toggle_item(0); + session.confirm(); + session.wait_for("Select bump type for 'crate-a'"); + session.assert_screen( + "bump type select menu", + indoc! {" + Select packages to include in changeset: crate-a (0.1.0) + Select bump type for 'crate-a': + > patch - Bug fixes (backwards compatible) + minor - New features (backwards compatible) + major - Breaking changes + none - No version bump (internal changes only)"}, + ); - session.wait_for("category"); session.cancel(); session.wait_for_exit(); } #[test] - fn interactive_full_flow_single_package() { - let workspace = create_single_crate_workspace(); + fn interactive_category_menu_rendering() { + let workspace = create_virtual_workspace(); let mut session = spawn_add_in_workspace(&workspace); - session.wait_for("Using package: test-crate"); + session.wait_for("Select packages to include in changeset"); + session.assert_screen( + "package menu before toggle", + indoc! {" + Select packages to include in changeset: + > [ ] crate-a (0.1.0) + [ ] crate-b (0.2.0)"}, + ); + session.toggle_item(0); + session.confirm(); + session.wait_for("Select bump type for 'crate-a'"); + session.assert_screen( + "bump type menu before confirm", + indoc! {" + Select packages to include in changeset: crate-a (0.1.0) + Select bump type for 'crate-a': + > patch - Bug fixes (backwards compatible) + minor - New features (backwards compatible) + major - Breaking changes + none - No version bump (internal changes only)"}, + ); + session.confirm(); + session.wait_for("Select change category"); + session.assert_screen( + "category select menu", + indoc! {" + Select packages to include in changeset: crate-a (0.1.0) + Select bump type for 'crate-a': patch - Bug fixes (backwards compatible) + Select change category: + > changed - General changes (default) + added - New features + fixed - Bug fixes + deprecated - Deprecated features + removed - Removed features + security - Security fixes"}, + ); - session.wait_for("bump type"); - session.send_raw("\n"); + session.cancel(); + session.wait_for_exit(); + } - session.wait_for("category"); - session.send_raw("\n"); + #[test] + fn interactive_full_flow_single_package() { + let workspace = create_single_crate_workspace(); + let mut session = spawn_add_in_workspace(&workspace); + + session.wait_for("Select bump type for 'test-crate'"); + session.assert_screen( + "single-crate bump type menu", + indoc! {" + Using package: test-crate (1.0.0) + Select bump type for 'test-crate': + > patch - Bug fixes (backwards compatible) + minor - New features (backwards compatible) + major - Breaking changes + none - No version bump (internal changes only)"}, + ); + session.confirm(); + + session.wait_for("Select change category"); + session.assert_screen( + "single-crate category menu", + indoc! {" + Using package: test-crate (1.0.0) + Select bump type for 'test-crate': patch - Bug fixes (backwards compatible) + Select change category: + > changed - General changes (default) + added - New features + fixed - Bug fixes + deprecated - Deprecated features + removed - Removed features + security - Security fixes"}, + ); + session.confirm(); - session.wait_for("description"); + session.wait_for("Enter description (press Enter 3 times to finish):"); + session.assert_screen( + "description prompt", + indoc! {" + Using package: test-crate (1.0.0) + Select bump type for 'test-crate': patch - Bug fixes (backwards compatible) + Select change category: changed - General changes (default) - session.send_line("Test description line 1"); - session.send_line("Test description line 2"); - session.send_line(""); - session.send_line(""); + Enter description (press Enter 3 times to finish):"}, + ); + session.type_line("Test description line 1"); + session.type_line("Test description line 2"); + session.type_line(""); + session.type_line(""); session.wait_for("Created changeset"); session.wait_for_exit(); @@ -624,24 +853,187 @@ MOCK_EDITOR_EOF let workspace = create_virtual_workspace(); let mut session = spawn_add_in_workspace(&workspace); - session.wait_for("Select packages"); - session.send_raw(" "); - session.send_raw("\n"); + session.wait_for("Select packages to include in changeset"); + session.assert_screen( + "package multi-select menu", + indoc! {" + Select packages to include in changeset: + > [ ] crate-a (0.1.0) + [ ] crate-b (0.2.0)"}, + ); + session.toggle_item(0); + session.toggle_item(1); + session.confirm(); + + session.wait_for("Select bump type for 'crate-a'"); + session.assert_screen( + "bump type for crate-a", + indoc! {" + Select packages to include in changeset: crate-a (0.1.0), crate-b (0.2.0) + Select bump type for 'crate-a': + > patch - Bug fixes (backwards compatible) + minor - New features (backwards compatible) + major - Breaking changes + none - No version bump (internal changes only)"}, + ); + session.confirm(); + + session.wait_for("Select bump type for 'crate-b'"); + session.assert_screen( + "bump type for crate-b", + indoc! {" + Select packages to include in changeset: crate-a (0.1.0), crate-b (0.2.0) + Select bump type for 'crate-a': patch - Bug fixes (backwards compatible) + Select bump type for 'crate-b': + > patch - Bug fixes (backwards compatible) + minor - New features (backwards compatible) + major - Breaking changes + none - No version bump (internal changes only)"}, + ); + session.confirm(); + + session.wait_for("Select change category"); + session.assert_screen( + "category menu", + indoc! {" + Select packages to include in changeset: crate-a (0.1.0), crate-b (0.2.0) + Select bump type for 'crate-a': patch - Bug fixes (backwards compatible) + Select bump type for 'crate-b': patch - Bug fixes (backwards compatible) + Select change category: + > changed - General changes (default) + added - New features + fixed - Bug fixes + deprecated - Deprecated features + removed - Removed features + security - Security fixes"}, + ); + session.confirm(); + + session.wait_for("Enter description (press Enter 3 times to finish):"); + session.assert_screen( + "description prompt", + indoc! {" + Select packages to include in changeset: crate-a (0.1.0), crate-b (0.2.0) + Select bump type for 'crate-a': patch - Bug fixes (backwards compatible) + Select bump type for 'crate-b': patch - Bug fixes (backwards compatible) + Select change category: changed - General changes (default) + + Enter description (press Enter 3 times to finish):"}, + ); + session.type_line("Multi-package changeset"); + session.type_line(""); + session.type_line(""); - session.wait_for("bump type"); - session.send_raw("\n"); + session.wait_for("Created changeset"); + session.wait_for_exit(); - session.wait_for("category"); - session.send_raw("\n"); + let changeset_dir = workspace.path().join(".changeset/changesets"); + let files: Vec<_> = fs::read_dir(&changeset_dir) + .expect("read dir") + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "md")) + .collect(); + assert_eq!(files.len(), 1, "should have one changeset file"); - session.wait_for("description"); + let content = fs::read_to_string(files[0].path()).expect("read file"); + assert!(content.contains("crate-a")); + assert!(content.contains("patch")); + assert!(content.contains("crate-b")); + assert!(content.contains("patch")); + assert!(content.contains("Multi-package changeset")); + } - session.send_line("Multi-package changeset"); - session.send_line(""); - session.send_line(""); + #[test] + fn interactive_multi_package_different_bumps() { + let workspace = create_virtual_workspace(); + let mut session = spawn_add_in_workspace(&workspace); + + session.wait_for("Select packages to include in changeset"); + session.assert_screen( + "package multi-select menu", + indoc! {" + Select packages to include in changeset: + > [ ] crate-a (0.1.0) + [ ] crate-b (0.2.0)"}, + ); + session.toggle_item(0); + session.toggle_item(1); + session.confirm(); + + session.wait_for("Select bump type for 'crate-a'"); + session.assert_screen( + "bump type for crate-a", + indoc! {" + Select packages to include in changeset: crate-a (0.1.0), crate-b (0.2.0) + Select bump type for 'crate-a': + > patch - Bug fixes (backwards compatible) + minor - New features (backwards compatible) + major - Breaking changes + none - No version bump (internal changes only)"}, + ); + session.select_item(1); + + session.wait_for("Select bump type for 'crate-b'"); + session.assert_screen( + "bump type for crate-b after crate-a got major", + indoc! {" + Select packages to include in changeset: crate-a (0.1.0), crate-b (0.2.0) + Select bump type for 'crate-a': major - Breaking changes + Select bump type for 'crate-b': + > patch - Bug fixes (backwards compatible) + minor - New features (backwards compatible) + major - Breaking changes + none - No version bump (internal changes only)"}, + ); + session.confirm(); + + session.wait_for("Select change category"); + session.assert_screen( + "category menu after different bumps", + indoc! {" + Select packages to include in changeset: crate-a (0.1.0), crate-b (0.2.0) + Select bump type for 'crate-a': major - Breaking changes + Select bump type for 'crate-b': patch - Bug fixes (backwards compatible) + Select change category: + > changed - General changes (default) + added - New features + fixed - Bug fixes + deprecated - Deprecated features + removed - Removed features + security - Security fixes"}, + ); + session.confirm(); + + session.wait_for("Enter description (press Enter 3 times to finish):"); + session.assert_screen( + "description prompt", + indoc! {" + Select packages to include in changeset: crate-a (0.1.0), crate-b (0.2.0) + Select bump type for 'crate-a': major - Breaking changes + Select bump type for 'crate-b': patch - Bug fixes (backwards compatible) + Select change category: changed - General changes (default) + + Enter description (press Enter 3 times to finish):"}, + ); + session.type_line("Different bumps per package"); + session.type_line(""); + session.type_line(""); session.wait_for("Created changeset"); session.wait_for_exit(); + + let changeset_dir = workspace.path().join(".changeset/changesets"); + let files: Vec<_> = fs::read_dir(&changeset_dir) + .expect("read dir") + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "md")) + .collect(); + + let content = fs::read_to_string(files[0].path()).expect("read file"); + assert!(content.contains("crate-a")); + assert!(content.contains("major")); + assert!(content.contains("crate-b")); + assert!(content.contains("patch")); } #[test] @@ -651,13 +1043,34 @@ MOCK_EDITOR_EOF let mut session = spawn_add_with_editor(&workspace, &editor); - session.wait_for("Using package: test-crate"); - - session.wait_for("bump type"); - session.send_raw("\n"); - - session.wait_for("category"); - session.send_raw("\n"); + session.wait_for("Select bump type for 'test-crate'"); + session.assert_screen( + "editor mode bump type menu", + indoc! {" + Using package: test-crate (1.0.0) + Select bump type for 'test-crate': + > patch - Bug fixes (backwards compatible) + minor - New features (backwards compatible) + major - Breaking changes + none - No version bump (internal changes only)"}, + ); + session.confirm(); + + session.wait_for("Select change category"); + session.assert_screen( + "editor mode category menu", + indoc! {" + Using package: test-crate (1.0.0) + Select bump type for 'test-crate': patch - Bug fixes (backwards compatible) + Select change category: + > changed - General changes (default) + added - New features + fixed - Bug fixes + deprecated - Deprecated features + removed - Removed features + security - Security fixes"}, + ); + session.confirm(); session.wait_for("Created changeset"); session.wait_for_exit(); @@ -686,13 +1099,34 @@ MOCK_EDITOR_EOF let mut session = spawn_add_with_editor(&workspace, &editor); - session.wait_for("Using package: test-crate"); - - session.wait_for("bump type"); - session.send_raw("\n"); - - session.wait_for("category"); - session.send_raw("\n"); + session.wait_for("Select bump type for 'test-crate'"); + session.assert_screen( + "editor comments bump type menu", + indoc! {" + Using package: test-crate (1.0.0) + Select bump type for 'test-crate': + > patch - Bug fixes (backwards compatible) + minor - New features (backwards compatible) + major - Breaking changes + none - No version bump (internal changes only)"}, + ); + session.confirm(); + + session.wait_for("Select change category"); + session.assert_screen( + "editor comments category menu", + indoc! {" + Using package: test-crate (1.0.0) + Select bump type for 'test-crate': patch - Bug fixes (backwards compatible) + Select change category: + > changed - General changes (default) + added - New features + fixed - Bug fixes + deprecated - Deprecated features + removed - Removed features + security - Security fixes"}, + ); + session.confirm(); session.wait_for("Created changeset"); session.wait_for_exit(); diff --git a/crates/cargo-changeset/tests/additional_packages.rs b/crates/cargo-changeset/tests/additional_packages.rs index 8886561..1ccdbd6 100644 --- a/crates/cargo-changeset/tests/additional_packages.rs +++ b/crates/cargo-changeset/tests/additional_packages.rs @@ -1063,6 +1063,7 @@ mod interactive_add_tests { use std::path::PathBuf; use changeset_test_helpers::terminal_session::TerminalSession; + use indoc::indoc; use super::*; @@ -1070,27 +1071,370 @@ mod interactive_add_tests { assert_cmd::cargo::cargo_bin("cargo-changeset") } + fn spawn_add(workspace: &tempfile::TempDir) -> TerminalSession { + TerminalSession::spawn(&bin_path(), workspace, &["additional-packages", "add"]) + } + #[test] - fn interactive_add_accepts_influence_patterns() { + fn interactive_add_cancel_at_name() { let workspace = create_workspace_with_helm_chart(); + let cargo_toml_before = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); - let mut session = - TerminalSession::spawn(&bin_path(), &workspace, &["additional-packages", "add"]); + let mut session = spawn_add(&workspace); session.wait_for("Package name"); + session.assert_screen( + "name prompt before ctrl-c", + indoc! {" + Package name:"}, + ); + session.ctrl_c(); + session.wait_for_exit(); + + let cargo_toml_after = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert_eq!( + cargo_toml_before, cargo_toml_after, + "Cargo.toml must not change after Ctrl+C at name prompt" + ); + } + + #[test] + fn interactive_add_cancel_at_directory_path() { + let workspace = create_workspace_with_helm_chart(); + let cargo_toml_before = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + + let mut session = spawn_add(&workspace); + session.wait_for("Package name"); + session.assert_screen( + "name prompt", + indoc! {" + Package name:"}, + ); session.type_line("my-helm-chart"); session.wait_for("Package directory path"); + session.assert_screen( + "path prompt before ctrl-c", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root):"}, + ); + session.ctrl_c(); + session.wait_for_exit(); + + let cargo_toml_after = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert_eq!( + cargo_toml_before, cargo_toml_after, + "Cargo.toml must not change after Ctrl+C at path prompt" + ); + } + + #[test] + fn interactive_add_cancel_at_manifest_format() { + let workspace = create_workspace_with_helm_chart(); + let cargo_toml_before = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + + let mut session = spawn_add(&workspace); + session.wait_for("Package name"); + session.assert_screen( + "name prompt", + indoc! {" + Package name:"}, + ); + session.type_line("my-helm-chart"); + session.wait_for("Package directory path"); + session.assert_screen( + "path prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root):"}, + ); session.type_line("charts/my-chart"); session.wait_for("Glob pattern"); + session.assert_screen( + "influence pattern prompt with default", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern [charts/my-chart/**]:"}, + ); session.type_line("charts/my-chart/**"); session.wait_for("Additional pattern"); - session.type_line("charts/shared/**"); + session.assert_screen( + "additional pattern prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern:"}, + ); + session.type_line(""); + session.wait_for("version manifest file"); + session.assert_screen( + "manifest file prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern: + Path to version manifest file:"}, + ); + session.type_line("charts/my-chart/Chart.yaml"); + session.wait_for("Manifest format"); + session.assert_screen( + "manifest format menu before cancel", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern: + Path to version manifest file: charts/my-chart/Chart.yaml + Manifest format: + toml + > yaml + json"}, + ); + session.cancel(); + session.wait_for_exit(); + + let cargo_toml_after = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert_eq!( + cargo_toml_before, cargo_toml_after, + "Cargo.toml must not change after cancel at format" + ); + } + + #[test] + fn interactive_add_full_flow() { + let workspace = create_workspace_with_helm_chart(); + + let mut session = spawn_add(&workspace); + session.wait_for("Package name"); + session.assert_screen( + "name prompt", + indoc! {" + Package name:"}, + ); + session.type_line("my-helm-chart"); + session.wait_for("Package directory path"); + session.assert_screen( + "path prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root):"}, + ); + session.type_line("charts/my-chart"); + session.wait_for("Glob pattern"); + session.assert_screen( + "influence pattern prompt with default", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern [charts/my-chart/**]:"}, + ); + session.type_line("charts/my-chart/**"); session.wait_for("Additional pattern"); session.type_line(""); session.wait_for("version manifest file"); + session.assert_screen( + "manifest file prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern: + Path to version manifest file:"}, + ); session.type_line("charts/my-chart/Chart.yaml"); session.wait_for("Manifest format"); + session.assert_screen( + "manifest format menu with yaml auto-detected", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern: + Path to version manifest file: charts/my-chart/Chart.yaml + Manifest format: + toml + > yaml + json"}, + ); session.confirm(); session.wait_for("version field"); + session.assert_screen( + "version field prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern: + Path to version manifest file: charts/my-chart/Chart.yaml + Manifest format: yaml + Path to version field in manifest (e.g. 'version' or 'info.version'):"}, + ); + session.type_line("version"); + session.wait_for("Added additional package"); + session.wait_for_exit(); + session.assert_screen( + "full flow complete", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern: + Path to version manifest file: charts/my-chart/Chart.yaml + Manifest format: yaml + Path to version field in manifest (e.g. 'version' or 'info.version'): version + Added additional package 'my-helm-chart'"}, + ); + + let cargo_toml = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert!( + cargo_toml.contains(r#"name = "my-helm-chart""#), + "expected package name in config, got:\n{cargo_toml}" + ); + assert!( + cargo_toml.contains(r#"path = "charts/my-chart""#), + "expected path in config, got:\n{cargo_toml}" + ); + assert!( + cargo_toml.contains("charts/my-chart/**"), + "expected influence pattern in config, got:\n{cargo_toml}" + ); + assert!( + cargo_toml.contains(r#"format = "yaml""#), + "expected yaml format in config, got:\n{cargo_toml}" + ); + } + + #[test] + fn interactive_add_multiple_influence_patterns() { + let workspace = create_workspace_with_helm_chart(); + + let mut session = spawn_add(&workspace); + session.wait_for("Package name"); + session.assert_screen( + "name prompt", + indoc! {" + Package name:"}, + ); + session.type_line("my-helm-chart"); + session.wait_for("Package directory path"); + session.assert_screen( + "path prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root):"}, + ); + session.type_line("charts/my-chart"); + session.wait_for("Glob pattern"); + session.assert_screen( + "first influence pattern prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern [charts/my-chart/**]:"}, + ); + session.type_line("charts/my-chart/**"); + session.wait_for("Additional pattern"); + session.assert_screen( + "second influence pattern prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern:"}, + ); + session.type_line("charts/shared/**"); + session.wait_for("charts/shared/**\nAdditional pattern:"); + session.assert_screen( + "third influence pattern prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern: charts/shared/** + Additional pattern:"}, + ); + session.type_line("common/**"); + session.wait_for("common/**\nAdditional pattern:"); + session.assert_screen( + "finish patterns prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern: charts/shared/** + Additional pattern: common/** + Additional pattern:"}, + ); + session.type_line(""); + session.wait_for("version manifest file"); + session.assert_screen( + "manifest file prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern: charts/shared/** + Additional pattern: common/** + Additional pattern: + Path to version manifest file:"}, + ); + session.type_line("charts/my-chart/Chart.yaml"); + session.wait_for("Manifest format"); + session.assert_screen( + "manifest format menu with yaml auto-detected", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern: charts/shared/** + Additional pattern: common/** + Additional pattern: + Path to version manifest file: charts/my-chart/Chart.yaml + Manifest format: + toml + > yaml + json"}, + ); + session.confirm(); + session.wait_for("version field"); + session.assert_screen( + "version field prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern: charts/shared/** + Additional pattern: common/** + Additional pattern: + Path to version manifest file: charts/my-chart/Chart.yaml + Manifest format: yaml + Path to version field in manifest (e.g. 'version' or 'info.version'):"}, + ); session.type_line("version"); session.wait_for("Added additional package"); session.wait_for_exit(); @@ -1099,15 +1443,514 @@ mod interactive_add_tests { fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); assert!( cargo_toml.contains("charts/my-chart/**"), - "expected first influence pattern in config, got:\n{cargo_toml}" + "expected first influence pattern, got:\n{cargo_toml}" ); assert!( cargo_toml.contains("charts/shared/**"), - "expected second influence pattern in config, got:\n{cargo_toml}" + "expected second influence pattern, got:\n{cargo_toml}" + ); + assert!( + cargo_toml.contains("common/**"), + "expected third influence pattern, got:\n{cargo_toml}" + ); + } + + #[test] + fn interactive_add_default_influence_pattern() { + let workspace = create_workspace_with_helm_chart(); + + let mut session = spawn_add(&workspace); + session.wait_for("Package name"); + session.assert_screen( + "name prompt", + indoc! {" + Package name:"}, + ); + session.type_line("my-helm-chart"); + session.wait_for("Package directory path"); + session.assert_screen( + "path prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root):"}, + ); + session.type_line("charts/my-chart"); + session.wait_for("Glob pattern"); + session.assert_screen( + "influence pattern prompt with default", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern [charts/my-chart/**]:"}, + ); + session.confirm(); + session.wait_for("Additional pattern"); + session.assert_screen( + "additional pattern prompt after accepting default", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern:"}, + ); + session.type_line(""); + session.wait_for("version manifest file"); + session.assert_screen( + "manifest file prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern: + Path to version manifest file:"}, + ); + session.type_line("charts/my-chart/Chart.yaml"); + session.wait_for("Manifest format"); + session.assert_screen( + "manifest format menu with yaml auto-detected", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern: + Path to version manifest file: charts/my-chart/Chart.yaml + Manifest format: + toml + > yaml + json"}, + ); + session.confirm(); + session.wait_for("version field"); + session.assert_screen( + "version field prompt", + indoc! {" + Package name: my-helm-chart + Package directory path (relative to workspace root): charts/my-chart + Enter glob patterns for files that influence this package (one per line, empty line to finish): + Glob pattern: charts/my-chart/** + Additional pattern: + Path to version manifest file: charts/my-chart/Chart.yaml + Manifest format: yaml + Path to version field in manifest (e.g. 'version' or 'info.version'):"}, + ); + session.type_line("version"); + session.wait_for("Added additional package"); + session.wait_for_exit(); + + let cargo_toml = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert!( + cargo_toml.contains("charts/my-chart/**"), + "expected default influence pattern, got:\n{cargo_toml}" ); } } +#[cfg(not(windows))] +mod interactive_remove_package_tests { + use std::path::PathBuf; + + use changeset_test_helpers::terminal_session::TerminalSession; + use indoc::indoc; + + use super::*; + + fn bin_path() -> PathBuf { + assert_cmd::cargo::cargo_bin("cargo-changeset") + } + + fn spawn_remove(workspace: &tempfile::TempDir) -> TerminalSession { + TerminalSession::spawn(&bin_path(), workspace, &["additional-packages", "remove"]) + } + + #[test] + fn interactive_remove_shows_package_menu() { + let workspace = create_workspace_with_helm_chart(); + add_helm_chart_config(&workspace); + + let mut session = spawn_remove(&workspace); + session.wait_for("Select package to remove"); + session.assert_screen( + "remove package menu", + indoc! {" + Select package to remove: + my-helm-chart (charts/my-chart)"}, + ); + session.cancel(); + session.wait_for_exit(); + } + + #[test] + fn interactive_remove_cancel_at_selection() { + let workspace = create_workspace_with_helm_chart(); + add_helm_chart_config(&workspace); + let cargo_toml_before = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + + let mut session = spawn_remove(&workspace); + session.wait_for("Select package to remove"); + session.assert_screen( + "remove package menu before cancel", + indoc! {" + Select package to remove: + my-helm-chart (charts/my-chart)"}, + ); + session.cancel(); + session.wait_for_exit(); + + let cargo_toml_after = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert_eq!( + cargo_toml_before, cargo_toml_after, + "Cargo.toml must not change after cancel" + ); + } + + #[test] + fn interactive_remove_decline_confirmation() { + let workspace = create_workspace_with_helm_chart(); + add_helm_chart_config(&workspace); + let cargo_toml_before = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + + let mut session = spawn_remove(&workspace); + session.wait_for("Select package to remove"); + session.select_item(0); + session.wait_for("Remove additional package"); + session.assert_screen( + "confirmation prompt", + indoc! {" + Select package to remove: my-helm-chart (charts/my-chart) + Remove additional package 'my-helm-chart'? [y/N]"}, + ); + session.type_line("n"); + session.wait_for_exit(); + + let cargo_toml_after = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert_eq!( + cargo_toml_before, cargo_toml_after, + "Cargo.toml must not change after declining removal" + ); + } + + #[test] + fn interactive_remove_full_flow() { + let workspace = create_workspace_with_helm_chart(); + add_helm_chart_config(&workspace); + + let mut session = spawn_remove(&workspace); + session.wait_for("Select package to remove"); + session.assert_screen( + "remove package menu", + indoc! {" + Select package to remove: + my-helm-chart (charts/my-chart)"}, + ); + session.select_item(0); + session.wait_for("Remove additional package"); + session.assert_screen( + "confirmation prompt", + indoc! {" + Select package to remove: my-helm-chart (charts/my-chart) + Remove additional package 'my-helm-chart'? [y/N]"}, + ); + session.type_line("y"); + session.wait_for("Removed additional package"); + session.wait_for_exit(); + session.assert_screen( + "removal complete", + indoc! {" + Select package to remove: my-helm-chart (charts/my-chart) + Remove additional package 'my-helm-chart'? yes + Removed additional package 'my-helm-chart'"}, + ); + + let cargo_toml = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert!( + !cargo_toml.contains("my-helm-chart"), + "expected package removed from Cargo.toml, got:\n{cargo_toml}" + ); + } + + #[test] + fn interactive_remove_no_packages_exits() { + let workspace = create_workspace_with_helm_chart(); + + let mut session = spawn_remove(&workspace); + session.wait_for("No additional packages"); + session.wait_for_exit(); + } +} + +#[cfg(not(windows))] +mod interactive_edit_package_tests { + use std::path::PathBuf; + + use changeset_test_helpers::terminal_session::TerminalSession; + use indoc::indoc; + + use super::*; + + fn bin_path() -> PathBuf { + assert_cmd::cargo::cargo_bin("cargo-changeset") + } + + fn spawn_edit(workspace: &tempfile::TempDir) -> TerminalSession { + TerminalSession::spawn(&bin_path(), workspace, &["additional-packages", "edit"]) + } + + #[test] + fn interactive_edit_shows_package_menu() { + let workspace = create_workspace_with_helm_chart(); + add_helm_chart_config(&workspace); + + let mut session = spawn_edit(&workspace); + session.wait_for("Select package to edit"); + session.assert_screen( + "edit package menu", + indoc! {" + Select package to edit: + my-helm-chart (charts/my-chart)"}, + ); + session.cancel(); + session.wait_for_exit(); + } + + #[test] + fn interactive_edit_cancel_at_package_selection() { + let workspace = create_workspace_with_helm_chart(); + add_helm_chart_config(&workspace); + let cargo_toml_before = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + + let mut session = spawn_edit(&workspace); + session.wait_for("Select package to edit"); + session.assert_screen( + "edit package menu before cancel", + indoc! {" + Select package to edit: + my-helm-chart (charts/my-chart)"}, + ); + session.cancel(); + session.wait_for_exit(); + + let cargo_toml_after = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert_eq!( + cargo_toml_before, cargo_toml_after, + "Cargo.toml must not change after cancel" + ); + } + + #[test] + fn interactive_edit_shows_field_menu() { + let workspace = create_workspace_with_helm_chart(); + add_helm_chart_config(&workspace); + + let mut session = spawn_edit(&workspace); + session.wait_for("Select package to edit"); + session.select_item(0); + session.wait_for("Which field"); + session.assert_screen( + "field selection menu", + indoc! {" + Select package to edit: my-helm-chart (charts/my-chart) + Which field would you like to edit?: + > path + influence patterns + manifest file path + manifest format + manifest version path + Done"}, + ); + session.cancel(); + session.wait_for_exit(); + } + + #[test] + fn interactive_edit_done_exits_immediately() { + let workspace = create_workspace_with_helm_chart(); + add_helm_chart_config(&workspace); + let cargo_toml_before = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + + let mut session = spawn_edit(&workspace); + session.wait_for("Select package to edit"); + session.assert_screen( + "edit package menu", + indoc! {" + Select package to edit: + my-helm-chart (charts/my-chart)"}, + ); + session.select_item(0); + session.wait_for("Which field"); + session.assert_screen( + "field menu before selecting Done", + indoc! {" + Select package to edit: my-helm-chart (charts/my-chart) + Which field would you like to edit?: + > path + influence patterns + manifest file path + manifest format + manifest version path + Done"}, + ); + session.select_item(4); + session.wait_for_exit(); + + let cargo_toml_after = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert_eq!( + cargo_toml_before, cargo_toml_after, + "Cargo.toml must not change when selecting Done immediately" + ); + } + + #[test] + fn interactive_edit_path_field() { + let workspace = create_workspace_with_helm_chart(); + add_helm_chart_config(&workspace); + + fs::create_dir_all(workspace.path().join("charts/new-path")).expect("create new-path dir"); + + let mut session = spawn_edit(&workspace); + session.wait_for("Select package to edit"); + session.assert_screen( + "edit package menu", + indoc! {" + Select package to edit: + my-helm-chart (charts/my-chart)"}, + ); + session.select_item(0); + session.wait_for("Which field"); + session.assert_screen( + "field menu", + indoc! {" + Select package to edit: my-helm-chart (charts/my-chart) + Which field would you like to edit?: + > path + influence patterns + manifest file path + manifest format + manifest version path + Done"}, + ); + session.confirm(); + session.wait_for("Package directory path"); + session.assert_screen( + "path input prompt", + indoc! {" + Select package to edit: my-helm-chart (charts/my-chart) + Which field would you like to edit?: path + Package directory path (relative to workspace root):"}, + ); + session.type_line("charts/new-path"); + session.wait_for("Which field"); + session.select_item(4); + session.wait_for("Updated"); + session.wait_for_exit(); + session.assert_screen( + "edit complete", + indoc! {" + Select package to edit: my-helm-chart (charts/my-chart) + Which field would you like to edit?: path + Package directory path (relative to workspace root): charts/new-path + Which field would you like to edit?: Done + Updated additional package 'my-helm-chart' (fields: path)"}, + ); + + let cargo_toml = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert!( + cargo_toml.contains(r#"path = "charts/new-path""#), + "expected updated path, got:\n{cargo_toml}" + ); + } + + #[test] + fn interactive_edit_manifest_format_field() { + let workspace = create_workspace_with_helm_chart(); + add_helm_chart_config(&workspace); + + let mut session = spawn_edit(&workspace); + session.wait_for("Select package to edit"); + session.assert_screen( + "edit package menu", + indoc! {" + Select package to edit: + my-helm-chart (charts/my-chart)"}, + ); + session.select_item(0); + session.wait_for("Which field"); + session.assert_screen( + "field menu before selecting manifest format", + indoc! {" + Select package to edit: my-helm-chart (charts/my-chart) + Which field would you like to edit?: + > path + influence patterns + manifest file path + manifest format + manifest version path + Done"}, + ); + session.select_item(2); + session.wait_for("Manifest format"); + session.assert_screen( + "manifest format menu", + indoc! {" + Select package to edit: my-helm-chart (charts/my-chart) + Which field would you like to edit?: manifest format + Manifest format: + > toml + yaml + json"}, + ); + session.select_item(1); + session.wait_for("Manifest format: json"); + session.assert_screen( + "field menu after format change", + indoc! {" + Select package to edit: my-helm-chart (charts/my-chart) + Which field would you like to edit?: manifest format + Manifest format: json + Which field would you like to edit?: + > path + influence patterns + manifest file path + manifest format + manifest version path + Done"}, + ); + session.select_item(4); + session.wait_for("Updated"); + session.wait_for_exit(); + + let cargo_toml = + fs::read_to_string(workspace.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert!( + cargo_toml.contains(r#"format = "json""#), + "expected updated format, got:\n{cargo_toml}" + ); + } + + #[test] + fn interactive_edit_no_packages_exits() { + let workspace = create_workspace_with_helm_chart(); + + let mut session = spawn_edit(&workspace); + session.wait_for("No additional packages"); + session.wait_for_exit(); + } +} + #[cfg(not(windows))] mod interactive_dependencies_tests { use std::path::PathBuf; diff --git a/crates/cargo-changeset/tests/init_command.rs b/crates/cargo-changeset/tests/init_command.rs index 0df2ad7..e4c0dc4 100644 --- a/crates/cargo-changeset/tests/init_command.rs +++ b/crates/cargo-changeset/tests/init_command.rs @@ -791,6 +791,7 @@ mod interactive_init_tests { use std::path::PathBuf; use changeset_test_helpers::terminal_session::TerminalSession; + use indoc::indoc; use super::*; @@ -798,26 +799,1206 @@ mod interactive_init_tests { assert_cmd::cargo::cargo_bin("cargo-changeset") } + fn skip_all_sections(session: &mut TerminalSession) { + session.wait_for("Configure git settings?"); + session.assert_screen( + "git settings prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure changelog settings?"); + session.assert_screen( + "changelog settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure version settings?"); + session.assert_screen( + "version settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure file filtering?"); + session.assert_screen( + "file filtering prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? no + Configure file filtering? [y/N]"}, + ); + session.send_raw("n"); + } + + #[test] + fn interactive_init_skip_all_sections() { + let dir = setup_single_package(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + skip_all_sections(&mut session); + session.wait_for("Proceed with initialization?"); + session.wait_for("No configuration will be written (using defaults)."); + let canonical = dir.path().canonicalize().expect("canonicalize temp dir"); + session.assert_screen( + "final confirmation with defaults", + &format!( + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? no + Configure file filtering? no + + === Initialization Summary === + + Directory: {}/.changeset (will be created) + - .gitkeep file will be created + + No configuration will be written (using defaults). + + Proceed with initialization? [Y/n]"}, + canonical.display() + ), + ); + session.send_raw("y"); + session.wait_for_exit(); + + assert!( + dir.path().join(".changeset").exists(), + ".changeset directory must be created" + ); + } + + #[test] + fn interactive_init_cancel_at_final_confirmation() { + let dir = setup_single_package(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + skip_all_sections(&mut session); + session.wait_for("Proceed with initialization?"); + session.wait_for("No configuration will be written (using defaults)."); + let canonical = dir.path().canonicalize().expect("canonicalize temp dir"); + session.assert_screen( + "final confirmation with defaults before cancel", + &format!( + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? no + Configure file filtering? no + + === Initialization Summary === + + Directory: {}/.changeset (will be created) + - .gitkeep file will be created + + No configuration will be written (using defaults). + + Proceed with initialization? [Y/n]"}, + canonical.display() + ), + ); + session.send_raw("n"); + session.wait_for_exit(); + + assert!( + !dir.path().join(".changeset").exists(), + ".changeset directory must not be created after declining" + ); + } + + #[test] + fn interactive_init_cancel_at_git_settings() { + let dir = setup_single_package(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + session.wait_for("Configure git settings?"); + session.assert_screen( + "git settings prompt before cancel", + indoc! {" + Configure git settings? [Y/n]"}, + ); + session.cancel(); + session.wait_for_exit(); + + assert!( + !dir.path().join(".changeset").exists(), + ".changeset directory must not be created after cancelling at git settings" + ); + } + + #[test] + fn interactive_init_cancel_at_changelog_settings() { + let dir = setup_single_package(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + session.wait_for("Configure git settings?"); + session.assert_screen( + "git settings prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure changelog settings?"); + session.assert_screen( + "changelog settings prompt before cancel", + indoc! {" + Configure git settings? no + Configure changelog settings? [Y/n]"}, + ); + session.cancel(); + session.wait_for_exit(); + + assert!( + !dir.path().join(".changeset").exists(), + ".changeset directory must not be created after cancelling at changelog settings" + ); + } + + #[test] + fn interactive_init_cancel_at_version_settings() { + let dir = setup_single_package(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + session.wait_for("Configure git settings?"); + session.assert_screen( + "git settings prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure changelog settings?"); + session.assert_screen( + "changelog settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure version settings?"); + session.assert_screen( + "version settings prompt before cancel", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? [Y/n]"}, + ); + session.cancel(); + session.wait_for_exit(); + + assert!( + !dir.path().join(".changeset").exists(), + ".changeset directory must not be created after cancelling at version settings" + ); + } + + #[test] + fn interactive_init_cancel_at_filtering() { + let dir = setup_single_package(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + session.wait_for("Configure git settings?"); + session.assert_screen( + "git settings prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure changelog settings?"); + session.assert_screen( + "changelog settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure version settings?"); + session.assert_screen( + "version settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure file filtering?"); + session.assert_screen( + "file filtering prompt before cancel", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? no + Configure file filtering? [y/N]"}, + ); + session.cancel(); + session.wait_for_exit(); + + assert!( + !dir.path().join(".changeset").exists(), + ".changeset directory must not be created after cancelling at filtering" + ); + } + + #[test] + fn interactive_init_cancel_mid_git_section() { + let dir = setup_single_package(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + session.wait_for("Configure git settings?"); + session.assert_screen( + "git settings prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); + session.send_raw("y"); + session.wait_for("Create git commits on release?"); + session.assert_screen( + "commits prompt before cancel", + indoc! {" + Configure git settings? yes + Create git commits on release? [Y/n]"}, + ); + session.cancel(); + session.wait_for_exit(); + + assert!( + !dir.path().join(".changeset").exists(), + ".changeset directory must not be created after cancelling mid git section" + ); + } + + #[test] + fn interactive_init_cancel_mid_changelog_section() { + let dir = setup_workspace(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + session.wait_for("Configure git settings?"); + session.assert_screen( + "git settings prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure changelog settings?"); + session.assert_screen( + "changelog settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? [Y/n]"}, + ); + session.send_raw("y"); + session.wait_for("Select changelog location"); + session.assert_screen( + "changelog location menu before cancel", + indoc! {" + Configure git settings? no + Configure changelog settings? yes + Select changelog location: + > root - Single CHANGELOG.md at project root (default) + per-package - CHANGELOG.md in each package directory"}, + ); + session.cancel(); + session.wait_for_exit(); + + assert!( + !dir.path().join(".changeset").exists(), + ".changeset directory must not be created after cancelling mid changelog section" + ); + } + + #[test] + fn interactive_init_cancel_mid_version_section() { + let dir = setup_single_package(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + session.wait_for("Configure git settings?"); + session.assert_screen( + "git settings prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure changelog settings?"); + session.assert_screen( + "changelog settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure version settings?"); + session.assert_screen( + "version settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? [Y/n]"}, + ); + session.send_raw("y"); + session.wait_for("Select zero version"); + session.assert_screen( + "zero version menu before cancel", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? yes + Select zero version (0.x.y) behavior: + > effective-minor - Major bump on 0.x increments minor (default) + auto-promote-on-major - Major bump on 0.x promotes to 1.0.0"}, + ); + session.cancel(); + session.wait_for_exit(); + + assert!( + !dir.path().join(".changeset").exists(), + ".changeset directory must not be created after cancelling mid version section" + ); + } + + #[test] + fn interactive_init_git_settings_full_flow() { + let dir = setup_single_package(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + session.wait_for("Configure git settings?"); + session.assert_screen( + "configure git prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); + session.send_raw("y"); + + session.wait_for("Create git commits on release?"); + session.assert_screen( + "commits prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? [Y/n]"}, + ); + session.send_raw("y"); + + session.wait_for("Create git tags on release?"); + session.assert_screen( + "tags prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? [Y/n]"}, + ); + session.send_raw("n"); + + session.wait_for("Keep changeset files after release?"); + session.assert_screen( + "keep changesets prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? no + Keep changeset files after release? [y/N]"}, + ); + session.send_raw("y"); + + session.wait_for("Select tag format"); + session.assert_screen( + "tag format menu", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? no + Keep changeset files after release? yes + Select tag format: + > version-only - Tags like v1.0.0 + crate-prefixed - Tags like crate-name@1.0.0"}, + ); + session.confirm(); + + session.wait_for("Default base branch"); + session.assert_screen( + "base branch prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? no + Keep changeset files after release? yes + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons [main]:"}, + ); + session.confirm(); + + session.wait_for("Commit title template"); + session.assert_screen( + "commit title prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? no + Keep changeset files after release? yes + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: main + Commit title template (placeholder: {new-version}) [{new-version}]:"}, + ); + session.confirm(); + + session.wait_for("Include version details in commit body?"); + session.assert_screen( + "commit body prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? no + Keep changeset files after release? yes + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: main + Commit title template (placeholder: {new-version}): {new-version} + Include version details in commit body? [Y/n]"}, + ); + session.send_raw("y"); + + session.wait_for("Configure changelog settings?"); + session.assert_screen( + "changelog section after git", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? no + Keep changeset files after release? yes + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: main + Commit title template (placeholder: {new-version}): {new-version} + Include version details in commit body? yes + Configure changelog settings? [Y/n]"}, + ); + session.send_raw("n"); + + session.wait_for("Configure version settings?"); + session.assert_screen( + "version settings prompt after git", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? no + Keep changeset files after release? yes + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: main + Commit title template (placeholder: {new-version}): {new-version} + Include version details in commit body? yes + Configure changelog settings? no + Configure version settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure file filtering?"); + session.assert_screen( + "file filtering prompt after git", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? no + Keep changeset files after release? yes + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: main + Commit title template (placeholder: {new-version}): {new-version} + Include version details in commit body? yes + Configure changelog settings? no + Configure version settings? no + Configure file filtering? [y/N]"}, + ); + session.send_raw("n"); + session.wait_for("Proceed with initialization?"); + let canonical = dir.path().canonicalize().expect("canonicalize temp dir"); + session.assert_screen( + "final confirmation after git settings", + &format!( + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? no + Keep changeset files after release? yes + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: main + Commit title template (placeholder: {{new-version}}): {{new-version}} + Include version details in commit body? yes + Configure changelog settings? no + Configure version settings? no + Configure file filtering? no + + === Initialization Summary === + + Directory: {}/.changeset (will be created) + - .gitkeep file will be created + + Configuration to be written to [package.metadata.changeset]: + commit = true + tags = false + keep_changesets = true + tag_format = \"version-only\" + base_branch = \"main\" + commit_title_template = \"{{new-version}}\" + changes_in_body = true + + Proceed with initialization? [Y/n]"}, + canonical.display() + ), + ); + session.send_raw("y"); + session.wait_for_exit(); + + let cargo_toml = + fs::read_to_string(dir.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert!( + cargo_toml.contains("tags = false"), + "expected tags = false, got:\n{cargo_toml}" + ); + assert!( + cargo_toml.contains("keep-changesets = true"), + "expected keep-changesets = true, got:\n{cargo_toml}" + ); + } + + #[test] + fn interactive_init_git_no_commit_skips_title_and_body() { + let dir = setup_single_package(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + session.wait_for("Configure git settings?"); + session.assert_screen( + "configure git prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); + session.send_raw("y"); + + session.wait_for("Create git commits on release?"); + session.assert_screen( + "commits prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? [Y/n]"}, + ); + session.send_raw("n"); + + session.wait_for("Create git tags on release?"); + session.assert_screen( + "tags prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? no + Create git tags on release? [Y/n]"}, + ); + session.send_raw("y"); + + session.wait_for("Keep changeset files after release?"); + session.assert_screen( + "keep changesets prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? no + Create git tags on release? yes + Keep changeset files after release? [y/N]"}, + ); + session.send_raw("n"); + + session.wait_for("Select tag format"); + session.assert_screen( + "tag format menu", + indoc! {" + Configure git settings? yes + Create git commits on release? no + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: + > version-only - Tags like v1.0.0 + crate-prefixed - Tags like crate-name@1.0.0"}, + ); + session.confirm(); + + session.wait_for("Default base branch"); + session.assert_screen( + "base branch prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? no + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons [main]:"}, + ); + session.confirm(); + + session.wait_for("Configure changelog settings?"); + session.assert_screen( + "skipped to changelog after base branch", + indoc! {" + Configure git settings? yes + Create git commits on release? no + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: main + Configure changelog settings? [Y/n]"}, + ); + session.send_raw("n"); + + session.wait_for("Configure version settings?"); + session.assert_screen( + "version settings prompt after git no-commit", + indoc! {" + Configure git settings? yes + Create git commits on release? no + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: main + Configure changelog settings? no + Configure version settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure file filtering?"); + session.assert_screen( + "file filtering prompt after git no-commit", + indoc! {" + Configure git settings? yes + Create git commits on release? no + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: main + Configure changelog settings? no + Configure version settings? no + Configure file filtering? [y/N]"}, + ); + session.send_raw("n"); + session.wait_for("Proceed with initialization?"); + let canonical = dir.path().canonicalize().expect("canonicalize temp dir"); + session.assert_screen( + "final confirmation after git no-commit", + &format!( + indoc! {" + Configure git settings? yes + Create git commits on release? no + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: main + Configure changelog settings? no + Configure version settings? no + Configure file filtering? no + + === Initialization Summary === + + Directory: {}/.changeset (will be created) + - .gitkeep file will be created + + Configuration to be written to [package.metadata.changeset]: + commit = false + tags = true + keep_changesets = false + tag_format = \"version-only\" + base_branch = \"main\" + + Proceed with initialization? [Y/n]"}, + canonical.display() + ), + ); + session.send_raw("y"); + session.wait_for_exit(); + + let cargo_toml = + fs::read_to_string(dir.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert!( + cargo_toml.contains("commit = false"), + "expected commit = false, got:\n{cargo_toml}" + ); + assert!( + !cargo_toml.contains("commit-title"), + "commit-title should not appear when commit=false, got:\n{cargo_toml}" + ); + assert!( + !cargo_toml.contains("changes-in-body"), + "changes-in-body should not appear when commit=false, got:\n{cargo_toml}" + ); + } + + #[test] + fn interactive_init_changelog_settings_full_flow() { + let dir = setup_workspace(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + session.wait_for("Configure git settings?"); + session.assert_screen( + "git settings prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); + session.send_raw("n"); + + session.wait_for("Configure changelog settings?"); + session.assert_screen( + "changelog settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? [Y/n]"}, + ); + session.send_raw("y"); + + session.wait_for("Select changelog location"); + session.assert_screen( + "changelog location menu", + indoc! {" + Configure git settings? no + Configure changelog settings? yes + Select changelog location: + > root - Single CHANGELOG.md at project root (default) + per-package - CHANGELOG.md in each package directory"}, + ); + session.select_item(0); + + session.wait_for("Select comparison links mode"); + session.assert_screen( + "comparison links menu", + indoc! {" + Configure git settings? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: + > auto - Generate links if git remote detected (default) + enabled - Always generate comparison links + disabled - Never generate comparison links"}, + ); + session.select_item(0); + + session.wait_for("Comparison links template"); + session.assert_screen( + "comparison links template prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: enabled - Always generate comparison links + Comparison links template (empty=auto-detect, placeholders: {repository}, {base}, {target}) []:"}, + ); + session.confirm(); + + session.wait_for("Dependency bump changelog template"); + session.assert_screen( + "dependency bump template prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: enabled - Always generate comparison links + Comparison links template (empty=auto-detect, placeholders: {repository}, {base}, {target}): + Dependency bump changelog template (placeholders: {dependency}, {version}) [Updated dependency `{dependency}` to v{version}]:"}, + ); + session.confirm(); + + session.wait_for("Configure version settings?"); + session.assert_screen( + "version settings prompt after changelog", + indoc! {" + Configure git settings? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: enabled - Always generate comparison links + Comparison links template (empty=auto-detect, placeholders: {repository}, {base}, {target}): + Dependency bump changelog template (placeholders: {dependency}, {version}): Updated dependency `{dependency}` to v{version} + Configure version settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure file filtering?"); + session.assert_screen( + "file filtering prompt after changelog", + indoc! {" + Configure git settings? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: enabled - Always generate comparison links + Comparison links template (empty=auto-detect, placeholders: {repository}, {base}, {target}): + Dependency bump changelog template (placeholders: {dependency}, {version}): Updated dependency `{dependency}` to v{version} + Configure version settings? no + Configure file filtering? [y/N]"}, + ); + session.send_raw("n"); + session.wait_for("Proceed with initialization?"); + let canonical = dir.path().canonicalize().expect("canonicalize temp dir"); + session.assert_screen( + "final confirmation after changelog settings", + &format!( + indoc! {" + Configure git settings? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: enabled - Always generate comparison links + Comparison links template (empty=auto-detect, placeholders: {{repository}}, {{base}}, {{target}}): + Dependency bump changelog template (placeholders: {{dependency}}, {{version}}): Updated dependency `{{dependency}}` to v{{version}} + Configure version settings? no + Configure file filtering? no + + === Initialization Summary === + + Directory: {}/.changeset (will be created) + - .gitkeep file will be created + + Configuration to be written to [workspace.metadata.changeset]: + changelog = \"per-package\" + comparison_links = \"enabled\" + + Proceed with initialization? [Y/n]"}, + canonical.display() + ), + ); + session.send_raw("y"); + session.wait_for_exit(); + + let cargo_toml = + fs::read_to_string(dir.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert!( + cargo_toml.contains("[workspace.metadata.changeset]"), + "expected changeset config, got:\n{cargo_toml}" + ); + } + + #[test] + fn interactive_init_version_settings_full_flow() { + let dir = setup_single_package(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + session.wait_for("Configure git settings?"); + session.assert_screen( + "git settings prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure changelog settings?"); + session.assert_screen( + "changelog settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? [Y/n]"}, + ); + session.send_raw("n"); + + session.wait_for("Configure version settings?"); + session.assert_screen( + "version settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? [Y/n]"}, + ); + session.send_raw("y"); + + session.wait_for("Select zero version"); + session.assert_screen( + "zero version menu", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? yes + Select zero version (0.x.y) behavior: + > effective-minor - Major bump on 0.x increments minor (default) + auto-promote-on-major - Major bump on 0.x promotes to 1.0.0"}, + ); + session.confirm(); + + session.wait_for("Select none bump behavior"); + session.assert_screen( + "none bump menu", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? yes + Select zero version (0.x.y) behavior: effective-minor - Major bump on 0.x increments minor (default) + Select none bump behavior: + > promote-to-patch - Treat none bumps as patch releases (default) + allow - Allow none bumps without version change + disallow - Reject changesets with none bump type"}, + ); + session.confirm(); + + session.wait_for("Changelog message template"); + session.assert_screen( + "promote message prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? yes + Select zero version (0.x.y) behavior: effective-minor - Major bump on 0.x increments minor (default) + Select none bump behavior: promote-to-patch - Treat none bumps as patch releases (default) + Changelog message template for promoted none bumps [Internal architectural changes]:"}, + ); + session.confirm(); + + session.wait_for("Configure file filtering?"); + session.assert_screen( + "file filtering prompt after version settings", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? yes + Select zero version (0.x.y) behavior: effective-minor - Major bump on 0.x increments minor (default) + Select none bump behavior: promote-to-patch - Treat none bumps as patch releases (default) + Changelog message template for promoted none bumps: Internal architectural changes + Configure file filtering? [y/N]"}, + ); + session.send_raw("n"); + session.wait_for("Proceed with initialization?"); + let canonical = dir.path().canonicalize().expect("canonicalize temp dir"); + session.assert_screen( + "final confirmation after version settings", + &format!( + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? yes + Select zero version (0.x.y) behavior: effective-minor - Major bump on 0.x increments minor (default) + Select none bump behavior: promote-to-patch - Treat none bumps as patch releases (default) + Changelog message template for promoted none bumps: Internal architectural changes + Configure file filtering? no + + === Initialization Summary === + + Directory: {}/.changeset (will be created) + - .gitkeep file will be created + + Configuration to be written to [package.metadata.changeset]: + zero_version_behavior = \"effective-minor\" + none_bump_behavior = \"promote-to-patch\" + none_bump_promote_message_template = \"Internal architectural changes\" + + Proceed with initialization? [Y/n]"}, + canonical.display() + ), + ); + session.send_raw("y"); + session.wait_for_exit(); + + let cargo_toml = + fs::read_to_string(dir.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert!( + cargo_toml.contains("[package.metadata.changeset]"), + "expected changeset config, got:\n{cargo_toml}" + ); + } + + #[test] + fn interactive_init_version_allow_skips_promote_message() { + let dir = setup_single_package(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + session.wait_for("Configure git settings?"); + session.assert_screen( + "git settings prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); + session.send_raw("n"); + session.wait_for("Configure changelog settings?"); + session.assert_screen( + "changelog settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? [Y/n]"}, + ); + session.send_raw("n"); + + session.wait_for("Configure version settings?"); + session.assert_screen( + "version settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? [Y/n]"}, + ); + session.send_raw("y"); + + session.wait_for("Select zero version"); + session.assert_screen( + "zero version menu", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? yes + Select zero version (0.x.y) behavior: + > effective-minor - Major bump on 0.x increments minor (default) + auto-promote-on-major - Major bump on 0.x promotes to 1.0.0"}, + ); + session.confirm(); + + session.wait_for("Select none bump behavior"); + session.assert_screen( + "none bump menu before selecting allow", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? yes + Select zero version (0.x.y) behavior: effective-minor - Major bump on 0.x increments minor (default) + Select none bump behavior: + > promote-to-patch - Treat none bumps as patch releases (default) + allow - Allow none bumps without version change + disallow - Reject changesets with none bump type"}, + ); + session.select_item(0); + + session.wait_for("Configure file filtering?"); + session.assert_screen( + "skipped to filtering after allow", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? yes + Select zero version (0.x.y) behavior: effective-minor - Major bump on 0.x increments minor (default) + Select none bump behavior: allow - Allow none bumps without version change + Configure file filtering? [y/N]"}, + ); + session.send_raw("n"); + session.wait_for("Proceed with initialization?"); + let canonical = dir.path().canonicalize().expect("canonicalize temp dir"); + session.assert_screen( + "final confirmation after version allow", + &format!( + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? yes + Select zero version (0.x.y) behavior: effective-minor - Major bump on 0.x increments minor (default) + Select none bump behavior: allow - Allow none bumps without version change + Configure file filtering? no + + === Initialization Summary === + + Directory: {}/.changeset (will be created) + - .gitkeep file will be created + + Configuration to be written to [package.metadata.changeset]: + zero_version_behavior = \"effective-minor\" + none_bump_behavior = \"allow\" + + Proceed with initialization? [Y/n]"}, + canonical.display() + ), + ); + session.send_raw("y"); + session.wait_for_exit(); + + let cargo_toml = + fs::read_to_string(dir.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert!( + !cargo_toml.contains("none-bump-promote-message-template"), + "promote message template should not appear when behavior=allow, got:\n{cargo_toml}" + ); + } + #[test] fn interactive_init_filtering_adds_patterns() { let dir = setup_single_package(); let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); session.wait_for("Configure git settings?"); + session.assert_screen( + "git settings prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); session.send_raw("n"); session.wait_for("Configure changelog settings?"); + session.assert_screen( + "changelog settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? [Y/n]"}, + ); session.send_raw("n"); session.wait_for("Configure version settings?"); + session.assert_screen( + "version settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? [Y/n]"}, + ); session.send_raw("n"); session.wait_for("Configure file filtering?"); + session.assert_screen( + "file filtering prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? no + Configure file filtering? [y/N]"}, + ); session.send_raw("y"); session.wait_for("Ignore pattern"); + session.assert_screen( + "first pattern prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? no + Configure file filtering? yes + Enter file patterns to exclude from change detection (one per line, empty line to finish): + Ignore pattern:"}, + ); session.type_line("*.log"); session.wait_for("Additional pattern"); + session.assert_screen( + "additional pattern prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? no + Configure file filtering? yes + Enter file patterns to exclude from change detection (one per line, empty line to finish): + Ignore pattern: *.log + Additional pattern:"}, + ); session.type_line("tmp/**"); - session.wait_for("Additional pattern"); + session.wait_for("tmp/**\nAdditional pattern:"); + session.assert_screen( + "second additional pattern prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? no + Configure file filtering? yes + Enter file patterns to exclude from change detection (one per line, empty line to finish): + Ignore pattern: *.log + Additional pattern: tmp/** + Additional pattern:"}, + ); session.type_line(""); session.wait_for("Proceed with initialization?"); + let canonical = dir.path().canonicalize().expect("canonicalize temp dir"); + session.assert_screen( + "final confirmation after filtering", + &format!( + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? no + Configure file filtering? yes + Enter file patterns to exclude from change detection (one per line, empty line to finish): + Ignore pattern: *.log + Additional pattern: tmp/** + Additional pattern: + + === Initialization Summary === + + Directory: {}/.changeset (will be created) + - .gitkeep file will be created + + Configuration to be written to [package.metadata.changeset]: + ignored_files = [\"*.log\", \"tmp/**\"] + + Proceed with initialization? [Y/n]"}, + canonical.display() + ), + ); session.send_raw("y"); session.wait_for_exit(); @@ -839,16 +2020,75 @@ mod interactive_init_tests { let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); session.wait_for("Configure git settings?"); + session.assert_screen( + "git settings prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); session.send_raw("n"); session.wait_for("Configure changelog settings?"); + session.assert_screen( + "changelog settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? [Y/n]"}, + ); session.send_raw("n"); session.wait_for("Configure version settings?"); + session.assert_screen( + "version settings prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? [Y/n]"}, + ); session.send_raw("n"); session.wait_for("Configure file filtering?"); + session.assert_screen( + "file filtering prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? no + Configure file filtering? [y/N]"}, + ); session.send_raw("y"); session.wait_for("Ignore pattern"); + session.assert_screen( + "first pattern prompt", + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? no + Configure file filtering? yes + Enter file patterns to exclude from change detection (one per line, empty line to finish): + Ignore pattern:"}, + ); session.type_line(""); session.wait_for("Proceed with initialization?"); + let canonical = dir.path().canonicalize().expect("canonicalize temp dir"); + session.assert_screen( + "final confirmation after empty filtering", + &format!( + indoc! {" + Configure git settings? no + Configure changelog settings? no + Configure version settings? no + Configure file filtering? yes + Enter file patterns to exclude from change detection (one per line, empty line to finish): + Ignore pattern: + + === Initialization Summary === + + Directory: {}/.changeset (will be created) + - .gitkeep file will be created + + No configuration will be written (using defaults). + + Proceed with initialization? [Y/n]"}, + canonical.display() + ), + ); session.send_raw("y"); session.wait_for_exit(); @@ -859,6 +2099,421 @@ mod interactive_init_tests { "expected no ignored_files in config, got:\n{cargo_toml}" ); } + + #[test] + fn interactive_init_full_flow_all_sections() { + let dir = setup_workspace(); + + let mut session = TerminalSession::spawn(&bin_path(), &dir, &["changeset", "init"]); + + session.wait_for("Configure git settings?"); + session.assert_screen( + "configure git prompt", + indoc! {" + Configure git settings? [Y/n]"}, + ); + session.send_raw("y"); + + session.wait_for("Create git commits on release?"); + session.assert_screen( + "commits prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? [Y/n]"}, + ); + session.send_raw("y"); + + session.wait_for("Create git tags on release?"); + session.assert_screen( + "tags prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? [Y/n]"}, + ); + session.send_raw("y"); + + session.wait_for("Keep changeset files after release?"); + session.assert_screen( + "keep changesets prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? [y/N]"}, + ); + session.send_raw("n"); + + session.wait_for("Select tag format"); + session.assert_screen( + "tag format menu (workspace default = crate-prefixed)", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: + version-only - Tags like v1.0.0 + > crate-prefixed - Tags like crate-name@1.0.0"}, + ); + session.select_item(0); + + session.wait_for("Default base branch"); + session.assert_screen( + "base branch prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons [main]:"}, + ); + session.type_line("develop"); + + session.wait_for("Commit title template"); + session.assert_screen( + "commit title prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: develop + Commit title template (placeholder: {new-version}) [{new-version}]:"}, + ); + session.type_line("release: {new-version}"); + + session.wait_for("Include version details in commit body?"); + session.assert_screen( + "commit body prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: develop + Commit title template (placeholder: {new-version}): release: {new-version} + Include version details in commit body? [Y/n]"}, + ); + session.send_raw("n"); + + session.wait_for("Configure changelog settings?"); + session.assert_screen( + "changelog section", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: develop + Commit title template (placeholder: {new-version}): release: {new-version} + Include version details in commit body? no + Configure changelog settings? [Y/n]"}, + ); + session.send_raw("y"); + + session.wait_for("Select changelog location"); + session.assert_screen( + "changelog location menu", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: develop + Commit title template (placeholder: {new-version}): release: {new-version} + Include version details in commit body? no + Configure changelog settings? yes + Select changelog location: + > root - Single CHANGELOG.md at project root (default) + per-package - CHANGELOG.md in each package directory"}, + ); + session.select_item(0); + + session.wait_for("Select comparison links mode"); + session.assert_screen( + "comparison links menu", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: develop + Commit title template (placeholder: {new-version}): release: {new-version} + Include version details in commit body? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: + > auto - Generate links if git remote detected (default) + enabled - Always generate comparison links + disabled - Never generate comparison links"}, + ); + session.confirm(); + + session.wait_for("Comparison links template"); + session.assert_screen( + "comparison links template prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: develop + Commit title template (placeholder: {new-version}): release: {new-version} + Include version details in commit body? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: auto - Generate links if git remote detected (default) + Comparison links template (empty=auto-detect, placeholders: {repository}, {base}, {target}) []:"}, + ); + session.confirm(); + + session.wait_for("Dependency bump changelog template"); + session.assert_screen( + "dependency bump template prompt", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: develop + Commit title template (placeholder: {new-version}): release: {new-version} + Include version details in commit body? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: auto - Generate links if git remote detected (default) + Comparison links template (empty=auto-detect, placeholders: {repository}, {base}, {target}): + Dependency bump changelog template (placeholders: {dependency}, {version}) [Updated dependency `{dependency}` to v{version}]:"}, + ); + session.confirm(); + + session.wait_for("Configure version settings?"); + session.assert_screen( + "version settings prompt in full flow", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: develop + Commit title template (placeholder: {new-version}): release: {new-version} + Include version details in commit body? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: auto - Generate links if git remote detected (default) + Comparison links template (empty=auto-detect, placeholders: {repository}, {base}, {target}): + Dependency bump changelog template (placeholders: {dependency}, {version}): Updated dependency `{dependency}` to v{version} + Configure version settings? [Y/n]"}, + ); + session.send_raw("y"); + + session.wait_for("Select zero version"); + session.assert_screen( + "zero version menu in full flow", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: develop + Commit title template (placeholder: {new-version}): release: {new-version} + Include version details in commit body? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: auto - Generate links if git remote detected (default) + Comparison links template (empty=auto-detect, placeholders: {repository}, {base}, {target}): + Dependency bump changelog template (placeholders: {dependency}, {version}): Updated dependency `{dependency}` to v{version} + Configure version settings? yes + Select zero version (0.x.y) behavior: + > effective-minor - Major bump on 0.x increments minor (default) + auto-promote-on-major - Major bump on 0.x promotes to 1.0.0"}, + ); + session.select_item(0); + + session.wait_for("Select none bump behavior"); + session.assert_screen( + "none bump menu in full flow", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: develop + Commit title template (placeholder: {new-version}): release: {new-version} + Include version details in commit body? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: auto - Generate links if git remote detected (default) + Comparison links template (empty=auto-detect, placeholders: {repository}, {base}, {target}): + Dependency bump changelog template (placeholders: {dependency}, {version}): Updated dependency `{dependency}` to v{version} + Configure version settings? yes + Select zero version (0.x.y) behavior: auto-promote-on-major - Major bump on 0.x promotes to 1.0.0 + Select none bump behavior: + > promote-to-patch - Treat none bumps as patch releases (default) + allow - Allow none bumps without version change + disallow - Reject changesets with none bump type"}, + ); + session.select_item(1); + + session.wait_for("Configure file filtering?"); + session.assert_screen( + "file filtering prompt in full flow", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: develop + Commit title template (placeholder: {new-version}): release: {new-version} + Include version details in commit body? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: auto - Generate links if git remote detected (default) + Comparison links template (empty=auto-detect, placeholders: {repository}, {base}, {target}): + Dependency bump changelog template (placeholders: {dependency}, {version}): Updated dependency `{dependency}` to v{version} + Configure version settings? yes + Select zero version (0.x.y) behavior: auto-promote-on-major - Major bump on 0.x promotes to 1.0.0 + Select none bump behavior: disallow - Reject changesets with none bump type + Configure file filtering? [y/N]"}, + ); + session.send_raw("y"); + + session.wait_for("Ignore pattern"); + session.assert_screen( + "ignore pattern prompt in full flow", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: develop + Commit title template (placeholder: {new-version}): release: {new-version} + Include version details in commit body? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: auto - Generate links if git remote detected (default) + Comparison links template (empty=auto-detect, placeholders: {repository}, {base}, {target}): + Dependency bump changelog template (placeholders: {dependency}, {version}): Updated dependency `{dependency}` to v{version} + Configure version settings? yes + Select zero version (0.x.y) behavior: auto-promote-on-major - Major bump on 0.x promotes to 1.0.0 + Select none bump behavior: disallow - Reject changesets with none bump type + Configure file filtering? yes + Enter file patterns to exclude from change detection (one per line, empty line to finish): + Ignore pattern:"}, + ); + session.type_line("*.tmp"); + session.wait_for("Additional pattern"); + session.assert_screen( + "additional pattern prompt in full flow", + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: develop + Commit title template (placeholder: {new-version}): release: {new-version} + Include version details in commit body? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: auto - Generate links if git remote detected (default) + Comparison links template (empty=auto-detect, placeholders: {repository}, {base}, {target}): + Dependency bump changelog template (placeholders: {dependency}, {version}): Updated dependency `{dependency}` to v{version} + Configure version settings? yes + Select zero version (0.x.y) behavior: auto-promote-on-major - Major bump on 0.x promotes to 1.0.0 + Select none bump behavior: disallow - Reject changesets with none bump type + Configure file filtering? yes + Enter file patterns to exclude from change detection (one per line, empty line to finish): + Ignore pattern: *.tmp + Additional pattern:"}, + ); + session.type_line(""); + + session.wait_for("Proceed with initialization?"); + let canonical = dir.path().canonicalize().expect("canonicalize temp dir"); + session.assert_screen( + "final confirmation in full flow", + &format!( + indoc! {" + Configure git settings? yes + Create git commits on release? yes + Create git tags on release? yes + Keep changeset files after release? no + Select tag format: version-only - Tags like v1.0.0 + Default base branch for git comparisons: develop + Commit title template (placeholder: {{new-version}}): release: {{new-version}} + Include version details in commit body? no + Configure changelog settings? yes + Select changelog location: per-package - CHANGELOG.md in each package directory + Select comparison links mode: auto - Generate links if git remote detected (default) + Comparison links template (empty=auto-detect, placeholders: {{repository}}, {{base}}, {{target}}): + Dependency bump changelog template (placeholders: {{dependency}}, {{version}}): Updated dependency `{{dependency}}` to v{{version}} + Configure version settings? yes + Select zero version (0.x.y) behavior: auto-promote-on-major - Major bump on 0.x promotes to 1.0.0 + Select none bump behavior: disallow - Reject changesets with none bump type + Configure file filtering? yes + Enter file patterns to exclude from change detection (one per line, empty line to finish): + Ignore pattern: *.tmp + Additional pattern: + + === Initialization Summary === + + Directory: {}/.changeset (will be created) + - .gitkeep file will be created + + Configuration to be written to [workspace.metadata.changeset]: + commit = true + tags = true + keep_changesets = false + tag_format = \"version-only\" + changelog = \"per-package\" + comparison_links = \"auto\" + zero_version_behavior = \"auto-promote-on-major\" + base_branch = \"develop\" + none_bump_behavior = \"disallow\" + commit_title_template = \"release: {{new-version}}\" + changes_in_body = false + ignored_files = [\"*.tmp\"] + + Proceed with initialization? [Y/n]"}, + canonical.display() + ), + ); + session.send_raw("y"); + session.wait_for_exit(); + + let cargo_toml = + fs::read_to_string(dir.path().join("Cargo.toml")).expect("read Cargo.toml"); + assert!( + cargo_toml.contains("base-branch = \"develop\""), + "expected base-branch = develop, got:\n{cargo_toml}" + ); + assert!( + cargo_toml.contains("release: {new-version}"), + "expected custom commit title, got:\n{cargo_toml}" + ); + assert!( + cargo_toml.contains("changes-in-body = false"), + "expected changes-in-body = false, got:\n{cargo_toml}" + ); + assert!( + cargo_toml.contains("*.tmp"), + "expected *.tmp pattern, got:\n{cargo_toml}" + ); + } } mod project_type_scenarios { diff --git a/crates/cargo-changeset/tests/manage_integration.rs b/crates/cargo-changeset/tests/manage_integration.rs index 24e2502..0984a3f 100644 --- a/crates/cargo-changeset/tests/manage_integration.rs +++ b/crates/cargo-changeset/tests/manage_integration.rs @@ -584,3 +584,611 @@ mod manage_graduation { .stderr(contains("stable")); } } + +#[cfg(not(windows))] +mod interactive_prerelease_tests { + use std::path::PathBuf; + + use changeset_test_helpers::terminal_session::TerminalSession; + use indoc::indoc; + + use super::*; + + fn bin_path() -> PathBuf { + assert_cmd::cargo::cargo_bin("cargo-changeset") + } + + fn spawn_prerelease(workspace: &TempDir) -> TerminalSession { + TerminalSession::spawn(&bin_path(), workspace, &["manage", "pre-release"]) + } + + fn workspace_with_changeset_dir() -> TempDir { + let workspace = create_virtual_workspace(); + fs::create_dir_all(workspace.path().join(".changeset")).expect("create changeset dir"); + workspace + } + + #[test] + fn interactive_prerelease_action_menu_rendering() { + let workspace = workspace_with_changeset_dir(); + + let mut session = spawn_prerelease(&workspace); + session.wait_for("What would you like to do?"); + session.assert_screen( + "prerelease action menu", + indoc! {" + What would you like to do?: + > Add crate to pre-release + Remove crate from pre-release + Graduate crate (move to graduation queue) + Done"}, + ); + session.cancel(); + session.wait_for_exit(); + } + + #[test] + fn interactive_prerelease_done_exits_cleanly() { + let workspace = workspace_with_changeset_dir(); + + let mut session = spawn_prerelease(&workspace); + session.wait_for("What would you like to do?"); + session.assert_screen( + "action menu before Done", + indoc! {" + What would you like to do?: + > Add crate to pre-release + Remove crate from pre-release + Graduate crate (move to graduation queue) + Done"}, + ); + session.select_item(2); + session.wait_for_exit(); + + assert!( + !workspace + .path() + .join(".changeset/pre-release.toml") + .exists(), + "pre-release.toml must not be created" + ); + } + + #[test] + fn interactive_prerelease_cancel_at_action_menu() { + let workspace = workspace_with_changeset_dir(); + + let mut session = spawn_prerelease(&workspace); + session.wait_for("What would you like to do?"); + session.assert_screen( + "action menu before cancel", + indoc! {" + What would you like to do?: + > Add crate to pre-release + Remove crate from pre-release + Graduate crate (move to graduation queue) + Done"}, + ); + session.cancel(); + session.wait_for_exit(); + + assert!( + !workspace + .path() + .join(".changeset/pre-release.toml") + .exists(), + "pre-release.toml must not be created after cancel" + ); + } + + #[test] + fn interactive_prerelease_add_cancel_at_crate_selection() { + let workspace = workspace_with_changeset_dir(); + + let mut session = spawn_prerelease(&workspace); + session.wait_for("What would you like to do?"); + session.assert_screen( + "action menu", + indoc! {" + What would you like to do?: + > Add crate to pre-release + Remove crate from pre-release + Graduate crate (move to graduation queue) + Done"}, + ); + session.confirm(); + session.wait_for("Select a crate to add to pre-release"); + session.assert_screen( + "crate selection", + indoc! {" + What would you like to do?: Add crate to pre-release + Select a crate to add to pre-release: + crate-a (0.1.0) + crate-b (0.2.0)"}, + ); + session.cancel(); + session.wait_for("> Add crate to pre-release"); + session.assert_screen( + "action menu after cancel", + indoc! {" + What would you like to do?: Add crate to pre-release + What would you like to do?: + > Add crate to pre-release + Remove crate from pre-release + Graduate crate (move to graduation queue) + Done"}, + ); + session.cancel(); + session.wait_for_exit(); + + assert!( + !workspace + .path() + .join(".changeset/pre-release.toml") + .exists(), + "pre-release.toml must not be created after cancel" + ); + } + + #[test] + fn interactive_prerelease_add_full_flow() { + let workspace = workspace_with_changeset_dir(); + + let mut session = spawn_prerelease(&workspace); + session.wait_for("What would you like to do?"); + session.assert_screen( + "action menu", + indoc! {" + What would you like to do?: + > Add crate to pre-release + Remove crate from pre-release + Graduate crate (move to graduation queue) + Done"}, + ); + session.confirm(); + session.wait_for("Select a crate to add to pre-release"); + session.assert_screen( + "crate selection", + indoc! {" + What would you like to do?: Add crate to pre-release + Select a crate to add to pre-release: + crate-a (0.1.0) + crate-b (0.2.0)"}, + ); + session.select_item(0); + session.wait_for("Enter pre-release tag"); + session.assert_screen( + "tag input", + indoc! {" + What would you like to do?: Add crate to pre-release + Select a crate to add to pre-release: crate-a (0.1.0) + Enter pre-release tag (e.g., alpha, beta, rc):"}, + ); + session.type_line("alpha"); + session.wait_for("> Add crate to pre-release"); + session.assert_screen( + "action menu after add", + indoc! {" + What would you like to do?: Add crate to pre-release + Select a crate to add to pre-release: crate-a (0.1.0) + Enter pre-release tag (e.g., alpha, beta, rc): alpha + What would you like to do?: + > Add crate to pre-release + Remove crate from pre-release + Graduate crate (move to graduation queue) + Done"}, + ); + session.select_item(2); + session.wait_for_exit(); + + let path = workspace.path().join(".changeset/pre-release.toml"); + assert!(path.exists(), "pre-release.toml should be created"); + let content = fs::read_to_string(&path).expect("read pre-release.toml"); + assert!(content.contains("crate-a")); + assert!(content.contains("alpha")); + } + + #[test] + fn interactive_prerelease_remove_full_flow() { + let workspace = workspace_with_changeset_dir(); + assert_cmd::cargo::cargo_bin_cmd!("cargo-changeset") + .args(["manage", "pre-release", "--add", "crate-a:alpha"]) + .current_dir(workspace.path()) + .assert() + .success(); + + let mut session = spawn_prerelease(&workspace); + session.wait_for("> Add crate to pre-release"); + session.assert_screen( + "action menu", + indoc! {" + What would you like to do?: + > Add crate to pre-release + Remove crate from pre-release + Graduate crate (move to graduation queue) + Done"}, + ); + session.select_item(0); + session.wait_for("Select a crate to remove from pre-release"); + session.assert_screen( + "crate selection", + indoc! {" + What would you like to do?: Remove crate from pre-release + Select a crate to remove from pre-release: + crate-a: alpha"}, + ); + session.select_item(0); + session.wait_for("> Add crate to pre-release"); + session.assert_screen( + "action menu after remove", + indoc! {" + What would you like to do?: Remove crate from pre-release + Select a crate to remove from pre-release: crate-a: alpha + What would you like to do?: + > Add crate to pre-release + Remove crate from pre-release + Graduate crate (move to graduation queue) + Done"}, + ); + session.select_item(2); + session.wait_for_exit(); + + assert!( + !workspace + .path() + .join(".changeset/pre-release.toml") + .exists(), + "pre-release.toml should be deleted after removing last entry" + ); + } + + #[test] + fn interactive_prerelease_graduate_flow() { + let workspace = workspace_with_changeset_dir(); + assert_cmd::cargo::cargo_bin_cmd!("cargo-changeset") + .args(["manage", "pre-release", "--add", "crate-a:alpha"]) + .current_dir(workspace.path()) + .assert() + .success(); + + let mut session = spawn_prerelease(&workspace); + session.wait_for("> Add crate to pre-release"); + session.assert_screen( + "action menu", + indoc! {" + What would you like to do?: + > Add crate to pre-release + Remove crate from pre-release + Graduate crate (move to graduation queue) + Done"}, + ); + session.select_item(1); + session.wait_for("Select a crate to graduate (move to graduation queue)"); + session.assert_screen( + "crate selection", + indoc! {" + What would you like to do?: Graduate crate (move to graduation queue) + Select a crate to graduate (move to graduation queue): + crate-a (0.1.0) + crate-b (0.2.0)"}, + ); + session.select_item(0); + session.wait_for("> Add crate to pre-release"); + session.assert_screen( + "action menu after graduate", + indoc! {" + What would you like to do?: Graduate crate (move to graduation queue) + Select a crate to graduate (move to graduation queue): crate-a (0.1.0) + What would you like to do?: + > Add crate to pre-release + Remove crate from pre-release + Graduate crate (move to graduation queue) + Done"}, + ); + session.select_item(2); + session.wait_for_exit(); + + assert!( + !workspace + .path() + .join(".changeset/pre-release.toml") + .exists(), + "pre-release.toml should be removed after graduation" + ); + let graduation_path = workspace.path().join(".changeset/graduation.toml"); + assert!( + graduation_path.exists(), + "graduation.toml should be created" + ); + let content = fs::read_to_string(&graduation_path).expect("read graduation.toml"); + assert!(content.contains("crate-a")); + } + + #[test] + fn interactive_prerelease_remove_no_packages_shows_message() { + let workspace = workspace_with_changeset_dir(); + + let mut session = spawn_prerelease(&workspace); + session.wait_for("What would you like to do?"); + session.assert_screen( + "action menu", + indoc! {" + What would you like to do?: + > Add crate to pre-release + Remove crate from pre-release + Graduate crate (move to graduation queue) + Done"}, + ); + session.select_item(0); + session.wait_for("What would you like to do?"); + session.assert_screen( + "action menu after remove attempt", + indoc! {" + What would you like to do?: Remove crate from pre-release + What would you like to do?: + > Add crate to pre-release + Remove crate from pre-release + Graduate crate (move to graduation queue) + Done"}, + ); + session.select_item(2); + session.wait_for("No packages are currently in pre-release mode"); + session.wait_for_exit(); + } +} + +#[cfg(not(windows))] +mod interactive_graduation_tests { + use std::path::PathBuf; + + use changeset_test_helpers::terminal_session::TerminalSession; + use indoc::indoc; + + use super::*; + + fn bin_path() -> PathBuf { + assert_cmd::cargo::cargo_bin("cargo-changeset") + } + + fn spawn_graduation(workspace: &TempDir) -> TerminalSession { + TerminalSession::spawn(&bin_path(), workspace, &["manage", "graduation"]) + } + + fn workspace_with_changeset_dir() -> TempDir { + let workspace = create_virtual_workspace(); + fs::create_dir_all(workspace.path().join(".changeset")).expect("create changeset dir"); + workspace + } + + #[test] + fn interactive_graduation_action_menu_rendering() { + let workspace = workspace_with_changeset_dir(); + + let mut session = spawn_graduation(&workspace); + session.wait_for("What would you like to do?"); + session.assert_screen( + "graduation action menu", + indoc! {" + What would you like to do?: + > Add crate to graduation queue + Remove crate from graduation queue + Done"}, + ); + session.cancel(); + session.wait_for_exit(); + } + + #[test] + fn interactive_graduation_done_exits_cleanly() { + let workspace = workspace_with_changeset_dir(); + + let mut session = spawn_graduation(&workspace); + session.wait_for("What would you like to do?"); + session.assert_screen( + "action menu before Done", + indoc! {" + What would you like to do?: + > Add crate to graduation queue + Remove crate from graduation queue + Done"}, + ); + session.select_item(1); + session.wait_for_exit(); + + assert!( + !workspace.path().join(".changeset/graduation.toml").exists(), + "graduation.toml must not be created" + ); + } + + #[test] + fn interactive_graduation_cancel_at_action_menu() { + let workspace = workspace_with_changeset_dir(); + + let mut session = spawn_graduation(&workspace); + session.wait_for("What would you like to do?"); + session.assert_screen( + "action menu before cancel", + indoc! {" + What would you like to do?: + > Add crate to graduation queue + Remove crate from graduation queue + Done"}, + ); + session.cancel(); + session.wait_for_exit(); + + assert!( + !workspace.path().join(".changeset/graduation.toml").exists(), + "graduation.toml must not be created after cancel" + ); + } + + #[test] + fn interactive_graduation_add_cancel_at_crate_selection() { + let workspace = workspace_with_changeset_dir(); + + let mut session = spawn_graduation(&workspace); + session.wait_for("What would you like to do?"); + session.assert_screen( + "action menu", + indoc! {" + What would you like to do?: + > Add crate to graduation queue + Remove crate from graduation queue + Done"}, + ); + session.confirm(); + session.wait_for("Select a crate to graduate (move to graduation queue)"); + session.assert_screen( + "crate selection", + indoc! {" + What would you like to do?: Add crate to graduation queue + Select a crate to graduate (move to graduation queue): + crate-a (0.1.0) + crate-b (0.2.0)"}, + ); + session.cancel(); + session.wait_for("> Add crate to graduation queue"); + session.assert_screen( + "action menu after cancel", + indoc! {" + What would you like to do?: Add crate to graduation queue + What would you like to do?: + > Add crate to graduation queue + Remove crate from graduation queue + Done"}, + ); + session.cancel(); + session.wait_for_exit(); + + assert!( + !workspace.path().join(".changeset/graduation.toml").exists(), + "graduation.toml must not be created after cancel" + ); + } + + #[test] + fn interactive_graduation_add_full_flow() { + let workspace = workspace_with_changeset_dir(); + + let mut session = spawn_graduation(&workspace); + session.wait_for("What would you like to do?"); + session.assert_screen( + "action menu", + indoc! {" + What would you like to do?: + > Add crate to graduation queue + Remove crate from graduation queue + Done"}, + ); + session.confirm(); + session.wait_for("Select a crate to graduate (move to graduation queue)"); + session.assert_screen( + "crate selection", + indoc! {" + What would you like to do?: Add crate to graduation queue + Select a crate to graduate (move to graduation queue): + crate-a (0.1.0) + crate-b (0.2.0)"}, + ); + session.select_item(0); + session.wait_for("> Add crate to graduation queue"); + session.assert_screen( + "action menu after add", + indoc! {" + What would you like to do?: Add crate to graduation queue + Select a crate to graduate (move to graduation queue): crate-a (0.1.0) + What would you like to do?: + > Add crate to graduation queue + Remove crate from graduation queue + Done"}, + ); + session.select_item(1); + session.wait_for_exit(); + + let path = workspace.path().join(".changeset/graduation.toml"); + assert!(path.exists(), "graduation.toml should be created"); + let content = fs::read_to_string(&path).expect("read graduation.toml"); + assert!(content.contains("crate-a")); + } + + #[test] + fn interactive_graduation_remove_full_flow() { + let workspace = workspace_with_changeset_dir(); + assert_cmd::cargo::cargo_bin_cmd!("cargo-changeset") + .args(["manage", "graduation", "--add", "crate-a"]) + .current_dir(workspace.path()) + .assert() + .success(); + + let mut session = spawn_graduation(&workspace); + session.wait_for("> Add crate to graduation queue"); + session.assert_screen( + "action menu", + indoc! {" + What would you like to do?: + > Add crate to graduation queue + Remove crate from graduation queue + Done"}, + ); + session.select_item(0); + session.wait_for("Select a crate to remove from graduation queue"); + session.assert_screen( + "crate selection", + indoc! {" + What would you like to do?: Remove crate from graduation queue + Select a crate to remove from graduation queue: + crate-a"}, + ); + session.select_item(0); + session.wait_for("> Add crate to graduation queue"); + session.assert_screen( + "action menu after remove", + indoc! {" + What would you like to do?: Remove crate from graduation queue + Select a crate to remove from graduation queue: crate-a + What would you like to do?: + > Add crate to graduation queue + Remove crate from graduation queue + Done"}, + ); + session.select_item(1); + session.wait_for_exit(); + + assert!( + !workspace.path().join(".changeset/graduation.toml").exists(), + "graduation.toml should be deleted after removing last entry" + ); + } + + #[test] + fn interactive_graduation_remove_no_packages_shows_message() { + let workspace = workspace_with_changeset_dir(); + + let mut session = spawn_graduation(&workspace); + session.wait_for("What would you like to do?"); + session.assert_screen( + "action menu", + indoc! {" + What would you like to do?: + > Add crate to graduation queue + Remove crate from graduation queue + Done"}, + ); + session.select_item(0); + session.wait_for("What would you like to do?"); + session.assert_screen( + "action menu after remove attempt", + indoc! {" + What would you like to do?: Remove crate from graduation queue + What would you like to do?: + > Add crate to graduation queue + Remove crate from graduation queue + Done"}, + ); + session.select_item(1); + session.wait_for("No packages are currently queued for graduation"); + session.wait_for_exit(); + } +} diff --git a/crates/changeset-test-helpers/src/terminal_session.rs b/crates/changeset-test-helpers/src/terminal_session.rs index ae3452c..8b2b991 100644 --- a/crates/changeset-test-helpers/src/terminal_session.rs +++ b/crates/changeset-test-helpers/src/terminal_session.rs @@ -11,7 +11,7 @@ const ARROW_DOWN: &str = "\x1b[B"; const ENTER: &str = "\r"; const ESC: &str = "\x1b"; const TIMEOUT: Duration = Duration::from_secs(30); -const KEY_DELAY: Duration = Duration::from_millis(50); +const KEY_DELAY: Duration = Duration::from_millis(20); const POLL_INTERVAL: Duration = Duration::from_millis(10); pub struct TerminalSessionBuilder<'a> { @@ -39,7 +39,7 @@ impl TerminalSessionBuilder<'_> { let pty = OsSession::spawn(cmd).expect("failed to spawn session"); TerminalSession { pty, - vt: vt100::Parser::new(24, 120, 100), + vt: vt100::Parser::new(1200, 400, 0), } } } @@ -146,11 +146,25 @@ impl TerminalSession { self } + pub fn toggle_item(&mut self, index: usize) -> &mut Self { + for _ in 0..index { + self.pty.send(ARROW_DOWN).expect("send arrow-down key"); + std::thread::sleep(KEY_DELAY); + } + self.pty.send(" ").expect("send space to toggle item"); + self + } + pub fn cancel(&mut self) -> &mut Self { self.pty.send(ESC).expect("send escape key"); self } + pub fn ctrl_c(&mut self) -> &mut Self { + self.pty.send("\x03").expect("send Ctrl+C"); + self + } + pub fn send_raw(&mut self, bytes: &str) -> &mut Self { self.pty.send(bytes).expect("send raw bytes to PTY"); self