swactor/crates/simulation/tests/ci_scenarios.rs
Zachery Aaron Shores-Chmielewski ee31b6623f
Some checks failed
ci/check Job 'check' failed: command exited with code 101 [stderr] | |_^ [stderr] | [stderr] = note: the lang item is first defined in crate `std` (which `ed25519_dalek` depends on) [stderr] = note: first definition in `std` loaded from /home/z...
ci/clippy Job 'clippy' failed: command exited with code 101 [stderr] help: try adding this [stderr] | [stderr] 157 + impl Default for TransportRouter { [stderr] 158 + fn default() -> Self { [stderr] 159 + Self::new() [stderr] 160 + } [st...
ci/ci Pipeline 'ci' failure
feat: mvp ci workflow (#42)
Not working too well and difficult to track, but has a skeleton there to work from.
Co-authored-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
Co-committed-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-02-16 15:57:55 +00:00

368 lines
11 KiB
Rust

//! Scenario tests for the CI simulation.
//!
//! Each test tells a story: set up a scenario, run the simulation, verify outcomes.
use swactor_ci::{EventType, WebhookEvent};
use simulation::ci::sim::{self, CiSimConfig, CiSimEvent};
fn basic_ci_yaml() -> String {
r#"
pipelines:
check:
triggers:
- event: push
branches: ["*"]
exclude: ["master"]
jobs:
fmt:
run: cargo fmt -- --check
clippy:
run: cargo clippy
test:
needs: [fmt, clippy]
run: cargo test
full:
triggers:
- event: push
branches: ["master"]
jobs:
test:
run: cargo test --all-features
timeout: 600
bench:
needs: [test]
run: cargo bench
"#
.into()
}
fn push_event(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,
}
}
// ─── Scenario: Single push triggers correct pipeline ────────────────────────
#[test]
fn single_push_to_feature_branch_triggers_check_pipeline() {
let config = CiSimConfig {
name: "single-push-feature".into(),
num_rounds: 30,
ci_yaml: basic_ci_yaml(),
webhook_schedule: vec![(1, push_event("feature-x", "abc123"))],
provision_latency: 1,
job_duration: 2,
..Default::default()
};
let trace = sim::run_simulation(config);
// A pipeline was created.
let pipeline_created = trace
.events
.iter()
.filter(|(_, e)| matches!(e, CiSimEvent::PipelineCreated { .. }))
.count();
assert_eq!(pipeline_created, 1, "exactly one pipeline should be created");
// The pipeline name should be "check" (not "full", since branch is not master).
let check_created = trace.events.iter().any(|(_, e)| {
matches!(e, CiSimEvent::PipelineCreated { name, .. } if name == "check")
});
assert!(check_created, "pipeline 'check' should be created");
// All jobs eventually complete (fmt, clippy, test).
let completed_jobs: Vec<_> = trace
.events
.iter()
.filter_map(|(_, e)| match e {
CiSimEvent::JobCompleted { job_id, passed } => Some((job_id.job_name.clone(), *passed)),
_ => None,
})
.collect();
assert_eq!(completed_jobs.len(), 3, "all 3 jobs should complete");
assert!(
completed_jobs.iter().all(|(_, passed)| *passed),
"all jobs should pass"
);
// A terminal Forgejo status is emitted.
assert!(
sim::check_all_webhooks_terminate(&trace),
"webhook should produce terminal status"
);
}
// ─── Scenario: Push to master triggers full pipeline ────────────────────────
#[test]
fn push_to_master_triggers_full_pipeline() {
let config = CiSimConfig {
name: "push-master".into(),
num_rounds: 30,
ci_yaml: basic_ci_yaml(),
webhook_schedule: vec![(1, push_event("master", "def456"))],
provision_latency: 1,
job_duration: 2,
..Default::default()
};
let trace = sim::run_simulation(config);
let full_created = trace.events.iter().any(|(_, e)| {
matches!(e, CiSimEvent::PipelineCreated { name, .. } if name == "full")
});
assert!(full_created, "pipeline 'full' should be created");
// Both test and bench should eventually complete.
let completed: Vec<String> = trace
.events
.iter()
.filter_map(|(_, e)| match e {
CiSimEvent::JobCompleted { job_id, .. } => Some(job_id.job_name.clone()),
_ => None,
})
.collect();
assert!(completed.contains(&"test".to_string()), "test should complete");
assert!(completed.contains(&"bench".to_string()), "bench should complete");
}
// ─── Scenario: Jobs execute in DAG order ────────────────────────────────────
#[test]
fn jobs_execute_in_dag_order() {
let config = CiSimConfig {
name: "dag-order".into(),
num_rounds: 30,
ci_yaml: basic_ci_yaml(),
webhook_schedule: vec![(1, push_event("feature-y", "aaa111"))],
provision_latency: 1,
job_duration: 2,
..Default::default()
};
let trace = sim::run_simulation(config);
// test should start after fmt and clippy complete.
assert!(
sim::check_dag_ordering(&trace),
"DAG ordering must be respected"
);
}
// ─── Scenario: Job failure skips dependents ─────────────────────────────────
#[test]
fn job_failure_skips_downstream_dependents() {
let config = CiSimConfig {
name: "failure-skip".into(),
num_rounds: 30,
ci_yaml: basic_ci_yaml(),
webhook_schedule: vec![(1, push_event("feature-z", "bbb222"))],
provision_latency: 1,
job_duration: 2,
// fmt will fail, so test (which needs fmt) should be skipped.
job_failure_schedule: vec![(0, "fmt".into())],
..Default::default()
};
let trace = sim::run_simulation(config);
// test should be skipped.
let test_skipped = trace.events.iter().any(|(_, e)| {
matches!(e, CiSimEvent::JobSkipped { job_id } if job_id.job_name == "test")
});
assert!(test_skipped, "test job should be skipped when fmt fails");
// Pipeline should be marked as failed.
let pipeline_failed = trace.status_updates.iter().any(|u| {
u.context == "ci/check" && u.state == "failure"
});
assert!(pipeline_failed, "pipeline should report failure status");
}
// ─── Scenario: Parallel pushes execute independently ────────────────────────
#[test]
fn parallel_pushes_execute_independently() {
let config = CiSimConfig {
name: "parallel-pushes".into(),
num_rounds: 40,
ci_yaml: basic_ci_yaml(),
webhook_schedule: vec![
(1, push_event("feature-a", "ccc333")),
(1, push_event("feature-b", "ddd444")),
],
provision_latency: 1,
job_duration: 2,
..Default::default()
};
let trace = sim::run_simulation(config);
// Two pipelines should be created.
let pipeline_count = trace
.events
.iter()
.filter(|(_, e)| matches!(e, CiSimEvent::PipelineCreated { .. }))
.count();
assert_eq!(pipeline_count, 2, "two pipelines should be created");
// Both should have terminal status.
assert!(
sim::check_all_webhooks_terminate(&trace),
"both webhooks should produce terminal statuses"
);
}
// ─── Scenario: Provisioner goes offline, jobs queue and resume ──────────────
#[test]
fn provisioner_offline_queues_then_resumes() {
let config = CiSimConfig {
name: "provisioner-offline".into(),
num_rounds: 50,
ci_yaml: basic_ci_yaml(),
// Push at round 3, provisioner offline rounds 1-10.
webhook_schedule: vec![(3, push_event("feature-q", "eee555"))],
provision_latency: 1,
job_duration: 2,
provisioner_offline_schedule: vec![(1, 10)],
..Default::default()
};
let trace = sim::run_simulation(config);
// Provisioner went offline and came back.
let went_offline = trace
.events
.iter()
.any(|(_, e)| matches!(e, CiSimEvent::ProvisionerWentOffline));
let came_online = trace
.events
.iter()
.any(|(_, e)| matches!(e, CiSimEvent::ProvisionerCameOnline));
assert!(went_offline, "provisioner should go offline");
assert!(came_online, "provisioner should come back online");
// Despite the outage, all jobs should eventually complete.
assert!(
sim::check_all_webhooks_terminate(&trace),
"pipeline should complete after provisioner returns"
);
}
// ─── Scenario: Spot instance interrupted mid-job ────────────────────────────
#[test]
fn spot_instance_interruption_reports_failure() {
// Use a simpler pipeline so we have a clear target to interrupt.
let yaml = r#"
pipelines:
check:
triggers:
- event: push
branches: ["*"]
jobs:
test:
run: cargo test
"#;
let config = CiSimConfig {
name: "spot-interrupt".into(),
num_rounds: 30,
ci_yaml: yaml.into(),
webhook_schedule: vec![(1, push_event("main", "fff666"))],
provision_latency: 1,
job_duration: 5,
// Interrupt the test job at round 4 (while it's still running).
instance_interrupt_schedule: vec![(4, "test".into())],
..Default::default()
};
let trace = sim::run_simulation(config);
// The pipeline should be terminal (failed due to interruption).
let has_failure_status = trace
.status_updates
.iter()
.any(|u| u.state == "failure" || u.state == "error");
assert!(
has_failure_status,
"interrupted job should produce a failure status"
);
// The instance should be terminated.
assert!(
sim::check_no_instance_leaks(&trace),
"interrupted instance should be terminated"
);
}
// ─── Scenario: All jobs pass → pipeline success ─────────────────────────────
#[test]
fn all_jobs_pass_marks_pipeline_success() {
let yaml = r#"
pipelines:
check:
triggers:
- event: push
branches: ["*"]
jobs:
lint:
run: cargo clippy
test:
needs: [lint]
run: cargo test
"#;
let config = CiSimConfig {
name: "all-pass".into(),
num_rounds: 30,
ci_yaml: yaml.into(),
webhook_schedule: vec![(1, push_event("main", "ggg777"))],
provision_latency: 1,
job_duration: 2,
..Default::default()
};
let trace = sim::run_simulation(config);
let has_success = trace
.status_updates
.iter()
.any(|u| u.state == "success" && u.context == "ci/check");
assert!(has_success, "pipeline should be marked as success");
}
// ─── Scenario: Every provisioned instance is terminated ─────────────────────
#[test]
fn no_instance_resource_leaks() {
let config = CiSimConfig {
name: "no-leaks".into(),
num_rounds: 40,
ci_yaml: basic_ci_yaml(),
webhook_schedule: vec![
(1, push_event("feature-1", "h1")),
(5, push_event("feature-2", "h2")),
],
provision_latency: 1,
job_duration: 2,
..Default::default()
};
let trace = sim::run_simulation(config);
assert!(
sim::check_no_instance_leaks(&trace),
"all provisioned instances must be terminated"
);
}