Skip to main content

lux_lib/operations/
test.rs

1use std::{io, ops::Deref, path::PathBuf, process::Command, sync::Arc};
2
3use crate::tree::InstallTree;
4use crate::workspace::{WorkspaceError, WorkspaceTreeError};
5use crate::{
6    build::BuildBehaviour,
7    config::{Config, ConfigError},
8    lua_installation::{LuaBinary, LuaBinaryError},
9    lua_rockspec::{LuaVersionError, TestSpecError, ValidatedTestSpec},
10    package::{PackageName, PackageVersionReqError},
11    path::{Paths, PathsError},
12    progress::{MultiProgress, Progress},
13    project::{project_toml::LocalProjectTomlValidationError, Project, ProjectError},
14    rockspec::Rockspec,
15    tree::{self, TreeError},
16    workspace::Workspace,
17};
18use bon::Builder;
19use itertools::Itertools;
20use path_slash::PathBufExt;
21use thiserror::Error;
22
23use super::{
24    BuildWorkspace, BuildWorkspaceError, Install, InstallError, PackageInstallSpec, Sync, SyncError,
25};
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)]
83pub enum RunTestsError {
84    #[error(transparent)]
85    Config(#[from] ConfigError),
86    #[error(transparent)]
87    InstallTestDependencies(#[from] InstallTestDependenciesError),
88    #[error("error building project:\n{0}")]
89    BuildProject(#[from] BuildWorkspaceError),
90    #[error("tests failed!")]
91    TestFailure,
92    #[error("failed to execute '{0}': {1}")]
93    RunCommandFailure(String, io::Error),
94    #[error(transparent)]
95    Io(#[from] io::Error),
96    #[error(transparent)]
97    Project(#[from] ProjectError),
98    #[error(transparent)]
99    Paths(#[from] PathsError),
100    #[error(transparent)]
101    Workspace(#[from] WorkspaceError),
102    #[error(transparent)]
103    Tree(#[from] WorkspaceTreeError),
104    #[error(transparent)]
105    ProjectTomlValidation(#[from] LocalProjectTomlValidationError),
106    #[error("failed to sync dependencies: {0}")]
107    Sync(#[from] SyncError),
108    #[error(transparent)]
109    TestSpec(#[from] TestSpecError),
110    #[error(transparent)]
111    LuaVersion(#[from] LuaVersionError),
112    #[error(transparent)]
113    LuaBinary(#[from] LuaBinaryError),
114}
115
116async fn run_tests(test: Test<'_>) -> Result<(), RunTestsError> {
117    let workspace = test.workspace;
118    let config = test.config;
119    let progress = test
120        .progress
121        .unwrap_or_else(|| MultiProgress::new_arc(config));
122    let no_lock = test.no_lock.unwrap_or(false);
123
124    if let Some(package) = test.package {
125        let project = workspace.select_member(&package)?;
126        let progress = Arc::clone(&progress);
127        run_project_tests(
128            &workspace, project, no_lock, &test.args, &test.env, progress, config,
129        )
130        .await
131    } else {
132        for project in workspace.members() {
133            let progress = Arc::clone(&progress);
134            run_project_tests(
135                &workspace, project, no_lock, &test.args, &test.env, progress, config,
136            )
137            .await?;
138        }
139        Ok(())
140    }
141}
142
143async fn run_project_tests(
144    workspace: &Workspace,
145    project: &Project,
146    no_lock: bool,
147    test_args: &[String],
148    test_env: &TestEnv,
149    progress: Arc<Progress<MultiProgress>>,
150    config: &Config,
151) -> Result<(), RunTestsError> {
152    let rocks = project.toml().into_local()?;
153    let test_spec = rocks.test().current_platform().to_validated(project)?;
154    let test_config = test_spec.test_config(config)?;
155
156    if no_lock {
157        let rockspec = project.toml().into_local()?;
158        ensure_test_dependencies(workspace, project, rockspec, &test_config, progress.clone())
159            .await?;
160    } else {
161        Sync::new(workspace, &test_config)
162            .progress(progress.clone())
163            .sync_test_dependencies()
164            .await?;
165    }
166
167    BuildWorkspace::new(workspace, &test_config)
168        .package(project.toml().package().clone())
169        .no_lock(no_lock)
170        .only_deps(false)
171        .build()
172        .await?;
173
174    let lua_version = project.lua_version(&test_config)?;
175    let project_tree = workspace.lua_version_tree(lua_version, &test_config)?;
176    let test_tree = workspace.test_tree(&test_config)?;
177    let mut paths = Paths::new(&project_tree)?;
178    let test_tree_paths = Paths::new(&test_tree)?;
179    paths.prepend(&test_tree_paths);
180
181    let test_executable = match &test_spec {
182        ValidatedTestSpec::Busted { .. } => BUSTED_EXE.to_string(),
183        ValidatedTestSpec::BustedNlua { .. } => BUSTED_EXE.to_string(),
184        ValidatedTestSpec::Command(spec) => spec.command.to_string(),
185        ValidatedTestSpec::LuaScript(_) => {
186            let lua_version = project.lua_version(&test_config)?;
187            let lua_binary = LuaBinary::new(lua_version, &test_config);
188            let lua_bin_path: PathBuf = lua_binary.try_into()?;
189            lua_bin_path.to_slash_lossy().to_string()
190        }
191    };
192    let mut command = Command::new(&test_executable);
193    let mut command = command
194        .current_dir(project.root().deref())
195        .args(test_spec.args())
196        .args(test_args)
197        .env("PATH", paths.path_prepended().joined())
198        .env("LUA_PATH", paths.package_path().joined())
199        .env("LUA_CPATH", paths.package_cpath().joined());
200    if let TestEnv::Pure = test_env {
201        // isolate the test runner from the user's own config/data files
202        // by initialising empty HOME and XDG base directory paths
203        let home = test_tree.root().join("home");
204        let xdg = home.join("xdg");
205        let _ = tokio::fs::remove_dir_all(&home).await;
206        let xdg_config_home = xdg.join("config");
207        tokio::fs::create_dir_all(&xdg_config_home).await?;
208        let xdg_state_home = xdg.join("local").join("state");
209        tokio::fs::create_dir_all(&xdg_state_home).await?;
210        let xdg_data_home = xdg.join("local").join("share");
211        tokio::fs::create_dir_all(&xdg_data_home).await?;
212        command = command
213            .env("HOME", home)
214            .env("XDG_CONFIG_HOME", xdg_config_home)
215            .env("XDG_STATE_HOME", xdg_state_home)
216            .env("XDG_DATA_HOME", xdg_data_home);
217    }
218    let status = match command.status() {
219        Ok(status) => Ok(status),
220        Err(err) => Err(RunTestsError::RunCommandFailure(test_executable, err)),
221    }?;
222    if !status.success() {
223        Err(RunTestsError::TestFailure)
224    } else {
225        Ok(())
226    }
227}
228
229#[derive(Error, Debug)]
230#[error("error installing test dependencies: {0}")]
231pub enum InstallTestDependenciesError {
232    WorkspaceTree(#[from] WorkspaceTreeError),
233    Tree(#[from] TreeError),
234    Install(#[from] InstallError),
235    PackageVersionReq(#[from] PackageVersionReqError),
236}
237
238/// Ensure test dependencies are installed
239/// This defaults to the local project tree if cwd is a project root.
240async fn ensure_test_dependencies(
241    workspace: &Workspace,
242    project: &Project,
243    rockspec: impl Rockspec,
244    config: &Config,
245    progress: Arc<Progress<MultiProgress>>,
246) -> Result<(), InstallTestDependenciesError> {
247    let test_tree = workspace.test_tree(config)?;
248    let rockspec_dependencies = rockspec.test_dependencies().current_platform();
249    let test_dependencies = rockspec
250        .test()
251        .current_platform()
252        .test_dependencies(project)
253        .iter()
254        .filter(|test_dep| {
255            !rockspec_dependencies
256                .iter()
257                .any(|dep| dep.name() == test_dep.name())
258        })
259        .filter_map(|dep| {
260            let build_behaviour = if test_tree
261                .match_rocks(dep)
262                .is_ok_and(|matches| matches.is_found())
263            {
264                Some(BuildBehaviour::NoForce)
265            } else {
266                Some(BuildBehaviour::Force)
267            };
268            build_behaviour.map(|build_behaviour| {
269                PackageInstallSpec::new(dep.clone(), tree::EntryType::Entrypoint)
270                    .build_behaviour(build_behaviour)
271                    .build()
272            })
273        })
274        .chain(
275            rockspec_dependencies
276                .iter()
277                .filter(|req| !req.name().eq(&PackageName::new("lua".into())))
278                .filter_map(|dep| {
279                    let build_behaviour = if test_tree
280                        .match_rocks(dep.package_req())
281                        .is_ok_and(|matches| matches.is_found())
282                    {
283                        Some(BuildBehaviour::NoForce)
284                    } else {
285                        Some(BuildBehaviour::Force)
286                    };
287                    build_behaviour.map(|build_behaviour| {
288                        PackageInstallSpec::new(
289                            dep.package_req().clone(),
290                            tree::EntryType::Entrypoint,
291                        )
292                        .build_behaviour(build_behaviour)
293                        .pin(*dep.pin())
294                        .opt(*dep.opt())
295                        .maybe_source(dep.source.clone())
296                        .build()
297                    })
298                }),
299        )
300        .collect();
301
302    Install::new(config)
303        .packages(test_dependencies)
304        .tree(test_tree)
305        .progress(progress.clone())
306        .install()
307        .await?;
308
309    Ok(())
310}
311
312#[cfg(test)]
313mod tests {
314    use std::path::Path;
315
316    use crate::{
317        config::ConfigBuilder, lua_installation::detect_installed_lua_version,
318        lua_version::LuaVersion,
319    };
320
321    use super::*;
322    use assert_fs::{prelude::PathCopy, TempDir};
323
324    #[tokio::test]
325    async fn test_command_spec() {
326        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
327            .join("resources/test/sample-projects/command-test/");
328        run_test(&project_root).await
329    }
330
331    #[tokio::test]
332    async fn test_lua_script_spec() {
333        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
334            .join("resources/test/sample-projects/lua-script-test/");
335        run_test(&project_root).await
336    }
337
338    async fn run_test(project_root: &Path) {
339        let temp_dir = TempDir::new().unwrap();
340        temp_dir.copy_from(project_root, &["**"]).unwrap();
341        let workspace_root = temp_dir.path();
342        let workspace = Workspace::from(workspace_root).unwrap().unwrap();
343        let tree_root = workspace.root().to_path_buf().join(".lux");
344        let _ = tokio::fs::remove_dir_all(&tree_root).await;
345
346        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
347
348        let config = ConfigBuilder::new()
349            .unwrap()
350            .user_tree(Some(tree_root))
351            .lua_version(lua_version)
352            .build()
353            .unwrap();
354
355        Test::new(workspace, &config).run().await.unwrap();
356    }
357}