Skip to main content

lux_lib/operations/
test.rs

1use std::{io, ops::Deref, process::Command};
2
3use crate::drivers::sync::{Sync, SyncError, SyncMode, TargetSet};
4
5pub(crate) mod tiniest;
6use crate::fs;
7use crate::tree::InstallTree;
8use crate::workspace::{WorkspaceError, WorkspaceTreeError};
9use crate::{
10    build::BuildBehaviour,
11    config::{Config, ConfigError},
12    lua_installation::LuaBinaryError,
13    lua_rockspec::{LuaVersionError, TestSpecError, ValidatedTestSpec},
14    lua_version::LuaVersion,
15    package::PackageName,
16    path::{Paths, PathsError},
17    project::{project_toml::LocalProjectTomlValidationError, Project, ProjectError},
18    rockspec::Rockspec,
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("build failed")]
87    #[diagnostic(forward(0))]
88    Sync(#[from] Box<SyncError>),
89    #[error("tests failed!")]
90    #[diagnostic(help("see the test runner's output for details"))]
91    TestFailure,
92    #[error("failed to execute '{cmd}'")]
93    RunCommandFailure {
94        cmd: String,
95        source: io::Error,
96        #[help]
97        help: Option<String>,
98    },
99    #[error(transparent)]
100    #[diagnostic(transparent)]
101    Fs(#[from] fs::FsError),
102    #[error(transparent)]
103    #[diagnostic(transparent)]
104    Project(#[from] ProjectError),
105    #[error(transparent)]
106    #[diagnostic(transparent)]
107    Paths(#[from] PathsError),
108    #[error(transparent)]
109    #[diagnostic(transparent)]
110    Workspace(#[from] WorkspaceError),
111    #[error(transparent)]
112    #[diagnostic(transparent)]
113    Tree(#[from] WorkspaceTreeError),
114    #[error(transparent)]
115    #[diagnostic(transparent)]
116    ProjectTomlValidation(#[from] LocalProjectTomlValidationError),
117    #[error(transparent)]
118    #[diagnostic(transparent)]
119    TestSpec(#[from] TestSpecError),
120    #[error(transparent)]
121    #[diagnostic(transparent)]
122    LuaVersion(#[from] LuaVersionError),
123    #[error(transparent)]
124    #[diagnostic(transparent)]
125    LuaBinary(#[from] LuaBinaryError),
126    #[error("failed to set up tiniest")]
127    #[diagnostic(forward(0))]
128    Tiniest(#[from] tiniest::TiniestError),
129    #[error(transparent)]
130    #[diagnostic(transparent)]
131    RunLua(#[from] super::run_lua::RunLuaError),
132}
133
134impl From<SyncError> for RunTestsError {
135    fn from(source: SyncError) -> Self {
136        Self::Sync(Box::new(source))
137    }
138}
139
140#[tracing::instrument(name = "🧪 Running tests", skip_all)]
141
142async fn run_tests(test: Test<'_>) -> Result<(), RunTestsError> {
143    let workspace = test.workspace;
144    let config = test.config;
145    let no_lock = test.no_lock.unwrap_or(false);
146
147    if let Some(package) = test.package {
148        let project = workspace.select_member(&package)?;
149        Box::pin(run_project_tests(
150            &workspace, project, no_lock, &test.args, &test.env, config,
151        ))
152        .await
153    } else {
154        for project in workspace.members() {
155            Box::pin(run_project_tests(
156                &workspace, project, no_lock, &test.args, &test.env, config,
157            ))
158            .await?;
159        }
160        Ok(())
161    }
162}
163
164async fn run_project_tests(
165    workspace: &Workspace,
166    project: &Project,
167    no_lock: bool,
168    test_args: &[String],
169    test_env: &TestEnv,
170    config: &Config,
171) -> Result<(), RunTestsError> {
172    let rocks = project.toml().into_local()?;
173    let test_spec = match rocks.test().current_platform().to_validated(project) {
174        Ok(test_spec) => test_spec,
175        Err(TestSpecError::NoTestSpecDetected)
176            if project.lua_version(config)? == LuaVersion::Luau =>
177        {
178            // tiniest is the default test framework for Luau projects
179            ValidatedTestSpec::Tiniest
180        }
181        Err(err) => return Err(err.into()),
182    };
183    let test_config = test_spec.test_config(config)?;
184
185    Sync::new(workspace, &test_config)
186        .mode(SyncMode::Open)
187        .targets(TargetSet::member(project.toml().package().clone(), true))
188        .no_lock(no_lock)
189        .behaviour(BuildBehaviour::Ignore)
190        .sync()
191        .await?;
192
193    let lua_version = project.lua_version(&test_config)?.clone();
194    let project_tree = workspace.lua_version_tree(lua_version.clone(), &test_config)?;
195    let test_tree = workspace.test_tree(&test_config)?;
196    let mut paths = Paths::new(&project_tree)?;
197    let test_tree_paths = Paths::new(&test_tree)?;
198    paths.prepend(&test_tree_paths);
199
200    let collector_path = match &test_spec {
201        ValidatedTestSpec::Tiniest => {
202            let spec_files = tiniest::discover_spec_files(project.root());
203            if spec_files.is_empty() {
204                tracing::warn!("no *.spec.luau or *.spec.lua files found in test/ or tests/");
205            }
206            let tiniest_src = tiniest::ensure_installed(&test_tree, &test_config).await?;
207            Some(tiniest::write_collector(workspace.root(), &spec_files, &tiniest_src).await?)
208        }
209        _ => None,
210    };
211    let mut runner_args = test_spec.args();
212    if let Some(collector_path) = &collector_path {
213        runner_args.push(collector_path.to_slash_lossy().to_string());
214    }
215
216    let test_executable = match &test_spec {
217        ValidatedTestSpec::Busted { .. } => BUSTED_EXE.to_string(),
218        ValidatedTestSpec::BustedNlua { .. } => BUSTED_EXE.to_string(),
219        ValidatedTestSpec::Command(spec) => spec.command.to_string(),
220        ValidatedTestSpec::Tiniest | ValidatedTestSpec::LuaScript(_) => {
221            let runtime =
222                super::run_lua::resolve_lua_runtime(&lua_version.clone(), &test_config).await?;
223            runtime.to_string_lossy().to_string()
224        }
225    };
226    let mut command = Command::new(&test_executable);
227    let mut command = command
228        .current_dir(project.root().deref())
229        .args(runner_args)
230        .args(test_args)
231        .env("PATH", paths.path_prepended().joined())
232        .env("LUA_PATH", paths.package_path().joined())
233        .env("LUA_CPATH", paths.package_cpath().joined());
234    if matches!(
235        &test_spec,
236        ValidatedTestSpec::Busted { .. } | ValidatedTestSpec::BustedNlua { .. }
237    ) && !test_spec
238        .args()
239        .iter()
240        .chain(test_args.iter())
241        .any(|arg| arg == "--directory" || arg == "-C" || arg.starts_with("--directory="))
242    {
243        let directory = fs::sync::absolute(project.root())?;
244        command = command
245            .arg("--directory")
246            .arg(directory.to_slash_lossy().to_string());
247    }
248    if let TestEnv::Pure = test_env {
249        // isolate the test runner from the user's own config/data files
250        // by initialising empty HOME and XDG base directory paths
251        let home = test_tree.root().join("home");
252        let xdg = home.join("xdg");
253        let _ = fs::tokio::remove_dir_all(&home).await;
254        let xdg_config_home = xdg.join("config");
255        fs::tokio::create_dir_all(&xdg_config_home).await?;
256        let xdg_state_home = xdg.join("local").join("state");
257        fs::tokio::create_dir_all(&xdg_state_home).await?;
258        let xdg_data_home = xdg.join("local").join("share");
259        fs::tokio::create_dir_all(&xdg_data_home).await?;
260        command = command
261            .env("HOME", home)
262            .env("XDG_CONFIG_HOME", xdg_config_home)
263            .env("XDG_STATE_HOME", xdg_state_home)
264            .env("XDG_DATA_HOME", xdg_data_home);
265    }
266    let status = match command.status() {
267        Ok(status) => Ok(status),
268        Err(err) => {
269            let help = if err.to_string().starts_with("No such file") {
270                Some(format!(
271                    "make sure '{}' is available on your PATH",
272                    test_executable
273                ))
274            } else {
275                None
276            };
277            Err(RunTestsError::RunCommandFailure {
278                cmd: test_executable,
279                source: err,
280                help,
281            })
282        }
283    }?;
284    if !status.success() {
285        Err(RunTestsError::TestFailure)
286    } else {
287        Ok(())
288    }
289}
290
291#[cfg(all(test, feature = "impure_tests"))]
292mod tests {
293    use std::path::{Path, PathBuf};
294
295    use crate::{
296        config::ConfigBuilder, fs, lua_installation::detect_installed_lua_version,
297        lua_version::LuaVersion,
298    };
299
300    use super::*;
301    use assert_fs::{prelude::PathCopy, TempDir};
302
303    #[tokio::test]
304    async fn test_command_spec() {
305        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
306            .join("resources/test/sample-projects/command-test/");
307        run_test(&project_root).await
308    }
309
310    #[tokio::test]
311    async fn test_lua_script_spec() {
312        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
313            .join("resources/test/sample-projects/lua-script-test/");
314        run_test(&project_root).await
315    }
316
317    async fn run_test(project_root: &Path) {
318        let temp_dir = TempDir::new().unwrap();
319        temp_dir.copy_from(project_root, &["**"]).unwrap();
320        let workspace_root = temp_dir.path();
321        let workspace = Workspace::from(workspace_root).unwrap().unwrap();
322        let tree_root = workspace.root().to_path_buf().join(".lux");
323        let _ = fs::tokio::remove_dir_all(&tree_root).await;
324
325        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
326
327        let config = ConfigBuilder::new()
328            .unwrap()
329            .user_tree(Some(tree_root))
330            .lua_version(lua_version)
331            .build()
332            .unwrap();
333
334        Test::new(workspace, &config).run().await.unwrap();
335    }
336}