Skip to main content

lux_lib/operations/
test.rs

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