swactor/crates/simulation/tests/local_ci_scenarios.rs
Zachery Aaron Shores-Chmielewski 0c6f9c9f39 feat: deploy CI pipeline with ci-relay and local-runner
Add ci-relay (VPS webhook receiver) and local-runner (Thinkpad CI
executor) crates that communicate over iroh. Includes .ci.yml smoke
test pipeline, simulation tests, and development docs.

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
2026-02-16 22:47:28 +07:00

324 lines
8.8 KiB
Rust

//! Scenario tests for the local CI simulation.
//!
//! Each test tells a story: set up a scenario, run the simulation, verify outcomes.
use simulation::ci::local_sim::{self, LocalSimConfig, LocalSimEvent};
use swactor_ci::{EventType, WebhookEvent};
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 → single job → passes ────────────────────────────
#[test]
fn single_push_runs_and_completes() {
let yaml = r#"
pipelines:
check:
triggers:
- event: push
branches: ["*"]
jobs:
test:
run: cargo test
"#;
let config = LocalSimConfig {
name: "single-push".into(),
num_rounds: 20,
ci_yaml: yaml.into(),
webhook_schedule: vec![(1, push_event("main", "sha1"))],
job_duration: 2,
..Default::default()
};
let trace = local_sim::run_simulation(config);
let created = trace
.events
.iter()
.filter(|(_, e)| matches!(e, LocalSimEvent::PipelineCreated { .. }))
.count();
assert_eq!(created, 1);
let completed = trace
.events
.iter()
.filter(|(_, e)| matches!(e, LocalSimEvent::JobCompleted { passed: true, .. }))
.count();
assert_eq!(completed, 1);
assert!(local_sim::check_all_webhooks_terminate(&trace));
}
// ─── Scenario: Push A, push A again while queued → only latest runs ─────────
#[test]
fn supersede_queued_same_branch() {
let yaml = r#"
pipelines:
check:
triggers:
- event: push
branches: ["*"]
jobs:
test:
run: cargo test
"#;
let config = LocalSimConfig {
name: "supersede-queued".into(),
num_rounds: 30,
ci_yaml: yaml.into(),
// Push A at round 1 occupies the runner. Push A' at round 2 queues.
// Push A'' at round 3 should supersede A'.
webhook_schedule: vec![
(1, push_event("feature", "sha-a1")),
(2, push_event("feature", "sha-a2")),
(3, push_event("feature", "sha-a3")),
],
job_duration: 5,
..Default::default()
};
let trace = local_sim::run_simulation(config);
// sha-a2 should be superseded.
let superseded = trace
.events
.iter()
.filter(|(_, e)| matches!(e, LocalSimEvent::PipelineSuperseded { .. }))
.count();
assert!(superseded >= 1, "at least one pipeline should be superseded");
// sha-a2 should have an "error" status (superseded).
let a2_error = trace
.status_updates
.iter()
.any(|u| u.commit_sha == "sha-a2" && u.state == "error");
assert!(a2_error, "superseded pipeline should report error status");
// sha-a1 and sha-a3 should both reach terminal status.
let a1_terminal = trace
.status_updates
.iter()
.any(|u| u.commit_sha == "sha-a1" && (u.state == "success" || u.state == "failure"));
let a3_terminal = trace
.status_updates
.iter()
.any(|u| u.commit_sha == "sha-a3" && (u.state == "success" || u.state == "failure"));
assert!(a1_terminal, "first push should complete");
assert!(a3_terminal, "latest push should complete");
assert!(local_sim::check_superseded_no_running(&trace));
}
// ─── Scenario: Push A, push A while running → running completes, new queues ─
#[test]
fn push_while_running_does_not_supersede_active() {
let yaml = r#"
pipelines:
check:
triggers:
- event: push
branches: ["*"]
jobs:
test:
run: cargo test
"#;
let config = LocalSimConfig {
name: "no-supersede-active".into(),
num_rounds: 30,
ci_yaml: yaml.into(),
// Push A at round 1 starts running immediately.
// Push A' at round 2 should queue (not cancel the running job).
webhook_schedule: vec![
(1, push_event("feature", "sha-run1")),
(2, push_event("feature", "sha-run2")),
],
job_duration: 4,
..Default::default()
};
let trace = local_sim::run_simulation(config);
// Both should reach terminal status.
let run1_terminal = trace
.status_updates
.iter()
.any(|u| u.commit_sha == "sha-run1" && u.state == "success");
let run2_terminal = trace
.status_updates
.iter()
.any(|u| u.commit_sha == "sha-run2" && u.state == "success");
assert!(run1_terminal, "running pipeline should complete normally");
assert!(run2_terminal, "queued pipeline should run after");
assert!(local_sim::check_one_at_a_time(&trace));
}
// ─── Scenario: Push A, push B → both run in FIFO order ─────────────────────
#[test]
fn different_branches_run_fifo() {
let yaml = r#"
pipelines:
check:
triggers:
- event: push
branches: ["*"]
jobs:
test:
run: cargo test
"#;
let config = LocalSimConfig {
name: "fifo-branches".into(),
num_rounds: 30,
ci_yaml: yaml.into(),
webhook_schedule: vec![
(1, push_event("feature-a", "sha-fa")),
(1, push_event("feature-b", "sha-fb")),
],
job_duration: 3,
..Default::default()
};
let trace = local_sim::run_simulation(config);
// Both should complete.
assert!(local_sim::check_all_webhooks_terminate(&trace));
// FIFO order respected.
assert!(local_sim::check_fifo_order(&trace));
// One at a time.
assert!(local_sim::check_one_at_a_time(&trace));
}
// ─── Scenario: Job failure → dependents skipped → next pipeline starts ──────
#[test]
fn job_failure_skips_dependents_and_advances() {
let config = LocalSimConfig {
name: "failure-skip-advance".into(),
num_rounds: 40,
ci_yaml: basic_ci_yaml(),
webhook_schedule: vec![
(1, push_event("feature-x", "sha-fail")),
(2, push_event("feature-y", "sha-next")),
],
job_duration: 2,
// fmt fails.
job_failure_schedule: vec![(0, "fmt".into())],
..Default::default()
};
let trace = local_sim::run_simulation(config);
// test should be skipped in feature-x pipeline (needs fmt which fails).
let test_skipped = trace.events.iter().any(|(_, e)| {
matches!(e, LocalSimEvent::JobSkipped { job_id } if job_id.job_name == "test")
});
assert!(test_skipped, "test should be skipped when fmt fails");
// Both pipelines should reach terminal.
assert!(local_sim::check_all_webhooks_terminate(&trace));
// One at a time.
assert!(local_sim::check_one_at_a_time(&trace));
}
// ─── Scenario: Diamond DAG → jobs serialize respecting deps ─────────────────
#[test]
fn diamond_dag_serialized_with_deps() {
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 = LocalSimConfig {
name: "diamond-dag".into(),
num_rounds: 50,
ci_yaml: yaml.into(),
webhook_schedule: vec![(1, push_event("main", "sha-diamond"))],
job_duration: 2,
..Default::default()
};
let trace = local_sim::run_simulation(config);
// All 4 jobs should complete.
let completed = trace
.events
.iter()
.filter(|(_, e)| matches!(e, LocalSimEvent::JobCompleted { .. }))
.count();
assert_eq!(completed, 4, "all 4 diamond jobs should complete");
// DAG ordering respected.
assert!(local_sim::check_dag_ordering(&trace));
// One at a time (serial).
assert!(local_sim::check_one_at_a_time(&trace));
// Pipeline should succeed.
let success = trace
.status_updates
.iter()
.any(|u| u.state == "success" && u.context == "ci/diamond");
assert!(success, "diamond pipeline should succeed");
}