greentic_component/cmd/
test.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::time::Instant;
5
6use anyhow::{Context, Result, bail};
7use base64::Engine as _;
8use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
9use blake3::Hasher;
10use clap::{ArgAction, Args, ValueEnum};
11use serde::Serialize;
12use serde_json::{Map, Value};
13use uuid::Uuid;
14
15use crate::capabilities::FilesystemMode;
16use crate::manifest::ComponentManifest;
17use crate::manifest::parse_manifest;
18use crate::test_harness::{ComponentInvokeError, HarnessConfig, TestHarness, WasiPreopen};
19use greentic_types::{EnvId, TeamId, TenantCtx, TenantId, UserId};
20
21#[derive(Clone, Debug, ValueEnum)]
22pub enum StateMode {
23    Inmem,
24}
25
26#[derive(Args, Debug)]
27pub struct TestArgs {
28    /// Path to the component wasm binary.
29    #[arg(long, value_name = "PATH")]
30    pub wasm: PathBuf,
31    /// Optional manifest path (defaults to component.manifest.json next to the wasm).
32    #[arg(long, value_name = "PATH")]
33    pub manifest: Option<PathBuf>,
34    /// Operation to invoke (repeat for multi-step runs).
35    #[arg(long, value_name = "OP", action = ArgAction::Append)]
36    pub op: Vec<String>,
37    /// Input JSON file path (repeat for multi-step runs).
38    #[arg(long, value_name = "PATH", action = ArgAction::Append, conflicts_with = "input_json")]
39    pub input: Vec<PathBuf>,
40    /// Inline input JSON string (repeat for multi-step runs).
41    #[arg(long, value_name = "JSON", action = ArgAction::Append, conflicts_with = "input")]
42    pub input_json: Vec<String>,
43    /// Write output JSON to a file.
44    #[arg(long, value_name = "PATH")]
45    pub output: Option<PathBuf>,
46    /// Write trace JSON output (overrides GREENTIC_TRACE_OUT).
47    #[arg(long, value_name = "PATH")]
48    pub trace_out: Option<PathBuf>,
49    /// Pretty-print JSON output.
50    #[arg(long)]
51    pub pretty: bool,
52    /// State backend (only inmem is supported).
53    #[arg(long, value_enum, default_value = "inmem")]
54    pub state: StateMode,
55    /// Dump in-memory state after invocation.
56    #[arg(long)]
57    pub state_dump: bool,
58    /// Seed in-memory state as KEY=BASE64 (repeatable).
59    #[arg(long = "state-set", value_name = "KEY=BASE64")]
60    pub state_set: Vec<String>,
61    /// Repeatable step marker for multi-step runs.
62    #[arg(long, action = ArgAction::Count)]
63    pub step: u8,
64    /// Load secrets from a .env style file.
65    #[arg(long, value_name = "PATH")]
66    pub secrets: Option<PathBuf>,
67    /// Load secrets from a JSON map file.
68    #[arg(long, value_name = "PATH")]
69    pub secrets_json: Option<PathBuf>,
70    /// Provide a secret inline as KEY=VALUE (repeatable).
71    #[arg(long = "secret", value_name = "KEY=VALUE")]
72    pub secret: Vec<String>,
73    /// Environment identifier for the exec context.
74    #[arg(long, default_value = "dev")]
75    pub env: String,
76    /// Tenant identifier for the exec context.
77    #[arg(long, default_value = "default")]
78    pub tenant: String,
79    /// Optional team identifier for the exec context.
80    #[arg(long)]
81    pub team: Option<String>,
82    /// Optional user identifier for the exec context.
83    #[arg(long)]
84    pub user: Option<String>,
85    /// Optional flow identifier for the exec context.
86    #[arg(long)]
87    pub flow: Option<String>,
88    /// Optional node identifier for the exec context.
89    #[arg(long)]
90    pub node: Option<String>,
91    /// Optional session identifier for the exec context.
92    #[arg(long)]
93    pub session: Option<String>,
94    /// Emit extra diagnostic output (e.g. generated session id).
95    #[arg(long)]
96    pub verbose: bool,
97}
98
99pub fn run(args: TestArgs) -> Result<()> {
100    let trace_out = resolve_trace_out(&args)?;
101    match run_inner(&args, trace_out.as_deref()) {
102        Ok(()) => Ok(()),
103        Err(err) => Err(TestCommandError::from_anyhow(err, args.pretty).into()),
104    }
105}
106
107fn run_inner(args: &TestArgs, trace_out: Option<&Path>) -> Result<()> {
108    let manifest_path = resolve_manifest_path(&args.wasm, args.manifest.as_deref())?;
109    let manifest_raw = fs::read_to_string(&manifest_path)
110        .with_context(|| format!("read manifest {}", manifest_path.display()))?;
111    let manifest_value: Value =
112        serde_json::from_str(&manifest_raw).context("manifest must be valid JSON")?;
113    let manifest = parse_manifest(&manifest_raw).context("parse manifest")?;
114
115    let steps = collect_steps(args)?;
116    let mut trace = TraceContext::new(trace_out, &manifest, &steps);
117    let start = Instant::now();
118
119    let result = (|| -> Result<Option<String>> {
120        for (op, _) in &steps {
121            if !manifest
122                .operations
123                .iter()
124                .any(|operation| operation.name == *op)
125            {
126                bail!("operation `{op}` not declared in manifest");
127            }
128        }
129        let wasm_bytes =
130            fs::read(&args.wasm).with_context(|| format!("read wasm {}", args.wasm.display()))?;
131
132        let (tenant_ctx, session_id, generated_session) = build_tenant_ctx(args)?;
133        if args.verbose && generated_session {
134            eprintln!("generated session id: {session_id}");
135        }
136
137        let (allow_state_read, allow_state_write, allow_state_delete) =
138            state_permissions(&manifest_value, &manifest);
139        if !args.state_set.is_empty() && !allow_state_write {
140            bail!("manifest does not declare host.state.write; add it to use --state-set");
141        }
142        let (allow_secrets, allowed_secrets) = secret_permissions(&manifest);
143
144        let secrets = load_secrets(args)?;
145        if !allow_secrets && !secrets.is_empty() {
146            bail!(
147                "manifest does not declare host.secrets; add host.secrets to enable secrets access"
148            );
149        }
150
151        let state_seeds = parse_state_seeds(args)?;
152        let wasi_preopens = resolve_wasi_preopens(&manifest)?;
153        let prefix = state_prefix(args.flow.as_deref(), &session_id);
154        let flow_id = args.flow.clone().unwrap_or_else(|| "test".to_string());
155        let harness = TestHarness::new(HarnessConfig {
156            wasm_bytes,
157            tenant_ctx: tenant_ctx.clone(),
158            flow_id,
159            node_id: args.node.clone(),
160            state_prefix: prefix,
161            state_seeds,
162            allow_state_read,
163            allow_state_write,
164            allow_state_delete,
165            allow_secrets,
166            allowed_secrets,
167            secrets,
168            wasi_preopens,
169        })?;
170
171        if steps.len() > 1 && args.output.is_some() {
172            bail!("--output is only supported for single-step runs");
173        }
174
175        let mut single_output = None;
176        for (idx, (op, input)) in steps.iter().enumerate() {
177            let output = harness.invoke(op, input)?;
178            if steps.len() == 1 {
179                single_output = Some(output.clone());
180            }
181            let output = format_output(&output, args.pretty)?;
182            if let Some(path) = &args.output {
183                fs::write(path, output.as_bytes())
184                    .with_context(|| format!("write output {}", path.display()))?;
185            }
186            if steps.len() > 1 {
187                println!("step {} output:\n{output}", idx + 1);
188            } else {
189                println!("{output}");
190            }
191        }
192
193        if args.state_dump {
194            let dump = harness.state_dump();
195            let dump_json = serde_json::to_string_pretty(&dump).unwrap_or_else(|_| "{}".into());
196            eprintln!("state dump:\n{dump_json}");
197        }
198
199        Ok(single_output)
200    })();
201
202    let duration_ms = duration_ms(start.elapsed());
203    match result {
204        Ok(output) => {
205            if let Some(output) = output.as_deref() {
206                trace.output_hash = Some(hash_bytes(output.as_bytes()));
207            }
208            trace.write(duration_ms, None)?;
209            Ok(())
210        }
211        Err(err) => {
212            let payload = error_payload_from_anyhow(&err);
213            if let Err(trace_err) = trace.write(duration_ms, Some(payload)) {
214                eprintln!("failed to write trace: {trace_err}");
215            }
216            if let Some(path) = trace.out_path.as_deref() {
217                eprintln!("#TRY_SAVE_TRACE {}", path.display());
218            }
219            Err(err)
220        }
221    }
222}
223
224fn resolve_manifest_path(wasm: &Path, manifest: Option<&Path>) -> Result<PathBuf> {
225    if let Some(path) = manifest {
226        return Ok(path.to_path_buf());
227    }
228    let dir = wasm
229        .parent()
230        .ok_or_else(|| anyhow::anyhow!("wasm path has no parent directory"))?;
231    let candidate = dir.join("component.manifest.json");
232    if candidate.exists() {
233        Ok(candidate)
234    } else {
235        bail!(
236            "manifest not found; pass --manifest or place component.manifest.json next to the wasm"
237        );
238    }
239}
240
241fn collect_steps(args: &TestArgs) -> Result<Vec<(String, Value)>> {
242    if args.op.is_empty() {
243        bail!("--op is required");
244    }
245    let inputs = if !args.input.is_empty() {
246        let mut values = Vec::new();
247        for path in &args.input {
248            let raw = fs::read_to_string(path)
249                .with_context(|| format!("read input {}", path.display()))?;
250            values.push(serde_json::from_str(&raw).context("input file must be valid JSON")?);
251        }
252        values
253    } else if !args.input_json.is_empty() {
254        let mut values = Vec::new();
255        for raw in &args.input_json {
256            values.push(serde_json::from_str(raw).context("input-json must be valid JSON")?);
257        }
258        values
259    } else {
260        bail!("--input or --input-json is required");
261    };
262
263    if args.op.len() != inputs.len() {
264        bail!("provide the same number of --op and --input/--input-json values");
265    }
266    if args.op.len() > 1 {
267        let expected_steps = args.op.len().saturating_sub(1);
268        if args.step == 0 {
269            bail!("use --step to indicate a multi-step run");
270        }
271        if args.step as usize != expected_steps {
272            bail!(
273                "expected {expected_steps} --step flags for {} operations",
274                args.op.len()
275            );
276        }
277    }
278
279    Ok(args.op.clone().into_iter().zip(inputs).collect())
280}
281
282fn build_tenant_ctx(args: &TestArgs) -> Result<(TenantCtx, String, bool)> {
283    let env: EnvId = args.env.clone().try_into().context("invalid --env")?;
284    let tenant: TenantId = args.tenant.clone().try_into().context("invalid --tenant")?;
285    let mut ctx = TenantCtx::new(env, tenant);
286    if let Some(team) = &args.team {
287        let team: TeamId = team.clone().try_into().context("invalid --team")?;
288        ctx = ctx.with_team(Some(team));
289    }
290    if let Some(user) = &args.user {
291        let user: UserId = user.clone().try_into().context("invalid --user")?;
292        ctx = ctx.with_user(Some(user));
293    }
294
295    let (session_id, generated) = match &args.session {
296        Some(session) => (session.clone(), false),
297        None => (Uuid::new_v4().to_string(), true),
298    };
299    ctx = ctx.with_session(session_id.clone());
300
301    if let Some(flow) = &args.flow {
302        ctx = ctx.with_flow(flow.clone());
303    }
304    if let Some(node) = &args.node {
305        ctx = ctx.with_node(node.clone());
306    }
307
308    Ok((ctx, session_id, generated))
309}
310
311fn resolve_trace_out(args: &TestArgs) -> Result<Option<PathBuf>> {
312    if let Some(path) = &args.trace_out {
313        return Ok(Some(path.clone()));
314    }
315    let value = std::env::var("GREENTIC_TRACE_OUT").ok();
316    Ok(value
317        .filter(|path| !path.trim().is_empty())
318        .map(PathBuf::from))
319}
320
321fn state_prefix(flow: Option<&str>, session: &str) -> String {
322    if let Some(flow) = flow {
323        format!("flow/{flow}/{session}")
324    } else {
325        format!("test/{session}")
326    }
327}
328
329fn resolve_wasi_preopens(manifest: &ComponentManifest) -> Result<Vec<WasiPreopen>> {
330    let Some(fs) = manifest.capabilities.wasi.filesystem.as_ref() else {
331        return Ok(Vec::new());
332    };
333    if fs.mode == FilesystemMode::None {
334        return Ok(Vec::new());
335    }
336    let host_root =
337        std::env::current_dir().context("resolve current working directory for mounts")?;
338    let meta = fs::metadata(&host_root)
339        .with_context(|| format!("failed to stat preopen {}", host_root.display()))?;
340    if !meta.is_dir() {
341        bail!("preopen {} must be a directory", host_root.display());
342    }
343    let read_only = matches!(fs.mode, FilesystemMode::ReadOnly);
344    let mut preopens = Vec::new();
345    for mount in &fs.mounts {
346        preopens.push(WasiPreopen::new(&host_root, mount.guest_path.clone()).read_only(read_only));
347    }
348    Ok(preopens)
349}
350
351fn state_permissions(
352    manifest_value: &Value,
353    manifest: &crate::manifest::ComponentManifest,
354) -> (bool, bool, bool) {
355    let mut allow_state_read = false;
356    let mut allow_state_write = false;
357    if let Some(state) = manifest.capabilities.host.state.as_ref() {
358        allow_state_read = state.read;
359        allow_state_write = state.write;
360    }
361    let allow_state_delete = manifest_value
362        .get("capabilities")
363        .and_then(|caps| caps.get("host"))
364        .and_then(|host| host.get("state"))
365        .and_then(|state| state.get("delete"))
366        .and_then(|value| value.as_bool())
367        .unwrap_or(false);
368    if allow_state_delete && !allow_state_write {
369        allow_state_write = true;
370    }
371    (allow_state_read, allow_state_write, allow_state_delete)
372}
373
374fn secret_permissions(manifest: &crate::manifest::ComponentManifest) -> (bool, HashSet<String>) {
375    let Some(secrets) = manifest.capabilities.host.secrets.as_ref() else {
376        return (false, HashSet::new());
377    };
378    let allowed = secrets
379        .required
380        .iter()
381        .map(|req| req.key.as_str().to_string())
382        .collect::<HashSet<_>>();
383    (true, allowed)
384}
385
386fn load_secrets(args: &TestArgs) -> Result<HashMap<String, String>> {
387    let mut secrets = HashMap::new();
388    if let Some(path) = &args.secrets {
389        let entries = parse_env_file(path)?;
390        secrets.extend(entries);
391    }
392    if let Some(path) = &args.secrets_json {
393        let entries = parse_json_secrets(path)?;
394        secrets.extend(entries);
395    }
396    for entry in &args.secret {
397        let (key, value) = entry
398            .split_once('=')
399            .ok_or_else(|| anyhow::anyhow!("invalid --secret `{entry}`; use KEY=VALUE"))?;
400        secrets.insert(key.to_string(), value.to_string());
401    }
402    Ok(secrets)
403}
404
405fn parse_state_seeds(args: &TestArgs) -> Result<Vec<(String, Vec<u8>)>> {
406    let mut seeds = Vec::new();
407    for entry in &args.state_set {
408        let (key, value) = entry
409            .split_once('=')
410            .ok_or_else(|| anyhow::anyhow!("invalid --state-set `{entry}`; use KEY=BASE64"))?;
411        let bytes = BASE64_STANDARD
412            .decode(value)
413            .with_context(|| format!("invalid base64 for state key `{key}`"))?;
414        seeds.push((key.to_string(), bytes));
415    }
416    Ok(seeds)
417}
418
419fn parse_env_file(path: &Path) -> Result<HashMap<String, String>> {
420    let contents =
421        fs::read_to_string(path).with_context(|| format!("read secrets {}", path.display()))?;
422    let mut secrets = HashMap::new();
423    for (idx, line) in contents.lines().enumerate() {
424        let line = line.trim();
425        if line.is_empty() || line.starts_with('#') {
426            continue;
427        }
428        let (key, value) = line.split_once('=').ok_or_else(|| {
429            anyhow::anyhow!(
430                "invalid secrets line {} in {} (expected KEY=VALUE)",
431                idx + 1,
432                path.display()
433            )
434        })?;
435        secrets.insert(key.trim().to_string(), value.trim().to_string());
436    }
437    Ok(secrets)
438}
439
440fn parse_json_secrets(path: &Path) -> Result<HashMap<String, String>> {
441    let contents =
442        fs::read_to_string(path).with_context(|| format!("read secrets {}", path.display()))?;
443    let value: Value = serde_json::from_str(&contents).context("secrets JSON must be valid")?;
444    let obj = value
445        .as_object()
446        .ok_or_else(|| anyhow::anyhow!("secrets JSON must be an object map"))?;
447    let mut secrets = HashMap::new();
448    for (key, value) in obj {
449        let value = value
450            .as_str()
451            .ok_or_else(|| anyhow::anyhow!("secret `{key}` must be a string value"))?;
452        secrets.insert(key.clone(), value.to_string());
453    }
454    Ok(secrets)
455}
456
457fn format_output(raw: &str, pretty: bool) -> Result<String> {
458    if !pretty {
459        return Ok(raw.to_string());
460    }
461    let value: Value = serde_json::from_str(raw).context("output is not valid JSON")?;
462    Ok(serde_json::to_string_pretty(&value)?)
463}
464
465#[derive(Debug, Serialize)]
466struct TestErrorPayload {
467    code: String,
468    message: String,
469    #[serde(skip_serializing_if = "Option::is_none")]
470    details: Option<Value>,
471}
472
473#[derive(Debug)]
474pub struct TestCommandError {
475    payload: TestErrorPayload,
476    pretty: bool,
477}
478
479impl TestCommandError {
480    fn from_anyhow(err: anyhow::Error, pretty: bool) -> Self {
481        Self {
482            payload: error_payload_from_anyhow(&err),
483            pretty,
484        }
485    }
486
487    pub fn render_json(&self) -> String {
488        if self.pretty {
489            serde_json::to_string_pretty(&self.payload).unwrap_or_else(|_| "{}".to_string())
490        } else {
491            serde_json::to_string(&self.payload).unwrap_or_else(|_| "{}".to_string())
492        }
493    }
494}
495
496impl std::fmt::Display for TestCommandError {
497    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
498        write!(f, "{}: {}", self.payload.code, self.payload.message)
499    }
500}
501
502impl std::error::Error for TestCommandError {}
503
504fn component_error_details(error: &ComponentInvokeError) -> Option<Value> {
505    let mut details = Map::new();
506    details.insert("retryable".into(), Value::Bool(error.retryable));
507    if let Some(backoff_ms) = error.backoff_ms {
508        details.insert("backoff_ms".into(), Value::Number(backoff_ms.into()));
509    }
510    if let Some(raw) = &error.details {
511        let parsed = serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
512        details.insert("details".into(), parsed);
513    }
514    if details.is_empty() {
515        None
516    } else {
517        Some(Value::Object(details))
518    }
519}
520
521fn error_payload_from_anyhow(err: &anyhow::Error) -> TestErrorPayload {
522    if let Some(component_err) = err
523        .chain()
524        .find_map(|source| source.downcast_ref::<ComponentInvokeError>())
525    {
526        return TestErrorPayload {
527            code: component_err.code.clone(),
528            message: component_err.message.clone(),
529            details: component_error_details(component_err),
530        };
531    }
532
533    TestErrorPayload {
534        code: "test.failure".to_string(),
535        message: err.to_string(),
536        details: None,
537    }
538}
539
540#[derive(Debug, Serialize)]
541struct TraceRecord {
542    trace_version: u8,
543    component_id: String,
544    operation: String,
545    input_hash: Option<String>,
546    output_hash: Option<String>,
547    duration_ms: u64,
548    #[serde(skip_serializing_if = "Option::is_none")]
549    error: Option<TestErrorPayload>,
550}
551
552struct TraceContext {
553    out_path: Option<PathBuf>,
554    component_id: String,
555    operation: String,
556    input_hash: Option<String>,
557    output_hash: Option<String>,
558}
559
560impl TraceContext {
561    fn new(
562        out_path: Option<&Path>,
563        manifest: &ComponentManifest,
564        steps: &[(String, Value)],
565    ) -> Self {
566        let (operation, input_hash) = match steps.first() {
567            Some((op, input)) => (op.clone(), Some(hash_json_value(input))),
568            None => ("unknown".to_string(), None),
569        };
570        Self {
571            out_path: out_path.map(|path| path.to_path_buf()),
572            component_id: manifest.id.as_str().to_string(),
573            operation,
574            input_hash,
575            output_hash: None,
576        }
577    }
578
579    fn write(&self, duration_ms: u64, error: Option<TestErrorPayload>) -> Result<()> {
580        let Some(path) = self.out_path.as_deref() else {
581            return Ok(());
582        };
583        let record = TraceRecord {
584            trace_version: 1,
585            component_id: self.component_id.clone(),
586            operation: self.operation.clone(),
587            input_hash: self.input_hash.clone(),
588            output_hash: self.output_hash.clone(),
589            duration_ms,
590            error,
591        };
592        let json = serde_json::to_string_pretty(&record).context("serialize trace JSON")?;
593        fs::write(path, json).with_context(|| format!("write trace {}", path.display()))?;
594        Ok(())
595    }
596}
597
598fn hash_json_value(value: &Value) -> String {
599    let raw = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
600    hash_bytes(raw.as_bytes())
601}
602
603fn hash_bytes(bytes: &[u8]) -> String {
604    let mut hasher = Hasher::new();
605    hasher.update(bytes);
606    format!("blake3:{}", hasher.finalize().to_hex())
607}
608
609fn duration_ms(duration: std::time::Duration) -> u64 {
610    duration.as_millis().try_into().unwrap_or(u64::MAX)
611}