mod forge_releases;
mod tags;
use crate::cli::publish::*;
use crate::package_manager::DependencyGraph;
#[tokio::test]
async fn default_publish_args() {
let args = PublishArgs::default();
assert!(args.packages.is_empty());
assert!(!args.no_git);
}
fn make_graph(edges: &[(&str, &[&str])]) -> DependencyGraph {
let adjacency = edges
.iter()
.map(|(k, vs)| (k.to_string(), vs.iter().map(|v| v.to_string()).collect()))
.collect();
DependencyGraph::from_adjacency(adjacency)
}
#[tokio::test]
async fn add_transitive_dependents_linear_chain() {
let graph = make_graph(&[("a", &["b"]), ("b", &["c"]), ("c", &[])]);
let mut blocked = std::collections::HashSet::new();
add_transitive_dependents(&graph, "c", &mut blocked);
assert!(blocked.contains("b"), "b depends on c");
assert!(blocked.contains("a"), "a depends on b (transitive)");
assert!(
!blocked.contains("c"),
"failed package itself not in blocked"
);
}
#[tokio::test]
async fn add_transitive_dependents_diamond() {
let graph = make_graph(&[("a", &["b", "c"]), ("b", &["d"]), ("c", &["d"]), ("d", &[])]);
let mut blocked = std::collections::HashSet::new();
add_transitive_dependents(&graph, "d", &mut blocked);
assert!(blocked.contains("b"));
assert!(blocked.contains("c"));
assert!(blocked.contains("a"));
assert!(!blocked.contains("d"));
}
#[tokio::test]
async fn add_transitive_dependents_cycle_terminates() {
let graph = make_graph(&[("a", &["b"]), ("b", &["a"])]);
let mut blocked = std::collections::HashSet::new();
add_transitive_dependents(&graph, "a", &mut blocked);
assert!(blocked.contains("b"));
}
#[tokio::test]
async fn add_transitive_dependents_independent_subtree_not_blocked() {
let graph = make_graph(&[("a", &["b"]), ("b", &[]), ("c", &["d"]), ("d", &[])]);
let mut blocked = std::collections::HashSet::new();
add_transitive_dependents(&graph, "b", &mut blocked);
assert!(blocked.contains("a"));
assert!(!blocked.contains("c"));
assert!(!blocked.contains("d"));
}
fn release_outcome(
releases_created: usize,
releases_already_present: usize,
forge_failed: bool,
) -> GitReleaseOutcome {
GitReleaseOutcome {
tags_created: 0,
tags_skipped: 0,
tags_push_failed: 0,
releases_created,
releases_already_present,
forge_failed,
}
}
fn make_empty_outcome() -> GitReleaseOutcome {
GitReleaseOutcome {
tags_created: 0,
tags_skipped: 0,
tags_push_failed: 0,
releases_created: 0,
releases_already_present: 0,
forge_failed: false,
}
}
fn make_published_package() -> PublishedPackage {
PublishedPackage {
name: "pkg".to_string(),
version: "1.0.0".parse().unwrap(),
project_path: crate::path::AbsolutePath::new("/nonexistent").unwrap(),
}
}
#[tokio::test]
async fn log_summary_line_non_dry_run_dep_skipped_note_in_log() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
let mut state = PublishState::new();
state.dep_skipped_count = 1; let flags = PublishFlags {
dry_run: false,
git_enabled: false,
forge_enabled: false,
no_git: false,
is_multi_package: false,
forge_name: "GitHub",
};
log_summary_line(&state, &flags, &make_empty_outcome());
let logs = crate::test_logging::take_logs();
assert!(
logs.iter()
.any(|(_, m)| m.contains("skipped (dependency failed)")),
"Expected dep-skipped note in log: {logs:?}"
);
}
#[tokio::test]
async fn log_summary_line_non_dry_run_unprepared_note_in_log() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
let mut state = PublishState::new();
state.unprepared_count = 1; let flags = PublishFlags {
dry_run: false,
git_enabled: false,
forge_enabled: false,
no_git: false,
is_multi_package: false,
forge_name: "GitHub",
};
log_summary_line(&state, &flags, &make_empty_outcome());
let logs = crate::test_logging::take_logs();
assert!(
logs.iter()
.any(|(_, m)| m.contains("skipped (not yet prepared)")),
"Expected unprepared note in log: {logs:?}"
);
}
#[tokio::test]
async fn log_summary_line_dry_run_git_disabled_no_tag_note() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
let mut state = PublishState::new();
state.published.push(make_published_package()); let flags = PublishFlags {
dry_run: true,
git_enabled: false, forge_enabled: false,
no_git: false,
is_multi_package: false,
forge_name: "GitHub",
};
log_summary_line(&state, &flags, &make_empty_outcome());
let logs = crate::test_logging::take_logs();
assert!(
!logs.iter().any(|(_, m)| m.contains("would be tagged")),
"Should NOT log 'would be tagged' when git is disabled: {logs:?}"
);
}
#[tokio::test]
async fn log_summary_line_dry_run_git_enabled_tag_note_present() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
let mut state = PublishState::new();
state.published.push(make_published_package());
let flags = PublishFlags {
dry_run: true,
git_enabled: true, forge_enabled: false,
no_git: false,
is_multi_package: false,
forge_name: "GitHub",
};
log_summary_line(&state, &flags, &make_empty_outcome());
let logs = crate::test_logging::take_logs();
assert!(
logs.iter().any(|(_, m)| m.contains("would be tagged")),
"Should log 'would be tagged' when git is enabled: {logs:?}"
);
}
#[tokio::test]
async fn log_publish_summary_tags_created_appears_in_log() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
let state = PublishState::new();
let flags = PublishFlags {
dry_run: false,
git_enabled: true,
forge_enabled: false,
no_git: false,
is_multi_package: false,
forge_name: "GitHub",
};
let outcome = GitReleaseOutcome {
tags_created: 2,
tags_skipped: 0,
tags_push_failed: 0,
releases_created: 0,
releases_already_present: 0,
forge_failed: false,
};
log_publish_summary(&state, &flags, &outcome);
let logs = crate::test_logging::take_logs();
assert!(
logs.iter()
.any(|(_, m)| m.contains("tag") && m.contains("created")),
"Expected 'tag(s) created' in logs: {logs:?}"
);
}
#[tokio::test]
async fn log_publish_summary_tags_push_failed_appears_in_log() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
let state = PublishState::new();
let flags = PublishFlags {
dry_run: false,
git_enabled: true,
forge_enabled: false,
no_git: false,
is_multi_package: false,
forge_name: "GitHub",
};
let outcome = GitReleaseOutcome {
tags_created: 0,
tags_skipped: 0,
tags_push_failed: 1,
releases_created: 0,
releases_already_present: 0,
forge_failed: false,
};
log_publish_summary(&state, &flags, &outcome);
let logs = crate::test_logging::take_logs();
assert!(
logs.iter()
.any(|(_, m)| m.contains("tag") && m.contains("failed")),
"Expected 'tag push(es) failed' in logs: {logs:?}"
);
}
#[tokio::test]
async fn log_publish_summary_dry_run_no_tag_log_lines() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
let state = PublishState::new();
let flags = PublishFlags {
dry_run: true, git_enabled: true,
forge_enabled: false,
no_git: false,
is_multi_package: false,
forge_name: "GitHub",
};
let outcome = GitReleaseOutcome {
tags_created: 3,
tags_skipped: 0,
tags_push_failed: 2,
releases_created: 0,
releases_already_present: 0,
forge_failed: false,
};
log_publish_summary(&state, &flags, &outcome);
let logs = crate::test_logging::take_logs();
assert!(
!logs
.iter()
.any(|(_, m)| m.contains("created") && m.contains("tag")),
"Should NOT log tag created count in dry-run: {logs:?}"
);
assert!(
!logs
.iter()
.any(|(_, m)| m.contains("tag") && m.contains("failed")),
"Should NOT log tag push failed in dry-run: {logs:?}"
);
}
#[tokio::test]
async fn log_forge_releases_summary_no_failure_logs_created_count() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
log_forge_releases_summary("GitLab", 3, 0, 0, "", &release_outcome(2, 0, false));
let logs = crate::test_logging::take_logs();
assert!(
logs.iter()
.any(|(_, m)| m.contains("3 published") && m.contains("2 GitLab Release")),
"Expected GitLab Release summary: {logs:?}"
);
}
#[tokio::test]
async fn log_forge_releases_summary_with_failure_logs_failed_count() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
log_forge_releases_summary("GitHub", 3, 0, 0, "", &release_outcome(2, 0, true));
let logs = crate::test_logging::take_logs();
assert!(
logs.iter()
.any(|(_, m)| m.contains("GitHub Release") && m.contains("failed")),
"Expected GitHub Release failure count: {logs:?}"
);
}
#[tokio::test]
async fn record_outcome_skipped_increments_skipped_count() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
use std::sync::Arc;
let runner = Arc::new(
crate::command::test_support::RecordingCommandRunner::new(1)
.with_stderr(b"npm ERR! code EPUBLISHCONFLICT".to_vec()),
);
let project = crate::package_manager::Project::new_test_with_runner(
"pkg",
"/nonexistent",
Arc::clone(&runner),
);
let graph = make_graph(&[]);
let mut state = PublishState::new();
state.record_outcome(&project, &graph, false).await;
assert_eq!(state.skipped_count, 1, "Expected skipped_count == 1");
assert_eq!(
state.published.len(),
1,
"Skipped package must be added to published so tags/releases are retried"
);
}
#[tokio::test]
async fn publish_state_private_tagged_count_starts_at_zero() {
let state = PublishState::new();
assert_eq!(state.private_tagged_count, 0);
}
#[tokio::test]
async fn log_forge_releases_summary_private_note_appears_when_nonzero() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
log_forge_releases_summary("GitHub", 2, 1, 0, "", &release_outcome(3, 0, false));
let logs = crate::test_logging::take_logs();
assert!(
logs.iter().any(|(_, m)| m.contains("private (tag only)")),
"Expected private note in log: {logs:?}"
);
}
#[tokio::test]
async fn log_forge_releases_summary_no_private_note_when_zero() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
log_forge_releases_summary("GitHub", 2, 0, 0, "", &release_outcome(2, 0, false));
let logs = crate::test_logging::take_logs();
assert!(
!logs.iter().any(|(_, m)| m.contains("private (tag only)")),
"Private note should not appear when count is zero: {logs:?}"
);
}
#[tokio::test]
async fn log_summary_line_dry_run_shows_private_note() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
let mut state = PublishState::new();
state.published.push(make_published_package());
state.private_tagged_count = 1;
let flags = PublishFlags {
dry_run: true,
git_enabled: false,
forge_enabled: false,
no_git: false,
is_multi_package: false,
forge_name: "GitHub",
};
log_summary_line(&state, &flags, &make_empty_outcome());
let logs = crate::test_logging::take_logs();
assert!(
logs.iter().any(|(_, m)| m.contains("private (tag only)")),
"Expected private note in dry-run summary: {logs:?}"
);
}
#[tokio::test]
async fn log_summary_line_dry_run_registry_published_excludes_private() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
let mut state = PublishState::new();
state.published.push(make_published_package());
state.published.push(make_published_package());
state.private_tagged_count = 1;
let flags = PublishFlags {
dry_run: true,
git_enabled: false,
forge_enabled: false,
no_git: false,
is_multi_package: false,
forge_name: "GitHub",
};
log_summary_line(&state, &flags, &make_empty_outcome());
let logs = crate::test_logging::take_logs();
assert!(
logs.iter().any(|(_, m)| m.contains("1 would be published")),
"Expected '1 would be published' (not 2) in summary: {logs:?}"
);
}
#[tokio::test]
async fn log_summary_line_non_dry_run_shows_private_note() {
crate::test_logging::init_test_logger();
let _ = crate::test_logging::take_logs();
let mut state = PublishState::new();
state.published.push(make_published_package());
state.private_tagged_count = 1;
let flags = PublishFlags {
dry_run: false,
git_enabled: false,
forge_enabled: false,
no_git: false,
is_multi_package: false,
forge_name: "GitHub",
};
log_summary_line(&state, &flags, &make_empty_outcome());
let logs = crate::test_logging::take_logs();
assert!(
logs.iter().any(|(_, m)| m.contains("private (tag only)")),
"Expected private note in non-dry-run summary: {logs:?}"
);
}
#[tokio::test]
async fn record_private_tagged_adds_to_published_and_increments_count() {
use std::sync::Arc;
let runner = Arc::new(
crate::command::test_support::RecordingCommandRunner::new(0).with_stdout(b"1.0.0".to_vec()),
);
let project = crate::package_manager::Project::new_test_with_runner(
"my-action",
"/nonexistent",
Arc::clone(&runner),
);
let mut state = PublishState::new();
state.record_private_tagged(&project);
assert_eq!(state.published.len(), 1, "Expected 1 package in published");
assert_eq!(
state.private_tagged_count, 1,
"Expected private_tagged_count == 1"
);
assert_eq!(state.published[0].name, "my-action");
}
#[tokio::test]
async fn add_transitive_dependents_with_prepopulated_blocked_set() {
let graph = make_graph(&[("a", &["b"]), ("b", &["c"]), ("c", &[])]);
let mut blocked = std::collections::HashSet::new();
blocked.insert("a".to_string()); add_transitive_dependents(&graph, "c", &mut blocked);
assert!(blocked.contains("b"));
assert!(blocked.contains("a"));
}