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