Skip to main content

expectation_harness/
lib.rs

1//! Runs a suite's stories as acceptance tests against the system under test, and writes the
2//! documentation from what happened.
3//!
4//! ```ignore
5//! let acceptance = Acceptance::load("expectations")?.with_coverage();
6//! let mut driver = Bindings::new(world).observe(...).set(...).on(...);
7//! acceptance.assert_passes(&mut driver, &RunOptions { docs_dir: Some("docs/behaviour".into()), ..Default::default() });
8//! ```
9//!
10//! The system under test plugs in through [`Driver`], or [`Bindings`] for closures.
11
12pub mod bindings;
13pub mod driver;
14pub mod run;
15/// The external services a run needs: see the crate.
16pub use expectation_service as service;
17
18pub use bindings::Bindings;
19pub use driver::{Driver, Need, Progress, Screenshot};
20pub use run::{RunOptions, run};
21
22use expectation_docs::Docs;
23use expectation_graph::Analysis;
24use expectation_language::Loaded;
25use expectation_model::diagnostic::{has_errors, render_all};
26use expectation_model::{Diagnostic, Suite};
27use expectation_story::{Plan, Report};
28use std::path::Path;
29
30/// A suite that has been checked and planned, ready to run.
31pub struct Acceptance {
32    pub suite: Suite,
33    pub analysis: Analysis,
34    pub plans: Vec<Plan>,
35    /// Warnings (and notes) from checking it; errors stop it loading.
36    pub diagnostics: Vec<Diagnostic>,
37    /// The directory it was loaded from, if it was.
38    pub dir: Option<std::path::PathBuf>,
39    /// Set by [`Acceptance::load_for`]: the platform this is the suite of. Its results are
40    /// its own, and its docs are one tree of several (see [`Acceptance::tree`]).
41    pub platform: Option<String>,
42}
43
44impl Acceptance {
45    /// Load, check and plan the suite in `dir`. Anything that's an error — a step outside the
46    /// vocabulary, an unsettled conflict, a story relying on unspecified behaviour — fails
47    /// it, with every diagnostic rendered.
48    pub fn load(dir: impl AsRef<Path>) -> Result<Self, String> {
49        Self::read(dir.as_ref(), None)
50    }
51
52    /// As [`Acceptance::load`], for one platform of a suite that declares several: the
53    /// variants written for it replace the plain lines, and what is `On:` elsewhere is
54    /// left out. Run the same suite on each platform with its own driver.
55    pub fn load_for(dir: impl AsRef<Path>, platform: &str) -> Result<Self, String> {
56        let mut a = Self::read(dir.as_ref(), Some(platform))?;
57        a.platform = a.suite.platform.clone();
58        Ok(a)
59    }
60
61    fn read(dir: &Path, platform: Option<&str>) -> Result<Self, String> {
62        let loaded = expectation_language::load_dir_for(dir, platform)
63            .map_err(|e| format!("couldn't read {}: {e}", dir.display()))?;
64        let mut a = Self::from_loaded(loaded)?;
65        a.dir = Some(dir.to_path_buf());
66        Ok(a)
67    }
68
69    /// Where this platform's docs go under the docs directory `dir`: the default platform's
70    /// (and a suite without platforms) are in `dir` itself, the others' in `dir/platforms/<name>`.
71    pub fn tree(&self, dir: &Path) -> std::path::PathBuf {
72        match self.suite.platform.as_deref() {
73            Some(p) if self.suite.platforms.first().map(String::as_str) != Some(p) => {
74                dir.join("platforms").join(expectation_model::text::slug(p))
75            }
76            _ => dir.to_path_buf(),
77        }
78    }
79
80    pub fn from_loaded(loaded: Loaded) -> Result<Self, String> {
81        let Loaded { suite, mut diagnostics } = loaded;
82        if has_errors(&diagnostics) {
83            return Err(render_all(&diagnostics, &suite.sources));
84        }
85        let analysis = expectation_graph::analyze(&suite);
86        diagnostics.extend(analysis.diagnostics.iter().cloned());
87        let planned = expectation_story::plan_stories(&suite, &analysis);
88        diagnostics.extend(planned.diagnostics);
89        diagnostics.extend(expectation_story::untested(&suite, &analysis, &planned.plans));
90        if has_errors(&diagnostics) {
91            return Err(render_all(&diagnostics, &suite.sources));
92        }
93        Ok(Acceptance { suite, analysis, plans: planned.plans, diagnostics, dir: None, platform: None })
94    }
95
96    /// Add generated tests for the expectations no story exercises.
97    pub fn with_coverage(mut self) -> Self {
98        let extra = expectation_story::coverage_plans(&self.suite, &self.analysis, &self.plans);
99        self.plans.extend(extra);
100        self
101    }
102
103    pub fn render_diagnostics(&self) -> String {
104        render_all(&self.diagnostics, &self.suite.sources)
105    }
106
107    pub fn run(&self, driver: &mut dyn Driver, opts: &RunOptions) -> Report {
108        run::run(&self.suite, &self.plans, driver, opts)
109    }
110
111    /// As [`Acceptance::run`], with what the driver needs from outside started first (see [`service`]).
112    pub fn run_with(&self, driver: &mut dyn Driver, opts: &RunOptions, services: &mut service::Services) -> Report {
113        run::run_with(&self.suite, &self.plans, driver, opts, services)
114    }
115
116    pub fn docs<'a>(&'a self, report: Option<&'a Report>) -> Docs<'a> {
117        Docs {
118            suite: &self.suite,
119            analysis: &self.analysis,
120            plans: &self.plans,
121            report,
122            suite_dir: self.dir.as_deref(),
123            siblings: &[],
124            outline: None,
125        }
126    }
127
128    /// Save `report` as `runner`'s results in `dir/results/` (a platform's runner saves its
129    /// own: `app@iOS.tsv`), merge them with the results that apply to this platform, and write
130    /// its docs (Markdown and the HTML site) from the lot. Returns the merged report.
131    pub fn publish(&self, dir: &Path, runner: &str, report: &Report) -> std::io::Result<Report> {
132        let results = dir.join("results");
133        std::fs::create_dir_all(&results)?;
134        let file = expectation_story::results_file(runner, self.platform.as_deref());
135        std::fs::write(results.join(file), report.save(&self.plans))?;
136        let merged = expectation_story::load_results_for(&results, &self.plans, self.suite.platform.as_deref());
137        self.docs(Some(&merged)).write(&self.tree(dir))?;
138        Ok(merged)
139    }
140
141    /// Run every plan, write the docs to `opts.docs_dir` (if set) whatever happened, and panic
142    /// with what failed. For `#[test]`s.
143    pub fn assert_passes(&self, driver: &mut dyn Driver, opts: &RunOptions) -> Report {
144        let report = self.run(driver, opts);
145        if let Some(dir) = &opts.docs_dir {
146            self.docs(Some(&report)).write(dir).expect("writing the docs");
147        }
148        if !report.passed() {
149            panic!("acceptance tests failed:\n{}", report.failures(&self.suite, &self.plans));
150        }
151        report
152    }
153}