crates/dwarves-runa/tests/pipeline.rs
//! Red-when-broken integration tests for the `runa` authoring layer.
//!
//! These assert the load-time contract: the design doc's `korp-ci` RON parses
//! into the exact typed structure, RON round-trips, and both malformed-DAG
//! shapes (cycle, unknown-`needs`) are rejected *at load*.
use dwarves_runa::{
DeployTarget, LintTool, NotifyChannel, NotifyOn, Pipeline, Profile, ProvisionBackend,
SbomPolicy, ScanKind, Step, Trigger, ValidationError,
};
/// The `korp-ci` example from `.nornir/rust-cicd-design.md`, verbatim
/// (comments and all — RON tolerates them).
const KORP_CI: &str = r#"Pipeline(
name: "korp-ci",
triggers: [ Push(branch: "main"), Cron("0 3 * * *") ],
stages: [
Stage(name: "build", steps: [
CargoBuild(features: ["iceberg", "containers"], profile: Release),
]),
Stage(name: "test", needs: ["build"], steps: [
Nextest(all_features: true),
Lint(tool: Clippy, deny_warnings: true),
Xtask(cmd: "verify-demo"), // ← escape hatch to compiled Rust
]),
Stage(name: "package", needs: ["test"], steps: [
Build(variant: "holger-embedded"), // ← build-thing variant by name
Build(variant: "holger-full"),
Sbom(policy: Block), // ← modgunn gate
]),
],
)"#;
#[test]
fn parses_korp_ci_example_into_exact_structure() {
let p = Pipeline::from_ron_str(KORP_CI).expect("korp-ci must load");
assert_eq!(p.name, "korp-ci");
// Triggers — both variants, in order.
assert_eq!(
p.triggers,
vec![
Trigger::Push {
branch: "main".to_string()
},
Trigger::Cron("0 3 * * *".to_string()),
]
);
// Three stages, in order, with the right needs wiring.
let names: Vec<&str> = p.stages.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["build", "test", "package"]);
let build = &p.stages[0];
assert!(build.needs.is_empty(), "root stage has no needs");
assert_eq!(
build.steps,
vec![Step::CargoBuild {
features: vec!["iceberg".to_string(), "containers".to_string()],
profile: Profile::Release,
target: None,
}]
);
let test = &p.stages[1];
assert_eq!(test.needs, vec!["build".to_string()]);
assert_eq!(
test.steps,
vec![
Step::Nextest { all_features: true, only: dwarves_runa::TestScope::All },
Step::Lint {
tool: LintTool::Clippy,
deny_warnings: true
},
// The pre-0.2.3 spelling, still valid: `args`/`repo` default, so an
// existing file means exactly what it meant.
Step::Xtask {
cmd: "verify-demo".to_string(),
args: Vec::new(),
repo: String::new(),
alias: String::new(),
},
]
);
let package = &p.stages[2];
assert_eq!(package.needs, vec!["test".to_string()]);
assert_eq!(
package.steps,
vec![
Step::Build {
variant: "holger-embedded".to_string(),
opts: Vec::new(),
params: Vec::new()
},
Step::Build {
variant: "holger-full".to_string(),
opts: Vec::new(),
params: Vec::new()
},
Step::Sbom {
policy: SbomPolicy::Block
},
]
);
}
#[test]
fn ron_round_trips() {
let parsed = Pipeline::from_ron_str(KORP_CI).expect("first load");
let serialised = parsed.to_ron_string().expect("serialise");
let reparsed = Pipeline::from_ron_str(&serialised)
.expect("re-load of serialised pipeline must succeed");
assert_eq!(parsed, reparsed, "parse -> serialise -> parse must be identity");
}
#[test]
fn cyclic_pipeline_is_rejected_at_load() {
// a -> b -> c -> a (via needs), a valid-looking but unschedulable graph.
let cyclic = r#"Pipeline(
name: "cyclic",
stages: [
Stage(name: "a", needs: ["c"], steps: [ CargoTest ]),
Stage(name: "b", needs: ["a"], steps: [ CargoTest ]),
Stage(name: "c", needs: ["b"], steps: [ CargoTest ]),
],
)"#;
let err = Pipeline::from_ron_str(cyclic).expect_err("a cyclic pipeline must not load");
match err {
dwarves_runa::RunaError::Validation(ValidationError::Cycle { nodes }) => {
assert!(!nodes.is_empty(), "cycle error must name the implicated stage(s)");
}
other => panic!("expected a Cycle validation error, got: {other:?}"),
}
}
#[test]
fn unknown_needs_is_rejected_at_load() {
let dangling = r#"Pipeline(
name: "dangling",
stages: [
Stage(name: "build", steps: [ CargoTest ]),
Stage(name: "test", needs: ["nope"], steps: [ CargoTest ]),
],
)"#;
let err = Pipeline::from_ron_str(dangling).expect_err("unknown needs must not load");
match err {
dwarves_runa::RunaError::Validation(ValidationError::UnknownNeed { stage, needs }) => {
assert_eq!(stage, "test");
assert_eq!(needs, "nope");
}
other => panic!("expected an UnknownNeed validation error, got: {other:?}"),
}
}
#[test]
fn triggers_default_to_empty_when_omitted() {
// A pipeline with no triggers is invoke-only, not a parse error.
let no_triggers = r#"Pipeline(
name: "manual-only",
stages: [ Stage(name: "only", steps: [ CargoTest ]) ],
)"#;
let p = Pipeline::from_ron_str(no_triggers).expect("must load");
assert!(p.triggers.is_empty());
}
/// A full human-mode CI/CD that exercises **every** new Tier-B/C step kind, in
/// the recommended default palette order. Each new `Step` must parse, validate,
/// and (below) round-trip byte-stable.
const FULL_PALETTE: &str = r##"Pipeline(
name: "full-palette",
triggers: [ Push(branch: "main") ],
stages: [
Stage(name: "build", steps: [
CargoBuild(profile: Release),
]),
Stage(name: "verify", needs: ["build"], steps: [
Lint(tool: Fmt),
Lint(tool: Clippy, deny_warnings: true),
SecurityScan(kind: Sast),
SecurityScan(kind: Deps),
SecurityScan(kind: Secret),
SecurityScan(kind: Container),
ProvisionTestEnv(backend: Draupnir(spec: "test-stack.draupnir.ron"), teardown: true),
ProvisionTestEnv(backend: Terraform(module: "envs/pr.tf"), teardown: false),
]),
Stage(name: "image", needs: ["verify"], steps: [
ContainerBuild(file: "Containerfile"),
PushImage(registry: "ghcr.io/nordisk", tags: ["latest", "1.2.3"]),
]),
Stage(name: "approval", needs: ["image"], steps: [
ApprovalGate(approvers: ["alice"], timeout: Some("24h")),
]),
Stage(name: "deploy", needs: ["approval"], steps: [
Deploy(target: Registry(name: "ghcr.io/nordisk")),
Deploy(target: Env(name: "production")),
Deploy(target: K8s(manifest: "k8s/deploy.yaml")),
Notify(channel: Slack(target: "#releases"), on: Always),
Notify(channel: Email(to: ["ops@example.com"]), on: Failure),
Notify(channel: Webhook(url: "https://hooks.example/ci")),
]),
],
)"##;
#[test]
fn full_palette_parses_and_validates_every_new_step() {
let p = Pipeline::from_ron_str(FULL_PALETTE).expect("full-palette must load & validate");
// Spot-check that each new Step variant landed as the exact typed value.
let verify = &p.stages[1];
assert_eq!(
verify.steps[0],
Step::Lint {
tool: LintTool::Fmt,
deny_warnings: false
}
);
assert_eq!(
verify.steps[2],
Step::SecurityScan {
kind: ScanKind::Sast
}
);
assert_eq!(
verify.steps[6],
Step::ProvisionTestEnv {
backend: ProvisionBackend::Draupnir {
spec: "test-stack.draupnir.ron".to_string()
},
teardown: true,
}
);
let image = &p.stages[2];
assert_eq!(
image.steps[1],
Step::PushImage {
registry: "ghcr.io/nordisk".to_string(),
tags: vec!["latest".to_string(), "1.2.3".to_string()],
}
);
let approval = &p.stages[3];
assert_eq!(
approval.steps[0],
Step::ApprovalGate {
approvers: vec!["alice".to_string()],
timeout: Some("24h".to_string()),
}
);
let deploy = &p.stages[4];
assert_eq!(
deploy.steps[1],
Step::Deploy {
target: DeployTarget::Env {
name: "production".to_string()
}
}
);
assert_eq!(
deploy.steps[3],
Step::Notify {
channel: NotifyChannel::Slack {
target: "#releases".to_string()
},
on: NotifyOn::Always,
}
);
// Defaulted `on` must fall back to Failure when omitted.
assert_eq!(
deploy.steps[5],
Step::Notify {
channel: NotifyChannel::Webhook {
url: "https://hooks.example/ci".to_string()
},
on: NotifyOn::Failure,
}
);
}
#[test]
fn full_palette_ron_round_trips() {
// parse -> serialise -> parse must be an identity for every new step.
let parsed = Pipeline::from_ron_str(FULL_PALETTE).expect("first load");
let serialised = parsed.to_ron_string().expect("serialise");
let reparsed = Pipeline::from_ron_str(&serialised).expect("re-load of serialised pipeline");
assert_eq!(parsed, reparsed, "new-step round-trip must be an identity");
}
#[test]
fn build_test_deploy_with_approval_gate_is_a_valid_dag() {
// The canonical human-mode shape: build -> test -> approval-gate -> deploy.
// It must validate as a well-formed (acyclic, fully-wired) DAG.
let src = r##"Pipeline(
name: "release-flow",
triggers: [ Push(branch: "main") ],
stages: [
Stage(name: "build", steps: [ CargoBuild(profile: Release) ]),
Stage(name: "test", needs: ["build"], steps: [ Nextest(all_features: true) ]),
Stage(name: "gate", needs: ["test"], steps: [
ApprovalGate(approvers: ["alice"], timeout: Some("12h")),
]),
Stage(name: "deploy", needs: ["gate"], steps: [
Deploy(target: Env(name: "production")),
Notify(channel: Slack(target: "#ops"), on: Always),
]),
],
)"##;
let p = Pipeline::from_ron_str(src).expect("build->test->gate->deploy must be a valid DAG");
let names: Vec<&str> = p.stages.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["build", "test", "gate", "deploy"]);
assert_eq!(p.stages[3].needs, vec!["gate".to_string()]);
}
/// **The bare `Build(variant: "x")` form STILL PARSES** — the compatibility half
/// of the 2026-08-27 option/param axis on [`Step::Build`].
///
/// Every shipped pipeline spells it bare: `dwarves.runa.ron:54`
/// (`Build(variant: "*")`), `holger.runa.ron:32-34`, `pipelines/release.runa.ron:49`
/// and edda's `fleet.runa.ron:44-48` — nine occurrences, not one of which names an
/// `opts` or a `params` field. Adding two required fields to the variant would have
/// broken all nine at LOAD, which is the one thing a schema extension must not do.
///
/// It is asserted rather than assumed because the two fields are `Vec<String>`, and
/// serde's `default` on a struct-variant field is exactly the kind of attribute that
/// is easy to write and easy to leave off one of the two.
#[test]
fn bare_build_step_still_parses_without_opts_or_params() {
let src = r##"Pipeline(
name: "bare-build",
stages: [
Stage(name: "package", steps: [
Build(variant: "gunnar-appliance-iso"),
]),
],
)"##;
let p = Pipeline::from_ron_str(src).expect("the bare Build form must still load");
assert_eq!(
p.stages[0].steps,
vec![Step::Build {
variant: "gunnar-appliance-iso".to_string(),
opts: Vec::new(),
params: Vec::new(),
}],
"a bare Build must default BOTH axes to empty"
);
}
/// **THE RED PROBE for the test above.** A field that is not `#[serde(default)]`
/// makes the bare form fail to load — so the guard is proven able to report red by
/// showing that a pipeline naming an UNKNOWN field is refused, i.e. that this parse
/// path is strict and the green above is not simply serde ignoring everything.
///
/// Without this, `bare_build_step_still_parses…` could pass against a deserializer
/// that silently accepts any input, which is the identity-value trap one door over.
#[test]
fn a_misspelled_build_field_is_refused_at_load() {
let src = r##"Pipeline(
name: "typo-build",
stages: [
Stage(name: "package", steps: [
Build(variant: "x", optz: ["wireguard=off"]),
]),
],
)"##;
let e = Pipeline::from_ron_str(src)
.expect_err("a misspelled field must be refused, or the parse path proves nothing");
let msg = format!("{e:#}");
assert!(
msg.contains("optz") || msg.to_lowercase().contains("unknown"),
"the refusal must name the field it did not understand; got: {msg}"
);
}
/// **The CUSTOMER CHOICE reaches the typed step** — a `*.runa.ron` can now express
/// `wireguard=off` and `admin_key=…` on the CI door, which is what
/// `Step::Build`'s two new fields exist for.
#[test]
fn build_step_carries_opts_and_params_from_ron() {
let src = r##"Pipeline(
name: "per-site-iso",
stages: [
Stage(name: "package", steps: [
Build(
variant: "gunnar-appliance-iso",
opts: ["wireguard=off"],
params: ["admin_key=ssh-ed25519 AAAAC3Nz"],
),
]),
],
)"##;
let p = Pipeline::from_ron_str(src).expect("the option/param form must load");
let Step::Build { variant, opts, params } = &p.stages[0].steps[0] else {
panic!("expected a Build step, got {:?}", p.stages[0].steps[0]);
};
assert_eq!(variant, "gunnar-appliance-iso");
assert_eq!(opts, &vec!["wireguard=off".to_string()]);
assert_eq!(params, &vec!["admin_key=ssh-ed25519 AAAAC3Nz".to_string()]);
// And it survives the serialize road too: a generated pipeline that carries a
// choice must be able to write itself back out.
let round = Pipeline::from_ron_str(&p.to_ron_string().expect("serialise"))
.expect("re-load of a serialised choice-carrying pipeline");
assert_eq!(round, p, "the two axes must survive parse -> serialise -> parse");
}
// ── `Xtask` grows an argv and a checkout (0.2.3) ────────────────────────────
//
// The step that makes a runa something a developer writes BESIDE their `xtask`.
// Before this, `Xtask` carried a bare `cmd`, so `cargo xtask build --variant
// korp-ui` — the constellation's most-typed verb — was unspellable, and every
// step ran in the forge's own checkout. The two tests below are the pair that
// matters: the new fields must be READ, and the old files must not break.
#[test]
fn an_xtask_step_carries_its_argv_and_its_checkout() {
let p = dwarves_runa::Pipeline::from_ron_str(
r#"Pipeline(
name: "beside-xtask",
stages: [
Stage(name: "build", steps: [
Xtask(cmd: "build", args: ["--variant", "korp-ui"], repo: "korp"),
]),
],
)"#,
)
.expect("parses");
assert_eq!(
p.stages[0].steps,
vec![Step::Xtask {
cmd: "build".to_string(),
// Each element is ONE argv slot. A single "--variant korp-ui" string
// would be a shell split waiting to happen, and this catalog has no
// shell by construction.
args: vec!["--variant".to_string(), "korp-ui".to_string()],
repo: "korp".to_string(),
alias: String::new(),
}]
);
}
#[test]
fn an_xtask_step_written_before_0_2_3_still_means_what_it_meant() {
// `deny_unknown_fields` is on `Step`, so a defaulted field is the ONLY way to
// add one without breaking every committed pipeline. This is that assertion:
// the old spelling parses, and it parses to the forge's own checkout with no
// arguments — not to some new default nobody wrote down.
let p = dwarves_runa::Pipeline::from_ron_str(
r#"Pipeline(name: "old", stages: [Stage(name: "s", steps: [Xtask(cmd: "fmt-check")])])"#,
)
.expect("the pre-0.2.3 spelling still parses");
assert_eq!(
p.stages[0].steps,
vec![Step::Xtask {
cmd: "fmt-check".to_string(),
args: Vec::new(),
repo: String::new(),
alias: String::new(),
}]
);
}
#[test]
fn a_misspelled_field_on_an_xtask_step_is_refused_not_dropped() {
// The reason `deny_unknown_fields` is on this enum at all: a dropped field is
// a step that ran with different arguments than the file says. `argz` must be
// an error, never an empty `args`.
let e = dwarves_runa::Pipeline::from_ron_str(
r#"Pipeline(name: "typo", stages: [Stage(name: "s", steps: [Xtask(cmd: "b", argz: ["x"])])])"#,
)
.expect_err("a misspelled field must not parse");
let msg = format!("{e:#}");
assert!(msg.contains("argz") || msg.to_lowercase().contains("unknown"), "{msg}");
}
#[test]
fn an_xtask_step_can_name_the_alias_its_verb_moved_behind() {
// THE LAW gives xtask ONE dependency; facett's non-build verbs moved to
// `cargo ops`. The step names that alias, and the default stays `xtask`.
let p = dwarves_runa::Pipeline::from_ron_str(
r#"Pipeline(name: "ops", stages: [Stage(name: "s", steps: [Xtask(cmd: "publish-holger", alias: "ops")])])"#,
)
.expect("an alias parses");
assert_eq!(
p.stages[0].steps,
vec![Step::Xtask {
cmd: "publish-holger".to_string(),
args: Vec::new(),
repo: String::new(),
alias: "ops".to_string(),
}]
);
assert_eq!(dwarves_runa::xtask_alias("ops"), "ops");
assert_eq!(dwarves_runa::xtask_alias(""), "xtask");
}
#[test]
fn an_xtask_alias_that_is_not_a_name_is_refused_at_load() {
for bad in ["../x", "/bin/sh", "ops publish", "OPS", "a;b"] {
let src = format!(
r#"Pipeline(name: "bad", stages: [Stage(name: "s", steps: [Xtask(cmd: "c", alias: "{bad}")])])"#
);
let e = dwarves_runa::Pipeline::from_ron_str(&src).err().unwrap_or_else(|| panic!("`{bad}` was accepted"));
assert!(e.to_string().contains("not a cargo alias name"), "{bad}: {e}");
}
}
/// Builder 1 (owner 2026-10-15: "prepare its builder to be callable from
/// RUNA"): `BuildWorkspace` beside `Build(variant)`. `only` defaults to `All`
/// and `repos` to empty, so the bare step reads the whole workspace; it
/// round-trips through RON.
#[test]
fn a_build_workspace_step_reads_a_workspace_and_round_trips() {
use dwarves_runa::WorkspaceScope;
let src = r#"Pipeline(name: "read", stages: [
Stage(name: "all", steps: [BuildWorkspace(workspace: "nordisk")]),
Stage(name: "some", needs: ["all"], steps: [BuildWorkspace(workspace: "nordisk", only: Affected, repos: ["skade", "nornir"])]),
])"#;
let p = Pipeline::from_ron_str(src).expect("loads");
assert_eq!(p.stages[0].steps, vec![Step::BuildWorkspace { workspace: "nordisk".into(), only: WorkspaceScope::All, repos: vec![] }]);
assert_eq!(p.stages[1].steps, vec![Step::BuildWorkspace { workspace: "nordisk".into(), only: WorkspaceScope::Affected, repos: vec!["skade".into(), "nornir".into()] }]);
let again = Pipeline::from_ron_str(&p.to_ron_string().expect("serialise")).expect("reloads");
assert_eq!(p, again, "parse -> serialise -> parse is the identity");
}
/// A workspace or a member is a NAME: a path is refused at load, by name.
#[test]
fn a_build_workspace_step_with_a_path_is_refused_at_load() {
for (ws, repos) in [("../x", "[]"), ("/srv/ws", "[]"), ("nordisk", r#"["a/b"]"#), ("nordisk", r#"[".."]"#), ("", "[]")] {
let src = format!(r#"Pipeline(name: "bad", stages: [Stage(name: "s", steps: [BuildWorkspace(workspace: "{ws}", repos: {repos})])])"#);
let e = Pipeline::from_ron_str(&src).err().unwrap_or_else(|| panic!("`{ws}` {repos} was accepted"));
assert!(matches!(e, dwarves_runa::RunaError::Validation(ValidationError::BadName { .. })), "{ws} {repos}: {e}");
}
}
/// The measuring steps: `Bench` and `Coverage` load with every field defaulted,
/// carry their fields, round-trip, and a path where a NAME belongs is refused at load.
#[test]
fn the_bench_and_coverage_steps_load_round_trip_and_refuse_a_path() {
let src = r#"Pipeline(
name: "measure",
stages: [
Stage(name: "test", steps: [ Nextest(), Coverage(), Coverage(all_features: true) ]),
Stage(name: "bench", needs: ["test"], steps: [ Bench(), Bench(package: "xtask"), Bench(dir: "bench", features: ["s3"]) ]),
],
)"#;
let p = Pipeline::from_ron_str(src).expect("loads");
assert_eq!(p.stages[0].steps[1], Step::Coverage { all_features: false, only: dwarves_runa::TestScope::All });
assert_eq!(p.stages[0].steps[2], Step::Coverage { all_features: true, only: dwarves_runa::TestScope::All });
assert_eq!(p.stages[1].steps[0], Step::Bench { package: String::new(), dir: String::new(), features: Vec::new() });
assert_eq!(p.stages[1].steps[1], Step::Bench { package: "xtask".into(), dir: String::new(), features: Vec::new() });
assert_eq!(p.stages[1].steps[2], Step::Bench { package: String::new(), dir: "bench".into(), features: vec!["s3".into()] });
let back = Pipeline::from_ron_str(&p.to_ron_string().unwrap()).unwrap();
assert_eq!(back, p);
for bad in [r#"Bench(dir: "../x")"#, r#"Bench(dir: "/etc")"#, r#"Bench(package: "a b")"#] {
let src = format!(r#"Pipeline(name: "m", stages: [ Stage(name: "b", steps: [ {bad} ]) ])"#);
assert!(Pipeline::from_ron_str(&src).is_err(), "{bad} must be refused at load");
}
let typo = r#"Pipeline(name: "m", stages: [ Stage(name: "b", steps: [ Coverage(all_feature: true) ]) ])"#;
assert!(Pipeline::from_ron_str(typo).is_err(), "a misspelled field is refused, not dropped");
}
/// `only: Affected` on the three test-running steps: Mímir's graph picks the tests
/// a change reaches. `All` is the default, and a bare `CargoTest` (every shipped
/// pipeline's spelling) still loads — as `All`.
#[test]
fn test_steps_take_only_affected_and_a_bare_cargo_test_still_loads() {
use dwarves_runa::TestScope;
let src = r#"Pipeline(
name: "inc",
stages: [
Stage(name: "t", steps: [
Nextest(only: Affected), CargoTest(only: Affected), Coverage(only: Affected),
CargoTest, CargoTest(), CargoTest , Nextest(), Coverage(all_features: true),
]),
],
)"#;
let p = Pipeline::from_ron_str(src).expect("loads");
let s = &p.stages[0].steps;
assert_eq!(s[0], Step::Nextest { all_features: false, only: TestScope::Affected });
assert_eq!(s[1], Step::CargoTest { only: TestScope::Affected });
assert_eq!(s[2], Step::Coverage { all_features: false, only: TestScope::Affected });
assert_eq!(s[3], Step::CargoTest { only: TestScope::All }, "bare CargoTest = All");
assert_eq!(s[4], Step::CargoTest { only: TestScope::All });
assert_eq!(s[5], Step::CargoTest { only: TestScope::All });
assert_eq!(s[6], Step::Nextest { all_features: false, only: TestScope::All });
assert_eq!(s[7], Step::Coverage { all_features: true, only: TestScope::All });
let back = Pipeline::from_ron_str(&p.to_ron_string().unwrap()).unwrap();
assert_eq!(back, p);
// a NAME "CargoTest" in a string stays a string (the bare-variant rewrite
// touches identifiers only, never literals or comments)
let named = r#"Pipeline(name: "CargoTest", stages: [ Stage(name: "CargoTest", steps: [ CargoTest /* CargoTest */ ]) ]) // CargoTest"#;
let p = Pipeline::from_ron_str(named).expect("loads");
assert_eq!(p.name, "CargoTest");
assert_eq!(p.stages[0].name, "CargoTest");
assert_eq!(p.stages[0].steps, vec![Step::CargoTest { only: TestScope::All }]);
for typo in ["Nextest(onyl: Affected)", "CargoTest(only: Afected)", "Coverage(only: Some)"] {
let src = format!(r#"Pipeline(name: "m", stages: [ Stage(name: "b", steps: [ {typo} ]) ])"#);
assert!(Pipeline::from_ron_str(&src).is_err(), "{typo} must be refused at load");
}
}
/// The run-watching steps: `Trace` (XRay), `Profile` (perf) and `LiveTrace`
/// (uprobes) load with their defaults, round-trip, and refuse a path where a NAME
/// belongs, a nameless Bin / Bench, and a probe that is not a function path.
#[test]
fn the_trace_profile_and_live_trace_steps_load_round_trip_and_refuse_bad_names() {
use dwarves_runa::RunTarget;
let src = r#"Pipeline(
name: "watch",
stages: [
Stage(name: "trace", steps: [
Trace(),
Trace(target: Test, name: "code_room", args: ["map_"], max_events: 500000),
Trace(target: Bin, name: "dwarves", args: ["--help"], package: "dwarves"),
Profile(target: Bench, name: "zip"),
LiveTrace(target: Bin, name: "dwarves", functions: ["dwarves::serve::*", "dwarves::main"], seconds: 30),
]),
],
)"#;
let p = Pipeline::from_ron_str(src).expect("loads");
let s = &p.stages[0].steps;
assert_eq!(s[0], Step::Trace { target: RunTarget::Test, name: String::new(), args: vec![], package: String::new(), max_events: 0 });
assert_eq!(s[1], Step::Trace { target: RunTarget::Test, name: "code_room".into(), args: vec!["map_".into()], package: String::new(), max_events: 500_000 });
assert_eq!(s[3], Step::Profile { target: RunTarget::Bench, name: "zip".into(), args: vec![], package: String::new() });
assert!(matches!(&s[4], Step::LiveTrace { functions, seconds: 30, .. } if functions.len() == 2));
let back = Pipeline::from_ron_str(&p.to_ron_string().unwrap()).unwrap();
assert_eq!(back, p);
for bad in [
r#"Trace(name: "../x")"#,
r#"Trace(package: "a b")"#,
r#"Trace(target: Bin)"#,
r#"Profile(target: Bench)"#,
r#"LiveTrace(target: Bin, name: "d", functions: [])"#,
r#"LiveTrace(target: Bin, name: "d", functions: ["a\"); system(\"x"])"#,
r#"LiveTrace(target: Bin, name: "d", functions: ["a*b"])"#,
r#"LiveTrace(target: Bin, name: "d", functions: ["a"], seconds: 9999)"#,
] {
let src = format!(r#"Pipeline(name: "m", stages: [ Stage(name: "b", steps: [ {bad} ]) ])"#);
assert!(Pipeline::from_ron_str(&src).is_err(), "{bad} must be refused at load");
}
let typo = r#"Pipeline(name: "m", stages: [ Stage(name: "b", steps: [ Trace(max_event: 1) ]) ])"#;
assert!(Pipeline::from_ron_str(typo).is_err(), "a misspelled field is refused, not dropped");
}