use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::{Context, Result, bail};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use blake3::Hasher;
use clap::{ArgAction, Args, ValueEnum};
use serde::Serialize;
use serde_json::{Map, Value};
use uuid::Uuid;
use super::component_world::canonical_component_world;
use crate::capabilities::FilesystemMode;
use crate::manifest::ComponentManifest;
use crate::manifest::parse_manifest;
use crate::test_harness::{
ComponentInvokeError, HarnessConfig, HarnessError, InvokeOutcome, TestHarness, WasiPreopen,
};
use greentic_types::{EnvId, TeamId, TenantCtx, TenantId, UserId};
const MAX_OUTPUT_BYTES: usize = 2 * 1024 * 1024;
#[derive(Clone, Debug, ValueEnum)]
pub enum StateMode {
Inmem,
}
#[derive(Args, Debug)]
pub struct TestArgs {
#[arg(long, value_name = "PATH")]
pub wasm: PathBuf,
#[arg(long, default_value = "greentic:component/component@0.6.0")]
pub world: String,
#[arg(long, value_name = "PATH")]
pub manifest: Option<PathBuf>,
#[arg(long, value_name = "OP", action = ArgAction::Append)]
pub op: Vec<String>,
#[arg(long, value_name = "PATH", action = ArgAction::Append, conflicts_with = "input_json")]
pub input: Vec<PathBuf>,
#[arg(long, value_name = "JSON", action = ArgAction::Append, conflicts_with = "input")]
pub input_json: Vec<String>,
#[arg(long, value_name = "PATH")]
pub output: Option<PathBuf>,
#[arg(long, value_name = "PATH|JSON")]
pub config: Option<String>,
#[arg(long, value_name = "PATH")]
pub trace_out: Option<PathBuf>,
#[arg(long)]
pub pretty: bool,
#[arg(long)]
pub raw_output: bool,
#[arg(long, default_value_t = true, value_name = "BOOL", action = ArgAction::Set)]
pub dry_run: bool,
#[arg(long)]
pub allow_http: bool,
#[arg(long)]
pub allow_fs_write: bool,
#[arg(long, default_value_t = 2000, value_name = "MS")]
pub timeout_ms: u64,
#[arg(long, default_value_t = 256, value_name = "MB")]
pub max_memory_mb: u64,
#[arg(long, value_enum, default_value = "inmem")]
pub state: StateMode,
#[arg(long)]
pub state_dump: bool,
#[arg(long = "state-set", value_name = "KEY=BASE64")]
pub state_set: Vec<String>,
#[arg(long, action = ArgAction::Count)]
pub step: u8,
#[arg(long, value_name = "PATH")]
pub secrets: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub secrets_json: Option<PathBuf>,
#[arg(long = "secret", value_name = "KEY=VALUE")]
pub secret: Vec<String>,
#[arg(long, default_value = "dev")]
pub env: String,
#[arg(long, default_value = "default")]
pub tenant: String,
#[arg(long)]
pub team: Option<String>,
#[arg(long)]
pub user: Option<String>,
#[arg(long)]
pub flow: Option<String>,
#[arg(long)]
pub node: Option<String>,
#[arg(long)]
pub session: Option<String>,
#[arg(long)]
pub verbose: bool,
}
pub fn run(args: TestArgs) -> Result<()> {
let trace_out = resolve_trace_out(&args)?;
match run_inner(&args, trace_out.as_deref()) {
Ok(()) => Ok(()),
Err(err) => Err(TestCommandError::from_anyhow(
err,
args.pretty,
args.raw_output,
&args.world,
&args.wasm,
)
.into()),
}
}
fn run_inner(args: &TestArgs, trace_out: Option<&Path>) -> Result<()> {
if args.world != canonical_component_world() {
return Err(anyhow::Error::new(UnsupportedWorldError {
world: args.world.clone(),
}));
}
let manifest_path = resolve_manifest_path(&args.wasm, args.manifest.as_deref())?;
let manifest_raw = fs::read_to_string(&manifest_path)
.with_context(|| format!("read manifest {}", manifest_path.display()))?;
let manifest_value: Value =
serde_json::from_str(&manifest_raw).context("manifest must be valid JSON")?;
let manifest = parse_manifest(&manifest_raw).context("parse manifest")?;
let steps = collect_steps(args)?;
let mut trace = TraceContext::new(trace_out, &manifest, &steps);
let start = Instant::now();
let mut timing = TimingMs::default();
let mut secret_values: Vec<String> = Vec::new();
let result = (|| -> Result<Vec<String>> {
for (op, _) in &steps {
if !manifest
.operations
.iter()
.any(|operation| operation.name == *op)
{
bail!("operation `{op}` not declared in manifest");
}
}
let wasm_bytes =
fs::read(&args.wasm).with_context(|| format!("read wasm {}", args.wasm.display()))?;
let (tenant_ctx, session_id, generated_session) = build_tenant_ctx(args)?;
if args.verbose && generated_session {
eprintln!("generated session id");
}
let (allow_state_read, allow_state_write, allow_state_delete) =
state_permissions(&manifest_value, &manifest);
if !args.state_set.is_empty() && !allow_state_write {
bail!("manifest does not declare host.state.write; add it to use --state-set");
}
let (allow_secrets, allowed_secrets) = secret_permissions(&manifest);
let secrets = load_secrets(args)?;
if !allow_secrets && !secrets.is_empty() {
bail!(
"manifest does not declare host.secrets; add host.secrets to enable secrets access"
);
}
secret_values = secrets
.values()
.filter(|value| !value.is_empty())
.cloned()
.collect();
let config = load_config(args)?;
let state_seeds = parse_state_seeds(args)?;
let allow_http = args.allow_http && !args.dry_run;
let allow_fs_write = args.allow_fs_write && !args.dry_run;
let max_memory_bytes = parse_max_memory_bytes(args.max_memory_mb)?;
let wasi_preopens = resolve_wasi_preopens(&manifest, allow_fs_write, args.dry_run)?;
let prefix = state_prefix(args.flow.as_deref(), &session_id);
let flow_id = args.flow.clone().unwrap_or_else(|| "test".to_string());
let harness = TestHarness::new(HarnessConfig {
wasm_bytes,
tenant_ctx: tenant_ctx.clone(),
flow_id,
node_id: args.node.clone(),
state_prefix: prefix,
state_seeds,
allow_state_read,
allow_state_write,
allow_state_delete,
allow_secrets,
allowed_secrets,
secrets,
wasi_preopens,
config,
allow_http,
timeout_ms: args.timeout_ms,
max_memory_bytes,
})?;
if steps.len() > 1 && args.output.is_some() {
bail!("--output is only supported for single-step runs");
}
let mut outputs = Vec::new();
for (op, input) in steps.iter() {
let InvokeOutcome {
output_json,
instantiate_ms,
run_ms,
} = harness.invoke(op, input)?;
if output_json.len() > MAX_OUTPUT_BYTES {
return Err(anyhow::Error::new(OutputLimitError {
limit: MAX_OUTPUT_BYTES,
actual: output_json.len(),
}));
}
timing.instantiate = timing.instantiate.saturating_add(instantiate_ms);
timing.run = timing.run.saturating_add(run_ms);
outputs.push(output_json);
}
if args.state_dump {
let dump = harness.state_dump();
let dump_json = serde_json::to_string_pretty(&dump).unwrap_or_else(|_| "{}".into());
eprintln!("state dump:\n{dump_json}");
}
Ok(outputs)
})();
timing.total = duration_ms(start.elapsed());
match result {
Ok(outputs) => {
if outputs.len() == 1 {
trace.output_hash = Some(hash_bytes(outputs[0].as_bytes()));
}
let mut redacted_outputs = Vec::new();
for raw in &outputs {
let mut value: Value =
serde_json::from_str(raw).context("output is not valid JSON")?;
redact_value(&mut value, &secret_values);
redacted_outputs.push(value);
}
if args.raw_output {
for (idx, value) in redacted_outputs.iter().enumerate() {
let output = format_value_output(value, args.pretty)?;
if let Some(path) = &args.output {
fs::write(path, output.as_bytes())
.with_context(|| format!("write output {}", path.display()))?;
}
if redacted_outputs.len() > 1 {
println!("step {} output:\n{output}", idx + 1);
} else {
println!("{output}");
}
}
} else {
let result_value = if redacted_outputs.len() == 1 {
redacted_outputs[0].clone()
} else {
Value::Array(redacted_outputs)
};
let envelope = TestOutputEnvelope {
status: "ok".to_string(),
world: args.world.clone(),
wasm: args.wasm.display().to_string(),
result: Some(result_value),
diagnostics: Vec::new(),
timing_ms: timing,
};
let output = format_envelope_output(&envelope, args.pretty)?;
if let Some(path) = &args.output {
fs::write(path, output.as_bytes())
.with_context(|| format!("write output {}", path.display()))?;
}
println!("{output}");
}
trace.write(timing.total, None)?;
Ok(())
}
Err(err) => {
let mut payload = error_payload_from_anyhow(&err);
redact_error_payload(&mut payload, &secret_values);
let failure = TestRunFailure {
payload: payload.clone(),
world: args.world.clone(),
wasm: args.wasm.clone(),
timing_ms: timing,
};
if let Err(trace_err) = trace.write(timing.total, Some(payload)) {
eprintln!("failed to write trace: {trace_err}");
}
if let Some(path) = trace.out_path.as_deref() {
eprintln!("#TRY_SAVE_TRACE {}", path.display());
}
Err(anyhow::Error::new(failure))
}
}
}
fn resolve_manifest_path(wasm: &Path, manifest: Option<&Path>) -> Result<PathBuf> {
if let Some(path) = manifest {
return Ok(path.to_path_buf());
}
let dir = wasm
.parent()
.ok_or_else(|| anyhow::anyhow!("wasm path has no parent directory"))?;
let candidate = dir.join("component.manifest.json");
if candidate.exists() {
Ok(candidate)
} else {
bail!(
"manifest not found; pass --manifest or place component.manifest.json next to the wasm"
);
}
}
fn collect_steps(args: &TestArgs) -> Result<Vec<(String, Value)>> {
if args.op.is_empty() {
bail!("--op is required");
}
let inputs = if !args.input.is_empty() {
let mut values = Vec::new();
for path in &args.input {
let raw = fs::read_to_string(path)
.with_context(|| format!("read input {}", path.display()))?;
values.push(serde_json::from_str(&raw).context("input file must be valid JSON")?);
}
values
} else if !args.input_json.is_empty() {
let mut values = Vec::new();
for raw in &args.input_json {
values.push(serde_json::from_str(raw).context("input-json must be valid JSON")?);
}
values
} else {
bail!("--input or --input-json is required");
};
if args.op.len() != inputs.len() {
bail!("provide the same number of --op and --input/--input-json values");
}
if args.op.len() > 1 {
let expected_steps = args.op.len().saturating_sub(1);
if args.step == 0 {
bail!("use --step to indicate a multi-step run");
}
if args.step as usize != expected_steps {
bail!(
"expected {expected_steps} --step flags for {} operations",
args.op.len()
);
}
}
Ok(args.op.clone().into_iter().zip(inputs).collect())
}
fn build_tenant_ctx(args: &TestArgs) -> Result<(TenantCtx, String, bool)> {
let env: EnvId = args.env.clone().try_into().context("invalid --env")?;
let tenant: TenantId = args.tenant.clone().try_into().context("invalid --tenant")?;
let mut ctx = TenantCtx::new(env, tenant);
if let Some(team) = &args.team {
let team: TeamId = team.clone().try_into().context("invalid --team")?;
ctx = ctx.with_team(Some(team));
}
if let Some(user) = &args.user {
let user: UserId = user.clone().try_into().context("invalid --user")?;
ctx = ctx.with_user(Some(user));
}
let (session_id, generated) = match &args.session {
Some(session) => (session.clone(), false),
None => (Uuid::new_v4().to_string(), true),
};
ctx = ctx.with_session(session_id.clone());
if let Some(flow) = &args.flow {
ctx = ctx.with_flow(flow.clone());
}
if let Some(node) = &args.node {
ctx = ctx.with_node(node.clone());
}
Ok((ctx, session_id, generated))
}
fn resolve_trace_out(args: &TestArgs) -> Result<Option<PathBuf>> {
if let Some(path) = &args.trace_out {
return Ok(Some(path.clone()));
}
let value = std::env::var("GREENTIC_TRACE_OUT").ok();
Ok(value
.filter(|path| !path.trim().is_empty())
.map(PathBuf::from))
}
fn state_prefix(flow: Option<&str>, session: &str) -> String {
if let Some(flow) = flow {
format!("flow/{flow}/{session}")
} else {
format!("test/{session}")
}
}
fn resolve_wasi_preopens(
manifest: &ComponentManifest,
allow_fs_write: bool,
dry_run: bool,
) -> Result<Vec<WasiPreopen>> {
let Some(fs) = manifest.capabilities.wasi.filesystem.as_ref() else {
return Ok(Vec::new());
};
if fs.mode == FilesystemMode::None {
return Ok(Vec::new());
}
let host_root =
std::env::current_dir().context("resolve current working directory for mounts")?;
let meta = fs::metadata(&host_root)
.with_context(|| format!("failed to stat preopen {}", host_root.display()))?;
if !meta.is_dir() {
bail!("preopen {} must be a directory", host_root.display());
}
let mut read_only = matches!(fs.mode, FilesystemMode::ReadOnly);
if dry_run || !allow_fs_write {
read_only = true;
}
let mut preopens = Vec::new();
for mount in &fs.mounts {
preopens.push(WasiPreopen::new(&host_root, mount.guest_path.clone()).read_only(read_only));
}
Ok(preopens)
}
fn state_permissions(
manifest_value: &Value,
manifest: &crate::manifest::ComponentManifest,
) -> (bool, bool, bool) {
let mut allow_state_read = false;
let mut allow_state_write = false;
if let Some(state) = manifest.capabilities.host.state.as_ref() {
allow_state_read = state.read;
allow_state_write = state.write;
}
let allow_state_delete = manifest_value
.get("capabilities")
.and_then(|caps| caps.get("host"))
.and_then(|host| host.get("state"))
.and_then(|state| state.get("delete"))
.and_then(|value| value.as_bool())
.unwrap_or(false);
if allow_state_delete && !allow_state_write {
allow_state_write = true;
}
(allow_state_read, allow_state_write, allow_state_delete)
}
fn secret_permissions(manifest: &crate::manifest::ComponentManifest) -> (bool, HashSet<String>) {
let Some(secrets) = manifest.capabilities.host.secrets.as_ref() else {
return (false, HashSet::new());
};
let allowed = secrets
.required
.iter()
.map(|req| req.key.as_str().to_string())
.collect::<HashSet<_>>();
(true, allowed)
}
fn load_secrets(args: &TestArgs) -> Result<HashMap<String, String>> {
let mut secrets = HashMap::new();
if let Some(path) = &args.secrets {
let entries = parse_env_file(path)?;
secrets.extend(entries);
}
if let Some(path) = &args.secrets_json {
let entries = parse_json_secrets(path)?;
secrets.extend(entries);
}
for entry in &args.secret {
let (key, value) = entry
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("invalid --secret `{entry}`; use KEY=VALUE"))?;
secrets.insert(key.to_string(), value.to_string());
}
Ok(secrets)
}
fn parse_state_seeds(args: &TestArgs) -> Result<Vec<(String, Vec<u8>)>> {
let mut seeds = Vec::new();
for entry in &args.state_set {
let (key, value) = entry
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("invalid --state-set `{entry}`; use KEY=BASE64"))?;
let bytes = BASE64_STANDARD
.decode(value)
.with_context(|| format!("invalid base64 for state key `{key}`"))?;
seeds.push((key.to_string(), bytes));
}
Ok(seeds)
}
fn parse_env_file(path: &Path) -> Result<HashMap<String, String>> {
let contents =
fs::read_to_string(path).with_context(|| format!("read secrets {}", path.display()))?;
let mut secrets = HashMap::new();
for (idx, line) in contents.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = line.split_once('=').ok_or_else(|| {
anyhow::anyhow!(
"invalid secrets line {} in {} (expected KEY=VALUE)",
idx + 1,
path.display()
)
})?;
secrets.insert(key.trim().to_string(), value.trim().to_string());
}
Ok(secrets)
}
fn parse_json_secrets(path: &Path) -> Result<HashMap<String, String>> {
let contents =
fs::read_to_string(path).with_context(|| format!("read secrets {}", path.display()))?;
let value: Value = serde_json::from_str(&contents).context("secrets JSON must be valid")?;
let obj = value
.as_object()
.ok_or_else(|| anyhow::anyhow!("secrets JSON must be an object map"))?;
let mut secrets = HashMap::new();
for (key, value) in obj {
let value = value
.as_str()
.ok_or_else(|| anyhow::anyhow!("secret `{key}` must be a string value"))?;
secrets.insert(key.clone(), value.to_string());
}
Ok(secrets)
}
fn load_config(args: &TestArgs) -> Result<Option<Value>> {
let Some(raw) = &args.config else {
return Ok(None);
};
let path = Path::new(raw);
let contents = if path.exists() {
fs::read_to_string(path).with_context(|| format!("read config {}", path.display()))?
} else {
raw.clone()
};
let value: Value = serde_json::from_str(&contents).context("config must be valid JSON")?;
Ok(Some(value))
}
fn parse_max_memory_bytes(max_memory_mb: u64) -> Result<usize> {
let bytes = max_memory_mb
.checked_mul(1024 * 1024)
.ok_or_else(|| anyhow::anyhow!("max memory MB is too large"))?;
usize::try_from(bytes).context("max memory MB is too large for this platform")
}
fn format_value_output(value: &Value, pretty: bool) -> Result<String> {
if pretty {
Ok(serde_json::to_string_pretty(value)?)
} else {
Ok(serde_json::to_string(value)?)
}
}
fn format_envelope_output(envelope: &TestOutputEnvelope, pretty: bool) -> Result<String> {
if pretty {
Ok(serde_json::to_string_pretty(envelope)?)
} else {
Ok(serde_json::to_string(envelope)?)
}
}
#[derive(Debug, Serialize, Clone)]
struct TestErrorPayload {
code: String,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<Value>,
}
#[derive(Debug, Serialize, Clone)]
struct Diagnostic {
severity: String,
code: String,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
hint: Option<String>,
}
#[derive(Debug, Serialize, Clone, Copy, Default)]
struct TimingMs {
instantiate: u64,
run: u64,
total: u64,
}
#[derive(Debug, Serialize)]
struct TestOutputEnvelope {
status: String,
world: String,
wasm: String,
result: Option<Value>,
diagnostics: Vec<Diagnostic>,
timing_ms: TimingMs,
}
#[derive(Debug)]
struct TestRunFailure {
payload: TestErrorPayload,
world: String,
wasm: PathBuf,
timing_ms: TimingMs,
}
#[derive(Debug)]
enum TestErrorOutput {
Raw(TestErrorPayload),
Envelope(TestOutputEnvelope),
}
#[derive(Debug)]
pub struct TestCommandError {
output: TestErrorOutput,
pretty: bool,
}
impl TestCommandError {
fn from_anyhow(
err: anyhow::Error,
pretty: bool,
raw_output: bool,
world: &str,
wasm: &Path,
) -> Self {
if let Some(failure) = err.downcast_ref::<TestRunFailure>() {
if raw_output {
return Self {
output: TestErrorOutput::Raw(failure.payload.clone()),
pretty,
};
}
let envelope = TestOutputEnvelope {
status: "error".to_string(),
world: failure.world.clone(),
wasm: failure.wasm.display().to_string(),
result: None,
diagnostics: vec![diagnostic_from_payload(&failure.payload)],
timing_ms: failure.timing_ms,
};
return Self {
output: TestErrorOutput::Envelope(envelope),
pretty,
};
}
let payload = error_payload_from_anyhow(&err);
if raw_output {
return Self {
output: TestErrorOutput::Raw(payload),
pretty,
};
}
let envelope = TestOutputEnvelope {
status: "error".to_string(),
world: world.to_string(),
wasm: wasm.display().to_string(),
result: None,
diagnostics: vec![diagnostic_from_payload(&payload)],
timing_ms: TimingMs::default(),
};
Self {
output: TestErrorOutput::Envelope(envelope),
pretty,
}
}
pub fn render_json(&self) -> String {
match &self.output {
TestErrorOutput::Raw(payload) => {
if self.pretty {
serde_json::to_string_pretty(payload).unwrap_or_else(|_| "{}".to_string())
} else {
serde_json::to_string(payload).unwrap_or_else(|_| "{}".to_string())
}
}
TestErrorOutput::Envelope(envelope) => {
if self.pretty {
serde_json::to_string_pretty(envelope).unwrap_or_else(|_| "{}".to_string())
} else {
serde_json::to_string(envelope).unwrap_or_else(|_| "{}".to_string())
}
}
}
}
}
impl std::fmt::Display for TestCommandError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.output {
TestErrorOutput::Raw(payload) => write!(f, "{}: {}", payload.code, payload.message),
TestErrorOutput::Envelope(envelope) => {
if let Some(diag) = envelope.diagnostics.first() {
write!(f, "{}: {}", diag.code, diag.message)
} else {
write!(f, "test.failure: test failure")
}
}
}
}
}
impl std::error::Error for TestCommandError {}
impl std::fmt::Display for TestRunFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.payload.code, self.payload.message)
}
}
impl std::error::Error for TestRunFailure {}
#[derive(Debug)]
struct UnsupportedWorldError {
world: String,
}
impl std::fmt::Display for UnsupportedWorldError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Unsupported world '{}'. Supported: {}",
self.world,
canonical_component_world()
)
}
}
impl std::error::Error for UnsupportedWorldError {}
#[derive(Debug)]
struct OutputLimitError {
limit: usize,
actual: usize,
}
impl std::fmt::Display for OutputLimitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"output size {} bytes exceeds limit {} bytes",
self.actual, self.limit
)
}
}
impl std::error::Error for OutputLimitError {}
fn diagnostic_from_payload(payload: &TestErrorPayload) -> Diagnostic {
Diagnostic {
severity: "error".to_string(),
code: payload.code.clone(),
message: payload.message.clone(),
details: payload.details.clone(),
path: None,
hint: None,
}
}
fn redact_error_payload(payload: &mut TestErrorPayload, secrets: &[String]) {
payload.message = redact_string(&payload.message, secrets);
if let Some(details) = payload.details.as_mut() {
redact_value(details, secrets);
}
}
fn redact_value(value: &mut Value, secrets: &[String]) {
match value {
Value::String(text) => {
*text = redact_string(text, secrets);
}
Value::Array(values) => {
for value in values {
redact_value(value, secrets);
}
}
Value::Object(map) => {
for value in map.values_mut() {
redact_value(value, secrets);
}
}
_ => {}
}
}
fn redact_string(value: &str, secrets: &[String]) -> String {
let mut out = value.to_string();
for secret in secrets {
if secret.is_empty() {
continue;
}
out = out.replace(secret, "***REDACTED***");
}
out
}
fn component_error_details(error: &ComponentInvokeError) -> Option<Value> {
let mut details = Map::new();
details.insert("retryable".into(), Value::Bool(error.retryable));
if let Some(backoff_ms) = error.backoff_ms {
details.insert("backoff_ms".into(), Value::Number(backoff_ms.into()));
}
if let Some(raw) = &error.details {
let parsed = serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
details.insert("details".into(), parsed);
}
if details.is_empty() {
None
} else {
Some(Value::Object(details))
}
}
fn error_payload_from_anyhow(err: &anyhow::Error) -> TestErrorPayload {
let chain: Vec<String> = err
.chain()
.skip(1)
.map(|source| source.to_string())
.collect();
let (code, message, base_details) = if let Some(harness_err) = err
.chain()
.find_map(|source| source.downcast_ref::<HarnessError>())
{
let (code, message) = match harness_err {
HarnessError::Timeout { .. } => ("test.timeout", harness_err.to_string()),
HarnessError::MemoryLimit { .. } => ("test.memory_limit", harness_err.to_string()),
};
(code.to_string(), message, None)
} else if let Some(world_err) = err
.chain()
.find_map(|source| source.downcast_ref::<UnsupportedWorldError>())
{
(
"test.world.unsupported".to_string(),
world_err.to_string(),
None,
)
} else if let Some(limit_err) = err
.chain()
.find_map(|source| source.downcast_ref::<OutputLimitError>())
{
(
"test.output.limit".to_string(),
limit_err.to_string(),
Some(serde_json::json!({
"limit": limit_err.limit,
"actual": limit_err.actual,
})),
)
} else if let Some(component_err) = err
.chain()
.find_map(|source| source.downcast_ref::<ComponentInvokeError>())
{
(
component_err.code.clone(),
component_err.message.clone(),
component_error_details(component_err),
)
} else {
("test.failure".to_string(), err.to_string(), None)
};
let mut details_map = match base_details {
Some(Value::Object(map)) => map,
Some(other) => {
let mut map = Map::new();
map.insert("details".into(), other);
map
}
None => Map::new(),
};
if !chain.is_empty() {
let chain_values = chain.into_iter().map(Value::String).collect();
details_map.insert("chain".into(), Value::Array(chain_values));
}
let details = if details_map.is_empty() {
None
} else {
Some(Value::Object(details_map))
};
TestErrorPayload {
code,
message,
details,
}
}
#[derive(Debug, Serialize)]
struct TraceRecord {
trace_version: u8,
component_id: String,
operation: String,
input_hash: Option<String>,
output_hash: Option<String>,
duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<TestErrorPayload>,
}
struct TraceContext {
out_path: Option<PathBuf>,
component_id: String,
operation: String,
input_hash: Option<String>,
output_hash: Option<String>,
}
impl TraceContext {
fn new(
out_path: Option<&Path>,
manifest: &ComponentManifest,
steps: &[(String, Value)],
) -> Self {
let (operation, input_hash) = match steps.first() {
Some((op, input)) => (op.clone(), Some(hash_json_value(input))),
None => ("unknown".to_string(), None),
};
Self {
out_path: out_path.map(|path| path.to_path_buf()),
component_id: manifest.id.as_str().to_string(),
operation,
input_hash,
output_hash: None,
}
}
fn write(&self, duration_ms: u64, error: Option<TestErrorPayload>) -> Result<()> {
let Some(path) = self.out_path.as_deref() else {
return Ok(());
};
let record = TraceRecord {
trace_version: 1,
component_id: self.component_id.clone(),
operation: self.operation.clone(),
input_hash: self.input_hash.clone(),
output_hash: self.output_hash.clone(),
duration_ms,
error,
};
let json = serde_json::to_string_pretty(&record).context("serialize trace JSON")?;
fs::write(path, json).with_context(|| format!("write trace {}", path.display()))?;
Ok(())
}
}
fn hash_json_value(value: &Value) -> String {
let raw = serde_json::to_string(value).unwrap_or_else(|_| "null".to_string());
hash_bytes(raw.as_bytes())
}
fn hash_bytes(bytes: &[u8]) -> String {
let mut hasher = Hasher::new();
hasher.update(bytes);
format!("blake3:{}", hasher.finalize().to_hex())
}
fn duration_ms(duration: std::time::Duration) -> u64 {
duration.as_millis().try_into().unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_output_preserves_legacy_error_shape() {
let payload = TestErrorPayload {
code: "test.failure".to_string(),
message: "boom".to_string(),
details: None,
};
let failure = TestRunFailure {
payload: payload.clone(),
world: canonical_component_world().to_string(),
wasm: PathBuf::from("component.wasm"),
timing_ms: TimingMs::default(),
};
let rendered = TestCommandError::from_anyhow(
anyhow::Error::new(failure),
false,
true,
canonical_component_world(),
Path::new("component.wasm"),
)
.render_json();
let value: Value = serde_json::from_str(&rendered).expect("raw output json");
assert_eq!(value["code"], payload.code);
assert!(value.get("status").is_none());
}
#[test]
fn envelope_includes_timeout_error_code() {
let payload =
error_payload_from_anyhow(&anyhow::Error::new(HarnessError::Timeout { timeout_ms: 1 }));
let failure = TestRunFailure {
payload,
world: canonical_component_world().to_string(),
wasm: PathBuf::from("component.wasm"),
timing_ms: TimingMs::default(),
};
let rendered = TestCommandError::from_anyhow(
anyhow::Error::new(failure),
false,
false,
canonical_component_world(),
Path::new("component.wasm"),
)
.render_json();
let value: Value = serde_json::from_str(&rendered).expect("envelope json");
assert_eq!(value["status"], "error");
assert_eq!(value["diagnostics"][0]["code"], "test.timeout");
}
#[test]
fn envelope_includes_memory_error_code() {
let payload = error_payload_from_anyhow(&anyhow::Error::new(HarnessError::MemoryLimit {
max_memory_bytes: 1024,
}));
let failure = TestRunFailure {
payload,
world: canonical_component_world().to_string(),
wasm: PathBuf::from("component.wasm"),
timing_ms: TimingMs::default(),
};
let rendered = TestCommandError::from_anyhow(
anyhow::Error::new(failure),
false,
false,
canonical_component_world(),
Path::new("component.wasm"),
)
.render_json();
let value: Value = serde_json::from_str(&rendered).expect("envelope json");
assert_eq!(value["diagnostics"][0]["code"], "test.memory_limit");
}
#[test]
fn fs_write_flags_toggle_preopens() {
let manifest_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/manifests/valid.component.json");
let manifest_raw = fs::read_to_string(&manifest_path).expect("manifest");
let mut manifest = parse_manifest(&manifest_raw).expect("manifest parse");
if let Some(fs_caps) = manifest.capabilities.wasi.filesystem.as_mut() {
fs_caps.mode = FilesystemMode::Sandbox;
}
let preopens = resolve_wasi_preopens(&manifest, false, false).expect("preopens");
assert!(
preopens.iter().all(|preopen| preopen.read_only),
"expected read-only preopens by default"
);
let preopens = resolve_wasi_preopens(&manifest, true, false).expect("preopens");
assert!(
preopens.iter().all(|preopen| !preopen.read_only),
"expected writable preopens when allowed"
);
let preopens = resolve_wasi_preopens(&manifest, true, true).expect("preopens");
assert!(
preopens.iter().all(|preopen| preopen.read_only),
"expected dry-run to force read-only preopens"
);
}
#[test]
fn redacts_secrets_in_output_and_diagnostics() {
let secret = "super-secret".to_string();
let mut value = serde_json::json!({
"token": secret,
"nested": { "value": "super-secret" }
});
redact_value(&mut value, &["super-secret".to_string()]);
assert_eq!(value["token"], "***REDACTED***");
assert_eq!(value["nested"]["value"], "***REDACTED***");
let mut payload = TestErrorPayload {
code: "test.failure".to_string(),
message: "super-secret failed".to_string(),
details: Some(serde_json::json!({ "hint": "super-secret" })),
};
redact_error_payload(&mut payload, &["super-secret".to_string()]);
assert!(!payload.message.contains("super-secret"));
assert_eq!(payload.details.unwrap()["hint"], "***REDACTED***");
}
}