Skip to main content

lux_lib/operations/
test.rs

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