#![cfg(feature = "constructors-wasm")]
#![allow(unexpected_cfgs)]
use std::path::PathBuf;
use std::sync::OnceLock;
use cloacina::executor::WorkflowExecutor;
use cloacina::packaging::constructor_provider::{
package_constructor_provider, ProviderPackageOptions,
};
use cloacina::registry::loader::{
load_constructor_node, set_provider_search_path, unpack_provider_archive,
};
use cloacina::runner::{DefaultRunner, DefaultRunnerConfig};
use cloacina::{task, workflow, Context, TaskError};
use serde_json::json;
fn examples_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/constructor-contract")
}
#[workflow(name = "onboard", description = "constructor! consumer-surface e2e")]
pub mod onboard {
use super::*;
#[task(id = "load_user", dependencies = [])]
pub async fn load_user(context: &mut Context<serde_json::Value>) -> Result<(), TaskError> {
context.insert("name", json!("world"))?;
Ok(())
}
constructor!(
id = "greet",
from = "prefix@0.1.0",
constructor = "prefix",
config = { prefix = "hello, " },
dependencies = ["load_user"],
);
#[task(id = "notify", dependencies = ["greet"])]
pub async fn notify(context: &mut Context<serde_json::Value>) -> Result<(), TaskError> {
let greeting = context
.get("result")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
context.insert("notified", json!(greeting))?;
Ok(())
}
}
#[workflow(name = "affixed", description = "reordered-config constructor! e2e")]
pub mod affixed {
use super::*;
#[task(id = "seed", dependencies = [])]
pub async fn seed(context: &mut Context<serde_json::Value>) -> Result<(), TaskError> {
context.insert("name", json!("world"))?;
Ok(())
}
constructor!(
id = "wrap",
from = "affix@0.1.0",
constructor = "affix",
config = { suffix = "!", prefix = "hello, " },
dependencies = ["seed"],
);
#[task(id = "announce", dependencies = ["wrap"])]
pub async fn announce(context: &mut Context<serde_json::Value>) -> Result<(), TaskError> {
let wrapped = context
.get("result")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
context.insert("notified", json!(wrapped))?;
Ok(())
}
}
fn stage_into(work: &tempfile::TempDir, providers: &PathBuf, fixture_dir: PathBuf) {
let archive = work.path().join(format!(
"{}.cloacina",
fixture_dir.file_name().unwrap().to_string_lossy()
));
let opts = ProviderPackageOptions {
crate_dir: fixture_dir,
output: Some(archive.clone()),
sign_key: None,
manifest_bin: "emit_manifest".to_string(),
runtime: cloacina_constructor_contract::ProviderRuntime::Wasm,
release: true,
};
package_constructor_provider(&opts).expect("package_constructor_provider");
unpack_provider_archive(&archive, providers, &[]).expect("unpack provider archive");
}
fn providers_dir() -> &'static PathBuf {
static PROVIDERS: OnceLock<(tempfile::TempDir, PathBuf)> = OnceLock::new();
&PROVIDERS
.get_or_init(|| {
let work = tempfile::TempDir::new().unwrap();
let providers = work.path().join("providers");
stage_into(
&work,
&providers,
examples_dir().join("task-constructor-macro-fixture"),
);
stage_into(
&work,
&providers,
examples_dir().join("task-constructor-twocfg-fixture"),
);
(work, providers)
})
.1
}
#[tokio::test]
async fn constructor_node_runs_in_workflow_with_deps_and_output() {
set_provider_search_path(providers_dir());
let config = DefaultRunnerConfig::builder()
.enable_registry_reconciler(false)
.build()
.unwrap();
let runner = DefaultRunner::with_config(":memory:", config)
.await
.expect("create DefaultRunner");
let result = runner
.execute("onboard", Context::new())
.await
.expect("workflow execution");
assert_eq!(
result.final_context.get("name"),
Some(&json!("world")),
"load_user ran first (dependency honored)"
);
assert_eq!(
result.final_context.get("result"),
Some(&json!("hello, world")),
"the packaged constructor ran as a node: config prefix + load_user's name"
);
assert_eq!(
result.final_context.get("notified"),
Some(&json!("hello, world")),
"the dependent #[task] saw the constructor node's output"
);
runner.shutdown().await.expect("shutdown");
}
#[tokio::test]
async fn reordered_config_binds_by_name() {
set_provider_search_path(providers_dir());
let config = DefaultRunnerConfig::builder()
.enable_registry_reconciler(false)
.build()
.unwrap();
let runner = DefaultRunner::with_config(":memory:", config)
.await
.expect("create DefaultRunner");
let result = runner
.execute("affixed", Context::new())
.await
.expect("workflow execution");
assert_eq!(
result.final_context.get("result"),
Some(&json!("hello, world!")),
"config kwargs bound by NAME despite reversed written order"
);
assert_eq!(
result.final_context.get("notified"),
Some(&json!("hello, world!")),
"the dependent #[task] saw the name-keyed constructor output"
);
runner.shutdown().await.expect("shutdown");
}
#[test]
fn config_kwarg_errors_are_clear() {
set_provider_search_path(providers_dir());
let unwrap_err = |r: Result<_, cloacina::registry::error::LoaderError>, what: &str| match r {
Ok(_) => panic!("{what}"),
Err(e) => e.to_string(),
};
let msg = unwrap_err(
load_constructor_node(
"wrap",
"affix@0.1.0",
"affix",
vec![
("prefix".to_string(), json!("hello, ")),
("suffix".to_string(), json!("!")),
("bogus".to_string(), json!("x")),
],
vec![],
cloacina::registry::loader::grants::GrantSpec::default(),
),
"unknown config key must fail closed",
);
assert!(
msg.contains("bogus") && msg.contains("not a #[config] field"),
"unknown-key error must name the offending key: {msg}"
);
let msg = unwrap_err(
load_constructor_node(
"wrap",
"affix@0.1.0",
"affix",
vec![("prefix".to_string(), json!("hello, "))],
vec![],
cloacina::registry::loader::grants::GrantSpec::default(),
),
"missing required config field must fail closed",
);
assert!(
msg.contains("missing required config field") && msg.contains("suffix"),
"missing-field error must name the missing field: {msg}"
);
}
#[test]
fn version_pin_is_enforced_at_load() {
set_provider_search_path(providers_dir());
let unwrap_err = |r: Result<_, cloacina::registry::error::LoaderError>, what: &str| match r {
Ok(_) => panic!("{what}"),
Err(e) => e.to_string(),
};
let node = |from: &str| {
load_constructor_node(
"wrap",
from,
"affix",
vec![
("prefix".to_string(), json!("hello, ")),
("suffix".to_string(), json!("!")),
],
vec![],
cloacina::registry::loader::grants::GrantSpec::default(),
)
};
assert!(node("affix@0.1.0").is_ok(), "exact pin must load");
assert!(node("affix@0.1").is_ok(), "segment-prefix pin must load");
assert!(node("affix").is_ok(), "unpinned ref must load");
let msg = unwrap_err(node("affix@9.9.9"), "mismatched pin must fail closed");
assert!(
msg.contains("9.9.9") && msg.contains("0.1.0") && msg.contains("pins"),
"pin-mismatch error must name the pin and the resolved version: {msg}"
);
let msg = unwrap_err(node("affix@0.10"), "0.10 pin must not match 0.1.x");
assert!(msg.contains("0.10"), "boundary error names the pin: {msg}");
}