swactor/crates/simulation/tests/ci_properties.rs

276 lines
7.4 KiB
Rust
Raw Normal View History

//! Property-based tests for the CI simulation.
//!
//! These verify invariants that should hold across all possible simulation configurations.
use swactor_ci::{EventType, WebhookEvent};
use simulation::ci::sim::{self, CiSimConfig};
fn simple_yaml() -> String {
r#"
pipelines:
check:
triggers:
- event: push
branches: ["*"]
jobs:
fmt:
run: cargo fmt -- --check
test:
needs: [fmt]
run: cargo test
"#
.into()
}
fn push(branch: &str, sha: &str) -> WebhookEvent {
WebhookEvent {
event_type: EventType::Push,
repo_owner: "user".into(),
repo_name: "repo".into(),
branch: branch.into(),
commit_sha: sha.into(),
tag: None,
}
}
// ─── Property: Every webhook produces a terminal status ─────────────────────
#[test]
fn property_all_webhooks_terminate_single() {
let config = CiSimConfig {
name: "prop-single".into(),
num_rounds: 40,
ci_yaml: simple_yaml(),
webhook_schedule: vec![(1, push("main", "sha-1"))],
provision_latency: 1,
job_duration: 2,
..Default::default()
};
let trace = sim::run_simulation(config);
assert!(
sim::check_all_webhooks_terminate(&trace),
"single webhook must terminate"
);
}
#[test]
fn property_all_webhooks_terminate_burst() {
// Burst of webhooks all at once.
let config = CiSimConfig {
name: "prop-burst".into(),
num_rounds: 60,
ci_yaml: simple_yaml(),
webhook_schedule: (1..=5)
.map(|i| (1, push("main", &format!("sha-burst-{i}"))))
.collect(),
provision_latency: 1,
job_duration: 2,
..Default::default()
};
let trace = sim::run_simulation(config);
assert!(
sim::check_all_webhooks_terminate(&trace),
"burst of 5 webhooks must all terminate"
);
}
#[test]
fn property_all_webhooks_terminate_staggered() {
// Webhooks spread across rounds.
let config = CiSimConfig {
name: "prop-staggered".into(),
num_rounds: 60,
ci_yaml: simple_yaml(),
webhook_schedule: vec![
(1, push("main", "sha-s1")),
(5, push("main", "sha-s2")),
(10, push("main", "sha-s3")),
(15, push("main", "sha-s4")),
],
provision_latency: 2,
job_duration: 3,
..Default::default()
};
let trace = sim::run_simulation(config);
assert!(
sim::check_all_webhooks_terminate(&trace),
"staggered webhooks must all terminate"
);
}
// ─── Property: DAG ordering is always respected ─────────────────────────────
#[test]
fn property_dag_ordering_always_respected() {
// Deep chain: a → b → c → d
let yaml = r#"
pipelines:
deep:
triggers:
- event: push
branches: ["*"]
jobs:
a:
run: echo a
b:
needs: [a]
run: echo b
c:
needs: [b]
run: echo c
d:
needs: [c]
run: echo d
"#;
let config = CiSimConfig {
name: "prop-dag-deep".into(),
num_rounds: 40,
ci_yaml: yaml.into(),
webhook_schedule: vec![(1, push("main", "sha-dag"))],
provision_latency: 1,
job_duration: 2,
..Default::default()
};
let trace = sim::run_simulation(config);
assert!(
sim::check_dag_ordering(&trace),
"deep DAG ordering must be respected"
);
}
#[test]
fn property_dag_ordering_diamond() {
let yaml = r#"
pipelines:
diamond:
triggers:
- event: push
branches: ["*"]
jobs:
root:
run: echo root
left:
needs: [root]
run: echo left
right:
needs: [root]
run: echo right
merge:
needs: [left, right]
run: echo merge
"#;
let config = CiSimConfig {
name: "prop-dag-diamond".into(),
num_rounds: 40,
ci_yaml: yaml.into(),
webhook_schedule: vec![(1, push("main", "sha-diamond"))],
provision_latency: 1,
job_duration: 2,
..Default::default()
};
let trace = sim::run_simulation(config);
assert!(
sim::check_dag_ordering(&trace),
"diamond DAG ordering must be respected"
);
}
// ─── Property: No instance leaks ────────────────────────────────────────────
#[test]
fn property_no_instance_leaks_under_failures() {
let config = CiSimConfig {
name: "prop-no-leaks-fail".into(),
num_rounds: 40,
ci_yaml: simple_yaml(),
webhook_schedule: vec![
(1, push("main", "sha-leak1")),
(3, push("main", "sha-leak2")),
],
provision_latency: 1,
job_duration: 2,
job_failure_schedule: vec![(0, "fmt".into())],
..Default::default()
};
let trace = sim::run_simulation(config);
assert!(
sim::check_no_instance_leaks(&trace),
"no instance leaks even when jobs fail"
);
}
#[test]
fn property_no_instance_leaks_under_interruption() {
let yaml = r#"
pipelines:
check:
triggers:
- event: push
branches: ["*"]
jobs:
test:
run: cargo test
"#;
let config = CiSimConfig {
name: "prop-no-leaks-interrupt".into(),
num_rounds: 40,
ci_yaml: yaml.into(),
webhook_schedule: vec![(1, push("main", "sha-int"))],
provision_latency: 1,
job_duration: 5,
instance_interrupt_schedule: vec![(4, "test".into())],
..Default::default()
};
let trace = sim::run_simulation(config);
assert!(
sim::check_no_instance_leaks(&trace),
"interrupted instances must be terminated"
);
}
// ─── Property: Bounded state ────────────────────────────────────────────────
#[test]
fn property_bounded_state_under_rapid_pushes() {
let config = CiSimConfig {
name: "prop-bounded".into(),
num_rounds: 100,
ci_yaml: simple_yaml(),
webhook_schedule: (1..=20)
.map(|i| (i, push("main", &format!("sha-rapid-{i}"))))
.collect(),
provision_latency: 1,
job_duration: 2,
..Default::default()
};
let trace = sim::run_simulation(config);
// With 20 pushes, each triggering 1 pipeline with 2 jobs, we should never
// have more than 20 active pipelines at once (and in practice much fewer).
assert!(
sim::check_bounded_state(&trace, 20),
"active pipelines should be bounded"
);
}
// ─── Property: Provisioner offline doesn't lose work ────────────────────────
#[test]
fn property_provisioner_offline_eventually_resolves() {
let config = CiSimConfig {
name: "prop-offline-resolve".into(),
num_rounds: 60,
ci_yaml: simple_yaml(),
webhook_schedule: vec![(3, push("main", "sha-offline"))],
provision_latency: 1,
job_duration: 2,
provisioner_offline_schedule: vec![(1, 15)],
..Default::default()
};
let trace = sim::run_simulation(config);
assert!(
sim::check_all_webhooks_terminate(&trace),
"webhooks during provisioner outage must still terminate"
);
}