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