crates/dwarves-embed/src/cicdwizard.rs
//! The **CI/CD quick-setup wizard** pane — the PRIORITY front door of the human-mode
//! forge. A human picks what to BUILD (artifact kind × target), a TEST step, a DEPLOY
//! target and a TRIGGER (+ an SSH source repo); the pane GENERATES a typed
//! `dwarves_runa::Pipeline` via [`dwarves::pipeline_wizard`] and shows the LIVE stage DAG on the
//! REUSED [`facett_pipeline::PipelineView`] — lit through
//! [`facett_pipeline::RunaPipelineAdapter`] (Law #1: "show another engine's pipeline =
//! a new ADAPTER, not a new viewer"). A **Run** dispatches the pipeline as real jera
//! jobs over a FAKE scheduler (no process spawn, no deploy) so the human sees the DAG
//! go green; **Save** writes the `*.runa.ron`.
//!
//! ONE code path, two front doors: this pane and the `dwarves pipeline` CLI both call
//! the SAME `pipeline_wizard::generate_pipeline` + the SAME `RunaStepRunner`/jera
//! runner — they never fork the schema or the run.
//!
//! Robot-testable: the whole wizard is driven headless through [`Facet::update_json`]
//! (`{"set_build":…}`, `{"set_deploy":…}`, `{"generate":true}`, `{"run":true}`) and
//! asserted through [`Facet::state_json`] (the generated pipeline, the DAG node/edge
//! counts, the run's dispatched jobs).
use std::collections::HashMap;
use facett_core::{Facet, Panel};
use facett_pipeline::{PipelineView, RunaPipelineAdapter, StageStatus};
use dwarves::pipeline_wizard::{
self, ArtifactKind, DeployChoice, GitSource, TestKind, TriggerChoice, WizardConfig,
};
/// The wizard pane state. The `cfg` is the human's picks; `pipeline` is regenerated
/// from it on every change; `status` lights the DAG (all `Idle` until a Run).
pub struct CicdWizardPanel {
cfg: WizardConfig,
pipeline: dwarves_runa::Pipeline,
status: HashMap<String, StageStatus>,
/// The last run's outcome (dispatched jobs + green), for the readout.
last_run: Option<LastRun>,
/// Manageability: the pipelines generated this session (names) + files saved.
generated: Vec<String>,
saved_files: Vec<String>,
/// The **credential-injection map** the human declares for the runner: each
/// `(vault_secret_name, env_key)` is pulled from the UNLOCKED vault into the runner
/// env on a Run (via `dwarves::vault::inject_secret_env`). These are NAMES + KEYS only
/// — never a secret value. Empty ⇒ the vault-free wizard.
cred_injections: Vec<(String, String)>,
/// The env KEYS the LAST run actually injected from the vault (the RED-when-broken
/// oracle that the cred→pipeline hop reached the runner) — KEYS only, never values.
injected_keys: Vec<String>,
/// Whether the last Run was REFUSED because a declared credential could not be pulled
/// (a LOCKED vault / missing secret — the fail-closed path).
run_refused: bool,
/// Where `Save` writes `<name>.runa.ron` (`None` = the current dir). A host/robot sets
/// it so the artifact lands in a chosen dir rather than cwd.
save_dir: Option<std::path::PathBuf>,
/// egui text buffers.
name_buf: String,
git_buf: String,
/// A transient status/error line shown under the buttons.
note: String,
}
#[derive(Clone)]
struct LastRun {
dispatched: usize,
green: bool,
stage_order: Vec<String>,
}
/// The height the action row (`▶ Run (dry)` · `💾 Save .runa.ron` · the validity word)
/// plus its separator is reserved at the BOTTOM of the pane, so those two buttons are on
/// the rendered surface whatever the window height is. Not a guess: the row measured
/// 18 px of button plus egui's item spacing and the separator above it, and the reserve
/// is deliberately generous — under-reserving costs the operator the primary action,
/// over-reserving costs a few pixels of scroll body.
const ACTION_ROW_HEIGHT: f32 = 44.0;
impl Default for CicdWizardPanel {
fn default() -> Self {
Self::new("my-ci")
}
}
impl CicdWizardPanel {
/// A fresh wizard opened on the minimal-viable default (build a Linux exe →
/// nextest → no deploy → manual, on the fast container runner).
pub fn new(name: impl Into<String>) -> Self {
let cfg = pipeline_wizard::default_config(name);
let name_buf = cfg.name.clone();
let mut p = Self {
pipeline: pipeline_wizard::generate_pipeline(&cfg),
status: HashMap::new(),
last_run: None,
generated: vec![cfg.name.clone()],
saved_files: Vec::new(),
cred_injections: Vec::new(),
injected_keys: Vec::new(),
run_refused: false,
save_dir: None,
name_buf,
git_buf: String::new(),
note: String::new(),
cfg,
};
p.regenerate();
p
}
/// Regenerate the runa pipeline from the current picks (and reset the DAG to Idle).
fn regenerate(&mut self) {
self.cfg.name = self.name_buf.trim().to_string();
if self.cfg.name.is_empty() {
self.cfg.name = "my-ci".into();
}
// Apply the git source buffer (SSH-only; a bad URL is reported, not stored).
let g = self.git_buf.trim();
if g.is_empty() {
self.cfg.source = None;
} else {
match GitSource::ssh(g) {
Ok(s) => {
self.cfg.source = Some(s);
self.note.clear();
}
Err(e) => {
self.cfg.source = None;
self.note = format!("source rejected: {e}");
}
}
}
self.pipeline = pipeline_wizard::generate_pipeline(&self.cfg);
// Idle every stage (a Run lights them).
self.status = self
.pipeline
.stages
.iter()
.map(|s| (s.name.clone(), StageStatus::Idle))
.collect();
if !self.generated.contains(&self.cfg.name) {
self.generated.push(self.cfg.name.clone());
}
}
/// Resolve the declared credential injections into a runner-env overlay.
///
/// With the `vault` feature: pull each declared `(name → env_key)` from the encrypted
/// vault (an UNLOCKED `Session` / `$DWARVES_VAULT_PASSPHRASE`) via
/// [`dwarves::vault::inject_secret_env`] — **fails closed** (returns `Err`) when the
/// vault is locked or a secret is missing, so the Run refuses rather than dispatching a
/// blank credential. Without the feature it is a no-op (empty overlay) and the wizard
/// runs vault-free. Returns the env overlay AND the KEYS that were injected.
fn resolve_injected_env(
&self,
) -> Result<(std::collections::BTreeMap<String, String>, Vec<String>), String> {
let mut env = std::collections::BTreeMap::new();
if self.cred_injections.is_empty() {
return Ok((env, Vec::new()));
}
#[cfg(feature = "vault")]
{
let vault = dwarves::vault::ZnippyVault::default_vault();
dwarves::vault::inject_secret_env(&vault, &mut env, &self.cred_injections)
.map_err(|e| format!("credential injection refused: {e}"))?;
let keys = self
.cred_injections
.iter()
.map(|(_, k)| k.clone())
.collect();
Ok((env, keys))
}
#[cfg(not(feature = "vault"))]
{
// The vault-free build: injections are declared but not pulled (honest).
let _ = &mut env;
Ok((env, Vec::new()))
}
}
/// Dispatch the generated pipeline as real jera jobs over a FAKE scheduler (no
/// process spawn, no deploy) and light the DAG from the outcome. Any declared
/// credential injections are pulled from the unlocked vault into the runner env first
/// (fail-closed: a locked vault REFUSES the run — the DAG stays Idle, never green).
fn run_fake(&mut self) {
self.injected_keys.clear();
self.run_refused = false;
let (extra_env, injected) = match self.resolve_injected_env() {
Ok(v) => v,
Err(e) => {
// Fail closed: refuse the run, leave the DAG Idle, surface the error.
self.run_refused = true;
self.last_run = None;
self.note = e;
return;
}
};
self.injected_keys = injected;
match pipeline_wizard::run_preview_with_env(&self.cfg, extra_env) {
Ok(report) => {
// The fake scheduler greens every job → every stage Done.
let done = report.is_green();
for st in self.status.values_mut() {
*st = if done {
StageStatus::Done
} else {
StageStatus::Failed
};
}
self.last_run = Some(LastRun {
dispatched: report.dispatched,
green: done,
stage_order: report.stage_order.clone(),
});
self.note = format!(
"ran (dry): {} jera job(s) dispatched · {}{}",
report.dispatched,
if done { "green" } else { "red" },
if self.injected_keys.is_empty() {
String::new()
} else {
format!(
" · injected {} vault cred(s) into the runner env",
self.injected_keys.len()
)
},
);
}
Err(e) => self.note = format!("run failed: {e}"),
}
}
/// Save the generated pipeline as `<name>.runa.ron` in the current dir.
fn save(&mut self) {
match pipeline_wizard::generate_ron(&self.cfg) {
Ok(ron) => {
let name = format!("{}.runa.ron", self.cfg.name);
let path = match &self.save_dir {
Some(dir) => dir.join(&name),
None => std::path::PathBuf::from(&name),
};
let file = path.display().to_string();
match std::fs::write(&path, ron) {
Ok(()) => {
if !self.saved_files.contains(&file) {
self.saved_files.push(file.clone());
}
self.note = format!("saved {file}");
}
Err(e) => self.note = format!("save failed: {e}"),
}
}
Err(e) => self.note = format!("serialize failed: {e}"),
}
}
/// Declare a credential injection: on a Run, pull the vault secret `name` into the
/// runner env under `env_key` (idempotent — re-declaring the same pair is a no-op).
/// Names + keys only; the value is fetched from the vault at run time, never stored.
pub fn add_cred_injection(&mut self, name: impl Into<String>, env_key: impl Into<String>) {
let pair = (name.into(), env_key.into());
if !self.cred_injections.contains(&pair) {
self.cred_injections.push(pair);
}
}
/// Point `Save` at `dir` (so the `<name>.runa.ron` lands there, not in cwd).
pub fn set_save_dir(&mut self, dir: impl Into<std::path::PathBuf>) {
self.save_dir = Some(dir.into());
}
/// The reused DAG viewer, lit from the current pipeline + status.
fn dag_view(&self) -> PipelineView {
let adapter = RunaPipelineAdapter::new(&self.pipeline).with_status(self.status.clone());
PipelineView::from_adapter(&adapter)
}
fn edge_count(&self) -> usize {
self.pipeline.stages.iter().map(|s| s.needs.len()).sum()
}
/// True iff the generated pipeline round-trips through runa's own parse+validate.
fn pipeline_valid(&self) -> bool {
self.pipeline
.to_ron_string()
.ok()
.and_then(|r| dwarves_runa::Pipeline::from_ron_str(&r).ok())
.is_some()
}
// ── the wizard form (left) ────────────────────────────────────────────────
fn picks_ui(&mut self, ui: &mut egui::Ui) {
let mut changed = false;
egui::Grid::new("cicd-wizard-picks")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
let name_lbl = ui.label("Name");
if ui
.text_edit_singleline(&mut self.name_buf)
.labelled_by(name_lbl.id)
.changed()
{
changed = true;
}
ui.end_row();
let src_lbl = ui.label("Source (SSH)");
if ui
.text_edit_singleline(&mut self.git_buf)
.labelled_by(src_lbl.id)
.on_hover_text("git@host:owner/repo.git — SSH-only (LAW)")
.changed()
{
changed = true;
}
ui.end_row();
// (i) BUILD
ui.label("Build");
ui.vertical(|ui| {
for kind in ArtifactKind::presets() {
let sel = self.cfg.build.kind == kind;
if ui.selectable_label(sel, kind.label()).clicked() {
self.cfg.build.kind = kind;
changed = true;
}
}
});
ui.end_row();
// (ii) TEST
ui.label("Test");
ui.horizontal(|ui| {
for (k, lbl) in [
(TestKind::Nextest, "nextest"),
(TestKind::CargoTest, "cargo test"),
(TestKind::None, "none"),
] {
if ui.selectable_label(self.cfg.test.kind == k, lbl).clicked() {
self.cfg.test.kind = k;
changed = true;
}
}
});
ui.end_row();
// (iii) DEPLOY
ui.label("Deploy");
ui.vertical(|ui| {
let opts: [(&str, DeployChoice); 4] = [
("none", DeployChoice::None),
("crates.io (gated)", DeployChoice::CratesIo),
(
"registry",
DeployChoice::Registry {
name: "ghcr.io/nordisk".into(),
tags: vec!["latest".into()],
},
),
("pages", DeployChoice::Pages),
];
for (lbl, choice) in opts {
let sel = std::mem::discriminant(&self.cfg.deploy)
== std::mem::discriminant(&choice);
if ui.selectable_label(sel, lbl).clicked() {
self.cfg.deploy = choice;
changed = true;
}
}
});
ui.end_row();
// (iv) TRIGGER
ui.label("Trigger");
ui.horizontal(|ui| {
let opts: [(&str, TriggerChoice); 4] = [
("manual", TriggerChoice::Manual),
(
"push",
TriggerChoice::Push {
branch: "main".into(),
},
),
("tag", TriggerChoice::Tag),
("cron", TriggerChoice::Cron("0 3 * * *".into())),
];
for (lbl, choice) in opts {
let sel = std::mem::discriminant(&self.cfg.trigger)
== std::mem::discriminant(&choice);
if ui.selectable_label(sel, lbl).clicked() {
self.cfg.trigger = choice;
changed = true;
}
}
});
ui.end_row();
});
// The fast runner (container-by-default) readout.
ui.add_space(4.0);
ui.label(format!(
"runner: {} · {}{}{}",
self.cfg.runner.image,
if self.cfg.runner.containerized {
"container"
} else {
"host"
},
if self.cfg.runner.mold { " + mold" } else { "" },
if self.cfg.runner.sccache {
" + sccache + holger-cache"
} else {
""
},
));
if changed {
self.regenerate();
}
}
// ── manageability: generated pipelines / saved files / runner ─────────────
fn manage_ui(&mut self, ui: &mut egui::Ui) {
ui.collapsing("Manage", |ui| {
ui.label(format!("pipelines this session: {}", self.generated.len()));
for n in &self.generated {
ui.small(format!(" • {n}"));
}
if !self.saved_files.is_empty() {
ui.label("saved:");
for f in &self.saved_files {
ui.small(format!(" • {f}"));
}
}
ui.small("running jera jobs + runners: see the Jobs pane (shared jera ledger).");
});
}
}
impl Facet for CicdWizardPanel {
fn title(&self) -> &str {
"CI/CD"
}
fn ui(&mut self, ui: &mut egui::Ui) {
ui.heading("CI/CD quick-setup");
ui.separator();
// ── THE ACTION ROW IS A FOOTER, NOT THE END OF A COLUMN ──────────────────
//
// MEASURED 2026-08-27 on a 1600×1200 canvas: `▶ Run (dry)` laid out at
// y 1204–1222 and `💾 Save .runa.ron` at the same band — FOUR pixels below a
// 1200 px surface. `robot_kittest_drives_the_cicd_wizard_real_widgets` had been
// red on `main` since the pane grew (206 passed / 1 failed on origin/main before
// any of today's lanes touched it), and it was red for the RIGHT reason: the two
// buttons that are this pane's whole point were off the rendered surface, so on
// any 1080p or 1200p window a human could not reach Run or Save at all. There is
// no ScrollArea above this pane — the shell mounts it as a plain `Panel` — so
// nothing else was going to bring them back.
//
// The fix is the shape, not the canvas size: everything that GROWS (the two
// columns, the readouts, the manage block) lives in a scroll area that takes the
// leftover height, and the action row is drawn after it, so it sits at the bottom
// of whatever surface exists. Enlarging the robot's canvas would have gone green
// and left the product bug — the pane would simply break again at the next widget.
let reserved = ACTION_ROW_HEIGHT;
let body_height = (ui.available_height() - reserved).max(160.0);
egui::ScrollArea::vertical()
.id_salt("cicd-wizard-body")
.max_height(body_height)
.auto_shrink([false, false])
.show(ui, |ui| {
ui.columns(2, |cols| {
// Left: the picks.
self.picks_ui(&mut cols[0]);
// Right: the live DAG (reused viewer).
cols[1].label("Live DAG");
let mut view = self.dag_view();
Facet::ui(&mut view, &mut cols[1]);
});
if let Some(r) = &self.last_run {
ui.label(format!(
"last DRY run (nothing was executed): {} · {} job(s) · stages {}",
if r.green { "green" } else { "red" },
r.dispatched,
r.stage_order.join(" → "),
));
}
if !self.note.is_empty() {
ui.small(self.note.clone());
}
self.manage_ui(ui);
});
ui.separator();
ui.horizontal(|ui| {
if ui
.button("▶ Run (dry)")
.on_hover_text("dispatch the generated pipeline as jera jobs over a fake scheduler — no real deploy")
.clicked()
{
self.run_fake();
}
if ui
.button("💾 Save .runa.ron")
.on_hover_text("write the generated pipeline to <name>.runa.ron")
.clicked()
{
self.save();
}
let valid = self.pipeline_valid();
ui.label(if valid {
"pipeline: valid ✓"
} else {
"pipeline: INVALID ✗"
});
});
}
fn state_json(&self) -> serde_json::Value {
let stages: Vec<_> = self
.pipeline
.stages
.iter()
.map(|s| {
serde_json::json!({
"name": s.name,
"needs": s.needs,
"steps": s.steps.len(),
"status": format!("{:?}", self.status.get(&s.name).copied().unwrap_or(StageStatus::Idle)),
})
})
.collect();
serde_json::json!({
"kind": "cicd_wizard",
"name": self.cfg.name,
"source": self.cfg.source.as_ref().map(|s| s.url.clone()),
"build": self.cfg.build.kind.label(),
"deploy": format!("{:?}", std::mem::discriminant(&self.cfg.deploy)),
"pipeline_valid": self.pipeline_valid(),
// The DAG the viewer renders: one node per stage, one edge per `needs`.
"dag_nodes": self.pipeline.stages.len(),
"dag_edges": self.edge_count(),
"stages": stages,
"triggers": self.pipeline.triggers.len(),
"runner": {
"image": self.cfg.runner.image,
"containerized": self.cfg.runner.containerized,
"mold": self.cfg.runner.mold,
"sccache": self.cfg.runner.sccache,
},
"last_run": self.last_run.as_ref().map(|r| serde_json::json!({
"dispatched": r.dispatched,
"green": r.green,
"stage_order": r.stage_order,
})),
"generated_count": self.generated.len(),
"saved": self.saved_files,
// The credential-injection surface — NAMES + env KEYS only (never a value).
// `cred_injections` is what the human declared; `injected_env_keys` is what the
// LAST run actually pulled from the unlocked vault (the cred→pipeline oracle).
"cred_injections": self.cred_injections.iter()
.map(|(n, k)| serde_json::json!({ "name": n, "env": k }))
.collect::<Vec<_>>(),
"injected_env_keys": self.injected_keys,
"run_refused": self.run_refused,
"vault_wired": cfg!(feature = "vault"),
})
}
fn update_json(&mut self, msg_json: &str) {
// The wizard's control surface — a robot drives it headless as a user:
// {"set_name":"x"} · {"set_git":"git@…"} ·
// {"set_build":"linux-exe|oci|macos-arm64|wasm|variant:<n>"} ·
// {"set_test":"nextest|cargo-test|none"} ·
// {"set_deploy":"none|crates-io|registry:<n>|env:<n>|pages"} ·
// {"set_trigger":"manual|push:<b>|tag|cron:<e>"} ·
// {"generate":true} (regenerate) · {"run":true} (dry dispatch) · {"save":true}
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
return;
};
if let Some(s) = v.get("set_name").and_then(|x| x.as_str()) {
self.name_buf = s.to_string();
self.regenerate();
}
if let Some(s) = v.get("set_git").and_then(|x| x.as_str()) {
self.git_buf = s.to_string();
self.regenerate();
}
// Declare a credential injection ({"set_cred":{"name":"X","env":"Y"}}) — pulled
// from the unlocked vault into the runner env on a Run. Names + keys only.
if let Some(c) = v.get("set_cred").and_then(|x| x.as_object()) {
if let (Some(name), Some(env)) = (
c.get("name").and_then(|x| x.as_str()),
c.get("env").and_then(|x| x.as_str()),
) {
self.add_cred_injection(name, env);
}
}
if v.get("clear_creds").and_then(|x| x.as_bool()) == Some(true) {
self.cred_injections.clear();
self.injected_keys.clear();
}
// Point Save at a directory ({"set_save_dir":"/path"}) — the artifact lands there.
if let Some(d) = v.get("set_save_dir").and_then(|x| x.as_str()) {
self.set_save_dir(d);
}
// Apply each provided pick individually (reusing the CLI's exact flag grammar
// via the shared `parse_*` helpers — one grammar, two front doors).
let mut touched = false;
if let Some(k) = v.get("set_build").and_then(|x| x.as_str()) {
if let Ok(kind) = pipeline_wizard::parse_build(k) {
self.cfg.build.kind = kind;
touched = true;
}
}
if let Some(k) = v.get("set_test").and_then(|x| x.as_str()) {
if let Ok(kind) = pipeline_wizard::parse_test(k) {
self.cfg.test.kind = kind;
touched = true;
}
}
if let Some(k) = v.get("set_deploy").and_then(|x| x.as_str()) {
if let Ok(d) = pipeline_wizard::parse_deploy(k) {
self.cfg.deploy = d;
touched = true;
}
}
if let Some(k) = v.get("set_trigger").and_then(|x| x.as_str()) {
if let Ok(t) = pipeline_wizard::parse_trigger(k) {
self.cfg.trigger = t;
touched = true;
}
}
if touched {
self.regenerate();
}
if v.get("generate").and_then(|x| x.as_bool()) == Some(true) {
self.regenerate();
}
if v.get("run").and_then(|x| x.as_bool()) == Some(true) {
self.run_fake();
}
if v.get("save").and_then(|x| x.as_bool()) == Some(true) {
self.save();
}
}
}
/// **The wizard seam.** Build the CI/CD quick-setup pane as a `Box<dyn Panel>` a host
/// (dwarves-gui) drops into a tab. Opens on the minimal-viable default pipeline.
pub fn cicd_wizard_panel() -> Box<dyn Panel> {
Box::new(CicdWizardPanel::new("my-ci"))
}
#[cfg(test)]
mod tests {
use super::*;
// ROBOT-UI (drive-as-a-user): pick OCI build + crates.io deploy + a push trigger
// through the pane's JSON control surface, then RUN — asserting the generated
// pipeline is valid, the DAG renders (nodes+edges), and the run dispatched real
// jera jobs and greened the DAG. RED if the wizard stops generating a valid
// pipeline, the DAG stops rendering, or a run stops dispatching.
#[test]
fn robot_drives_wizard_generates_valid_pipeline_dag_and_run() {
let mut p = CicdWizardPanel::new("robot-ci");
// Drive the four picks + an SSH source as a user would.
Facet::update_json(&mut p, r#"{"set_git":"git@forge.example:nordisk/korp.git"}"#);
Facet::update_json(&mut p, r#"{"set_build":"oci"}"#);
Facet::update_json(&mut p, r#"{"set_deploy":"crates-io"}"#);
Facet::update_json(&mut p, r#"{"set_trigger":"push:main"}"#);
let s = Facet::state_json(&p);
// A valid runa pipeline is generated.
assert_eq!(s["pipeline_valid"], serde_json::json!(true), "state: {s}");
assert_eq!(s["source"], "git@forge.example:nordisk/korp.git");
// The DAG renders: build → test → deploy = 3 nodes, 2 `needs` edges.
assert_eq!(s["dag_nodes"], serde_json::json!(3), "state: {s}");
assert_eq!(s["dag_edges"], serde_json::json!(2), "state: {s}");
// A Run dispatches real jera jobs (fake scheduler, no deploy) and greens.
Facet::update_json(&mut p, r#"{"run":true}"#);
let s = Facet::state_json(&p);
let last = &s["last_run"];
assert!(
last["dispatched"].as_u64().unwrap_or(0) > 0,
"a run must dispatch jera jobs: {s}"
);
assert_eq!(last["green"], serde_json::json!(true), "state: {s}");
// A dead/blank wizard (no stages) would be the RED state the oracle rejects.
let blank_is_impossible = s["dag_nodes"].as_u64().unwrap_or(0) >= 1;
facett_core::testmatrix::emit(
"dwarves-embed",
"cicd_wizard_generates_dag_and_runs",
s["pipeline_valid"] == serde_json::json!(true)
&& last["green"] == serde_json::json!(true)
&& blank_is_impossible,
"CI/CD wizard pane driven as a user (pick build/test/deploy/trigger + SSH source) GENERATES a valid runa pipeline, RENDERS the stage DAG (3 nodes / 2 edges for build→test→deploy), and a Run DISPATCHES real jera jobs that green the DAG — RED if it generates nothing/invalid or a run dispatches no jobs",
);
}
// The credential-injection SURFACE (names + env keys only, never a value): a human
// declares `{"set_cred":{...}}`, the wizard shows it in `cred_injections`, and a Run
// records the injected KEYS. In the vault-free build (the default test build) nothing
// is pulled (honest: `injected_env_keys` empty, `vault_wired:false`) and the run still
// greens — the full unlocked-vault pull is proven by the dwarves-gui journey.
#[test]
fn cred_injection_surface_is_names_and_keys_only() {
let mut p = CicdWizardPanel::new("cred-ci");
Facet::update_json(
&mut p,
r#"{"set_cred":{"name":"crates-io","env":"CARGO_REGISTRY_TOKEN"}}"#,
);
let s = Facet::state_json(&p);
let creds = s["cred_injections"].as_array().unwrap();
assert_eq!(creds.len(), 1);
assert_eq!(creds[0]["name"], "crates-io");
assert_eq!(creds[0]["env"], "CARGO_REGISTRY_TOKEN");
// NEVER a value anywhere in the pane state.
let whole = serde_json::to_string(&s).unwrap();
assert!(
!whole.contains("value"),
"no secret value in the wizard state: {whole}"
);
assert_eq!(s["vault_wired"], serde_json::json!(cfg!(feature = "vault")));
Facet::update_json(&mut p, r#"{"run":true}"#);
let s = Facet::state_json(&p);
#[cfg(not(feature = "vault"))]
{
// vault-free build: injections are declared but not pulled; run still greens.
assert_eq!(s["run_refused"], serde_json::json!(false));
assert!(s["injected_env_keys"].as_array().unwrap().is_empty());
assert_eq!(s["last_run"]["green"], serde_json::json!(true));
}
#[cfg(feature = "vault")]
{
// vault-wired build with NO unlocked vault here ⇒ the run FAILS CLOSED (it
// refuses rather than dispatch a blank credential). The unlocked-vault pull is
// proven by the dwarves-gui journey (which unlocks a real Session).
assert_eq!(
s["run_refused"],
serde_json::json!(true),
"locked vault ⇒ fail closed: {s}"
);
assert!(s["injected_env_keys"].as_array().unwrap().is_empty());
}
}
// The DEFAULT wizard is the minimal-viable happy path: build → test, valid.
#[test]
fn default_wizard_is_minimal_viable() {
let p = CicdWizardPanel::new("d");
let s = Facet::state_json(&p);
assert_eq!(s["pipeline_valid"], serde_json::json!(true));
assert!(s["dag_nodes"].as_u64().unwrap() >= 2, "build + test: {s}");
assert_eq!(s["runner"]["mold"], serde_json::json!(true));
assert_eq!(s["runner"]["containerized"], serde_json::json!(true));
}
}