Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/agent_stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,14 @@ mod tests {
);
}

#[test]
fn test_sanitize_for_markdown_strips_shorthand_pipeline_command() {
assert_eq!(
sanitize_for_markdown("##[error]Something bad"),
"[filtered][error]Something bad"
);
}

#[test]
fn test_internal_tools_excluded_from_count() {
let entries = vec![
Expand Down
112 changes: 112 additions & 0 deletions src/compile/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4773,4 +4773,116 @@ mod tests {
let warnings = validate::warn_potential_secrets("my-mcp", &env, &headers);
assert!(warnings.is_empty(), "non-secret env var should not produce warnings");
}

// ─── standalone setup/teardown/finalize/checkout/repositories generators ───

#[test]
fn test_generate_setup_job_empty_returns_empty() {
assert!(generate_setup_job(&[], "MyPool").is_empty());
}

#[test]
fn test_generate_setup_job_with_steps() {
let step: serde_yaml::Value = serde_yaml::from_str("bash: echo setup").unwrap();
let out = generate_setup_job(&[step], "MyPool");
assert!(out.contains("- job: Setup"), "out: {out}");
assert!(out.contains("displayName: \"Setup\""), "out: {out}");
assert!(out.contains("name: MyPool"), "out: {out}");
assert!(out.contains("- checkout: self"), "out: {out}");
assert!(out.contains("echo setup"), "out: {out}");
}

#[test]
fn test_generate_teardown_job_empty_returns_empty() {
assert!(generate_teardown_job(&[], "MyPool").is_empty());
}

#[test]
fn test_generate_teardown_job_with_steps() {
let step: serde_yaml::Value = serde_yaml::from_str("bash: echo td").unwrap();
let out = generate_teardown_job(&[step], "MyPool");
assert!(out.contains("- job: Teardown"), "out: {out}");
assert!(out.contains("dependsOn: Execution"), "out: {out}");
assert!(out.contains("name: MyPool"), "out: {out}");
assert!(out.contains("echo td"), "out: {out}");
}

#[test]
fn test_generate_agentic_depends_on_empty_steps() {
assert!(generate_agentic_depends_on(&[]).is_empty());
}

#[test]
fn test_generate_agentic_depends_on_with_steps() {
let step: serde_yaml::Value = serde_yaml::from_str("bash: x").unwrap();
assert_eq!(generate_agentic_depends_on(&[step]), "dependsOn: Setup");
}

#[test]
fn test_generate_finalize_steps_empty() {
assert!(generate_finalize_steps(&[]).is_empty());
}

#[test]
fn test_generate_finalize_steps_with_step() {
let step: serde_yaml::Value = serde_yaml::from_str("bash: echo done").unwrap();
let out = generate_finalize_steps(&[step]);
assert!(out.contains("echo done"), "out: {out}");
}

#[test]
fn test_generate_checkout_steps_empty() {
assert!(generate_checkout_steps(&[]).is_empty());
}

#[test]
fn test_generate_checkout_steps_multiple() {
let aliases = vec!["repo-a".to_string(), "repo-b".to_string()];
let out = generate_checkout_steps(&aliases);
assert!(out.contains("- checkout: repo-a"), "out: {out}");
assert!(out.contains("- checkout: repo-b"), "out: {out}");
}

#[test]
fn test_generate_repositories_empty() {
assert!(generate_repositories(&[]).is_empty());
}

#[test]
fn test_generate_repositories_single() {
let repos = vec![Repository {
repository: "my-repo".to_string(),
repo_type: "git".to_string(),
name: "org/my-repo".to_string(),
repo_ref: "refs/heads/main".to_string(),
}];
let out = generate_repositories(&repos);
assert!(out.contains("- repository: my-repo"), "out: {out}");
assert!(out.contains("type: git"), "out: {out}");
assert!(out.contains("name: org/my-repo"), "out: {out}");
assert!(out.contains("ref: refs/heads/main"), "out: {out}");
}

#[test]
fn test_generate_repositories_multiple() {
let repos = vec![
Repository {
repository: "repo-a".to_string(),
repo_type: "git".to_string(),
name: "org/repo-a".to_string(),
repo_ref: "refs/heads/main".to_string(),
},
Repository {
repository: "repo-b".to_string(),
repo_type: "git".to_string(),
name: "org/repo-b".to_string(),
repo_ref: "refs/heads/develop".to_string(),
},
];
let out = generate_repositories(&repos);
assert!(out.contains("- repository: repo-a"), "out: {out}");
assert!(out.contains("- repository: repo-b"), "out: {out}");
assert!(out.contains("name: org/repo-a"), "out: {out}");
assert!(out.contains("ref: refs/heads/develop"), "out: {out}");
}
}
48 changes: 48 additions & 0 deletions src/safeoutputs/create_branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,54 @@ mod tests {
assert!(result.is_err());
}

#[test]
fn test_validation_rejects_branch_starting_with_dash() {
let params = CreateBranchParams {
branch_name: "-bad".to_string(),
source_branch: None,
source_commit: None,
repository: None,
};
let result: Result<CreateBranchResult, _> = params.try_into();
assert!(result.is_err(), "branch starting with '-' should be rejected");
}

#[test]
fn test_validation_rejects_branch_with_spaces() {
let params = CreateBranchParams {
branch_name: "my branch".to_string(),
source_branch: None,
source_commit: None,
repository: None,
};
let result: Result<CreateBranchResult, _> = params.try_into();
assert!(result.is_err(), "branch with spaces should be rejected");
}

#[test]
fn test_validation_rejects_branch_over_200_chars() {
let params = CreateBranchParams {
branch_name: "a".repeat(201),
source_branch: None,
source_commit: None,
repository: None,
};
let result: Result<CreateBranchResult, _> = params.try_into();
assert!(result.is_err(), "branch >200 chars should be rejected");
}

#[test]
fn test_validation_rejects_source_branch_with_traversal() {
let params = CreateBranchParams {
branch_name: "feature/valid".to_string(),
source_branch: Some("../evil".to_string()),
source_commit: None,
repository: None,
};
let result: Result<CreateBranchResult, _> = params.try_into();
assert!(result.is_err(), "source_branch with '..' should be rejected");
}

#[test]
fn test_result_serializes_correctly() {
let params = CreateBranchParams {
Expand Down
30 changes: 30 additions & 0 deletions src/safeoutputs/create_pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2367,4 +2367,34 @@ new file mode 100755
assert!(config.auto_complete);
}

// ─── truncate_error_body ────────────────────────────────────────────────

#[test]
fn test_truncate_error_body_shorter_than_max() {
assert_eq!(truncate_error_body("hello", 100), "hello");
}

#[test]
fn test_truncate_error_body_exact_max() {
assert_eq!(truncate_error_body("hello", 5), "hello");
}

#[test]
fn test_truncate_error_body_longer_than_max() {
assert_eq!(truncate_error_body("hello world", 5), "hello");
}

#[test]
fn test_truncate_error_body_multibyte_boundary() {
// "héllo" — é is 2 bytes; truncation must not split a multi-byte char.
// max_len is char count: 3 chars = "hél".
let s = "héllo";
let result = truncate_error_body(s, 3);
assert_eq!(result, "hél");
}

#[test]
fn test_truncate_error_body_empty_input() {
assert_eq!(truncate_error_body("", 5), "");
}
}
62 changes: 61 additions & 1 deletion src/safeoutputs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ pub(crate) fn validate_git_ref_name(name: &str, label: &str) -> anyhow::Result<(

ensure!(!name.is_empty(), "{label} must not be empty");
ensure!(!name.contains(".."), "{label} must not contain '..'");
ensure!(!name.contains("@{{"), "{label} must not contain '@{{'");
ensure!(!name.contains("@{"), "{label} must not contain '@{{'");
ensure!(!name.ends_with('.'), "{label} must not end with '.'");
ensure!(!name.ends_with(".lock"), "{label} must not end with '.lock'");
ensure!(
Expand Down Expand Up @@ -391,4 +391,64 @@ mod tests {
"ALL_KNOWN_SAFE_OUTPUTS should be the union of write + diagnostic + non-MCP lists"
);
}

// ─── validate_git_ref_name ──────────────────────────────────────────────

#[test]
fn test_validate_git_ref_name_rejects_at_brace() {
assert!(validate_git_ref_name("branch@{0}", "b").is_err());
}

#[test]
fn test_validate_git_ref_name_rejects_dotlock_suffix() {
assert!(validate_git_ref_name("my-branch.lock", "b").is_err());
}

#[test]
fn test_validate_git_ref_name_rejects_consecutive_slashes() {
assert!(validate_git_ref_name("feat//thing", "b").is_err());
}

#[test]
fn test_validate_git_ref_name_rejects_backslash() {
assert!(validate_git_ref_name("feat\\evil", "b").is_err());
}

#[test]
fn test_validate_git_ref_name_rejects_special_chars() {
for ch in ['~', '^', ':', '?', '*', '['] {
let name = format!("feat{ch}bad");
assert!(
validate_git_ref_name(&name, "b").is_err(),
"should reject '{ch}'"
);
}
}

#[test]
fn test_validate_git_ref_name_rejects_component_starting_with_dot() {
assert!(validate_git_ref_name("feat/.hidden", "b").is_err());
}

#[test]
fn test_validate_git_ref_name_rejects_trailing_dot() {
assert!(validate_git_ref_name("my-branch.", "b").is_err());
}

#[test]
fn test_validate_git_ref_name_rejects_double_dot() {
assert!(validate_git_ref_name("foo..bar", "b").is_err());
}

#[test]
fn test_validate_git_ref_name_rejects_empty() {
assert!(validate_git_ref_name("", "b").is_err());
}

#[test]
fn test_validate_git_ref_name_accepts_valid_refs() {
assert!(validate_git_ref_name("feature/add-login", "b").is_ok());
assert!(validate_git_ref_name("v1.2.3", "b").is_ok());
assert!(validate_git_ref_name("release/2026-04-17", "b").is_ok());
}
}
28 changes: 28 additions & 0 deletions tests/compiler_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3110,6 +3110,34 @@ fn test_standalone_complete_compiled_output_is_valid_yaml() {
assert_valid_yaml(&compiled, "complete-agent.md");
}

/// Test that the complete standalone fixture emits Setup/Teardown jobs and
/// that the agentic task waits on Setup. The fixture has `setup:`,
/// `teardown:`, and `post-steps:` sections so all three should appear.
#[test]
fn test_standalone_complete_agent_has_setup_and_teardown_jobs() {
let compiled = compile_fixture("complete-agent.md");
assert!(
compiled.contains("- job: Setup"),
"Should generate Setup job: {compiled}"
);
assert!(
compiled.contains("- job: Teardown"),
"Should generate Teardown job"
);
assert!(
compiled.contains("dependsOn: Setup"),
"Agentic task should depend on Setup job"
);
assert!(
compiled.contains("echo \"Setup step\"") || compiled.contains("echo 'Setup step'"),
"Should include setup step content"
);
assert!(
compiled.contains("echo \"Teardown step\"") || compiled.contains("echo 'Teardown step'"),
"Should include teardown step content"
);
}

/// Test that the pipeline-trigger fixture produces valid YAML
#[test]
fn test_standalone_pipeline_trigger_compiled_output_is_valid_yaml() {
Expand Down