Skip to main content

yosh_plugin_manager/
runner.rs

1//! Drive `yosh:plugin/*` exports against a `TestCtx`. Used by both
2//! `yosh plugin run` (single invocation) and `yosh plugin test`
3//! (scenario stepping).
4
5use std::path::Path;
6use std::time::Duration;
7
8use wasmtime::Store;
9use wasmtime::component::Component;
10
11use crate::generated::{PluginWorld, PluginWorldPre};
12use crate::precompile::make_engine;
13use crate::test_host::{TestCtx, TestState, build_linker, register_imports};
14
15pub struct LoadedPlugin {
16    pub world: PluginWorld,
17    pub store: Store<TestCtx>,
18    pub engine: wasmtime::Engine,
19}
20
21#[derive(Debug)]
22pub enum RunnerError {
23    Load(String),
24    Trap(String),
25    Timeout(String),
26}
27
28impl std::fmt::Display for RunnerError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            RunnerError::Load(s) => write!(f, "load: {}", s),
32            RunnerError::Trap(s) => write!(f, "trap: {}", s),
33            RunnerError::Timeout(s) => write!(f, "timeout: {}", s),
34        }
35    }
36}
37
38pub fn load_plugin(
39    wasm_path: &Path,
40    state: TestState,
41    timeout: Duration,
42) -> Result<LoadedPlugin, RunnerError> {
43    let engine = make_engine().map_err(|e| RunnerError::Load(e.to_string()))?;
44    let wasm_bytes = std::fs::read(wasm_path)
45        .map_err(|e| RunnerError::Load(format!("read {}: {}", wasm_path.display(), e)))?;
46    let component = Component::new(&engine, &wasm_bytes)
47        .map_err(|e| RunnerError::Load(format!("compile: {}", e)))?;
48
49    let mut linker = build_linker(&engine).map_err(|e| RunnerError::Load(e.to_string()))?;
50    register_imports(&mut linker).map_err(|e| RunnerError::Load(e.to_string()))?;
51
52    let pre = PluginWorldPre::new(
53        linker
54            .instantiate_pre(&component)
55            .map_err(|e| RunnerError::Load(format!("instantiate_pre: {}", e)))?,
56    )
57    .map_err(|e| RunnerError::Load(format!("bindings: {}", e)))?;
58
59    let mut store = Store::new(&engine, TestCtx::new(state));
60    store.set_epoch_deadline(1);
61
62    let watchdog_engine = engine.clone();
63    let _watchdog = std::thread::Builder::new()
64        .name("yosh-plugin-test-watchdog".into())
65        .spawn(move || {
66            std::thread::sleep(timeout);
67            watchdog_engine.increment_epoch();
68        });
69
70    let world = pre
71        .instantiate(&mut store)
72        .map_err(|e| RunnerError::Load(format!("instantiate: {}", e)))?;
73    Ok(LoadedPlugin {
74        world,
75        store,
76        engine,
77    })
78}
79
80use crate::test_host::ExecRecord;
81
82/// Outcome of one guest invocation. Includes everything the formatters
83/// and scenario evaluator need.
84#[derive(Debug, Clone)]
85pub struct RunOutcome {
86    pub exit_code: Option<i32>, // Some for exec, None for hooks
87    pub stdout: Vec<u8>,
88    pub stderr: Vec<u8>,
89    pub set_log: Vec<(String, String)>,
90    pub export_log: Vec<(String, String)>,
91    pub write_log: Vec<(std::path::PathBuf, usize)>,
92    pub exec_log: Vec<ExecRecord>,
93    pub error: Option<String>, // populated on trap/denied/timeout
94    pub error_kind: Option<&'static str>,
95}
96
97impl RunOutcome {
98    fn from_state(
99        state: TestState,
100        exit_code: Option<i32>,
101        error: Option<(&'static str, String)>,
102    ) -> Self {
103        let (kind, msg) = match error {
104            Some((k, m)) => (Some(k), Some(m)),
105            None => (None, None),
106        };
107        RunOutcome {
108            exit_code,
109            stdout: state.stdout,
110            stderr: state.stderr,
111            set_log: state.set_log,
112            export_log: state.export_log,
113            write_log: state.write_log,
114            exec_log: state.exec_log,
115            error: msg,
116            error_kind: kind,
117        }
118    }
119}
120
121pub fn invoke_exec(mut loaded: LoadedPlugin, command: &str, args: &[String]) -> RunOutcome {
122    let plugin = loaded.world.yosh_plugin_plugin();
123    let res = plugin.call_exec(&mut loaded.store, command, args);
124    let state = loaded.store.into_data().state;
125    match res {
126        Ok(code) => RunOutcome::from_state(state, Some(code), None),
127        Err(e) => {
128            let kind = classify_trap(&e);
129            RunOutcome::from_state(state, None, Some((kind, e.to_string())))
130        }
131    }
132}
133
134/// Bucket a wasmtime call failure into `"timeout"` (epoch deadline
135/// interrupt) vs `"trap"` (everything else). Epoch traps are detected
136/// structurally by downcasting to `wasmtime::Trap::Interrupt`; the
137/// substring fallback covers other wasmtime versions / future error
138/// shapes where the trap is nested inside an anyhow chain.
139fn classify_trap(err: &wasmtime::Error) -> &'static str {
140    if let Some(trap) = err.downcast_ref::<wasmtime::Trap>()
141        && matches!(trap, wasmtime::Trap::Interrupt)
142    {
143        return "timeout";
144    }
145    let msg = err.to_string();
146    if msg.contains("epoch") || msg.contains("deadline") || msg.contains("interrupt") {
147        "timeout"
148    } else {
149        "trap"
150    }
151}
152
153pub enum HookCall {
154    PreExec {
155        command_line: String,
156    },
157    PostExec {
158        command_line: String,
159        exit_code: i32,
160    },
161    OnCd {
162        old: String,
163        new: String,
164    },
165    PrePrompt,
166}
167
168pub fn invoke_hook(mut loaded: LoadedPlugin, hook: HookCall) -> RunOutcome {
169    let hooks = loaded.world.yosh_plugin_hooks();
170    let res = match &hook {
171        HookCall::PreExec { command_line } => hooks.call_pre_exec(&mut loaded.store, command_line),
172        HookCall::PostExec {
173            command_line,
174            exit_code,
175        } => hooks.call_post_exec(&mut loaded.store, command_line, *exit_code),
176        HookCall::OnCd { old, new } => hooks.call_on_cd(&mut loaded.store, old, new),
177        HookCall::PrePrompt => hooks.call_pre_prompt(&mut loaded.store),
178    };
179    let state = loaded.store.into_data().state;
180    match res {
181        Ok(()) => RunOutcome::from_state(state, None, None),
182        Err(e) => {
183            let kind = classify_trap(&e);
184            RunOutcome::from_state(state, None, Some((kind, e.to_string())))
185        }
186    }
187}
188
189use std::fmt::Write as _;
190
191pub fn format_human(o: &RunOutcome) -> String {
192    let mut out = String::new();
193    let _ = writeln!(out, "[stdout]\n{}", String::from_utf8_lossy(&o.stdout));
194    let _ = writeln!(out, "[stderr]\n{}", String::from_utf8_lossy(&o.stderr));
195    match o.exit_code {
196        Some(c) => {
197            let _ = writeln!(out, "[exit] {}", c);
198        }
199        None => {
200            let _ = writeln!(out, "[exit] (hook — no exit code)");
201        }
202    }
203    for (k, v) in &o.set_log {
204        let _ = writeln!(out, "[vars set]    {}={}", k, v);
205    }
206    for (k, v) in &o.export_log {
207        let _ = writeln!(out, "[vars export] {}={}", k, v);
208    }
209    for (p, n) in &o.write_log {
210        let _ = writeln!(out, "[files write] {} ({} bytes)", p.display(), n);
211    }
212    for r in &o.exec_log {
213        let _ = writeln!(
214            out,
215            "[exec]        {} {} → exit {} ({} bytes stdout)",
216            r.program,
217            r.args.join(" "),
218            r.exit_code,
219            r.stdout_len
220        );
221    }
222    if let (Some(kind), Some(msg)) = (o.error_kind, &o.error) {
223        let _ = writeln!(out, "[error] {}: {}", kind, msg);
224    }
225    out
226}
227
228pub fn format_json(o: &RunOutcome) -> serde_json::Value {
229    serde_json::json!({
230        "exit": o.exit_code,
231        "stdout": String::from_utf8_lossy(&o.stdout),
232        "stderr": String::from_utf8_lossy(&o.stderr),
233        "vars_set":    o.set_log.iter().map(|(k,v)| serde_json::json!({"key":k,"value":v})).collect::<Vec<_>>(),
234        "vars_export": o.export_log.iter().map(|(k,v)| serde_json::json!({"key":k,"value":v})).collect::<Vec<_>>(),
235        "files_write": o.write_log.iter().map(|(p,n)| serde_json::json!({"path": p.display().to_string(),"bytes": n})).collect::<Vec<_>>(),
236        "exec":        o.exec_log.iter().map(|r| serde_json::json!({
237            "program": r.program, "args": r.args, "exit": r.exit_code, "stdout_bytes": r.stdout_len
238        })).collect::<Vec<_>>(),
239        "error":       o.error.as_ref().map(|m| serde_json::json!({"kind": o.error_kind, "message": m})),
240    })
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn load_missing_wasm_returns_load_error() {
249        let result = load_plugin(
250            Path::new("/no/such/file.wasm"),
251            TestState::default(),
252            Duration::from_secs(1),
253        );
254        match result {
255            Err(RunnerError::Load(_)) => {}
256            Err(other) => panic!("expected Load error, got {:?}", other),
257            Ok(_) => panic!("expected Load error, got Ok"),
258        }
259    }
260
261    #[test]
262    fn load_non_wasm_file_returns_load_error() {
263        let tmp = tempfile::NamedTempFile::new().unwrap();
264        std::fs::write(tmp.path(), b"not wasm").unwrap();
265        let result = load_plugin(tmp.path(), TestState::default(), Duration::from_secs(1));
266        match result {
267            Err(RunnerError::Load(_)) => {}
268            Err(other) => panic!("expected Load error, got {:?}", other),
269            Ok(_) => panic!("expected Load error, got Ok"),
270        }
271    }
272
273    #[test]
274    fn invoke_exec_runs_test_plugin_test_cmd() {
275        let wasm = match plugin_artifact() {
276            Some(p) => p,
277            None => return, // wasm not built; skip silently
278        };
279        let state = TestState {
280            caps: yosh_plugin_api::CAP_IO,
281            ..Default::default()
282        };
283        let loaded = load_plugin(&wasm, state, Duration::from_secs(5)).expect("load");
284        let outcome = invoke_exec(loaded, "test_cmd", &["arg1".to_string()]);
285        assert_eq!(outcome.exit_code, Some(0));
286        assert!(outcome.stdout.starts_with(b"test_cmd args=["));
287        assert!(outcome.error.is_none());
288    }
289
290    #[test]
291    fn invoke_hook_pre_exec_records_event() {
292        let wasm = match plugin_artifact() {
293            Some(p) => p,
294            None => return,
295        };
296        let state = TestState {
297            caps: yosh_plugin_api::CAP_HOOK_PRE_EXEC
298                | yosh_plugin_api::CAP_VARIABLES_WRITE
299                | yosh_plugin_api::CAP_IO,
300            ..Default::default()
301        };
302        let loaded = load_plugin(&wasm, state, Duration::from_secs(5)).expect("load");
303        let outcome = invoke_hook(
304            loaded,
305            HookCall::PreExec {
306                command_line: "ls -l".into(),
307            },
308        );
309        assert!(outcome.error.is_none());
310        // test_plugin records pre_exec:ls -l in its internal log; the
311        // dump-events command flushes that log to a shell var, but we
312        // don't drive it from here — we only need to confirm the hook
313        // dispatched without trap.
314    }
315
316    fn plugin_artifact() -> Option<std::path::PathBuf> {
317        let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
318            .join("../../target/wasm32-wasip2/release/test_plugin.wasm");
319        if p.exists() { Some(p) } else { None }
320    }
321
322    #[test]
323    fn format_json_round_trip_fields() {
324        let mut o = RunOutcome {
325            exit_code: Some(0),
326            stdout: b"hi\n".to_vec(),
327            stderr: Vec::new(),
328            set_log: vec![("X".into(), "y".into())],
329            export_log: Vec::new(),
330            write_log: Vec::new(),
331            exec_log: Vec::new(),
332            error: None,
333            error_kind: None,
334        };
335        let j = format_json(&o);
336        assert_eq!(j["exit"], serde_json::json!(0));
337        assert_eq!(j["stdout"], serde_json::json!("hi\n"));
338        assert_eq!(j["vars_set"][0]["key"], serde_json::json!("X"));
339        o.error = Some("boom".into());
340        o.error_kind = Some("trap");
341        let j2 = format_json(&o);
342        assert_eq!(j2["error"]["kind"], serde_json::json!("trap"));
343    }
344
345    #[test]
346    fn format_human_includes_sections() {
347        let o = RunOutcome {
348            exit_code: Some(0),
349            stdout: b"hi\n".to_vec(),
350            stderr: Vec::new(),
351            set_log: vec![("X".into(), "y".into())],
352            export_log: Vec::new(),
353            write_log: Vec::new(),
354            exec_log: Vec::new(),
355            error: None,
356            error_kind: None,
357        };
358        let s = format_human(&o);
359        assert!(s.contains("[stdout]"));
360        assert!(s.contains("[exit] 0"));
361        assert!(s.contains("[vars set]    X=y"));
362    }
363}