Skip to main content

lux_lib/operations/
test.rs

1use std::{io, ops::Deref, path::PathBuf, process::Command, sync::Arc};
2
3use crate::{
4    build::BuildBehaviour,
5    config::{Config, ConfigError},
6    lua_installation::{LuaBinary, LuaBinaryError},
7    lua_rockspec::{LuaVersionError, TestSpecError, ValidatedTestSpec},
8    package::{PackageName, PackageVersionReqError},
9    path::{Paths, PathsError},
10    progress::{MultiProgress, Progress},
11    project::{
12        project_toml::LocalProjectTomlValidationError, Project, ProjectError, ProjectTreeError,
13    },
14    rockspec::Rockspec,
15    tree::{self, TreeError},
16};
17use bon::Builder;
18use itertools::Itertools;
19use path_slash::PathBufExt;
20use thiserror::Error;
21
22use super::{
23    BuildProject, BuildProjectError, Install, InstallError, PackageInstallSpec, Sync, SyncError,
24};
25
26#[cfg(target_family = "unix")]
27const BUSTED_EXE: &str = "busted";
28#[cfg(target_family = "windows")]
29const BUSTED_EXE: &str = "busted.bat";
30
31#[derive(Builder)]
32#[builder(start_fn = new, finish_fn(name = _run, vis = ""))]
33pub struct Test<'a> {
34    #[builder(start_fn)]
35    project: Project,
36    #[builder(start_fn)]
37    config: &'a Config,
38
39    #[builder(field)]
40    args: Vec<String>,
41
42    no_lock: Option<bool>,
43
44    #[builder(default)]
45    env: TestEnv,
46    progress: Option<Arc<Progress<MultiProgress>>>,
47}
48
49impl<State: test_builder::State> TestBuilder<'_, State> {
50    pub fn arg(mut self, arg: impl Into<String>) -> Self {
51        self.args.push(arg.into());
52        self
53    }
54
55    pub fn args(mut self, args: impl IntoIterator<Item: Into<String>>) -> Self {
56        self.args.extend(args.into_iter().map_into());
57        self
58    }
59
60    pub async fn run(self) -> Result<(), RunTestsError>
61    where
62        State: test_builder::IsComplete,
63    {
64        run_tests(self._run()).await
65    }
66}
67
68#[derive(Default)]
69pub enum TestEnv {
70    /// An environment that is isolated from `HOME` and `XDG` base directories (default).
71    #[default]
72    Pure,
73    /// An impure environment in which `HOME` and `XDG` base directories can influence
74    /// the test results.
75    Impure,
76}
77
78#[derive(Error, Debug)]
79pub enum RunTestsError {
80    #[error(transparent)]
81    Config(#[from] ConfigError),
82    #[error(transparent)]
83    InstallTestDependencies(#[from] InstallTestDependenciesError),
84    #[error("error building project:\n{0}")]
85    BuildProject(#[from] BuildProjectError),
86    #[error("tests failed!")]
87    TestFailure,
88    #[error("failed to execute `{0}`: {1}")]
89    RunCommandFailure(String, io::Error),
90    #[error(transparent)]
91    Io(#[from] io::Error),
92    #[error(transparent)]
93    Project(#[from] ProjectError),
94    #[error(transparent)]
95    Paths(#[from] PathsError),
96    #[error(transparent)]
97    Tree(#[from] ProjectTreeError),
98    #[error(transparent)]
99    ProjectTomlValidation(#[from] LocalProjectTomlValidationError),
100    #[error("failed to sync dependencies: {0}")]
101    Sync(#[from] SyncError),
102    #[error(transparent)]
103    TestSpec(#[from] TestSpecError),
104    #[error(transparent)]
105    LuaVersion(#[from] LuaVersionError),
106    #[error(transparent)]
107    LuaBinary(#[from] LuaBinaryError),
108}
109
110async fn run_tests(test: Test<'_>) -> Result<(), RunTestsError> {
111    let rocks = test.project.toml().into_local()?;
112
113    let test_spec = rocks
114        .test()
115        .current_platform()
116        .to_validated(&test.project)?;
117
118    let config = test_spec.test_config(test.config)?;
119
120    let no_lock = test.no_lock.unwrap_or(false);
121
122    let progress = test
123        .progress
124        .unwrap_or_else(|| MultiProgress::new_arc(&config));
125
126    if no_lock {
127        ensure_test_dependencies(&test.project, &rocks, &config, progress).await?;
128    } else {
129        Sync::new(&test.project, &config)
130            .progress(progress.clone())
131            .sync_test_dependencies()
132            .await?;
133    }
134
135    BuildProject::new(&test.project, &config)
136        .no_lock(no_lock)
137        .only_deps(false)
138        .build()
139        .await?;
140
141    let project_tree = test.project.tree(&config)?;
142    let test_tree = test.project.test_tree(&config)?;
143    let mut paths = Paths::new(&project_tree)?;
144    let test_tree_paths = Paths::new(&test_tree)?;
145    paths.prepend(&test_tree_paths);
146
147    let test_executable = match &test_spec {
148        ValidatedTestSpec::Busted(_) => BUSTED_EXE.to_string(),
149        ValidatedTestSpec::BustedNlua(_) => BUSTED_EXE.to_string(),
150        ValidatedTestSpec::Command(spec) => spec.command.to_string(),
151        ValidatedTestSpec::LuaScript(_) => {
152            let lua_version = test.project.lua_version(&config)?;
153            let lua_binary = LuaBinary::new(lua_version, &config);
154            let lua_bin_path: PathBuf = lua_binary.try_into()?;
155            lua_bin_path.to_slash_lossy().to_string()
156        }
157    };
158    let mut command = Command::new(&test_executable);
159    let mut command = command
160        .current_dir(test.project.root().deref())
161        .args(test_spec.args())
162        .args(test.args)
163        .env("PATH", paths.path_prepended().joined())
164        .env("LUA_PATH", paths.package_path().joined())
165        .env("LUA_CPATH", paths.package_cpath().joined());
166    if let TestEnv::Pure = test.env {
167        // isolate the test runner from the user's own config/data files
168        // by initialising empty HOME and XDG base directory paths
169        let home = test_tree.root().join("home");
170        let xdg = home.join("xdg");
171        let _ = tokio::fs::remove_dir_all(&home).await;
172        let xdg_config_home = xdg.join("config");
173        std::fs::create_dir_all(&xdg_config_home)?;
174        let xdg_state_home = xdg.join("local").join("state");
175        std::fs::create_dir_all(&xdg_state_home)?;
176        let xdg_data_home = xdg.join("local").join("share");
177        std::fs::create_dir_all(&xdg_data_home)?;
178        command = command
179            .env("HOME", home)
180            .env("XDG_CONFIG_HOME", xdg_config_home)
181            .env("XDG_STATE_HOME", xdg_state_home)
182            .env("XDG_DATA_HOME", xdg_data_home);
183    }
184    let status = match command.status() {
185        Ok(status) => Ok(status),
186        Err(err) => Err(RunTestsError::RunCommandFailure(test_executable, err)),
187    }?;
188    if status.success() {
189        Ok(())
190    } else {
191        Err(RunTestsError::TestFailure)
192    }
193}
194
195#[derive(Error, Debug)]
196#[error("error installing test dependencies: {0}")]
197pub enum InstallTestDependenciesError {
198    ProjectTree(#[from] ProjectTreeError),
199    Tree(#[from] TreeError),
200    Install(#[from] InstallError),
201    PackageVersionReq(#[from] PackageVersionReqError),
202}
203
204/// Ensure test dependencies are installed
205/// This defaults to the local project tree if cwd is a project root.
206async fn ensure_test_dependencies(
207    project: &Project,
208    rockspec: &impl Rockspec,
209    config: &Config,
210    progress: Arc<Progress<MultiProgress>>,
211) -> Result<(), InstallTestDependenciesError> {
212    let test_tree = project.test_tree(config)?;
213    let rockspec_dependencies = rockspec.test_dependencies().current_platform();
214    let test_dependencies = rockspec
215        .test()
216        .current_platform()
217        .test_dependencies(project)
218        .iter()
219        .filter(|test_dep| {
220            !rockspec_dependencies
221                .iter()
222                .any(|dep| dep.name() == test_dep.name())
223        })
224        .filter_map(|dep| {
225            let build_behaviour = if test_tree
226                .match_rocks(dep)
227                .is_ok_and(|matches| matches.is_found())
228            {
229                Some(BuildBehaviour::NoForce)
230            } else {
231                Some(BuildBehaviour::Force)
232            };
233            build_behaviour.map(|build_behaviour| {
234                PackageInstallSpec::new(dep.clone(), tree::EntryType::Entrypoint)
235                    .build_behaviour(build_behaviour)
236                    .build()
237            })
238        })
239        .chain(
240            rockspec_dependencies
241                .iter()
242                .filter(|req| !req.name().eq(&PackageName::new("lua".into())))
243                .filter_map(|dep| {
244                    let build_behaviour = if test_tree
245                        .match_rocks(dep.package_req())
246                        .is_ok_and(|matches| matches.is_found())
247                    {
248                        Some(BuildBehaviour::NoForce)
249                    } else {
250                        Some(BuildBehaviour::Force)
251                    };
252                    build_behaviour.map(|build_behaviour| {
253                        PackageInstallSpec::new(
254                            dep.package_req().clone(),
255                            tree::EntryType::Entrypoint,
256                        )
257                        .build_behaviour(build_behaviour)
258                        .pin(*dep.pin())
259                        .opt(*dep.opt())
260                        .maybe_source(dep.source.clone())
261                        .build()
262                    })
263                }),
264        )
265        .collect();
266
267    Install::new(config)
268        .packages(test_dependencies)
269        .tree(test_tree)
270        .progress(progress.clone())
271        .install()
272        .await?;
273
274    Ok(())
275}
276
277#[cfg(test)]
278mod tests {
279    use std::path::Path;
280
281    use crate::{
282        config::ConfigBuilder, lua_installation::detect_installed_lua_version,
283        lua_version::LuaVersion,
284    };
285
286    use super::*;
287    use assert_fs::{prelude::PathCopy, TempDir};
288
289    #[tokio::test]
290    async fn test_command_spec() {
291        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
292            .join("resources/test/sample-projects/command-test/");
293        run_test(&project_root).await
294    }
295
296    #[tokio::test]
297    async fn test_lua_script_spec() {
298        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
299            .join("resources/test/sample-projects/lua-script-test/");
300        run_test(&project_root).await
301    }
302
303    async fn run_test(project_root: &Path) {
304        let temp_dir = TempDir::new().unwrap();
305        temp_dir.copy_from(project_root, &["**"]).unwrap();
306        let project_root = temp_dir.path();
307        let project: Project = Project::from(project_root).unwrap().unwrap();
308        let tree_root = project.root().to_path_buf().join(".lux");
309        let _ = tokio::fs::remove_dir_all(&tree_root).await;
310
311        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
312
313        let config = ConfigBuilder::new()
314            .unwrap()
315            .user_tree(Some(tree_root))
316            .lua_version(lua_version)
317            .build()
318            .unwrap();
319
320        Test::new(project, &config).run().await.unwrap();
321    }
322}