Files
wfe/wfectl/tests/commands.rs
Sienna Meridian Satterwhite 0c239cd484 feat(wfectl): new CLI client + wfe-ci builder image
wfectl is a command-line client for wfe-server with 17 subcommands
covering the full workflow lifecycle:

* Auth: login (OAuth2 PKCE via Ory Hydra), logout, whoami
* Definitions: register (YAML → gRPC), validate (local compile),
  definitions list
* Instances: run, get, list, cancel, suspend, resume
* Events: publish
* Streaming: watch (lifecycle), logs, search-logs (full-text)

Key design points:

* `validate` compiles YAML locally via `wfe-yaml::load_workflow_from_str`
  with the full executor feature set enabled — instant feedback, no
  server round-trip, no auth required. Uses the same compile path as
  the server's `register` RPC so what passes validation is guaranteed
  to register.
* Lookup commands accept either UUID or human name; the server
  resolves the identifier for us. Display tables show both columns.
* `run --name <N>` lets users override the auto-generated
  `{def_id}-{N}` instance name when they want a sticky reference.
* Table and JSON output formats, shared bearer-token or cached-login
  auth path, direct token injection via `WFECTL_TOKEN`.
* 5 new unit tests for the validate command cover happy path, unknown
  step type rejection, and missing file handling.

Dockerfile.ci ships the prebuilt image used as the `image:` for
kubernetes CI steps: rust stable, cargo-nextest, cargo-llvm-cov,
sccache (configured via WFE_SCCACHE_* env), buildctl for in-cluster
buildkitd, kubectl, tea for Gitea releases, and git. Published to
`src.sunbeam.pt/studio/wfe-ci:latest`.
2026-04-07 19:09:26 +01:00

402 lines
12 KiB
Rust

