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