use serde_json::{Value, json};
use super::*;
use crate::pipeline::model::PipelineNode;
fn defs() -> Vec<NodeDefinition> {
crate::pipeline::nodes::builtin_node_definitions()
}
fn def(kind: &str) -> NodeDefinition {
defs().into_iter().find(|d| d.kind == kind).expect("kind")
}
fn build(body: &str) -> PipelineGraph {
crate::platform::shell::parser::build_pipeline_graph_with_definitions("check-test", body, &defs()).expect("builds")
}
fn check(body: &str) -> PipelineCheck {
check_pipeline(&build(body), &defs(), Catalogue::Complete, &no_credentials)
}
fn refusals(body: &str) -> Vec<String> {
check(body).refusals.iter().map(ToString::to_string).collect()
}
fn warnings(body: &str) -> Vec<String> {
let found = check(body);
assert!(found.refusals.is_empty(), "{:?}", found.refusals);
found.warnings.iter().map(ToString::to_string).collect()
}
fn config_problems(kind: &str, config: Value) -> Vec<String> {
check_node_config(&def(kind), &config).into_iter().map(|p| p.message).collect()
}
fn has(found: &[String], needle: &str) -> bool {
found.iter().any(|p| p.contains(needle))
}
const UPLOAD: &str = "| trigger.webhook --route /up --method POST";
#[test]
fn a_word_outside_a_closed_choice_is_refused_naming_the_words() {
let found = refusals(&format!("{UPLOAD} | fs.image.thumbnail --from \"{{{{ input.webhook.files.photo }}}}\" --format jpeg"));
assert_eq!(found, vec!["node `n1`: fs.image.thumbnail --format 'jpeg' must be one of jpg, png, webp".to_string()]);
let found = refusals(&format!("{UPLOAD} | fs.file.put --from \"{{{{ input.webhook.files.photo }}}}\" --accept image --accept movie"));
assert!(has(&found, "fs.file.put --accept 'movie' must be one of image, pdf"), "{found:?}");
assert!(refusals(&format!("{UPLOAD} | fs.image.thumbnail --from \"{{{{ input.webhook.files.photo }}}}\" --format PNG")).is_empty());
}
#[test]
fn a_key_the_kind_does_not_declare_is_refused() {
let found = config_problems("fs.image.thumbnail", json!({ "from": "a.png", "source_key": "files.photo" }));
assert!(has(&found, "fs.image.thumbnail takes no --source-key (config key `source_key`)"), "{found:?}");
assert!(has(&found, "— it takes --from"), "{found:?}");
let found = config_problems("fs.image.thumbnail", json!({ "form": "a.png" }));
assert!(has(&found, "did you mean --from?"), "{found:?}");
assert!(config_problems("fs.image.thumbnail", json!({ "from": "a.png", "title": "Thumb", "ui": { "x": 1 }, "preview": { "out": { "as": "image" } } })).is_empty());
}
#[test]
fn a_missing_required_flag_is_refused() {
assert_eq!(config_problems("fs.image.thumbnail", json!({})), vec!["fs.image.thumbnail needs --from".to_string()]);
assert!(has(&config_problems("fs.image.thumbnail", json!({ "from": " " })), "needs --from"));
}
#[test]
fn numbers_durations_and_sizes_must_parse() {
let found = config_problems("fs.image.thumbnail", json!({ "from": "a.png", "width": "wide" }));
assert_eq!(found, vec!["fs.image.thumbnail --width 'wide' is not a number".to_string()]);
let found = config_problems("kv.entry.put", json!({ "key": "k", "value": "v", "ttl": 30 }));
assert!(has(&found, "kv.entry.put --ttl"), "{found:?}");
let found = config_problems("fs.file.put", json!({ "from": "a.png", "max_size": "ten megabytes" }));
assert!(has(&found, "fs.file.put --max-size"), "{found:?}");
assert!(config_problems("fs.file.put", json!({ "from": "a.png", "max_size": "10MB" })).is_empty());
let found = config_problems("kv.entry.get", json!({ "key": "k", "timeout": "2h" }));
assert_eq!(found, vec!["kv.entry.get --timeout '2h' must be a duration from 1s to 1h, such as 30s, 2m or 1h".to_string()]);
assert!(config_problems("kv.entry.get", json!({ "key": "k", "timeout": "30s" })).is_empty());
}
#[test]
fn a_value_still_to_resolve_is_left_to_the_run() {
let found = refusals(&format!(
"{UPLOAD} | fs.image.thumbnail --from \"{{{{ input.webhook.files.photo }}}}\" --format \"{{{{ input.webhook.body.format }}}}\" --width \"{{{{ input.webhook.body.w }}}}\" --timeout \"{{{{ input.webhook.body.t }}}}\""
));
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn a_literal_only_flag_refuses_an_expression() {
let found = config_problems("sqlite.query.run", json!({ "query": "SELECT 1", "write": "{{ input.w }}" }));
assert_eq!(found, vec!["sqlite.query.run --write is literal, never {{ }}".to_string()]);
let found = config_problems("ai.audio.generate", json!({ "provider": "piper", "credential_id": "c", "text": "hi", "return": "{{ input.r }}" }));
assert!(has(&found, "ai.audio.generate --return is literal, never {{ }}; write one of inline, file"), "{found:?}");
let found = config_problems("ai.text.generate", json!({ "provider": "{{ input.p }}", "credential_id": "c", "prompt": "hi" }));
assert!(has(&found, "ai.text.generate --provider is literal"), "{found:?}");
assert_eq!(found.len(), 1, "{found:?}");
}
#[test]
fn a_provider_profile_is_held_at_save() {
let found = config_problems("ai.text.generate", json!({ "provider": "openai", "credential_id": "c", "prompt": "hi", "option": { "nonsense": "1" } }));
assert!(has(&found, "ai.text.generate --option 'nonsense' is not a setting of --provider openai"), "{found:?}");
let found = config_problems("ai.text.generate", json!({ "provider": "acme", "credential_id": "c", "prompt": "hi" }));
assert!(has(&found, "--provider 'acme' must be one of openai, openrouter"), "{found:?}");
}
#[test]
fn a_credential_of_another_providers_kind_is_refused_when_it_exists() {
let graph = build("| trigger.manual | ai.text.generate --provider openai --credential main --prompt hi");
let kind = |id: &str| (id == "main").then(|| "smtp".to_string());
let found: Vec<String> = check_graph_nodes(&graph, &defs(), Catalogue::Complete, &kind).iter().map(ToString::to_string).collect();
assert!(has(&found, "credential 'main' is kind 'smtp'; --provider openai takes a credential of kind openai"), "{found:?}");
assert!(check_graph_nodes(&graph, &defs(), Catalogue::Complete, &no_credentials).is_empty());
}
#[test]
fn an_unknown_kind_is_refused_with_the_kinds_it_likely_meant() {
let mut graph = build("| trigger.manual | javascript.script.run -- \"return 1\"");
graph.nodes.push(PipelineNode { id: "gen".into(), kind: "static.page.generate".into(), ..graph.nodes[1].clone() });
graph.nodes[2].config = json!({});
let found: Vec<String> = check_graph_nodes(&graph, &defs(), Catalogue::Complete, &no_credentials).iter().map(ToString::to_string).collect();
assert_eq!(found.len(), 1, "{found:?}");
assert!(found[0].starts_with("node `gen`: unknown node kind `static.page.generate`; did you mean"), "{found:?}");
assert!(found[0].contains("`web.site.generate`"), "{found:?}");
graph.nodes[2].kind = "x.acme.invoice.create".into();
assert!(check_graph_nodes(&graph, &defs(), Catalogue::Official, &no_credentials).is_empty());
assert_eq!(check_graph_nodes(&graph, &defs(), Catalogue::Complete, &no_credentials).len(), 1);
}
#[test]
fn the_flow_rules_are_refusals_too() {
let found = refusals(
"[a] trigger.manual\n[b] javascript.script.run -- \"return 1\"\n[c] javascript.script.run -- \"return 2\"\n[a] -> [b]\n[b] -> [c]\n[c] -> [b]\n",
);
assert!(has(&found, "has a cycle"), "{found:?}");
}
#[test]
fn every_problem_is_listed_at_once() {
let found = refusals(&format!("{UPLOAD} | fs.image.thumbnail --format jpeg --fit stretch --width wide"));
assert_eq!(found.len(), 4, "missing --from, two words, one number: {found:?}");
let message = check(&format!("{UPLOAD} | fs.image.thumbnail --from x.png --format jpeg")).refusal_message("api/up.zf.json");
assert_eq!(
message,
"pipeline 'api/up.zf.json' is refused — 1 problem:\n- node `n1`: fs.image.thumbnail --format 'jpeg' must be one of jpg, png, webp"
);
}
#[test]
fn a_pipeline_reading_what_its_nodes_answer_is_clean() {
let found = check(&format!(
"{UPLOAD} | javascript.script.run -- \"return 1\" | web.response.send --body \"{{{{ {{ n: input.script, b: input.webhook.body, s: $nodes.n1.script, w: $nodes.n1.webhook }} }}}}\""
));
assert!(found.is_clean(), "{found:?}");
}
#[test]
fn a_mistyped_answer_key_names_the_key_it_meant() {
let found = warnings(&format!("{UPLOAD} | javascript.script.run -- \"return 1\" | web.response.send --body \"{{{{ input.result }}}}\""));
assert_eq!(found.len(), 1, "{found:?}");
assert!(found[0].contains("`input.result` — no node upstream of `n2` answers `result`; did you mean `input.script`?"), "{found:?}");
let found = warnings(&format!("{UPLOAD} | web.response.send --body \"{{{{ input.body.name }}}}\""));
assert!(has(&found, "did you mean `input.webhook.body`?"), "{found:?}");
let found = warnings(&format!("{UPLOAD} | javascript.script.run -- \"return 1\" | web.response.send --body \"{{{{ input.scirpt }}}}\""));
assert!(has(&found, "did you mean `input.script`?"), "{found:?}");
let found = warnings(&format!("{UPLOAD} | sqlite.query.run -- \"SELECT 1\" | web.response.send --body \"{{{{ $nodes.n1.rows }}}}\""));
assert!(has(&found, "`$nodes.n1.rows` — `n1` answers no `rows` (its answer is `query`); did you mean `$nodes.n1.query.rows`?"), "{found:?}");
}
#[test]
fn an_input_key_two_upstream_nodes_answer_names_both() {
let found = warnings(&format!(
"{UPLOAD} | sqlite.query.run -- \"SELECT 1\" | sqlite.query.run -- \"SELECT 2\" | web.response.send --body \"{{{{ input.query.rows }}}}\""
));
assert_eq!(found.len(), 1, "{found:?}");
assert!(found[0].contains("`$nodes.n1.query` and `$nodes.n2.query`"), "{found:?}");
}
#[test]
fn a_loop_item_and_its_close_answer_what_they_say() {
let body = "[a] trigger.manual\n\
[f] logic.foreach --from \"input.manual.body.items\"\n\
[b] crypto.base64.encode --text \"{{ input.item }}\"\n\
[c] logic.collect\n\
[o] web.response.send --body \"{{ { all: input.collect, asked: input.manual } }}\"\n\
[a] -> [f]\n[f]:item -> [b]\n[b] -> [c]\n[c] -> [o]\n";
let found = check(body);
assert!(found.is_clean(), "{found:?}");
let found = warnings(&body.replace("input.item", "input.manual"));
assert!(has(&found, "no node upstream of `b` answers `manual`"), "{found:?}");
}
#[test]
fn a_webhook_path_without_a_response_warns_once_per_sink() {
assert!(check(&format!("{UPLOAD} | javascript.script.run -- \"return 1\" | web.response.send --body \"{{{{ input.script }}}}\"")).is_clean());
let found = warnings(&format!("{UPLOAD} | javascript.script.run -- \"return 1\""));
assert_eq!(found, vec!["node `n1`: this route never answers a body (204) on the path ending at `n1` — end it in web.response.send to answer the caller".to_string()]);
let body = "[t] trigger.webhook --route /flag --method POST\n\
[i] logic.if --when \"input.webhook.body.ok\"\n\
[yes] web.response.send --body \"{{ { ok: true } }}\"\n\
[no] javascript.script.run -- \"return 0\"\n\
[t] -> [i]\n[i]:true -> [yes]\n[i]:false -> [no]\n";
let found = warnings(body);
assert_eq!(found.len(), 1, "{found:?}");
assert!(found[0].contains("on the path ending at `no`"), "{found:?}");
assert!(check(&format!("{UPLOAD} | web.response.send --body \"{{{{ {{ ok: true }} }}}}\" | javascript.script.run -- \"return 1\"")).is_clean());
assert!(check("| trigger.manual | javascript.script.run -- \"return 1\"").is_clean());
}
#[test]
fn a_published_tool_path_without_a_response_warns_once_per_sink() {
let tool = "| trigger.mcp --route /library --name lookup --description \"Look a title up.\" --auth none";
let found = warnings(&format!("{tool} | javascript.script.run -- \"return 1\""));
assert_eq!(found, vec!["node `n1`: this tool never answers a result on the path ending at `n1` — end it in web.response.send to answer the agent".to_string()]);
assert!(check(&format!("{tool} | web.response.send --body \"{{{{ {{ ok: true }} }}}}\"")).is_clean());
}
#[test]
fn an_error_handler_without_a_response_warns_too() {
let body = "[t] trigger.webhook --route /save --method POST\n\
[q] sqlite.query.run -- \"SELECT 1\"\n\
[ok] web.response.send --body \"{{ input.query.rows }}\"\n\
[h] javascript.script.run -- \"return 'logged'\"\n\
[t] -> [q]\n[q] -> [ok]\n[q]:error -> [h]\n";
let found = warnings(body);
assert_eq!(found.len(), 1, "{found:?}");
assert!(found[0].starts_with("node `h`: this route never answers a body (204) on the path ending at `h`"), "{found:?}");
let answered = body.replace(
"[h] javascript.script.run -- \"return 'logged'\"",
"[h] web.response.send --status 500 --body \"{{ { ok: false, error: { code: input.query.error.code }, sent: input.webhook.body } }}\"",
);
assert!(check(&answered).is_clean(), "{:?}", check(&answered));
}
#[test]
fn the_old_failure_envelope_is_pointed_at_the_failing_nodes_key() {
let body = "[t] trigger.webhook --route /save --method POST\n\
[q] sqlite.query.run -- \"SELECT 1\"\n\
[ok] web.response.send --body \"{{ input.query.rows }}\"\n\
[h] web.response.send --status 500 --body \"{{ { code: input.error.code } }}\"\n\
[t] -> [q]\n[q] -> [ok]\n[q]:error -> [h]\n";
let found = warnings(body);
assert!(
has(&found, "no node upstream of `h` answers `error`; a failure arrives under the failing node's key — did you mean `input.query.error`?"),
"{found:?}"
);
let body = "[t] trigger.webhook --route /each --method POST\n\
[f] logic.foreach --from \"input.webhook.body.rows\"\n\
[x] javascript.script.run -- \"return 1\"\n\
[c] logic.collect\n\
[ok] web.response.send --body \"{{ input.collect }}\"\n\
[h] web.response.send --status 500 --body \"{{ { code: input.foreach.error.code } }}\"\n\
[t] -> [f]\n[f]:item -> [x]\n[x] -> [c]\n[c] -> [ok]\n[f]:error -> [h]\n";
assert!(check(body).is_clean(), "{:?}", check(body));
}
#[test]
fn a_failure_replaces_its_key_so_input_reads_it_without_ambiguity() {
let body = "[t] trigger.webhook --route /twice --method POST\n\
[s] javascript.script.run -- \"return 1\"\n\
[q] javascript.script.run -- \"return 2\"\n\
[ok] web.response.send --body \"{{ $nodes.q.script }}\"\n\
[h] web.response.send --status 500 --body \"{{ { code: input.script.error.code, first: $nodes.s.script } }}\"\n\
[t] -> [s]\n[s] -> [q]\n[q] -> [ok]\n[q]:error -> [h]\n";
assert!(check(body).is_clean(), "{:?}", check(body));
let found = warnings(&body.replace("{{ $nodes.q.script }}", "{{ input.script }}"));
assert_eq!(found.len(), 1, "{found:?}");
assert!(found[0].starts_with("node `ok`: `input.script` — 2 upstream nodes answer `script`"), "{found:?}");
}
#[test]
fn the_failing_node_is_null_on_its_own_error_path() {
let body = "[t] trigger.webhook --route /fail --method POST\n\
[q] sqlite.query.run -- \"SELECT 1\"\n\
[ok] web.response.send --body \"{{ input.query.rows }}\"\n\
[h] web.response.send --status 500 --body \"{{ { code: $nodes.q.error.code } }}\"\n\
[t] -> [q]\n[q] -> [ok]\n[q]:error -> [h]\n";
let found = warnings(body);
assert_eq!(
found,
vec!["node `h`: `$nodes.q.error` — on the `:error` path of `q`, `$nodes.q` is null; read `input.query.error`".to_string()],
"{found:?}"
);
let own = body.replace("$nodes.q.error.code", "$nodes.q.query.error.code");
assert!(has(&warnings(&own), "`$nodes.q.query` — on the `:error` path of `q`, `$nodes.q` is null; read `input.query.error` — did you mean `input.query`?"));
let ok = body.replace("{{ input.query.rows }}", "{{ $nodes.q.query.rows }}").replace("$nodes.q.error.code", "input.query.error.code");
assert!(check(&ok).is_clean(), "{:?}", check(&ok));
}