//! Drive the wfectl command handlers against a stub gRPC server. This tests
//! the full command -> client -> server -> response -> formatter pipeline.
mod stub;
use std::path::PathBuf;
use wfectl::client::build as build_client;
use wfectl::commands;
use wfectl::output::OutputFormat;
use stub::spawn_stub;
#[tokio::test]
async fn register_command_table_output() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
// Write a temp YAML file
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "workflow:\n id: x\n version: 1\n steps: []").unwrap();
let args = commands::register::RegisterArgs {
file: tmp.path().to_path_buf(),
config: vec![("key".into(), "val".into())],
};
commands::register::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn register_command_json_output() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "workflow:\n id: x\n version: 1\n steps: []").unwrap();
let args = commands::register::RegisterArgs {
file: tmp.path().to_path_buf(),
config: vec![],
};
commands::register::run(args, client, OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn register_command_missing_file_errors() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::register::RegisterArgs {
file: PathBuf::from("/nonexistent/file.yaml"),
config: vec![],
};
let result = commands::register::run(args, client, OutputFormat::Table).await;
assert!(result.is_err());
}
#[tokio::test]
async fn definitions_list_table() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::definitions::DefinitionsArgs {
cmd: commands::definitions::DefinitionsCmd::List,
};
commands::definitions::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn definitions_list_json() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::definitions::DefinitionsArgs {
cmd: commands::definitions::DefinitionsCmd::List,
};
commands::definitions::run(args, client, OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn run_command_with_inline_data() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::run::RunArgs {
definition_id: "ci".into(),
version: 1,
data: None,
data_json: Some(r#"{"key":"value"}"#.into()),
name: None,
};
commands::run::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn run_command_with_data_file() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), r#"{"deploy":true}"#).unwrap();
let args = commands::run::RunArgs {
definition_id: "ci".into(),
version: 2,
data: Some(tmp.path().to_path_buf()),
data_json: None,
name: None,
};
commands::run::run(args, client, OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn run_command_no_data() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::run::RunArgs {
definition_id: "ci".into(),
version: 1,
data: None,
data_json: None,
name: None,
};
commands::run::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn run_command_invalid_json_errors() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::run::RunArgs {
definition_id: "ci".into(),
version: 1,
data: None,
data_json: Some("not json".into()),
name: None,
};
let result = commands::run::run(args, client, OutputFormat::Table).await;
assert!(result.is_err());
}
#[tokio::test]
async fn get_command_table() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::get::GetArgs {
workflow_id: "wf-1".into(),
};
commands::get::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn get_command_json() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::get::GetArgs {
workflow_id: "wf-1".into(),
};
commands::get::run(args, client, OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn list_command_no_filters() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::list::ListArgs {
query: None,
status: None,
limit: 10,
skip: 0,
};
commands::list::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn list_command_all_filters() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::list::ListArgs {
query: Some("foo".into()),
status: Some(commands::list::StatusFilter::Complete),
limit: 50,
skip: 10,
};
commands::list::run(args, client, OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn list_command_each_status_variant() {
use commands::list::StatusFilter;
for status in [
StatusFilter::Runnable,
StatusFilter::Suspended,
StatusFilter::Complete,
StatusFilter::Terminated,
] {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::list::ListArgs {
query: None,
status: Some(status),
limit: 10,
skip: 0,
};
commands::list::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
}
#[tokio::test]
async fn cancel_suspend_resume_commands() {
let (server, _seen) = spawn_stub().await;
let client1 = build_client(&server, "test-token").await.unwrap();
let client2 = build_client(&server, "test-token").await.unwrap();
let client3 = build_client(&server, "test-token").await.unwrap();
commands::cancel::run(
commands::cancel::CancelArgs {
workflow_id: "wf-1".into(),
},
client1,
)
.await
.unwrap();
commands::suspend::run(
commands::suspend::SuspendArgs {
workflow_id: "wf-1".into(),
},
client2,
)
.await
.unwrap();
commands::resume::run(
commands::resume::ResumeArgs {
workflow_id: "wf-1".into(),
},
client3,
)
.await
.unwrap();
}
#[tokio::test]
async fn publish_command_with_inline_data() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::publish::PublishArgs {
event_name: "order.paid".into(),
event_key: "order-42".into(),
data: None,
data_json: Some(r#"{"amount":100}"#.into()),
};
commands::publish::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn publish_command_with_file() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), r#"{"amount":100}"#).unwrap();
let args = commands::publish::PublishArgs {
event_name: "order.paid".into(),
event_key: "order-42".into(),
data: Some(tmp.path().to_path_buf()),
data_json: None,
};
commands::publish::run(args, client, OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn publish_command_no_data() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::publish::PublishArgs {
event_name: "test".into(),
event_key: "x".into(),
data: None,
data_json: None,
};
commands::publish::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn watch_command_streams_events() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::watch::WatchArgs {
workflow_id: Some("wf-1".into()),
};
commands::watch::run(args, client).await.unwrap();
}
#[tokio::test]
async fn watch_command_no_filter() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::watch::WatchArgs { workflow_id: None };
commands::watch::run(args, client).await.unwrap();
}
#[tokio::test]
async fn logs_command_basic() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::logs::LogsArgs {
workflow_id: "wf-1".into(),
step: None,
follow: false,
};
commands::logs::run(args, client).await.unwrap();
}
#[tokio::test]
async fn logs_command_with_step_filter() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::logs::LogsArgs {
workflow_id: "wf-1".into(),
step: Some("build".into()),
follow: true,
};
commands::logs::run(args, client).await.unwrap();
}
#[tokio::test]
async fn search_logs_command_table() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::search_logs::SearchLogsArgs {
query: "needle".into(),
workflow: None,
step: None,
stream: None,
limit: 10,
skip: 0,
};
commands::search_logs::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
#[tokio::test]
async fn search_logs_command_with_filters_json() {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::search_logs::SearchLogsArgs {
query: "needle".into(),
workflow: Some("wf-1".into()),
step: Some("build".into()),
stream: Some(commands::search_logs::StreamFilter::Stdout),
limit: 100,
skip: 5,
};
commands::search_logs::run(args, client, OutputFormat::Json)
.await
.unwrap();
}
#[tokio::test]
async fn search_logs_each_stream_variant() {
use commands::search_logs::StreamFilter;
for stream in [StreamFilter::Stdout, StreamFilter::Stderr] {
let (server, _seen) = spawn_stub().await;
let client = build_client(&server, "test-token").await.unwrap();
let args = commands::search_logs::SearchLogsArgs {
query: "x".into(),
workflow: None,
step: None,
stream: Some(stream),
limit: 10,
skip: 0,
};
commands::search_logs::run(args, client, OutputFormat::Table)
.await
.unwrap();
}
}