greentic_component/test_harness/
mod.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4use anyhow::{Context, Result};
5use greentic_interfaces_host::component::v0_5::exports::greentic::component::node;
6use greentic_interfaces_host::component::v0_5::exports::greentic::component::node::GuestIndices;
7use greentic_types::TenantCtx;
8use serde_json::Value;
9use wasmtime::component::{Component, InstancePre};
10use wasmtime::{Config, Engine, Store};
11
12use crate::test_harness::linker::{HostState, build_linker};
13use crate::test_harness::secrets::InMemorySecretsStore;
14use crate::test_harness::state::{InMemoryStateStore, StateDumpEntry, StateScope};
15
16mod linker;
17mod secrets;
18mod state;
19
20pub struct HarnessConfig {
21    pub wasm_bytes: Vec<u8>,
22    pub tenant_ctx: TenantCtx,
23    pub flow_id: String,
24    pub node_id: Option<String>,
25    pub state_prefix: String,
26    pub state_seeds: Vec<(String, Vec<u8>)>,
27    pub allow_state_read: bool,
28    pub allow_state_write: bool,
29    pub allow_state_delete: bool,
30    pub allow_secrets: bool,
31    pub allowed_secrets: HashSet<String>,
32    pub secrets: HashMap<String, String>,
33}
34
35pub struct TestHarness {
36    engine: Engine,
37    instance_pre: InstancePre<HostState>,
38    guest_indices: GuestIndices,
39    state_store: Arc<InMemoryStateStore>,
40    secrets_store: Arc<InMemorySecretsStore>,
41    state_scope: StateScope,
42    allow_state_read: bool,
43    allow_state_write: bool,
44    allow_state_delete: bool,
45    exec_ctx: node::ExecCtx,
46}
47
48impl TestHarness {
49    pub fn new(config: HarnessConfig) -> Result<Self> {
50        let mut wasmtime_config = Config::new();
51        wasmtime_config.wasm_component_model(true);
52        wasmtime_config.wasm_backtrace_details(wasmtime::WasmBacktraceDetails::Enable);
53        let engine = Engine::new(&wasmtime_config).context("create wasmtime engine")?;
54
55        let component =
56            Component::from_binary(&engine, &config.wasm_bytes).context("load component wasm")?;
57
58        let linker = build_linker(&engine)?;
59        let instance_pre = linker
60            .instantiate_pre(&component)
61            .context("prepare component instance")?;
62        let guest_indices = GuestIndices::new(&instance_pre).context("load guest indices")?;
63
64        let state_store = Arc::new(InMemoryStateStore::new());
65        let secrets_store = InMemorySecretsStore::new(config.allow_secrets, config.allowed_secrets);
66        let secrets_store = Arc::new(secrets_store.with_secrets(config.secrets));
67        let scope = StateScope::from_tenant_ctx(&config.tenant_ctx, config.state_prefix);
68        for (key, value) in config.state_seeds {
69            state_store.write(&scope, &key, value);
70        }
71
72        let exec_ctx = node::ExecCtx {
73            tenant: make_component_tenant_ctx(&config.tenant_ctx),
74            flow_id: config.flow_id,
75            node_id: config.node_id,
76        };
77
78        Ok(Self {
79            engine,
80            instance_pre,
81            guest_indices,
82            state_store,
83            secrets_store,
84            state_scope: scope,
85            allow_state_read: config.allow_state_read,
86            allow_state_write: config.allow_state_write,
87            allow_state_delete: config.allow_state_delete,
88            exec_ctx,
89        })
90    }
91
92    pub fn invoke(&self, operation: &str, input_json: &Value) -> Result<String> {
93        let host_state = HostState::new(
94            self.state_scope.clone(),
95            self.state_store.clone(),
96            self.secrets_store.clone(),
97            self.allow_state_read,
98            self.allow_state_write,
99            self.allow_state_delete,
100        );
101        let mut store = Store::new(&self.engine, host_state);
102        let instance = self
103            .instance_pre
104            .instantiate(&mut store)
105            .context("instantiate component")?;
106        let exports = self
107            .guest_indices
108            .load(&mut store, &instance)
109            .context("load component exports")?;
110
111        let input = serde_json::to_string(input_json).context("serialize input json")?;
112        let result = exports
113            .call_invoke(&mut store, &self.exec_ctx, operation, &input)
114            .context("invoke component")?;
115
116        use greentic_interfaces_host::component::v0_5::exports::greentic::component::node::InvokeResult;
117
118        match result {
119            InvokeResult::Ok(output_json) => Ok(output_json),
120            InvokeResult::Err(err) => Err(anyhow::anyhow!(
121                "component error {}: {}",
122                err.code,
123                err.message
124            )),
125        }
126    }
127
128    pub fn state_dump(&self) -> Vec<StateDumpEntry> {
129        self.state_store.dump()
130    }
131}
132
133fn make_component_tenant_ctx(tenant: &TenantCtx) -> node::TenantCtx {
134    node::TenantCtx {
135        tenant: tenant.tenant.as_str().to_string(),
136        team: tenant.team.as_ref().map(|t| t.as_str().to_string()),
137        user: tenant.user.as_ref().map(|u| u.as_str().to_string()),
138        trace_id: tenant.trace_id.clone(),
139        correlation_id: tenant.correlation_id.clone(),
140        deadline_unix_ms: tenant.deadline.and_then(|deadline| {
141            let millis = deadline.unix_millis();
142            if millis >= 0 {
143                u64::try_from(millis).ok()
144            } else {
145                None
146            }
147        }),
148        attempt: tenant.attempt,
149        idempotency_key: tenant.idempotency_key.clone(),
150    }
151}