mod support;
use cuttlefish_host::catalog::{ArtifactKind, Catalog, ResolutionContext};
use cuttlefish_host::pipeline::{
check, read_stage_signature, resolve_and_load, PipelineError, ResolvedInput,
};
use std::path::PathBuf;
use support::block_with;
use wasmtime::Engine;
fn direct(path: PathBuf) -> ResolvedInput {
let name = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
ResolvedInput {
name,
kind: ArtifactKind::Block,
resolved: None,
bytes: std::fs::read(&path)
.unwrap_or_else(|e| panic!("reading fixture {}: {e}", path.display())),
script: None,
}
}
fn direct_bytes(name: &str, kind: ArtifactKind, bytes: Vec<u8>) -> ResolvedInput {
ResolvedInput {
name: name.to_string(),
kind,
resolved: None,
bytes,
script: None,
}
}
fn make_bundle(manifest_json: &[u8]) -> Vec<u8> {
let mut bytes = b"CFBD".to_vec();
bytes.extend_from_slice(&(manifest_json.len() as u64).to_le_bytes());
bytes.extend_from_slice(manifest_json);
bytes
}
#[test]
fn a_pipeline_whose_seams_line_up_is_accepted() {
let dir = tempfile::tempdir().unwrap();
let first = block_with(dir.path(), "seam_ok_a", "{path: text}", "{chunks: [text]}");
let second = block_with(
dir.path(),
"seam_ok_b",
"{chunks: [text]}",
"{summary: text}",
);
let checked =
check(&Engine::default(), &[direct(first), direct(second)]).expect("the seams line up");
assert_eq!(checked.stages().len(), 2);
assert_eq!(checked.input().to_string(), "{path: text}");
assert_eq!(checked.output().to_string(), "{summary: text}");
}
#[test]
fn a_mismatched_seam_is_rejected_naming_both_blocks_and_both_types() {
let dir = tempfile::tempdir().unwrap();
let producer = block_with(dir.path(), "seam_bad_a", "{path: text}", "{summary: text}");
let consumer = block_with(dir.path(), "seam_bad_b", "{chunks: [text]}", "{out: text}");
let err = check(&Engine::default(), &[direct(producer), direct(consumer)])
.err()
.expect("a mismatched seam must be rejected");
assert!(matches!(err, PipelineError::SeamMismatch { .. }), "{err:?}");
let msg = err.to_string();
assert!(msg.contains("seam_bad_a"), "names the producer: {msg}");
assert!(msg.contains("seam_bad_b"), "names the consumer: {msg}");
assert!(
msg.contains("{summary: text}"),
"shows what was produced: {msg}"
);
assert!(
msg.contains("{chunks: [text]}"),
"shows what was expected: {msg}"
);
}
#[test]
fn a_producer_may_add_fields_its_consumer_does_not_need() {
let dir = tempfile::tempdir().unwrap();
let wide = block_with(dir.path(), "wide_a", "{path: text}", "{a: text, b: text}");
let narrow = block_with(dir.path(), "narrow_b", "{a: text}", "{out: text}");
assert!(check(&Engine::default(), &[direct(wide), direct(narrow)]).is_ok());
}
#[test]
fn a_json_seam_accepts_anything() {
let dir = tempfile::tempdir().unwrap();
let typed = block_with(dir.path(), "json_a", "{path: text}", "{a: text}");
let loose = block_with(dir.path(), "json_b", "json", "{out: text}");
assert!(check(&Engine::default(), &[direct(typed), direct(loose)]).is_ok());
}
#[test]
fn a_specific_input_does_not_accept_json() {
let dir = tempfile::tempdir().unwrap();
let loose = block_with(dir.path(), "rev_a", "{path: text}", "json");
let typed = block_with(dir.path(), "rev_b", "{needed: text}", "{out: text}");
assert!(check(&Engine::default(), &[direct(loose), direct(typed)]).is_err());
}
#[test]
fn a_single_block_pipeline_is_fine() {
let dir = tempfile::tempdir().unwrap();
let only = block_with(dir.path(), "single_a", "{path: text}", "{summary: text}");
let checked =
check(&Engine::default(), &[direct(only)]).expect("one block is a valid pipeline");
assert_eq!(checked.stages().len(), 1);
assert_eq!(checked.input().to_string(), "{path: text}");
assert_eq!(checked.output().to_string(), "{summary: text}");
}
#[test]
fn a_bundle_stage_is_checked_from_its_cached_manifest_signature() {
let bundle =
make_bundle(br#"{"nodes":[],"edges":[],"signature":"{path: text} -> {summary: text}"}"#);
let checked = check(
&Engine::default(),
&[direct_bytes("my_bundle", ArtifactKind::Bundle, bundle)],
)
.expect("a well-formed bundle manifest checks fine");
assert_eq!(checked.stages().len(), 1);
assert_eq!(checked.input().to_string(), "{path: text}");
assert_eq!(checked.output().to_string(), "{summary: text}");
}
#[test]
fn an_uninspectable_bundle_names_the_stage_exactly_once() {
let too_short = b"CFBD".to_vec();
let err = check(
&Engine::default(),
&[direct_bytes("my_bundle", ArtifactKind::Bundle, too_short)],
)
.err()
.expect("a truncated bundle header must be rejected");
assert!(
matches!(err, PipelineError::Uninspectable { .. }),
"{err:?}"
);
let msg = err.to_string();
assert_eq!(
msg.matches("my_bundle").count(),
1,
"the stage name must appear exactly once: {msg}"
);
assert!(
msg.contains("shorter than the bundle header"),
"the underlying reason must still be present: {msg}"
);
}
#[test]
fn an_empty_pipeline_is_rejected() {
let err = check(&Engine::default(), &[])
.err()
.expect("nothing to run");
assert!(matches!(err, PipelineError::Empty));
}
#[test]
fn read_stage_signature_matches_what_check_already_produced_for_a_block() {
let dir = tempfile::tempdir().unwrap();
let block = block_with(dir.path(), "read_sig_a", "text", "text");
let input = ResolvedInput {
name: "x".into(),
kind: ArtifactKind::Block,
resolved: None,
bytes: std::fs::read(&block).unwrap(),
script: None,
};
let sig = read_stage_signature(&Engine::default(), &input).unwrap();
assert_eq!(sig.input.describe(), "text");
assert_eq!(sig.output.describe(), "text");
}
fn tagging_block(dir: &std::path::Path, name: &str, tag: &str) -> PathBuf {
let crate_dir = dir.join(name);
std::fs::create_dir_all(crate_dir.join("src")).unwrap();
let sdk = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../crates/cuttlefish-sdk");
std::fs::write(
crate_dir.join("Cargo.toml"),
format!(
"[package]\nname = \"{name}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n\
[lib]\ncrate-type = [\"cdylib\"]\n\n[dependencies]\n\
cuttlefish-sdk = {{ path = '{}' }}\nserde_json = \"1\"\n\n[workspace]\n",
sdk.display().to_string().replace('\\', "/")
),
)
.unwrap();
std::fs::write(
crate_dir.join("src/lib.rs"),
format!(
r#"use cuttlefish_sdk::{{export_block, Block, Command, Event}};
#[derive(Default)]
struct B;
impl Block for B {{
fn start(&mut self, input: serde_json::Value) -> Command {{
let mut seen: Vec<String> = input
.get("seen")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
seen.push("{tag}".to_string());
Command::Done {{ result: serde_json::json!({{ "seen": seen }}) }}
}}
fn step(&mut self, _event: Event) -> Command {{
Command::Fail {{ code: "unexpected".into(), message: "no commands issued".into() }}
}}
}}
export_block!(B);
"#
),
)
.unwrap();
let status = crate::support::clean_cargo(env!("CARGO"))
.current_dir(&crate_dir)
.args(["build", "--target", "wasm32-unknown-unknown"])
.status()
.expect("cargo should start");
assert!(status.success(), "building {name} failed");
crate_dir
.join("target/wasm32-unknown-unknown/debug")
.join(format!("{}.wasm", name.replace('-', "_")))
}
fn checked_node(
name: &str,
module_bytes: Vec<u8>,
input: Option<cuttlefish_core::graph::InputExpr>,
) -> cuttlefish_host::dag::CheckedNode {
cuttlefish_host::dag::CheckedNode {
name: name.to_string(),
kind: ArtifactKind::Block,
resolved: None,
module_bytes,
signature: cuttlefish_abi::Signature {
input: cuttlefish_abi::Ty::Json,
output: cuttlefish_abi::Ty::Json,
},
input,
repeat_until: None,
max_iterations: None,
script: None,
over: None,
item_output: None,
accept: Vec::new(),
on_fail: Vec::new(),
}
}
#[tokio::test]
async fn a_pipeline_threads_each_result_into_the_next_block() {
use cuttlefish_core::graph::InputExpr;
use cuttlefish_host::{
caps::Capabilities,
infer::StubBackend,
runner::{run_job, JobSpec},
};
use std::sync::Arc;
let dir = tempfile::tempdir().unwrap();
let first = std::fs::read(tagging_block(dir.path(), "chain_one", "first")).unwrap();
let second = std::fs::read(tagging_block(dir.path(), "chain_two", "second")).unwrap();
let third = std::fs::read(tagging_block(dir.path(), "chain_three", "third")).unwrap();
let (tx, _rx) = tokio::sync::mpsc::channel(64);
let ledger_dir = tempfile::tempdir().unwrap();
let ledger = cuttlefish_host::ledger::Ledger::open(
&ledger_dir.path().join("ledger.sqlite"),
"test-fingerprint",
)
.unwrap();
let envelope = run_job(
Arc::new(Engine::default()),
Arc::new(StubBackend::default()),
JobSpec {
nodes: vec![
checked_node("n0", first, None),
checked_node("n1", second, Some(InputExpr::FromNode("n0".to_string()))),
checked_node("n2", third, Some(InputExpr::FromNode("n1".to_string()))),
],
exclusive_to: std::collections::HashMap::new(),
input: serde_json::json!({}),
caps: Capabilities::default(),
alternates: Default::default(),
embedder: None,
warehouse: None,
},
tx,
tokio_util::sync::CancellationToken::new(),
&ledger,
&cuttlefish_host::module_cache::ModuleCache::new(),
)
.await;
assert_eq!(envelope.status, cuttlefish_abi::JobStatus::Completed);
assert_eq!(
envelope.result.unwrap()["seen"],
serde_json::json!(["first", "second", "third"])
);
}
#[tokio::test]
async fn a_failing_stage_ends_the_job_and_names_the_stage() {
use cuttlefish_core::graph::InputExpr;
use cuttlefish_host::{
caps::Capabilities,
infer::StubBackend,
runner::{run_job, JobSpec},
};
use std::sync::Arc;
let dir = tempfile::tempdir().unwrap();
let good = std::fs::read(tagging_block(dir.path(), "fail_one", "first")).unwrap();
let (tx, _rx) = tokio::sync::mpsc::channel(64);
let ledger_dir = tempfile::tempdir().unwrap();
let ledger = cuttlefish_host::ledger::Ledger::open(
&ledger_dir.path().join("ledger.sqlite"),
"test-fingerprint",
)
.unwrap();
let envelope = run_job(
Arc::new(Engine::default()),
Arc::new(StubBackend::default()),
JobSpec {
nodes: vec![
checked_node("n0", good, None),
checked_node(
"n1",
b"not wasm".to_vec(),
Some(InputExpr::FromNode("n0".to_string())),
),
],
exclusive_to: std::collections::HashMap::new(),
input: serde_json::json!({}),
caps: Capabilities::default(),
alternates: Default::default(),
embedder: None,
warehouse: None,
},
tx,
tokio_util::sync::CancellationToken::new(),
&ledger,
&cuttlefish_host::module_cache::ModuleCache::new(),
)
.await;
assert_eq!(envelope.status, cuttlefish_abi::JobStatus::Failed);
let message = envelope.error.unwrap().message;
assert!(
message.contains("block 2"),
"the failing stage must be named: {message}"
);
assert!(envelope.result.is_none());
}
#[test]
fn a_relative_direct_entry_resolves_against_spec_dir_not_cwd() {
let spec_dir = tempfile::tempdir().unwrap();
let wasm = block_with(spec_dir.path(), "rel_a", "text", "text");
let relative_entry = "rel_a/target/wasm32-unknown-unknown/debug/rel_a.wasm".to_string();
let catalog = Catalog::open(spec_dir.path().join("unused-catalog"));
let resolved = resolve_and_load(
&catalog,
spec_dir.path(),
&relative_entry,
ResolutionContext::Interactive,
)
.unwrap();
assert_eq!(resolved.bytes, std::fs::read(&wasm).unwrap());
assert_eq!(resolved.resolved, None);
}
#[test]
fn a_bare_catalog_name_is_not_joined_against_spec_dir() {
let src_dir = tempfile::tempdir().unwrap();
let catalog_dir = tempfile::tempdir().unwrap();
let wasm = block_with(src_dir.path(), "cat_a", "text", "text");
let catalog = Catalog::open(catalog_dir.path());
catalog.add("cat-a@1", &wasm, &Engine::default()).unwrap();
let empty_spec_dir = tempfile::tempdir().unwrap();
let resolved = resolve_and_load(
&catalog,
empty_spec_dir.path(),
"cat-a@1",
ResolutionContext::Interactive,
)
.unwrap();
assert_eq!(resolved.resolved, Some("cat-a@1".to_string()));
assert_eq!(resolved.bytes, std::fs::read(&wasm).unwrap());
}
#[test]
fn a_dotted_catalog_name_still_resolves_through_the_catalog_not_a_join() {
let src_dir = tempfile::tempdir().unwrap();
let catalog_dir = tempfile::tempdir().unwrap();
let wasm = block_with(src_dir.path(), "team_cat_a", "text", "text");
let catalog = Catalog::open(catalog_dir.path());
catalog
.add("team.cat-a@1", &wasm, &Engine::default())
.unwrap();
let empty_spec_dir = tempfile::tempdir().unwrap();
let resolved = resolve_and_load(
&catalog,
empty_spec_dir.path(),
"team.cat-a@1",
ResolutionContext::Interactive,
)
.unwrap();
assert_eq!(resolved.resolved, Some("team.cat-a@1".to_string()));
assert_eq!(resolved.bytes, std::fs::read(&wasm).unwrap());
}
#[test]
fn an_unqualified_name_in_durable_context_is_rejected() {
let catalog_dir = tempfile::tempdir().unwrap();
let catalog = Catalog::open(catalog_dir.path());
let spec_dir = tempfile::tempdir().unwrap();
let err = resolve_and_load(
&catalog,
spec_dir.path(),
"no-version",
ResolutionContext::Durable,
)
.unwrap_err();
assert!(matches!(err, PipelineError::Resolution(_)));
}
#[test]
fn a_source_directory_is_rejected_with_a_friendly_message() {
let spec_dir = tempfile::tempdir().unwrap();
let block_src_dir = spec_dir.path().join("some-block-src");
std::fs::create_dir(&block_src_dir).unwrap();
let catalog = Catalog::open(spec_dir.path().join("unused-catalog"));
let err = resolve_and_load(
&catalog,
spec_dir.path(),
"./some-block-src",
ResolutionContext::Interactive,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("directory"), "{msg}");
}
#[test]
fn a_missing_direct_path_names_the_path() {
let spec_dir = tempfile::tempdir().unwrap();
let catalog = Catalog::open(spec_dir.path().join("unused-catalog"));
let err = resolve_and_load(
&catalog,
spec_dir.path(),
"no/such/block.wasm",
ResolutionContext::Interactive,
)
.unwrap_err();
assert!(err.to_string().contains("block.wasm"), "{err}");
}
#[test]
fn a_script_referenced_by_path_resolves_without_the_catalog() {
let spec_dir = tempfile::tempdir().unwrap();
let catalog = Catalog::open(spec_dir.path().join("unused-catalog"));
std::fs::write(
spec_dir.path().join("stray.rhai"),
"//! signature: {n: json} -> {n: json}\ninput\n",
)
.unwrap();
let resolved = resolve_and_load(
&catalog,
spec_dir.path(),
"stray.rhai",
ResolutionContext::Interactive,
)
.expect("a .rhai path that exists on disk must resolve");
assert_eq!(
resolved.kind,
cuttlefish_host::catalog::ArtifactKind::Script
);
assert!(resolved.script.unwrap().contains("input"));
assert!(
resolved.resolved.is_none(),
"a path reference has no name@version, and that is the point"
);
}
#[test]
fn resolving_a_cataloged_script_yields_the_interpreter_bytes_and_the_script_text() {
let tmp = tempfile::tempdir().unwrap();
let catalog = Catalog::open(tmp.path().join("catalog"));
let engine = Engine::default();
let script_path = tmp.path().join("echo.rhai");
std::fs::write(
&script_path,
"//! signature: {n: json} -> {n: json}\ninput\n",
)
.unwrap();
catalog.add("echo@1", &script_path, &engine).unwrap();
let resolved = resolve_and_load(
&catalog,
tmp.path(),
"echo@1",
ResolutionContext::Interactive,
)
.unwrap();
assert_eq!(resolved.kind, ArtifactKind::Script);
assert!(
!resolved.bytes.is_empty(),
"should resolve to the interpreter's own wasm bytes"
);
assert_eq!(
resolved.script.as_deref(),
Some("//! signature: {n: json} -> {n: json}\ninput\n")
);
}
#[test]
fn resolving_a_block_never_populates_the_script_field() {
let tmp = tempfile::tempdir().unwrap();
let catalog = Catalog::open(tmp.path().join("catalog"));
let engine = Engine::default();
let wasm_path = block_with(tmp.path(), "example", "text", "text");
catalog.add("example@1", &wasm_path, &engine).unwrap();
let resolved = resolve_and_load(
&catalog,
tmp.path(),
"example@1",
ResolutionContext::Interactive,
)
.unwrap();
assert_eq!(resolved.kind, ArtifactKind::Block);
assert!(resolved.script.is_none());
}