1use std::{io, ops::Deref, path::PathBuf, process::Command};
2
3use super::{
4 BuildWorkspace, BuildWorkspaceError, Install, InstallError, PackageInstallSpec, Sync, SyncError,
5};
6use crate::fs;
7use crate::tree::InstallTree;
8use crate::workspace::{WorkspaceError, WorkspaceTreeError};
9use crate::{
10 build::BuildBehaviour,
11 config::{Config, ConfigError},
12 lua_installation::{LuaBinary, LuaBinaryError},
13 lua_rockspec::{LuaVersionError, TestSpecError, ValidatedTestSpec},
14 package::{PackageName, PackageVersionReqError},
15 path::{Paths, PathsError},
16 project::{project_toml::LocalProjectTomlValidationError, Project, ProjectError},
17 rockspec::Rockspec,
18 tree::{self, TreeError},
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: 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 #[default]
75 Pure,
76 Impure,
79}
80
81#[derive(Error, Debug, Diagnostic)]
82pub enum RunTestsError {
83 #[error(transparent)]
84 #[diagnostic(transparent)]
85 Config(#[from] ConfigError),
86 #[error(transparent)]
87 #[diagnostic(transparent)]
88 InstallTestDependencies(#[from] Box<InstallTestDependenciesError>),
89 #[error("build failed")]
90 #[diagnostic(forward(0))]
91 BuildWorkspace(#[from] Box<BuildWorkspaceError>),
92 #[error("tests failed!")]
93 #[diagnostic(help("see the test runner's output for details"))]
94 TestFailure,
95 #[error("failed to execute '{cmd}'")]
96 RunCommandFailure {
97 cmd: String,
98 source: io::Error,
99 #[help]
100 help: Option<String>,
101 },
102 #[error(transparent)]
103 #[diagnostic(transparent)]
104 Fs(#[from] fs::FsError),
105 #[error(transparent)]
106 #[diagnostic(transparent)]
107 Project(#[from] ProjectError),
108 #[error(transparent)]
109 #[diagnostic(transparent)]
110 Paths(#[from] PathsError),
111 #[error(transparent)]
112 #[diagnostic(transparent)]
113 Workspace(#[from] WorkspaceError),
114 #[error(transparent)]
115 #[diagnostic(transparent)]
116 Tree(#[from] WorkspaceTreeError),
117 #[error(transparent)]
118 #[diagnostic(transparent)]
119 ProjectTomlValidation(#[from] LocalProjectTomlValidationError),
120 #[error("failed to sync dependencies")]
121 #[diagnostic(forward(0))]
122 Sync(#[from] Box<SyncError>),
123 #[error(transparent)]
124 #[diagnostic(transparent)]
125 TestSpec(#[from] TestSpecError),
126 #[error(transparent)]
127 #[diagnostic(transparent)]
128 LuaVersion(#[from] LuaVersionError),
129 #[error(transparent)]
130 #[diagnostic(transparent)]
131 LuaBinary(#[from] LuaBinaryError),
132}
133
134impl From<InstallTestDependenciesError> for RunTestsError {
135 fn from(source: InstallTestDependenciesError) -> Self {
136 Self::InstallTestDependencies(Box::new(source))
137 }
138}
139
140impl From<BuildWorkspaceError> for RunTestsError {
141 fn from(source: BuildWorkspaceError) -> Self {
142 Self::BuildWorkspace(Box::new(source))
143 }
144}
145
146impl From<SyncError> for RunTestsError {
147 fn from(source: SyncError) -> Self {
148 Self::Sync(Box::new(source))
149 }
150}
151
152#[tracing::instrument(name = "🧪 Running tests", skip_all)]
153
154async fn run_tests(test: Test<'_>) -> Result<(), RunTestsError> {
155 let workspace = test.workspace;
156 let config = test.config;
157 let no_lock = test.no_lock.unwrap_or(false);
158
159 if let Some(package) = test.package {
160 let project = workspace.select_member(&package)?;
161 run_project_tests(&workspace, project, no_lock, &test.args, &test.env, config).await
162 } else {
163 for project in workspace.members() {
164 run_project_tests(&workspace, project, no_lock, &test.args, &test.env, config).await?;
165 }
166 Ok(())
167 }
168}
169
170async fn run_project_tests(
171 workspace: &Workspace,
172 project: &Project,
173 no_lock: bool,
174 test_args: &[String],
175 test_env: &TestEnv,
176 config: &Config,
177) -> Result<(), RunTestsError> {
178 let rocks = project.toml().into_local()?;
179 let test_spec = rocks.test().current_platform().to_validated(project)?;
180 let test_config = test_spec.test_config(config)?;
181
182 if no_lock {
183 let rockspec = project.toml().into_local()?;
184 ensure_test_dependencies(workspace, project, rockspec, &test_config).await?;
185 } else {
186 Sync::new(workspace, &test_config).test(true).sync().await?;
187 }
188
189 BuildWorkspace::new(workspace, &test_config)
190 .package(project.toml().package().clone())
191 .no_lock(no_lock)
192 .only_deps(false)
193 .build()
194 .await?;
195
196 let lua_version = project.lua_version(&test_config)?;
197 let project_tree = workspace.lua_version_tree(lua_version, &test_config)?;
198 let test_tree = workspace.test_tree(&test_config)?;
199 let mut paths = Paths::new(&project_tree)?;
200 let test_tree_paths = Paths::new(&test_tree)?;
201 paths.prepend(&test_tree_paths);
202
203 let test_executable = match &test_spec {
204 ValidatedTestSpec::Busted { .. } => BUSTED_EXE.to_string(),
205 ValidatedTestSpec::BustedNlua { .. } => BUSTED_EXE.to_string(),
206 ValidatedTestSpec::Command(spec) => spec.command.to_string(),
207 ValidatedTestSpec::LuaScript(_) => {
208 let lua_version = project.lua_version(&test_config)?;
209 let lua_binary = LuaBinary::new(lua_version, &test_config);
210 let lua_bin_path: PathBuf = lua_binary.try_into()?;
211 lua_bin_path.to_slash_lossy().to_string()
212 }
213 };
214 let mut command = Command::new(&test_executable);
215 let mut command = command
216 .current_dir(project.root().deref())
217 .args(test_spec.args())
218 .args(test_args)
219 .env("PATH", paths.path_prepended().joined())
220 .env("LUA_PATH", paths.package_path().joined())
221 .env("LUA_CPATH", paths.package_cpath().joined());
222 if matches!(
223 &test_spec,
224 ValidatedTestSpec::Busted { .. } | ValidatedTestSpec::BustedNlua { .. }
225 ) && !test_spec
226 .args()
227 .iter()
228 .chain(test_args.iter())
229 .any(|arg| arg == "--directory" || arg == "-C" || arg.starts_with("--directory="))
230 {
231 let directory = fs::sync::absolute(project.root())?;
232 command = command
233 .arg("--directory")
234 .arg(directory.to_slash_lossy().to_string());
235 }
236 if let TestEnv::Pure = test_env {
237 let home = test_tree.root().join("home");
240 let xdg = home.join("xdg");
241 let _ = fs::tokio::remove_dir_all(&home).await;
242 let xdg_config_home = xdg.join("config");
243 fs::tokio::create_dir_all(&xdg_config_home).await?;
244 let xdg_state_home = xdg.join("local").join("state");
245 fs::tokio::create_dir_all(&xdg_state_home).await?;
246 let xdg_data_home = xdg.join("local").join("share");
247 fs::tokio::create_dir_all(&xdg_data_home).await?;
248 command = command
249 .env("HOME", home)
250 .env("XDG_CONFIG_HOME", xdg_config_home)
251 .env("XDG_STATE_HOME", xdg_state_home)
252 .env("XDG_DATA_HOME", xdg_data_home);
253 }
254 let status = match command.status() {
255 Ok(status) => Ok(status),
256 Err(err) => {
257 let help = if err.to_string().starts_with("No such file") {
258 Some(format!(
259 "make sure '{}' is available on your PATH",
260 test_executable
261 ))
262 } else {
263 None
264 };
265 Err(RunTestsError::RunCommandFailure {
266 cmd: test_executable,
267 source: err,
268 help,
269 })
270 }
271 }?;
272 if !status.success() {
273 Err(RunTestsError::TestFailure)
274 } else {
275 Ok(())
276 }
277}
278
279#[derive(Error, Debug, Diagnostic)]
280#[error("error installing test dependencies")]
281#[diagnostic(forward(0))]
282pub enum InstallTestDependenciesError {
283 WorkspaceTree(#[from] WorkspaceTreeError),
284 Tree(#[from] TreeError),
285 Install(#[from] Box<InstallError>),
286 PackageVersionReq(#[from] PackageVersionReqError),
287}
288
289impl From<InstallError> for InstallTestDependenciesError {
290 fn from(source: InstallError) -> Self {
291 Self::Install(Box::new(source))
292 }
293}
294
295async fn ensure_test_dependencies(
298 workspace: &Workspace,
299 project: &Project,
300 rockspec: impl Rockspec,
301 config: &Config,
302) -> Result<(), InstallTestDependenciesError> {
303 let test_tree = workspace.test_tree(config)?;
304 let rockspec_dependencies = rockspec.test_dependencies().current_platform();
305 let test_dependencies = rockspec
306 .test()
307 .current_platform()
308 .test_dependencies(project)
309 .iter()
310 .filter(|test_dep| {
311 !rockspec_dependencies
312 .iter()
313 .any(|dep| dep.name() == test_dep.name())
314 })
315 .filter_map(|dep| {
316 let build_behaviour = if test_tree
317 .match_rocks(dep)
318 .is_ok_and(|matches| matches.is_found())
319 {
320 Some(BuildBehaviour::NoForce)
321 } else {
322 Some(BuildBehaviour::Force)
323 };
324 build_behaviour.map(|build_behaviour| {
325 PackageInstallSpec::new(dep.clone(), tree::EntryType::Entrypoint)
326 .build_behaviour(build_behaviour)
327 .build()
328 })
329 })
330 .chain(
331 rockspec_dependencies
332 .iter()
333 .filter(|req| !req.name().eq(&PackageName::new("lua".into())))
334 .filter_map(|dep| {
335 let build_behaviour = if test_tree
336 .match_rocks(dep.package_req())
337 .is_ok_and(|matches| matches.is_found())
338 {
339 Some(BuildBehaviour::NoForce)
340 } else {
341 Some(BuildBehaviour::Force)
342 };
343 build_behaviour.map(|build_behaviour| {
344 PackageInstallSpec::new(
345 dep.package_req().clone(),
346 tree::EntryType::Entrypoint,
347 )
348 .build_behaviour(build_behaviour)
349 .pin(*dep.pin())
350 .opt(*dep.opt())
351 .maybe_source(dep.source.clone())
352 .build()
353 })
354 }),
355 )
356 .collect();
357
358 Install::new(config)
359 .packages(test_dependencies)
360 .tree(test_tree)
361 .install()
362 .await?;
363
364 Ok(())
365}
366
367#[cfg(test)]
368mod tests {
369 use std::path::Path;
370
371 use crate::{
372 config::ConfigBuilder, fs, lua_installation::detect_installed_lua_version,
373 lua_version::LuaVersion,
374 };
375
376 use super::*;
377 use assert_fs::{prelude::PathCopy, TempDir};
378
379 #[tokio::test]
380 async fn test_command_spec() {
381 let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
382 .join("resources/test/sample-projects/command-test/");
383 run_test(&project_root).await
384 }
385
386 #[tokio::test]
387 async fn test_lua_script_spec() {
388 let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
389 .join("resources/test/sample-projects/lua-script-test/");
390 run_test(&project_root).await
391 }
392
393 async fn run_test(project_root: &Path) {
394 let temp_dir = TempDir::new().unwrap();
395 temp_dir.copy_from(project_root, &["**"]).unwrap();
396 let workspace_root = temp_dir.path();
397 let workspace = Workspace::from(workspace_root).unwrap().unwrap();
398 let tree_root = workspace.root().to_path_buf().join(".lux");
399 let _ = fs::tokio::remove_dir_all(&tree_root).await;
400
401 let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
402
403 let config = ConfigBuilder::new()
404 .unwrap()
405 .user_tree(Some(tree_root))
406 .lua_version(lua_version)
407 .build()
408 .unwrap();
409
410 Test::new(workspace, &config).run().await.unwrap();
411 }
412}