1use 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#[derive(Debug, Clone)]
85pub struct RunOutcome {
86 pub exit_code: Option<i32>, 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>, 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
134fn 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, };
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 }
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}