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 #[arg(long, value_name = "PATH")]
35 pub wasm: PathBuf,
36 #[arg(long, default_value = "greentic:component/component@0.5.0")]
38 pub world: String,
39 #[arg(long, value_name = "PATH")]
41 pub manifest: Option<PathBuf>,
42 #[arg(long, value_name = "OP", action = ArgAction::Append)]
44 pub op: Vec<String>,
45 #[arg(long, value_name = "PATH", action = ArgAction::Append, conflicts_with = "input_json")]
47 pub input: Vec<PathBuf>,
48 #[arg(long, value_name = "JSON", action = ArgAction::Append, conflicts_with = "input")]
50 pub input_json: Vec<String>,
51 #[arg(long, value_name = "PATH")]
53 pub output: Option<PathBuf>,
54 #[arg(long, value_name = "PATH|JSON")]
56 pub config: Option<String>,
57 #[arg(long, value_name = "PATH")]
59 pub trace_out: Option<PathBuf>,
60 #[arg(long)]
62 pub pretty: bool,
63 #[arg(long)]
65 pub raw_output: bool,
66 #[arg(long, default_value_t = true, value_name = "BOOL", action = ArgAction::Set)]
68 pub dry_run: bool,
69 #[arg(long)]
71 pub allow_http: bool,
72 #[arg(long)]
74 pub allow_fs_write: bool,
75 #[arg(long, default_value_t = 2000, value_name = "MS")]
77 pub timeout_ms: u64,
78 #[arg(long, default_value_t = 256, value_name = "MB")]
80 pub max_memory_mb: u64,
81 #[arg(long, value_enum, default_value = "inmem")]
83 pub state: StateMode,
84 #[arg(long)]
86 pub state_dump: bool,
87 #[arg(long = "state-set", value_name = "KEY=BASE64")]
89 pub state_set: Vec<String>,
90 #[arg(long, action = ArgAction::Count)]
92 pub step: u8,
93 #[arg(long, value_name = "PATH")]
95 pub secrets: Option<PathBuf>,
96 #[arg(long, value_name = "PATH")]
98 pub secrets_json: Option<PathBuf>,
99 #[arg(long = "secret", value_name = "KEY=VALUE")]
101 pub secret: Vec<String>,
102 #[arg(long, default_value = "dev")]
104 pub env: String,
105 #[arg(long, default_value = "default")]
107 pub tenant: String,
108 #[arg(long)]
110 pub team: Option<String>,
111 #[arg(long)]
113 pub user: Option<String>,
114 #[arg(long)]
116 pub flow: Option<String>,
117 #[arg(long)]
119 pub node: Option<String>,
120 #[arg(long)]
122 pub session: Option<String>,
123 #[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}