greentic_component/test_harness/
mod.rs

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