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