Skip to main content

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 super::component_world::canonical_component_world;
16use crate::capabilities::FilesystemMode;
17use crate::manifest::ComponentManifest;
18use crate::manifest::parse_manifest;
19use crate::test_harness::{
20    ComponentInvokeError, HarnessConfig, HarnessError, InvokeOutcome, TestHarness, WasiPreopen,
21};
22use greentic_types::{EnvId, TeamId, TenantCtx, TenantId, UserId};
23
24const MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
25
26#[derive(Clone, Debug, ValueEnum)]
27pub enum StateMode {
28    Inmem,
29}
30
31#[derive(Args, Debug)]
32pub struct TestArgs {
33    /// Path to the component wasm binary.
34    #[arg(long, value_name = "PATH")]
35    pub wasm: PathBuf,
36    /// Component world to invoke.
37    #[arg(long, default_value = "greentic:component/component@0.5.0")]
38    pub world: String,
39    /// Optional manifest path (defaults to component.manifest.json next to the wasm).
40    #[arg(long, value_name = "PATH")]
41    pub manifest: Option<PathBuf>,
42    /// Operation to invoke (repeat for multi-step runs).
43    #[arg(long, value_name = "OP", action = ArgAction::Append)]
44    pub op: Vec<String>,
45    /// Input JSON file path (repeat for multi-step runs).
46    #[arg(long, value_name = "PATH", action = ArgAction::Append, conflicts_with = "input_json")]
47    pub input: Vec<PathBuf>,
48    /// Inline input JSON string (repeat for multi-step runs).
49    #[arg(long, value_name = "JSON", action = ArgAction::Append, conflicts_with = "input")]
50    pub input_json: Vec<String>,
51    /// Write output JSON to a file.
52    #[arg(long, value_name = "PATH")]
53    pub output: Option<PathBuf>,
54    /// Optional component configuration JSON (file path or inline JSON).
55    #[arg(long, value_name = "PATH|JSON")]
56    pub config: Option<String>,
57    /// Write trace JSON output (overrides GREENTIC_TRACE_OUT).
58    #[arg(long, value_name = "PATH")]
59    pub trace_out: Option<PathBuf>,
60    /// Pretty-print JSON output.
61    #[arg(long)]
62    pub pretty: bool,
63    /// Emit raw (legacy) output without the JSON envelope.
64    #[arg(long)]
65    pub raw_output: bool,
66    /// Run in dry-run mode (disables HTTP and filesystem writes).
67    #[arg(long, default_value_t = true, value_name = "BOOL", action = ArgAction::Set)]
68    pub dry_run: bool,
69    /// Allow HTTP requests (ignored in dry-run).
70    #[arg(long)]
71    pub allow_http: bool,
72    /// Allow filesystem writes (ignored in dry-run).
73    #[arg(long)]
74    pub allow_fs_write: bool,
75    /// Timeout in milliseconds.
76    #[arg(long, default_value_t = 2000, value_name = "MS")]
77    pub timeout_ms: u64,
78    /// Max memory in megabytes.
79    #[arg(long, default_value_t = 256, value_name = "MB")]
80    pub max_memory_mb: u64,
81    /// State backend (only inmem is supported).
82    #[arg(long, value_enum, default_value = "inmem")]
83    pub state: StateMode,
84    /// Dump in-memory state after invocation.
85    #[arg(long)]
86    pub state_dump: bool,
87    /// Seed in-memory state as KEY=BASE64 (repeatable).
88    #[arg(long = "state-set", value_name = "KEY=BASE64")]
89    pub state_set: Vec<String>,
90    /// Repeatable step marker for multi-step runs.
91    #[arg(long, action = ArgAction::Count)]
92    pub step: u8,
93    /// Load secrets from a .env style file.
94    #[arg(long, value_name = "PATH")]
95    pub secrets: Option<PathBuf>,
96    /// Load secrets from a JSON map file.
97    #[arg(long, value_name = "PATH")]
98    pub secrets_json: Option<PathBuf>,
99    /// Provide a secret inline as KEY=VALUE (repeatable).
100    #[arg(long = "secret", value_name = "KEY=VALUE")]
101    pub secret: Vec<String>,
102    /// Environment identifier for the exec context.
103    #[arg(long, default_value = "dev")]
104    pub env: String,
105    /// Tenant identifier for the exec context.
106    #[arg(long, default_value = "default")]
107    pub tenant: String,
108    /// Optional team identifier for the exec context.
109    #[arg(long)]
110    pub team: Option<String>,
111    /// Optional user identifier for the exec context.
112    #[arg(long)]
113    pub user: Option<String>,
114    /// Optional flow identifier for the exec context.
115    #[arg(long)]
116    pub flow: Option<String>,
117    /// Optional node identifier for the exec context.
118    #[arg(long)]
119    pub node: Option<String>,
120    /// Optional session identifier for the exec context.
121    #[arg(long)]
122    pub session: Option<String>,
123    /// Emit extra diagnostic output (e.g. generated session id).
124    #[arg(long)]
125    pub verbose: bool,
126}
127
128pub fn run(args: TestArgs) -> Result<()> {
129    let trace_out = resolve_trace_out(&args)?;
130    match run_inner(&args, trace_out.as_deref()) {
131        Ok(()) => Ok(()),
132        Err(err) => Err(TestCommandError::from_anyhow(
133            err,
134            args.pretty,
135            args.raw_output,
136            &args.world,
137            &args.wasm,
138        )
139        .into()),
140    }
141}
142
143fn run_inner(args: &TestArgs, trace_out: Option<&Path>) -> Result<()> {
144    if args.world != canonical_component_world() {
145        return Err(anyhow::Error::new(UnsupportedWorldError {
146            world: args.world.clone(),
147        }));
148    }
149
150    let manifest_path = resolve_manifest_path(&args.wasm, args.manifest.as_deref())?;
151    let manifest_raw = fs::read_to_string(&manifest_path)
152        .with_context(|| format!("read manifest {}", manifest_path.display()))?;
153    let manifest_value: Value =
154        serde_json::from_str(&manifest_raw).context("manifest must be valid JSON")?;
155    let manifest = parse_manifest(&manifest_raw).context("parse manifest")?;
156
157    let steps = collect_steps(args)?;
158    let mut trace = TraceContext::new(trace_out, &manifest, &steps);
159    let start = Instant::now();
160
161    let mut timing = TimingMs::default();
162    let mut secret_values: Vec<String> = Vec::new();
163
164    let result = (|| -> Result<Vec<String>> {
165        for (op, _) in &steps {
166            if !manifest
167                .operations
168                .iter()
169                .any(|operation| operation.name == *op)
170            {
171                bail!("operation `{op}` not declared in manifest");
172            }
173        }
174        let wasm_bytes =
175            fs::read(&args.wasm).with_context(|| format!("read wasm {}", args.wasm.display()))?;
176
177        let (tenant_ctx, session_id, generated_session) = build_tenant_ctx(args)?;
178        if args.verbose && generated_session {
179            eprintln!("generated session id");
180        }
181
182        let (allow_state_read, allow_state_write, allow_state_delete) =
183            state_permissions(&manifest_value, &manifest);
184        if !args.state_set.is_empty() && !allow_state_write {
185            bail!("manifest does not declare host.state.write; add it to use --state-set");
186        }
187        let (allow_secrets, allowed_secrets) = secret_permissions(&manifest);
188
189        let secrets = load_secrets(args)?;
190        if !allow_secrets && !secrets.is_empty() {
191            bail!(
192                "manifest does not declare host.secrets; add host.secrets to enable secrets access"
193            );
194        }
195        secret_values = secrets
196            .values()
197            .filter(|value| !value.is_empty())
198            .cloned()
199            .collect();
200
201        let config = load_config(args)?;
202        let state_seeds = parse_state_seeds(args)?;
203        let allow_http = args.allow_http && !args.dry_run;
204        let allow_fs_write = args.allow_fs_write && !args.dry_run;
205        let max_memory_bytes = parse_max_memory_bytes(args.max_memory_mb)?;
206        let wasi_preopens = resolve_wasi_preopens(&manifest, allow_fs_write, args.dry_run)?;
207        let prefix = state_prefix(args.flow.as_deref(), &session_id);
208        let flow_id = args.flow.clone().unwrap_or_else(|| "test".to_string());
209        let harness = TestHarness::new(HarnessConfig {
210            wasm_bytes,
211            tenant_ctx: tenant_ctx.clone(),
212            flow_id,
213            node_id: args.node.clone(),
214            state_prefix: prefix,
215            state_seeds,
216            allow_state_read,
217            allow_state_write,
218            allow_state_delete,
219            allow_secrets,
220            allowed_secrets,
221            secrets,
222            wasi_preopens,
223            config,
224            allow_http,
225            timeout_ms: args.timeout_ms,
226            max_memory_bytes,
227        })?;
228
229        if steps.len() > 1 && args.output.is_some() {
230            bail!("--output is only supported for single-step runs");
231        }
232
233        let mut outputs = Vec::new();
234        for (op, input) in steps.iter() {
235            let InvokeOutcome {
236                output_json,
237                instantiate_ms,
238                run_ms,
239            } = harness.invoke(op, input)?;
240            if output_json.len() > MAX_OUTPUT_BYTES {
241                return Err(anyhow::Error::new(OutputLimitError {
242                    limit: MAX_OUTPUT_BYTES,
243                    actual: output_json.len(),
244                }));
245            }
246            timing.instantiate = timing.instantiate.saturating_add(instantiate_ms);
247            timing.run = timing.run.saturating_add(run_ms);
248            outputs.push(output_json);
249        }
250
251        if args.state_dump {
252            let dump = harness.state_dump();
253            let dump_json = serde_json::to_string_pretty(&dump).unwrap_or_else(|_| "{}".into());
254            eprintln!("state dump:\n{dump_json}");
255        }
256
257        Ok(outputs)
258    })();
259
260    timing.total = duration_ms(start.elapsed());
261    match result {
262        Ok(outputs) => {
263            if outputs.len() == 1 {
264                trace.output_hash = Some(hash_bytes(outputs[0].as_bytes()));
265            }
266
267            let mut redacted_outputs = Vec::new();
268            for raw in &outputs {
269                let mut value: Value =
270                    serde_json::from_str(raw).context("output is not valid JSON")?;
271                redact_value(&mut value, &secret_values);
272                redacted_outputs.push(value);
273            }
274
275            if args.raw_output {
276                for (idx, value) in redacted_outputs.iter().enumerate() {
277                    let output = format_value_output(value, args.pretty)?;
278                    if let Some(path) = &args.output {
279                        fs::write(path, output.as_bytes())
280                            .with_context(|| format!("write output {}", path.display()))?;
281                    }
282                    if redacted_outputs.len() > 1 {
283                        println!("step {} output:\n{output}", idx + 1);
284                    } else {
285                        println!("{output}");
286                    }
287                }
288            } else {
289                let result_value = if redacted_outputs.len() == 1 {
290                    redacted_outputs[0].clone()
291                } else {
292                    Value::Array(redacted_outputs)
293                };
294                let envelope = TestOutputEnvelope {
295                    status: "ok".to_string(),
296                    world: args.world.clone(),
297                    wasm: args.wasm.display().to_string(),
298                    result: Some(result_value),
299                    diagnostics: Vec::new(),
300                    timing_ms: timing,
301                };
302                let output = format_envelope_output(&envelope, args.pretty)?;
303                if let Some(path) = &args.output {
304                    fs::write(path, output.as_bytes())
305                        .with_context(|| format!("write output {}", path.display()))?;
306                }
307                println!("{output}");
308            }
309
310            trace.write(timing.total, None)?;
311            Ok(())
312        }
313        Err(err) => {
314            let mut payload = error_payload_from_anyhow(&err);
315            redact_error_payload(&mut payload, &secret_values);
316            let failure = TestRunFailure {
317                payload: payload.clone(),
318                world: args.world.clone(),
319                wasm: args.wasm.clone(),
320                timing_ms: timing,
321            };
322            if let Err(trace_err) = trace.write(timing.total, Some(payload)) {
323                eprintln!("failed to write trace: {trace_err}");
324            }
325            if let Some(path) = trace.out_path.as_deref() {
326                eprintln!("#TRY_SAVE_TRACE {}", path.display());
327            }
328            Err(anyhow::Error::new(failure))
329        }
330    }
331}
332
333fn resolve_manifest_path(wasm: &Path, manifest: Option<&Path>) -> Result<PathBuf> {
334    if let Some(path) = manifest {
335        return Ok(path.to_path_buf());
336    }
337    let dir = wasm
338        .parent()
339        .ok_or_else(|| anyhow::anyhow!("wasm path has no parent directory"))?;
340    let candidate = dir.join("component.manifest.json");
341    if candidate.exists() {
342        Ok(candidate)
343    } else {
344        bail!(
345            "manifest not found; pass --manifest or place component.manifest.json next to the wasm"
346        );
347    }
348}
349
350fn collect_steps(args: &TestArgs) -> Result<Vec<(String, Value)>> {
351    if args.op.is_empty() {
352        bail!("--op is required");
353    }
354    let inputs = if !args.input.is_empty() {
355        let mut values = Vec::new();
356        for path in &args.input {
357            let raw = fs::read_to_string(path)
358                .with_context(|| format!("read input {}", path.display()))?;
359            values.push(serde_json::from_str(&raw).context("input file must be valid JSON")?);
360        }
361        values
362    } else if !args.input_json.is_empty() {
363        let mut values = Vec::new();
364        for raw in &args.input_json {
365            values.push(serde_json::from_str(raw).context("input-json must be valid JSON")?);
366        }
367        values
368    } else {
369        bail!("--input or --input-json is required");
370    };
371
372    if args.op.len() != inputs.len() {
373        bail!("provide the same number of --op and --input/--input-json values");
374    }
375    if args.op.len() > 1 {
376        let expected_steps = args.op.len().saturating_sub(1);
377        if args.step == 0 {
378            bail!("use --step to indicate a multi-step run");
379        }
380        if args.step as usize != expected_steps {
381            bail!(
382                "expected {expected_steps} --step flags for {} operations",
383                args.op.len()
384            );
385        }
386    }
387
388    Ok(args.op.clone().into_iter().zip(inputs).collect())
389}
390
391fn build_tenant_ctx(args: &TestArgs) -> Result<(TenantCtx, String, bool)> {
392    let env: EnvId = args.env.clone().try_into().context("invalid --env")?;
393    let tenant: TenantId = args.tenant.clone().try_into().context("invalid --tenant")?;
394    let mut ctx = TenantCtx::new(env, tenant);
395    if let Some(team) = &args.team {
396        let team: TeamId = team.clone().try_into().context("invalid --team")?;
397        ctx = ctx.with_team(Some(team));
398    }
399    if let Some(user) = &args.user {
400        let user: UserId = user.clone().try_into().context("invalid --user")?;
401        ctx = ctx.with_user(Some(user));
402    }
403
404    let (session_id, generated) = match &args.session {
405        Some(session) => (session.clone(), false),
406        None => (Uuid::new_v4().to_string(), true),
407    };
408    ctx = ctx.with_session(session_id.clone());
409
410    if let Some(flow) = &args.flow {
411        ctx = ctx.with_flow(flow.clone());
412    }
413    if let Some(node) = &args.node {
414        ctx = ctx.with_node(node.clone());
415    }
416
417    Ok((ctx, session_id, generated))
418}
419
420fn resolve_trace_out(args: &TestArgs) -> Result<Option<PathBuf>> {
421    if let Some(path) = &args.trace_out {
422        return Ok(Some(path.clone()));
423    }
424    let value = std::env::var("GREENTIC_TRACE_OUT").ok();
425    Ok(value
426        .filter(|path| !path.trim().is_empty())
427        .map(PathBuf::from))
428}
429
430fn state_prefix(flow: Option<&str>, session: &str) -> String {
431    if let Some(flow) = flow {
432        format!("flow/{flow}/{session}")
433    } else {
434        format!("test/{session}")
435    }
436}
437
438fn resolve_wasi_preopens(
439    manifest: &ComponentManifest,
440    allow_fs_write: bool,
441    dry_run: bool,
442) -> Result<Vec<WasiPreopen>> {
443    let Some(fs) = manifest.capabilities.wasi.filesystem.as_ref() else {
444        return Ok(Vec::new());
445    };
446    if fs.mode == FilesystemMode::None {
447        return Ok(Vec::new());
448    }
449    let host_root =
450        std::env::current_dir().context("resolve current working directory for mounts")?;
451    let meta = fs::metadata(&host_root)
452        .with_context(|| format!("failed to stat preopen {}", host_root.display()))?;
453    if !meta.is_dir() {
454        bail!("preopen {} must be a directory", host_root.display());
455    }
456    let mut read_only = matches!(fs.mode, FilesystemMode::ReadOnly);
457    if dry_run || !allow_fs_write {
458        read_only = true;
459    }
460    let mut preopens = Vec::new();
461    for mount in &fs.mounts {
462        preopens.push(WasiPreopen::new(&host_root, mount.guest_path.clone()).read_only(read_only));
463    }
464    Ok(preopens)
465}
466
467fn state_permissions(
468    manifest_value: &Value,
469    manifest: &crate::manifest::ComponentManifest,
470) -> (bool, bool, bool) {
471    let mut allow_state_read = false;
472    let mut allow_state_write = false;
473    if let Some(state) = manifest.capabilities.host.state.as_ref() {
474        allow_state_read = state.read;
475        allow_state_write = state.write;
476    }
477    let allow_state_delete = manifest_value
478        .get("capabilities")
479        .and_then(|caps| caps.get("host"))
480        .and_then(|host| host.get("state"))
481        .and_then(|state| state.get("delete"))
482        .and_then(|value| value.as_bool())
483        .unwrap_or(false);
484    if allow_state_delete && !allow_state_write {
485        allow_state_write = true;
486    }
487    (allow_state_read, allow_state_write, allow_state_delete)
488}
489
490fn secret_permissions(manifest: &crate::manifest::ComponentManifest) -> (bool, HashSet<String>) {
491    let Some(secrets) = manifest.capabilities.host.secrets.as_ref() else {
492        return (false, HashSet::new());
493    };
494    let allowed = secrets
495        .required
496        .iter()
497        .map(|req| req.key.as_str().to_string())
498        .collect::<HashSet<_>>();
499    (true, allowed)
500}
501
502fn load_secrets(args: &TestArgs) -> Result<HashMap<String, String>> {
503    let mut secrets = HashMap::new();
504    if let Some(path) = &args.secrets {
505        let entries = parse_env_file(path)?;
506        secrets.extend(entries);
507    }
508    if let Some(path) = &args.secrets_json {
509        let entries = parse_json_secrets(path)?;
510        secrets.extend(entries);
511    }
512    for entry in &args.secret {
513        let (key, value) = entry
514            .split_once('=')
515            .ok_or_else(|| anyhow::anyhow!("invalid --secret `{entry}`; use KEY=VALUE"))?;
516        secrets.insert(key.to_string(), value.to_string());
517    }
518    Ok(secrets)
519}
520
521fn parse_state_seeds(args: &TestArgs) -> Result<Vec<(String, Vec<u8>)>> {
522    let mut seeds = Vec::new();
523    for entry in &args.state_set {
524        let (key, value) = entry
525            .split_once('=')
526            .ok_or_else(|| anyhow::anyhow!("invalid --state-set `{entry}`; use KEY=BASE64"))?;
527        let bytes = BASE64_STANDARD
528            .decode(value)
529            .with_context(|| format!("invalid base64 for state key `{key}`"))?;
530        seeds.push((key.to_string(), bytes));
531    }
532    Ok(seeds)
533}
534
535fn parse_env_file(path: &Path) -> Result<HashMap<String, String>> {
536    let contents =
537        fs::read_to_string(path).with_context(|| format!("read secrets {}", path.display()))?;
538    let mut secrets = HashMap::new();
539    for (idx, line) in contents.lines().enumerate() {
540        let line = line.trim();
541        if line.is_empty() || line.starts_with('#') {
542            continue;
543        }
544        let (key, value) = line.split_once('=').ok_or_else(|| {
545            anyhow::anyhow!(
546                "invalid secrets line {} in {} (expected KEY=VALUE)",
547                idx + 1,
548                path.display()
549            )
550        })?;
551        secrets.insert(key.trim().to_string(), value.trim().to_string());
552    }
553    Ok(secrets)
554}
555
556fn parse_json_secrets(path: &Path) -> Result<HashMap<String, String>> {
557    let contents =
558        fs::read_to_string(path).with_context(|| format!("read secrets {}", path.display()))?;
559    let value: Value = serde_json::from_str(&contents).context("secrets JSON must be valid")?;
560    let obj = value
561        .as_object()
562        .ok_or_else(|| anyhow::anyhow!("secrets JSON must be an object map"))?;
563    let mut secrets = HashMap::new();
564    for (key, value) in obj {
565        let value = value
566            .as_str()
567            .ok_or_else(|| anyhow::anyhow!("secret `{key}` must be a string value"))?;
568        secrets.insert(key.clone(), value.to_string());
569    }
570    Ok(secrets)
571}
572
573fn load_config(args: &TestArgs) -> Result<Option<Value>> {
574    let Some(raw) = &args.config else {
575        return Ok(None);
576    };
577    let path = Path::new(raw);
578    let contents = if path.exists() {
579        fs::read_to_string(path).with_context(|| format!("read config {}", path.display()))?
580    } else {
581        raw.clone()
582    };
583    let value: Value = serde_json::from_str(&contents).context("config must be valid JSON")?;
584    Ok(Some(value))
585}
586
587fn parse_max_memory_bytes(max_memory_mb: u64) -> Result<usize> {
588    let bytes = max_memory_mb
589        .checked_mul(1024 * 1024)
590        .ok_or_else(|| anyhow::anyhow!("max memory MB is too large"))?;
591    usize::try_from(bytes).context("max memory MB is too large for this platform")
592}
593
594fn format_value_output(value: &Value, pretty: bool) -> Result<String> {
595    if pretty {
596        Ok(serde_json::to_string_pretty(value)?)
597    } else {
598        Ok(serde_json::to_string(value)?)
599    }
600}
601
602fn format_envelope_output(envelope: &TestOutputEnvelope, pretty: bool) -> Result<String> {
603    if pretty {
604        Ok(serde_json::to_string_pretty(envelope)?)
605    } else {
606        Ok(serde_json::to_string(envelope)?)
607    }
608}
609
610#[derive(Debug, Serialize, Clone)]
611struct TestErrorPayload {
612    code: String,
613    message: String,
614    #[serde(skip_serializing_if = "Option::is_none")]
615    details: Option<Value>,
616}
617
618#[derive(Debug, Serialize, Clone)]
619struct Diagnostic {
620    severity: String,
621    code: String,
622    message: String,
623    #[serde(skip_serializing_if = "Option::is_none")]
624    details: Option<Value>,
625    #[serde(skip_serializing_if = "Option::is_none")]
626    path: Option<String>,
627    #[serde(skip_serializing_if = "Option::is_none")]
628    hint: Option<String>,
629}
630
631#[derive(Debug, Serialize, Clone, Copy, Default)]
632struct TimingMs {
633    instantiate: u64,
634    run: u64,
635    total: u64,
636}
637
638#[derive(Debug, Serialize)]
639struct TestOutputEnvelope {
640    status: String,
641    world: String,
642    wasm: String,
643    result: Option<Value>,
644    diagnostics: Vec<Diagnostic>,
645    timing_ms: TimingMs,
646}
647
648#[derive(Debug)]
649struct TestRunFailure {
650    payload: TestErrorPayload,
651    world: String,
652    wasm: PathBuf,
653    timing_ms: TimingMs,
654}
655
656#[derive(Debug)]
657enum TestErrorOutput {
658    Raw(TestErrorPayload),
659    Envelope(TestOutputEnvelope),
660}
661
662#[derive(Debug)]
663pub struct TestCommandError {
664    output: TestErrorOutput,
665    pretty: bool,
666}
667
668impl TestCommandError {
669    fn from_anyhow(
670        err: anyhow::Error,
671        pretty: bool,
672        raw_output: bool,
673        world: &str,
674        wasm: &Path,
675    ) -> Self {
676        if let Some(failure) = err.downcast_ref::<TestRunFailure>() {
677            if raw_output {
678                return Self {
679                    output: TestErrorOutput::Raw(failure.payload.clone()),
680                    pretty,
681                };
682            }
683            let envelope = TestOutputEnvelope {
684                status: "error".to_string(),
685                world: failure.world.clone(),
686                wasm: failure.wasm.display().to_string(),
687                result: None,
688                diagnostics: vec![diagnostic_from_payload(&failure.payload)],
689                timing_ms: failure.timing_ms,
690            };
691            return Self {
692                output: TestErrorOutput::Envelope(envelope),
693                pretty,
694            };
695        }
696
697        let payload = error_payload_from_anyhow(&err);
698        if raw_output {
699            return Self {
700                output: TestErrorOutput::Raw(payload),
701                pretty,
702            };
703        }
704        let envelope = TestOutputEnvelope {
705            status: "error".to_string(),
706            world: world.to_string(),
707            wasm: wasm.display().to_string(),
708            result: None,
709            diagnostics: vec![diagnostic_from_payload(&payload)],
710            timing_ms: TimingMs::default(),
711        };
712        Self {
713            output: TestErrorOutput::Envelope(envelope),
714            pretty,
715        }
716    }
717
718    pub fn render_json(&self) -> String {
719        match &self.output {
720            TestErrorOutput::Raw(payload) => {
721                if self.pretty {
722                    serde_json::to_string_pretty(payload).unwrap_or_else(|_| "{}".to_string())
723                } else {
724                    serde_json::to_string(payload).unwrap_or_else(|_| "{}".to_string())
725                }
726            }
727            TestErrorOutput::Envelope(envelope) => {
728                if self.pretty {
729                    serde_json::to_string_pretty(envelope).unwrap_or_else(|_| "{}".to_string())
730                } else {
731                    serde_json::to_string(envelope).unwrap_or_else(|_| "{}".to_string())
732                }
733            }
734        }
735    }
736}
737
738impl std::fmt::Display for TestCommandError {
739    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
740        match &self.output {
741            TestErrorOutput::Raw(payload) => write!(f, "{}: {}", payload.code, payload.message),
742            TestErrorOutput::Envelope(envelope) => {
743                if let Some(diag) = envelope.diagnostics.first() {
744                    write!(f, "{}: {}", diag.code, diag.message)
745                } else {
746                    write!(f, "test.failure: test failure")
747                }
748            }
749        }
750    }
751}
752
753impl std::error::Error for TestCommandError {}
754
755impl std::fmt::Display for TestRunFailure {
756    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
757        write!(f, "{}: {}", self.payload.code, self.payload.message)
758    }
759}
760
761impl std::error::Error for TestRunFailure {}
762
763#[derive(Debug)]
764struct UnsupportedWorldError {
765    world: String,
766}
767
768impl std::fmt::Display for UnsupportedWorldError {
769    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
770        write!(
771            f,
772            "Unsupported world '{}'. Supported: {}",
773            self.world,
774            canonical_component_world()
775        )
776    }
777}
778
779impl std::error::Error for UnsupportedWorldError {}
780
781#[derive(Debug)]
782struct OutputLimitError {
783    limit: usize,
784    actual: usize,
785}
786
787impl std::fmt::Display for OutputLimitError {
788    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789        write!(
790            f,
791            "output size {} bytes exceeds limit {} bytes",
792            self.actual, self.limit
793        )
794    }
795}
796
797impl std::error::Error for OutputLimitError {}
798
799fn diagnostic_from_payload(payload: &TestErrorPayload) -> Diagnostic {
800    Diagnostic {
801        severity: "error".to_string(),
802        code: payload.code.clone(),
803        message: payload.message.clone(),
804        details: payload.details.clone(),
805        path: None,
806        hint: None,
807    }
808}
809
810fn redact_error_payload(payload: &mut TestErrorPayload, secrets: &[String]) {
811    payload.message = redact_string(&payload.message, secrets);
812    if let Some(details) = payload.details.as_mut() {
813        redact_value(details, secrets);
814    }
815}
816
817fn redact_value(value: &mut Value, secrets: &[String]) {
818    match value {
819        Value::String(text) => {
820            *text = redact_string(text, secrets);
821        }
822        Value::Array(values) => {
823            for value in values {
824                redact_value(value, secrets);
825            }
826        }
827        Value::Object(map) => {
828            for value in map.values_mut() {
829                redact_value(value, secrets);
830            }
831        }
832        _ => {}
833    }
834}
835
836fn redact_string(value: &str, secrets: &[String]) -> String {
837    let mut out = value.to_string();
838    for secret in secrets {
839        if secret.is_empty() {
840            continue;
841        }
842        out = out.replace(secret, "***REDACTED***");
843    }
844    out
845}
846
847fn component_error_details(error: &ComponentInvokeError) -> Option<Value> {
848    let mut details = Map::new();
849    details.insert("retryable".into(), Value::Bool(error.retryable));
850    if let Some(backoff_ms) = error.backoff_ms {
851        details.insert("backoff_ms".into(), Value::Number(backoff_ms.into()));
852    }
853    if let Some(raw) = &error.details {
854        let parsed = serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
855        details.insert("details".into(), parsed);
856    }
857    if details.is_empty() {
858        None
859    } else {
860        Some(Value::Object(details))
861    }
862}
863
864fn error_payload_from_anyhow(err: &anyhow::Error) -> TestErrorPayload {
865    if let Some(harness_err) = err
866        .chain()
867        .find_map(|source| source.downcast_ref::<HarnessError>())
868    {
869        let (code, message) = match harness_err {
870            HarnessError::Timeout { .. } => ("test.timeout", harness_err.to_string()),
871            HarnessError::MemoryLimit { .. } => ("test.memory_limit", harness_err.to_string()),
872        };
873        return TestErrorPayload {
874            code: code.to_string(),
875            message,
876            details: None,
877        };
878    }
879
880    if let Some(world_err) = err
881        .chain()
882        .find_map(|source| source.downcast_ref::<UnsupportedWorldError>())
883    {
884        return TestErrorPayload {
885            code: "test.world.unsupported".to_string(),
886            message: world_err.to_string(),
887            details: None,
888        };
889    }
890
891    if let Some(limit_err) = err
892        .chain()
893        .find_map(|source| source.downcast_ref::<OutputLimitError>())
894    {
895        return TestErrorPayload {
896            code: "test.output.limit".to_string(),
897            message: limit_err.to_string(),
898            details: Some(serde_json::json!({
899                "limit": limit_err.limit,
900                "actual": limit_err.actual,
901            })),
902        };
903    }
904
905    if let Some(component_err) = err
906        .chain()
907        .find_map(|source| source.downcast_ref::<ComponentInvokeError>())
908    {
909        return TestErrorPayload {
910            code: component_err.code.clone(),
911            message: component_err.message.clone(),
912            details: component_error_details(component_err),
913        };
914    }
915
916    TestErrorPayload {
917        code: "test.failure".to_string(),
918        message: err.to_string(),
919        details: None,
920    }
921}
922
923#[derive(Debug, Serialize)]
924struct TraceRecord {
925    trace_version: u8,
926    component_id: String,
927    operation: String,
928    input_hash: Option<String>,
929    output_hash: Option<String>,
930    duration_ms: u64,
931    #[serde(skip_serializing_if = "Option::is_none")]
932    error: Option<TestErrorPayload>,
933}
934
935struct TraceContext {
936    out_path: Option<PathBuf>,
937    component_id: String,
938    operation: String,
939    input_hash: Option<String>,
940    output_hash: Option<String>,
941}
942
943impl TraceContext {
944    fn new(
945        out_path: Option<&Path>,
946        manifest: &ComponentManifest,
947        steps: &[(String, Value)],
948    ) -> Self {
949        let (operation, input_hash) = match steps.first() {
950            Some((op, input)) => (op.clone(), Some(hash_json_value(input))),
951            None => ("unknown".to_string(), None),
952        };
953        Self {
954            out_path: out_path.map(|path| path.to_path_buf()),
955            component_id: manifest.id.as_str().to_string(),
956            operation,
957            input_hash,
958            output_hash: None,
959        }
960    }
961
962    fn write(&self, duration_ms: u64, error: Option<TestErrorPayload>) -> Result<()> {
963        let Some(path) = self.out_path.as_deref() else {
964            return Ok(());
965        };
966        let record = TraceRecord {
967            trace_version: 1,
968            component_id: self.component_id.clone(),
969            operation: self.operation.clone(),
970            input_hash: self.input_hash.clone(),
971            output_hash: self.output_hash.clone(),
972            duration_ms,
973            error,
974        };
975        let json = serde_json::to_string_pretty(&record).context("serialize trace JSON")?;
976        fs::write(path, json).with_context(|| format!("write trace {}", path.display()))?;
977        Ok(())
978    }
979}
980
981fn hash_json_value(value: &Value) -> String {
982    let raw = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
983    hash_bytes(raw.as_bytes())
984}
985
986fn hash_bytes(bytes: &[u8]) -> String {
987    let mut hasher = Hasher::new();
988    hasher.update(bytes);
989    format!("blake3:{}", hasher.finalize().to_hex())
990}
991
992fn duration_ms(duration: std::time::Duration) -> u64 {
993    duration.as_millis().try_into().unwrap_or(u64::MAX)
994}
995
996#[cfg(test)]
997mod tests {
998    use super::*;
999
1000    #[test]
1001    fn raw_output_preserves_legacy_error_shape() {
1002        let payload = TestErrorPayload {
1003            code: "test.failure".to_string(),
1004            message: "boom".to_string(),
1005            details: None,
1006        };
1007        let failure = TestRunFailure {
1008            payload: payload.clone(),
1009            world: canonical_component_world().to_string(),
1010            wasm: PathBuf::from("component.wasm"),
1011            timing_ms: TimingMs::default(),
1012        };
1013        let rendered = TestCommandError::from_anyhow(
1014            anyhow::Error::new(failure),
1015            false,
1016            true,
1017            canonical_component_world(),
1018            Path::new("component.wasm"),
1019        )
1020        .render_json();
1021        let value: Value = serde_json::from_str(&rendered).expect("raw output json");
1022        assert_eq!(value["code"], payload.code);
1023        assert!(value.get("status").is_none());
1024    }
1025
1026    #[test]
1027    fn envelope_includes_timeout_error_code() {
1028        let payload =
1029            error_payload_from_anyhow(&anyhow::Error::new(HarnessError::Timeout { timeout_ms: 1 }));
1030        let failure = TestRunFailure {
1031            payload,
1032            world: canonical_component_world().to_string(),
1033            wasm: PathBuf::from("component.wasm"),
1034            timing_ms: TimingMs::default(),
1035        };
1036        let rendered = TestCommandError::from_anyhow(
1037            anyhow::Error::new(failure),
1038            false,
1039            false,
1040            canonical_component_world(),
1041            Path::new("component.wasm"),
1042        )
1043        .render_json();
1044        let value: Value = serde_json::from_str(&rendered).expect("envelope json");
1045        assert_eq!(value["status"], "error");
1046        assert_eq!(value["diagnostics"][0]["code"], "test.timeout");
1047    }
1048
1049    #[test]
1050    fn envelope_includes_memory_error_code() {
1051        let payload = error_payload_from_anyhow(&anyhow::Error::new(HarnessError::MemoryLimit {
1052            max_memory_bytes: 1024,
1053        }));
1054        let failure = TestRunFailure {
1055            payload,
1056            world: canonical_component_world().to_string(),
1057            wasm: PathBuf::from("component.wasm"),
1058            timing_ms: TimingMs::default(),
1059        };
1060        let rendered = TestCommandError::from_anyhow(
1061            anyhow::Error::new(failure),
1062            false,
1063            false,
1064            canonical_component_world(),
1065            Path::new("component.wasm"),
1066        )
1067        .render_json();
1068        let value: Value = serde_json::from_str(&rendered).expect("envelope json");
1069        assert_eq!(value["diagnostics"][0]["code"], "test.memory_limit");
1070    }
1071
1072    #[test]
1073    fn fs_write_flags_toggle_preopens() {
1074        let manifest_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1075            .join("tests/fixtures/manifests/valid.component.json");
1076        let manifest_raw = fs::read_to_string(&manifest_path).expect("manifest");
1077        let mut manifest = parse_manifest(&manifest_raw).expect("manifest parse");
1078        if let Some(fs_caps) = manifest.capabilities.wasi.filesystem.as_mut() {
1079            fs_caps.mode = FilesystemMode::Sandbox;
1080        }
1081
1082        let preopens = resolve_wasi_preopens(&manifest, false, false).expect("preopens");
1083        assert!(
1084            preopens.iter().all(|preopen| preopen.read_only),
1085            "expected read-only preopens by default"
1086        );
1087
1088        let preopens = resolve_wasi_preopens(&manifest, true, false).expect("preopens");
1089        assert!(
1090            preopens.iter().all(|preopen| !preopen.read_only),
1091            "expected writable preopens when allowed"
1092        );
1093
1094        let preopens = resolve_wasi_preopens(&manifest, true, true).expect("preopens");
1095        assert!(
1096            preopens.iter().all(|preopen| preopen.read_only),
1097            "expected dry-run to force read-only preopens"
1098        );
1099    }
1100
1101    #[test]
1102    fn redacts_secrets_in_output_and_diagnostics() {
1103        let secret = "super-secret".to_string();
1104        let mut value = serde_json::json!({
1105            "token": secret,
1106            "nested": { "value": "super-secret" }
1107        });
1108        redact_value(&mut value, &["super-secret".to_string()]);
1109        assert_eq!(value["token"], "***REDACTED***");
1110        assert_eq!(value["nested"]["value"], "***REDACTED***");
1111
1112        let mut payload = TestErrorPayload {
1113            code: "test.failure".to_string(),
1114            message: "super-secret failed".to_string(),
1115            details: Some(serde_json::json!({ "hint": "super-secret" })),
1116        };
1117        redact_error_payload(&mut payload, &["super-secret".to_string()]);
1118        assert!(!payload.message.contains("super-secret"));
1119        assert_eq!(payload.details.unwrap()["hint"], "***REDACTED***");
1120    }
1121}