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: 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 #[default]
76 Pure,
77 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 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
238async 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}