Skip to main content

lux_lib/operations/
test.rs

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