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