1use std::collections::BTreeMap;
6use std::path::PathBuf;
7
8use serde::Deserialize;
9
10#[derive(Debug, Deserialize)]
11pub struct Scenario {
12 pub plugin: PathBuf,
13 #[serde(default)]
14 pub description: String,
15 #[serde(default)]
16 pub env: EnvConfig,
17 #[serde(default)]
18 pub files: BTreeMap<String, String>,
19 #[serde(rename = "step", default)]
20 pub steps: Vec<Step>,
21}
22
23#[derive(Debug, Default, Deserialize)]
24pub struct EnvConfig {
25 #[serde(default)]
26 pub caps: Vec<String>,
27 #[serde(default)]
28 pub vars: BTreeMap<String, String>,
29 #[serde(default)]
30 pub exported: Vec<String>,
31 #[serde(default)]
32 pub cwd: String,
33 #[serde(default)]
34 pub allow_exec: Vec<String>,
35 #[serde(default)]
36 pub sandbox_root: String,
37 #[serde(default = "default_timeout_ms")]
38 pub timeout_ms: u64,
39}
40
41fn default_timeout_ms() -> u64 {
42 5000
43}
44
45#[derive(Debug, Deserialize)]
46#[serde(tag = "call", rename_all = "lowercase")]
47pub enum Step {
48 Exec {
49 args: Vec<String>,
50 #[serde(default)]
51 expect: Expect,
52 },
53 Hook {
54 name: HookName,
55 args: Vec<toml::Value>,
56 #[serde(default)]
57 expect: Expect,
58 },
59}
60
61#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
62#[serde(rename_all = "kebab-case")]
63pub enum HookName {
64 PreExec,
65 PostExec,
66 OnCd,
67 PrePrompt,
68}
69
70#[derive(Debug, Default, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct Expect {
73 pub exit: Option<i32>,
74 pub stdout: Option<String>,
75 pub stderr: Option<String>,
76 pub stdout_contains: Option<String>,
77 pub stderr_contains: Option<String>,
78 pub stdout_regex: Option<String>,
79 pub stderr_regex: Option<String>,
80 pub vars_set: Option<BTreeMap<String, String>>,
81 pub vars_export: Option<BTreeMap<String, String>>,
82 pub files_write: Option<BTreeMap<String, FileExpect>>,
83 pub exec_called: Option<Vec<ExecCallExpect>>,
84 pub trap: Option<bool>,
85}
86
87#[derive(Debug, Deserialize)]
96#[serde(untagged)]
97pub enum FileExpect {
98 Bytes(String),
99 Struct {
100 #[serde(default)]
101 len: Option<usize>,
102 #[serde(default)]
103 bytes_eq: Option<String>,
104 },
105}
106
107#[derive(Debug, Deserialize)]
108pub struct ExecCallExpect {
109 pub program: String,
110 #[serde(default)]
111 pub args: Vec<String>,
112 pub exit: Option<i32>,
113}
114
115pub fn parse(path: &std::path::Path) -> Result<Scenario, String> {
116 let s = std::fs::read_to_string(path).map_err(|e| format!("read {}: {}", path.display(), e))?;
117 let parsed: Scenario =
118 toml::from_str(&s).map_err(|e| format!("parse {}: {}", path.display(), e))?;
119 Ok(parsed)
120}
121
122use crate::runner::{HookCall, RunOutcome, invoke_exec, invoke_hook, load_plugin};
123use crate::test_host::TestState;
124use yosh_plugin_api::pattern::CommandPattern;
125use yosh_plugin_api::{capabilities_to_bitflags, parse_capability};
126
127#[derive(Debug)]
128pub enum StepResult {
129 Pass,
130 Fail(String),
131}
132
133pub fn run_scenario(path: &std::path::Path) -> Vec<StepResult> {
134 let scenario = match parse(path) {
135 Ok(s) => s,
136 Err(e) => return vec![StepResult::Fail(format!("parse error: {}", e))],
137 };
138
139 let wasm_path = path
140 .parent()
141 .map(|p| p.join(&scenario.plugin))
142 .unwrap_or(scenario.plugin.clone());
143 let mut results = Vec::new();
144
145 for (idx, step) in scenario.steps.iter().enumerate() {
146 let state = build_state(&scenario);
147 let timeout = std::time::Duration::from_millis(scenario.env.timeout_ms);
148 let loaded = match load_plugin(&wasm_path, state, timeout) {
149 Ok(l) => l,
150 Err(e) => {
151 results.push(StepResult::Fail(format!("step {}: load: {}", idx + 1, e)));
152 continue;
153 }
154 };
155
156 let (outcome, expect) = match step {
157 Step::Exec { args, expect } => {
158 if args.is_empty() {
159 results.push(StepResult::Fail(format!(
160 "step {}: exec needs at least 1 arg",
161 idx + 1
162 )));
163 continue;
164 }
165 let (cmd, rest) = (&args[0], &args[1..]);
166 (invoke_exec(loaded, cmd, rest), expect)
167 }
168 Step::Hook { name, args, expect } => {
169 let call = match build_hook_call(*name, args) {
170 Ok(c) => c,
171 Err(e) => {
172 results.push(StepResult::Fail(format!(
173 "step {}: hook args: {}",
174 idx + 1,
175 e
176 )));
177 continue;
178 }
179 };
180 (invoke_hook(loaded, call), expect)
181 }
182 };
183
184 results.push(evaluate(idx + 1, &outcome, expect));
185 }
186
187 if results.is_empty() {
188 results.push(StepResult::Pass);
189 }
190 results
191}
192
193fn build_state(scenario: &Scenario) -> TestState {
194 let mut state = TestState::default();
195 let parsed_caps: Vec<_> = scenario
196 .env
197 .caps
198 .iter()
199 .filter_map(|s| parse_capability(s))
200 .collect();
201 state.caps = capabilities_to_bitflags(&parsed_caps);
202 for (k, v) in &scenario.env.vars {
203 state.vars.insert(k.clone(), v.clone());
204 }
205 for k in &scenario.env.exported {
206 state.exported.insert(k.clone());
207 }
208 if !scenario.env.cwd.is_empty() {
209 state.cwd = scenario.env.cwd.clone().into();
210 }
211 state.allow_exec = scenario
212 .env
213 .allow_exec
214 .iter()
215 .filter_map(|p| CommandPattern::parse(p).ok())
216 .collect();
217 if !scenario.env.sandbox_root.is_empty() {
218 state.sandbox_root = Some(std::path::PathBuf::from(&scenario.env.sandbox_root));
219 } else {
220 for (k, v) in &scenario.files {
221 state
222 .files
223 .insert(std::path::PathBuf::from(k), v.as_bytes().to_vec());
224 }
225 }
226 state
227}
228
229fn build_hook_call(name: HookName, args: &[toml::Value]) -> Result<HookCall, String> {
230 fn s(v: &toml::Value) -> Result<String, String> {
231 v.as_str()
232 .map(|s| s.to_string())
233 .ok_or_else(|| "expected string".into())
234 }
235 fn i(v: &toml::Value) -> Result<i32, String> {
236 v.as_integer()
237 .map(|i| i as i32)
238 .ok_or_else(|| "expected integer".into())
239 }
240 match name {
241 HookName::PreExec => Ok(HookCall::PreExec {
242 command_line: s(args.first().ok_or("missing arg")?)?,
243 }),
244 HookName::PostExec => {
245 let cl = s(args.first().ok_or("missing command_line")?)?;
246 let ec = i(args.get(1).ok_or("missing exit_code")?)?;
247 Ok(HookCall::PostExec {
248 command_line: cl,
249 exit_code: ec,
250 })
251 }
252 HookName::OnCd => {
253 let old = s(args.first().ok_or("missing old")?)?;
254 let new = s(args.get(1).ok_or("missing new")?)?;
255 Ok(HookCall::OnCd { old, new })
256 }
257 HookName::PrePrompt => Ok(HookCall::PrePrompt),
258 }
259}
260
261fn evaluate(step_idx: usize, o: &RunOutcome, e: &Expect) -> StepResult {
262 macro_rules! fail {
263 ($($t:tt)*) => {{ return StepResult::Fail(format!("step {}: {}", step_idx, format_args!($($t)*))) }};
264 }
265
266 if let Some(want) = e.exit {
267 match o.exit_code {
268 Some(got) if got == want => {}
269 Some(got) => fail!("exit: want {}, got {}", want, got),
270 None => fail!("exit: want {}, got (no exit code — hook?)", want),
271 }
272 }
273
274 let stdout_str = String::from_utf8_lossy(&o.stdout);
275 let stderr_str = String::from_utf8_lossy(&o.stderr);
276
277 if let Some(want) = &e.stdout
278 && stdout_str != *want
279 {
280 fail!("stdout mismatch: want {:?}, got {:?}", want, stdout_str);
281 }
282 if let Some(want) = &e.stderr
283 && stderr_str != *want
284 {
285 fail!("stderr mismatch: want {:?}, got {:?}", want, stderr_str);
286 }
287 if let Some(sub) = &e.stdout_contains
288 && !stdout_str.contains(sub.as_str())
289 {
290 fail!("stdout_contains {:?} not found in {:?}", sub, stdout_str);
291 }
292 if let Some(sub) = &e.stderr_contains
293 && !stderr_str.contains(sub.as_str())
294 {
295 fail!("stderr_contains {:?} not found in {:?}", sub, stderr_str);
296 }
297 if let Some(re) = &e.stdout_regex {
298 let rx = regex::Regex::new(re).map_err(|err| err.to_string());
299 match rx {
300 Ok(rx) if !rx.is_match(&stdout_str) => {
301 fail!("stdout_regex {:?} did not match {:?}", re, stdout_str)
302 }
303 Err(err) => fail!("stdout_regex invalid: {}", err),
304 _ => {}
305 }
306 }
307 if let Some(re) = &e.stderr_regex {
308 let rx = regex::Regex::new(re).map_err(|err| err.to_string());
309 match rx {
310 Ok(rx) if !rx.is_match(&stderr_str) => {
311 fail!("stderr_regex {:?} did not match {:?}", re, stderr_str)
312 }
313 Err(err) => fail!("stderr_regex invalid: {}", err),
314 _ => {}
315 }
316 }
317
318 if let Some(want) = &e.vars_set {
319 let got: BTreeMap<String, String> = o.set_log.iter().cloned().collect();
320 if got != *want {
321 fail!("vars_set: want {:?}, got {:?}", want, got);
322 }
323 }
324 if let Some(want) = &e.vars_export {
325 let got: BTreeMap<String, String> = o.export_log.iter().cloned().collect();
326 if got != *want {
327 fail!("vars_export: want {:?}, got {:?}", want, got);
328 }
329 }
330
331 if let Some(want) = &e.files_write {
332 let got: BTreeMap<String, usize> = o
333 .write_log
334 .iter()
335 .map(|(p, n)| (p.display().to_string(), *n))
336 .collect();
337 for (path, expectation) in want {
338 match expectation {
339 FileExpect::Bytes(b) => {
340 let want_len = b.len();
341 match got.get(path) {
342 Some(actual) if *actual == want_len => {}
343 Some(actual) => fail!(
344 "files_write[{}] len: want {}, got {}",
345 path,
346 want_len,
347 actual
348 ),
349 None => fail!("files_write[{}] not written", path),
350 }
351 }
352 FileExpect::Struct { len, bytes_eq } => {
353 if let Some(l) = len {
354 match got.get(path) {
355 Some(actual) if *actual == *l => {}
356 Some(actual) => {
357 fail!("files_write[{}] len: want {}, got {}", path, l, actual)
358 }
359 None => fail!("files_write[{}] not written", path),
360 }
361 }
362 if let Some(b) = bytes_eq {
363 let want_len = b.len();
364 match got.get(path) {
365 Some(actual) if *actual == want_len => {}
366 Some(actual) => fail!(
367 "files_write[{}] bytes_eq len: want {}, got {}",
368 path,
369 want_len,
370 actual
371 ),
372 None => fail!("files_write[{}] not written", path),
373 }
374 }
375 }
376 }
377 }
378 }
379
380 if let Some(want_seq) = &e.exec_called {
381 if want_seq.len() != o.exec_log.len() {
382 fail!(
383 "exec_called: want {} calls, got {}",
384 want_seq.len(),
385 o.exec_log.len()
386 );
387 }
388 for (i, (w, g)) in want_seq.iter().zip(o.exec_log.iter()).enumerate() {
389 if w.program != g.program {
390 fail!(
391 "exec_called[{}].program: want {}, got {}",
392 i,
393 w.program,
394 g.program
395 );
396 }
397 if w.args != g.args {
398 fail!(
399 "exec_called[{}].args: want {:?}, got {:?}",
400 i,
401 w.args,
402 g.args
403 );
404 }
405 if let Some(exit) = w.exit
406 && exit != g.exit_code
407 {
408 fail!(
409 "exec_called[{}].exit: want {}, got {}",
410 i,
411 exit,
412 g.exit_code
413 );
414 }
415 }
416 }
417
418 if let Some(want) = e.trap {
419 let got = o.error_kind == Some("trap");
420 if got != want {
421 fail!("trap: want {}, got {}", want, got);
422 }
423 }
424
425 StepResult::Pass
426}
427
428#[cfg(test)]
429mod evaluator_tests {
430 use super::*;
431 use crate::runner::RunOutcome;
432
433 fn outcome_with(exit: Option<i32>, stdout: &[u8]) -> RunOutcome {
434 RunOutcome {
435 exit_code: exit,
436 stdout: stdout.to_vec(),
437 stderr: Vec::new(),
438 set_log: Vec::new(),
439 export_log: Vec::new(),
440 write_log: Vec::new(),
441 exec_log: Vec::new(),
442 error: None,
443 error_kind: None,
444 }
445 }
446
447 #[test]
448 fn expect_exit_match_passes() {
449 let o = outcome_with(Some(0), b"");
450 let e = Expect {
451 exit: Some(0),
452 ..Default::default()
453 };
454 assert!(matches!(evaluate(1, &o, &e), StepResult::Pass));
455 }
456
457 #[test]
458 fn expect_exit_mismatch_fails() {
459 let o = outcome_with(Some(2), b"");
460 let e = Expect {
461 exit: Some(0),
462 ..Default::default()
463 };
464 match evaluate(1, &o, &e) {
465 StepResult::Fail(s) => assert!(s.contains("exit")),
466 _ => panic!("expected fail"),
467 }
468 }
469
470 #[test]
471 fn expect_stdout_contains_works() {
472 let o = outcome_with(Some(0), b"hello world\n");
473 let e = Expect {
474 stdout_contains: Some("world".into()),
475 ..Default::default()
476 };
477 assert!(matches!(evaluate(1, &o, &e), StepResult::Pass));
478 }
479
480 #[test]
481 fn run_dir_collects_toml_files() {
482 let tmp = tempfile::tempdir().unwrap();
483 let a = tmp.path().join("a.toml");
484 std::fs::write(
485 &a,
486 r#"
487 plugin = "missing.wasm"
488 [[step]]
489 call = "exec"
490 args = ["x"]
491 "#,
492 )
493 .unwrap();
494 let reports = run_dir(tmp.path(), None);
495 assert_eq!(reports.len(), 1);
496 assert!(!reports[0].passed()); }
498
499 #[test]
500 fn format_summary_json_has_summary_line() {
501 let reports = vec![];
502 let s = format_summary_json(&reports);
503 assert!(s.contains("\"summary\""));
504 }
505}
506
507#[derive(Debug)]
508pub struct ScenarioReport {
509 pub file: std::path::PathBuf,
510 pub steps: Vec<StepResult>,
511}
512
513impl ScenarioReport {
514 pub fn passed(&self) -> bool {
515 self.steps.iter().all(|r| matches!(r, StepResult::Pass))
516 }
517}
518
519pub fn run_dir(path: &std::path::Path, filter: Option<&str>) -> Vec<ScenarioReport> {
520 let mut reports = Vec::new();
521 let filter_rx = filter.and_then(|f| match regex::Regex::new(f) {
522 Ok(rx) => Some(rx),
523 Err(e) => {
524 eprintln!(
525 "yosh-plugin: ignoring invalid --filter regex {:?}: {}",
526 f, e
527 );
528 None
529 }
530 });
531
532 fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
533 let Ok(rd) = std::fs::read_dir(dir) else {
534 return;
535 };
536 for entry in rd.flatten() {
537 let p = entry.path();
538 if p.is_dir() {
539 walk(&p, out);
540 } else if p.extension().and_then(|s| s.to_str()) == Some("toml") {
541 out.push(p);
542 }
543 }
544 }
545
546 let mut paths = Vec::new();
547 if path.is_dir() {
548 walk(path, &mut paths);
549 } else if path.exists() {
550 paths.push(path.to_path_buf());
551 }
552 paths.sort();
553
554 for p in paths {
555 if let Some(rx) = &filter_rx
556 && !rx.is_match(&p.to_string_lossy())
557 {
558 continue;
559 }
560 let results = run_scenario(&p);
561 reports.push(ScenarioReport {
562 file: p,
563 steps: results,
564 });
565 }
566 reports
567}
568
569pub fn format_summary_human(reports: &[ScenarioReport]) -> String {
570 use std::fmt::Write as _;
571 let mut out = String::new();
572 let _ = writeln!(out, "running {} scenarios", reports.len());
573 let mut passed = 0;
574 let mut failed = 0;
575 for r in reports {
576 if r.passed() {
577 passed += 1;
578 let _ = writeln!(out, " \u{2713} {}", r.file.display());
579 } else {
580 failed += 1;
581 let _ = writeln!(out, " \u{2717} {}", r.file.display());
582 for s in &r.steps {
583 if let StepResult::Fail(msg) = s {
584 let _ = writeln!(out, " {}", msg);
585 }
586 }
587 }
588 }
589 let _ = writeln!(out, "{} passed, {} failed", passed, failed);
590 out
591}
592
593pub fn format_summary_json(reports: &[ScenarioReport]) -> String {
594 use std::fmt::Write as _;
595 let mut out = String::new();
596 let mut passed = 0;
597 let mut failed = 0;
598 for r in reports {
599 if r.passed() {
600 passed += 1;
601 let _ = writeln!(
602 out,
603 "{}",
604 serde_json::json!({
605 "file": r.file.display().to_string(),
606 "status": "pass",
607 "steps": r.steps.len()
608 })
609 );
610 } else {
611 failed += 1;
612 let reason = r
613 .steps
614 .iter()
615 .find_map(|s| match s {
616 StepResult::Fail(m) => Some(m.clone()),
617 _ => None,
618 })
619 .unwrap_or_default();
620 let _ = writeln!(
621 out,
622 "{}",
623 serde_json::json!({
624 "file": r.file.display().to_string(),
625 "status": "fail",
626 "reason": reason
627 })
628 );
629 }
630 }
631 let _ = writeln!(
632 out,
633 "{}",
634 serde_json::json!({
635 "summary": { "passed": passed, "failed": failed, "total": reports.len() }
636 })
637 );
638 out
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 fn parse_str(s: &str) -> Result<Scenario, String> {
646 toml::from_str(s).map_err(|e| e.to_string())
647 }
648
649 #[test]
650 fn minimal_scenario_parses() {
651 let sc = parse_str(
652 r#"
653 plugin = "a.wasm"
654 [[step]]
655 call = "exec"
656 args = ["echo", "hi"]
657 "#,
658 )
659 .unwrap();
660 assert_eq!(sc.plugin.to_str().unwrap(), "a.wasm");
661 assert_eq!(sc.steps.len(), 1);
662 match &sc.steps[0] {
663 Step::Exec { args, .. } => {
664 assert_eq!(args, &vec!["echo".to_string(), "hi".to_string()])
665 }
666 _ => panic!("expected exec step"),
667 }
668 }
669
670 #[test]
671 fn unknown_expect_key_rejected() {
672 let err = parse_str(
673 r#"
674 plugin = "a.wasm"
675 [[step]]
676 call = "exec"
677 args = ["x"]
678 [step.expect]
679 mystery = "boom"
680 "#,
681 )
682 .unwrap_err();
683 assert!(err.contains("mystery") || err.contains("unknown field"));
684 }
685
686 #[test]
687 fn hook_step_parses() {
688 let sc = parse_str(
689 r#"
690 plugin = "a.wasm"
691 [[step]]
692 call = "hook"
693 name = "on-cd"
694 args = ["/old", "/new"]
695 "#,
696 )
697 .unwrap();
698 match &sc.steps[0] {
699 Step::Hook { name, args, .. } => {
700 assert_eq!(*name, HookName::OnCd);
701 assert_eq!(args.len(), 2);
702 }
703 _ => panic!("expected hook step"),
704 }
705 }
706}