Skip to main content

lux_lib/operations/
test.rs

1use std::{io, ops::Deref, path::PathBuf, process::Command};
2
3use crate::drivers::sync::{Sync, SyncError, SyncMode, TargetSet};
4use crate::fs;
5use crate::tree::InstallTree;
6use crate::workspace::{WorkspaceError, WorkspaceTreeError};
7use crate::{
8    build::BuildBehaviour,
9    config::{Config, ConfigError},
10    lua_installation::{LuaBinary, LuaBinaryError},
11    lua_rockspec::{LuaVersionError, TestSpecError, ValidatedTestSpec},
12    package::PackageName,
13    path::{Paths, PathsError},
14    project::{project_toml::LocalProjectTomlValidationError, Project, ProjectError},
15    rockspec::Rockspec,
16    workspace::Workspace,
17};
18use bon::Builder;
19use itertools::Itertools;
20use miette::Diagnostic;
21use path_slash::PathBufExt;
22use thiserror::Error;
23
24#[cfg(target_family = "unix")]
25const BUSTED_EXE: &str = "busted";
26#[cfg(target_family = "windows")]
27const BUSTED_EXE: &str = "busted.bat";
28
29#[derive(Builder)]
30#[builder(start_fn = new, finish_fn(name = _run, vis = ""))]
31pub struct Test<'a> {
32    #[builder(start_fn)]
33    workspace: Workspace,
34    #[builder(start_fn)]
35    config: &'a Config,
36
37    #[builder(field)]
38    args: Vec<String>,
39
40    /// Package to run tests for
41    package: Option<PackageName>,
42
43    no_lock: Option<bool>,
44
45    #[builder(default)]
46    env: TestEnv,
47}
48
49impl<State: test_builder::State> TestBuilder<'_, State> {
50    pub fn arg(mut self, arg: impl Into<String>) -> Self {
51        self.args.push(arg.into());
52        self
53    }
54
55    pub fn args(mut self, args: impl IntoIterator<Item: Into<String>>) -> Self {
56        self.args.extend(args.into_iter().map_into());
57        self
58    }
59
60    pub async fn run(self) -> Result<(), RunTestsError>
61    where
62        State: test_builder::IsComplete,
63    {
64        run_tests(self._run()).await
65    }
66}
67
68#[derive(Default)]
69pub enum TestEnv {
70    /// An environment that is isolated from `HOME` and `XDG` base directories (default).
71    #[default]
72    Pure,
73    /// An impure environment in which `HOME` and `XDG` base directories can influence
74    /// the test results.
75    Impure,
76}
77
78#[derive(Error, Debug, Diagnostic)]
79pub enum RunTestsError {
80    #[error(transparent)]
81    #[diagnostic(transparent)]
82    Config(#[from] ConfigError),
83    #[error("build failed")]
84    #[diagnostic(forward(0))]
85    Sync(#[from] Box<SyncError>),
86    #[error("tests failed!")]
87    #[diagnostic(help("see the test runner's output for details"))]
88    TestFailure,
89    #[error("failed to execute '{cmd}'")]
90    RunCommandFailure {
91        cmd: String,
92        source: io::Error,
93        #[help]
94        help: Option<String>,
95    },
96    #[error(transparent)]
97    #[diagnostic(transparent)]
98    Fs(#[from] fs::FsError),
99    #[error(transparent)]
100    #[diagnostic(transparent)]
101    Project(#[from] ProjectError),
102    #[error(transparent)]
103    #[diagnostic(transparent)]
104    Paths(#[from] PathsError),
105    #[error(transparent)]
106    #[diagnostic(transparent)]
107    Workspace(#[from] WorkspaceError),
108    #[error(transparent)]
109    #[diagnostic(transparent)]
110    Tree(#[from] WorkspaceTreeError),
111    #[error(transparent)]
112    #[diagnostic(transparent)]
113    ProjectTomlValidation(#[from] LocalProjectTomlValidationError),
114    #[error(transparent)]
115    #[diagnostic(transparent)]
116    TestSpec(#[from] TestSpecError),
117    #[error(transparent)]
118    #[diagnostic(transparent)]
119    LuaVersion(#[from] LuaVersionError),
120    #[error(transparent)]
121    #[diagnostic(transparent)]
122    LuaBinary(#[from] LuaBinaryError),
123}
124
125impl From<SyncError> for RunTestsError {
126    fn from(source: SyncError) -> Self {
127        Self::Sync(Box::new(source))
128    }
129}
130
131#[tracing::instrument(name = "🧪 Running tests", skip_all)]
132
133async fn run_tests(test: Test<'_>) -> Result<(), RunTestsError> {
134    let workspace = test.workspace;
135    let config = test.config;
136    let no_lock = test.no_lock.unwrap_or(false);
137
138    if let Some(package) = test.package {
139        let project = workspace.select_member(&package)?;
140        Box::pin(run_project_tests(
141            &workspace, project, no_lock, &test.args, &test.env, config,
142        ))
143        .await
144    } else {
145        for project in workspace.members() {
146            Box::pin(run_project_tests(
147                &workspace, project, no_lock, &test.args, &test.env, config,
148            ))
149            .await?;
150        }
151        Ok(())
152    }
153}
154
155async fn run_project_tests(
156    workspace: &Workspace,
157    project: &Project,
158    no_lock: bool,
159    test_args: &[String],
160    test_env: &TestEnv,
161    config: &Config,
162) -> Result<(), RunTestsError> {
163    let rocks = project.toml().into_local()?;
164    let test_spec = rocks.test().current_platform().to_validated(project)?;
165    let test_config = test_spec.test_config(config)?;
166
167    Sync::new(workspace, &test_config)
168        .mode(SyncMode::Open)
169        .targets(TargetSet::member(project.toml().package().clone(), true))
170        .no_lock(no_lock)
171        .behaviour(BuildBehaviour::Ignore)
172        .sync()
173        .await?;
174
175    let lua_version = project.lua_version(&test_config)?;
176    let project_tree = workspace.lua_version_tree(lua_version, &test_config)?;
177    let test_tree = workspace.test_tree(&test_config)?;
178    let mut paths = Paths::new(&project_tree)?;
179    let test_tree_paths = Paths::new(&test_tree)?;
180    paths.prepend(&test_tree_paths);
181
182    let test_executable = match &test_spec {
183        ValidatedTestSpec::Busted { .. } => BUSTED_EXE.to_string(),
184        ValidatedTestSpec::BustedNlua { .. } => BUSTED_EXE.to_string(),
185        ValidatedTestSpec::Command(spec) => spec.command.to_string(),
186        ValidatedTestSpec::LuaScript(_) => {
187            let lua_version = project.lua_version(&test_config)?;
188            let lua_binary = LuaBinary::new(lua_version, &test_config);
189            let lua_bin_path: PathBuf = lua_binary.try_into()?;
190            lua_bin_path.to_slash_lossy().to_string()
191        }
192    };
193    let mut command = Command::new(&test_executable);
194    let mut command = command
195        .current_dir(project.root().deref())
196        .args(test_spec.args())
197        .args(test_args)
198        .env("PATH", paths.path_prepended().joined())
199        .env("LUA_PATH", paths.package_path().joined())
200        .env("LUA_CPATH", paths.package_cpath().joined());
201    if matches!(
202        &test_spec,
203        ValidatedTestSpec::Busted { .. } | ValidatedTestSpec::BustedNlua { .. }
204    ) && !test_spec
205        .args()
206        .iter()
207        .chain(test_args.iter())
208        .any(|arg| arg == "--directory" || arg == "-C" || arg.starts_with("--directory="))
209    {
210        let directory = fs::sync::absolute(project.root())?;
211        command = command
212            .arg("--directory")
213            .arg(directory.to_slash_lossy().to_string());
214    }
215    if let TestEnv::Pure = test_env {
216        // isolate the test runner from the user's own config/data files
217        // by initialising empty HOME and XDG base directory paths
218        let home = test_tree.root().join("home");
219        let xdg = home.join("xdg");
220        let _ = fs::tokio::remove_dir_all(&home).await;
221        let xdg_config_home = xdg.join("config");
222        fs::tokio::create_dir_all(&xdg_config_home).await?;
223        let xdg_state_home = xdg.join("local").join("state");
224        fs::tokio::create_dir_all(&xdg_state_home).await?;
225        let xdg_data_home = xdg.join("local").join("share");
226        fs::tokio::create_dir_all(&xdg_data_home).await?;
227        command = command
228            .env("HOME", home)
229            .env("XDG_CONFIG_HOME", xdg_config_home)
230            .env("XDG_STATE_HOME", xdg_state_home)
231            .env("XDG_DATA_HOME", xdg_data_home);
232    }
233    let status = match command.status() {
234        Ok(status) => Ok(status),
235        Err(err) => {
236            let help = if err.to_string().starts_with("No such file") {
237                Some(format!(
238                    "make sure '{}' is available on your PATH",
239                    test_executable
240                ))
241            } else {
242                None
243            };
244            Err(RunTestsError::RunCommandFailure {
245                cmd: test_executable,
246                source: err,
247                help,
248            })
249        }
250    }?;
251    if !status.success() {
252        Err(RunTestsError::TestFailure)
253    } else {
254        Ok(())
255    }
256}
257
258#[cfg(all(test, feature = "impure_tests"))]
259mod tests {
260    use std::path::Path;
261
262    use crate::{
263        config::ConfigBuilder, fs, lua_installation::detect_installed_lua_version,
264        lua_version::LuaVersion,
265    };
266
267    use super::*;
268    use assert_fs::{prelude::PathCopy, TempDir};
269
270    #[tokio::test]
271    async fn test_command_spec() {
272        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
273            .join("resources/test/sample-projects/command-test/");
274        run_test(&project_root).await
275    }
276
277    #[tokio::test]
278    async fn test_lua_script_spec() {
279        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
280            .join("resources/test/sample-projects/lua-script-test/");
281        run_test(&project_root).await
282    }
283
284    async fn run_test(project_root: &Path) {
285        let temp_dir = TempDir::new().unwrap();
286        temp_dir.copy_from(project_root, &["**"]).unwrap();
287        let workspace_root = temp_dir.path();
288        let workspace = Workspace::from(workspace_root).unwrap().unwrap();
289        let tree_root = workspace.root().to_path_buf().join(".lux");
290        let _ = fs::tokio::remove_dir_all(&tree_root).await;
291
292        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
293
294        let config = ConfigBuilder::new()
295            .unwrap()
296            .user_tree(Some(tree_root))
297            .lua_version(lua_version)
298            .build()
299            .unwrap();
300
301        Test::new(workspace, &config).run().await.unwrap();
302    }
303}