use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; #[path = "../dever-tests/tests/support/process.rs"] pub mod process; #[cfg(all(target_os = "linux", target_arch = "x86_64"))] #[path = "support/llvm_pack.rs"] pub mod llvm_pack; static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); struct TemporaryProject(PathBuf); impl TemporaryProject { fn new() -> Self { let suffix = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed); let root = std::env::temp_dir().join(format!("dever-cms-project-{}-{suffix}", std::process::id())); fs::create_dir(&root).unwrap(); Self(root) } fn copy_from(source: &Path) -> Self { let project = Self::new(); copy_directory(&source.join("config"), &project.path().join("config")); copy_directory(&source.join("module"), &project.path().join("module")); let tests = source.join("test"); if tests.exists() { copy_directory(&tests, &project.path().join("test")); } fs::create_dir(project.path().join("data")).unwrap(); project } fn package(source: &Path, executable: &Path) -> Self { let project = Self::new(); copy_directory(&source.join("config"), &project.path().join("config")); fs::copy(executable, project.path().join("cms-app")).unwrap(); project } fn path(&self) -> &Path { &self.0 } fn block_deployment_database(&self) { // Package metadata needs valid JSON. A directory as the configured // database proves tests use their own SQLite configuration instead. fs::create_dir(self.path().join("data/deployment.db")).unwrap(); fs::write( self.path().join("config/setting.json"), r#"{"database":{"default":{"type":"sqlite","path":"data/deployment.db"}}}"#, ) .unwrap(); } } impl Drop for TemporaryProject { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn copy_directory(source: &Path, target: &Path) { fs::create_dir(target).unwrap(); for entry in fs::read_dir(source).unwrap() { let entry = entry.unwrap(); let source = entry.path(); let target = target.join(entry.file_name()); if entry.file_type().unwrap().is_dir() { copy_directory(&source, &target); } else { fs::copy(source, target).unwrap(); } } } fn dever(project: &Path, action: &str, arguments: &[&str]) -> Output { dever_with_pid(project, action, arguments).1 } fn dever_with_pid(project: &Path, action: &str, arguments: &[&str]) -> (u32, Output) { output_with_pid( Command::new(env!("CARGO_BIN_EXE_dever")) .arg(action) .arg(project) .args(arguments), ) } fn output_with_pid(command: &mut Command) -> (u32, Output) { let capture = TemporaryProject::new(); let stdout = capture.path().join("stdout"); let stderr = capture.path().join("stderr"); let mut child = command .env_clear() .stdout(Stdio::from(fs::File::create(&stdout).unwrap())) .stderr(Stdio::from(fs::File::create(&stderr).unwrap())) .spawn() .unwrap(); let pid = child.id(); let status = process::wait(&mut child, Duration::from_secs(180)).unwrap(); ( pid, Output { status, stdout: fs::read(stdout).unwrap(), stderr: fs::read(stderr).unwrap(), }, ) } fn assert_test_artifacts_removed(pid: u32) { let test_prefix = format!("dever-application-test-{pid}-"); let build_prefix = format!("dever-build-{pid}-"); let leftovers = fs::read_dir(std::env::temp_dir()) .unwrap() .filter_map(Result::ok) .map(|entry| entry.file_name().to_string_lossy().into_owned()) .filter(|name| name.starts_with(&test_prefix) || name.starts_with(&build_prefix)) .collect::>(); assert!( leftovers.is_empty(), "temporary test artifacts remain: {leftovers:?}" ); } fn assert_success(output: &Output) { assert!( output.status.success(), "stdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } #[cfg(unix)] #[test] fn clean_removes_only_stale_owned_dever_artifacts() { use std::os::unix::fs::{MetadataExt, symlink}; use std::time::{SystemTime, UNIX_EPOCH}; let project = TemporaryProject::new(); let pid = u64::from(std::process::id()) + 1_000_000; let suffix = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed); let stale = std::env::temp_dir().join(format!("dever-build-{pid}-{suffix}")); fs::create_dir(&stale).unwrap(); let owner = fs::metadata(&stale).unwrap().uid(); fs::write( stale.join(".dever-owner"), format!( "dever-build-owner-v1\ncreated=1\npid={pid}\nowner={owner}\ncompiler={}\n", env!("CARGO_PKG_VERSION"), ), ) .unwrap(); fs::write(stale.join("program"), b"stale").unwrap(); let fresh_pid = pid + 1; let fresh = std::env::temp_dir().join(format!("dever-build-{fresh_pid}-{suffix}")); fs::create_dir(&fresh).unwrap(); let created = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); fs::write( fresh.join(".dever-owner"), format!( "dever-build-owner-v1\ncreated={created}\npid={fresh_pid}\nowner={owner}\ncompiler={}\n", env!("CARGO_PKG_VERSION"), ), ).unwrap(); let unknown = std::env::temp_dir().join(format!("dever-build-{}-{suffix}", pid + 2)); fs::create_dir(&unknown).unwrap(); let link = std::env::temp_dir().join(format!("dever-build-{}-{suffix}", pid + 3)); symlink(&unknown, &link).unwrap(); let mut active_process = Command::new("/bin/sleep").arg("60").spawn().unwrap(); let active_pid = u64::from(active_process.id()); let active = std::env::temp_dir().join(format!("dever-build-{active_pid}-{suffix}")); fs::create_dir(&active).unwrap(); fs::write( active.join(".dever-owner"), format!( "dever-build-owner-v1\ncreated=1\npid={active_pid}\nowner={owner}\ncompiler={}\n", env!("CARGO_PKG_VERSION"), ), ) .unwrap(); fs::write(active.join("program"), b"active").unwrap(); let legacy = project.path().join(format!(".dever-run-{pid}")); fs::write(&legacy, b"stale legacy").unwrap(); let legacy_file = fs::OpenOptions::new().write(true).open(&legacy).unwrap(); legacy_file .set_times( fs::FileTimes::new().set_modified(UNIX_EPOCH + std::time::Duration::from_secs(1)), ) .unwrap(); let fresh_legacy = project.path().join(format!(".dever-run-{}", pid + 1)); fs::write(&fresh_legacy, b"fresh legacy").unwrap(); let active_legacy = project.path().join(format!(".dever-run-{active_pid}")); fs::write(&active_legacy, b"active legacy").unwrap(); let active_legacy_file = fs::OpenOptions::new() .write(true) .open(&active_legacy) .unwrap(); active_legacy_file .set_times( fs::FileTimes::new().set_modified(UNIX_EPOCH + std::time::Duration::from_secs(1)), ) .unwrap(); let explicit = project.path().join("application"); fs::write(&explicit, b"explicit output").unwrap(); let output = dever(project.path(), "clean", &[]); active_process.kill().unwrap(); active_process.wait().unwrap(); assert_success(&output); assert!( String::from_utf8_lossy(&output.stdout).starts_with("cleaned 1 directories, 3 files, ") ); assert!(!stale.exists()); assert!(fresh.exists()); assert!(unknown.exists()); assert!(link.symlink_metadata().is_ok()); assert!(active.exists()); assert!(!legacy.exists()); assert!(fresh_legacy.exists()); assert!(active_legacy.exists()); assert!(explicit.exists()); fs::remove_dir_all(fresh).unwrap(); fs::remove_file(link).unwrap(); fs::remove_dir_all(unknown).unwrap(); fs::remove_dir_all(active).unwrap(); } #[test] fn cms_project_checks_runs_and_builds_from_an_isolated_copy() { let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); for format in ["dever", "md"] { let project = TemporaryProject::copy_from(&workspace.join("examples/cms").join(format)); let settings_path = project.path().join("config/setting.json"); let production_settings = fs::read(&settings_path).unwrap(); project.block_deployment_database(); for _ in 0..2 { let (pid, output) = dever_with_pid(project.path(), "test", &[]); assert_cms_tests(&output); assert_test_artifacts_removed(pid); } fs::write(&settings_path, production_settings).unwrap(); fs::write(project.path().join("test/ignored.dever"), "not valid Dever").unwrap(); assert_success(&dever(project.path(), "check", &[])); for _ in 0..2 { assert_health_stdout(&dever( project.path(), "run", &["--", "system.health.ping", "{}"], )); } let executable = build(project.path(), "cms-app"); let package = TemporaryProject::package(project.path(), &executable); assert!(!package.path().join("module").exists()); for _ in 0..2 { let output = output_with_pid( Command::new(package.path().join("cms-app")) .args(["system.health.ping", "{}"]) .current_dir(package.path()), ) .1; assert_health_stdout(&output); } assert!(fs::read_dir(project.path()).unwrap().all(|entry| { !entry .unwrap() .file_name() .to_string_lossy() .starts_with(".dever-run-") })); } } #[test] fn project_without_tests_reports_zero_and_succeeds() { let project = TemporaryProject::new(); fs::create_dir(project.path().join("module")).unwrap(); fs::write(project.path().join("module/main.dever"), "main() () {}").unwrap(); let (pid, output) = dever_with_pid(project.path(), "test", &[]); assert_success(&output); assert_eq!( String::from_utf8_lossy(&output.stdout), "running 0 tests\n\ntest result: ok. 0 passed; 0 failed; 0 not run\n" ); assert_test_artifacts_removed(pid); } #[test] fn mixed_suite_initializes_database_only_for_database_cases() { let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let project = TemporaryProject::copy_from(&workspace.join("examples/cms/dever")); project.block_deployment_database(); let health = project.path().join("test/system/health"); fs::create_dir_all(&health).unwrap(); fs::write( health.join("ping.dever"), "ping() () { assert_eq(\"ready\", \"ready\") }\n", ) .unwrap(); let (pid, output) = dever_with_pid(project.path(), "test", &[]); assert_success(&output); assert_eq!( String::from_utf8_lossy(&output.stdout), "running 6 tests\ntest content/category/lifecycle ... ok\ntest news/article/publish ... ok\ntest system/health/ping ... ok\ntest user/account/bootstrap ... ok\ntest user/account/validation ... ok\ntest user/session/lifecycle ... ok\n\ntest result: ok. 6 passed; 0 failed; 0 not run\n" ); assert_test_artifacts_removed(pid); } #[test] fn failed_test_reports_captured_output_and_does_not_stop_later_tests() { let project = TemporaryProject::new(); fs::create_dir(project.path().join("module")).unwrap(); fs::create_dir_all(project.path().join("test/sample/case")).unwrap(); fs::write(project.path().join("module/main.dever"), "main() () {}").unwrap(); fs::write( project.path().join("test/sample/case/fails.dever"), r#"fails() () { blocking(dever.io.println("failed output")) assert_eq(1, 2) } "#, ) .unwrap(); fs::write( project.path().join("test/sample/case/passes.dever"), r#"passes() () { blocking(dever.io.println("hidden successful output")) assert(true) } "#, ) .unwrap(); let (pid, output) = dever_with_pid(project.path(), "test", &[]); assert!(!output.status.success()); assert!(output.stderr.is_empty()); let stdout = String::from_utf8_lossy(&output.stdout); let failed = stdout.find("test sample/case/fails ... FAILED").unwrap(); let passed = stdout.find("test sample/case/passes ... ok").unwrap(); assert!(failed < passed, "{stdout}"); assert!( stdout.contains("---- stdout ----\nfailed output\n"), "{stdout}" ); assert!(stdout.contains("---- stderr ----"), "{stdout}"); assert!( stdout.contains("assert_eq failed: actual = 1, expected = 2"), "{stdout}" ); assert!(!stdout.contains("hidden successful output"), "{stdout}"); assert!( stdout.ends_with("test result: FAILED. 1 passed; 1 failed; 0 not run\n"), "{stdout}" ); assert_test_artifacts_removed(pid); } #[cfg(all(target_os = "linux", target_arch = "x86_64"))] #[test] fn suite_compilation_failure_marks_every_discovered_case_failed() { let project = TemporaryProject::new(); fs::create_dir(project.path().join("module")).unwrap(); fs::create_dir_all(project.path().join("test/sample/case")).unwrap(); fs::write(project.path().join("module/main.dever"), "main() () {}").unwrap(); fs::write( project.path().join("test/sample/case/first.dever"), "first() () { assert(true) }\n", ) .unwrap(); fs::write( project.path().join("test/sample/case/second.dever"), "second() () { assert(true) }\n", ) .unwrap(); let toolchain = TemporaryProject::new(); let compiler = llvm_pack::compiler( Path::new(env!("CARGO_BIN_EXE_dever")), toolchain.path(), "dever", ); let (pid, output) = output_with_pid(Command::new(compiler).arg("test").arg(project.path())); assert!(!output.status.success()); assert!(output.stderr.is_empty()); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("test sample/case/first ... FAILED"), "{stdout}" ); assert!( stdout.contains("test sample/case/second ... FAILED"), "{stdout}" ); assert_eq!( stdout.matches("cannot inspect runtime pack").count(), 2, "{stdout}" ); assert!( stdout.ends_with("test result: FAILED. 0 passed; 2 failed; 0 not run\n"), "{stdout}" ); assert_test_artifacts_removed(pid); assert!(!toolchain.path().join("cache").exists()); } fn build(project: &Path, name: &str) -> PathBuf { let executable = project.join(name); let build = dever( project, "build", &["--output", executable.to_str().unwrap()], ); assert_success(&build); assert!(executable.is_file()); executable } fn assert_health_stdout(output: &Output) { assert_success(output); let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(body["code"], 0); assert_eq!(body["message"], "ok"); assert_eq!(body["data"]["status"], "ready"); } fn assert_cms_tests(output: &Output) { assert_success(output); assert_eq!( String::from_utf8_lossy(&output.stdout), "running 5 tests\ntest content/category/lifecycle ... ok\ntest news/article/publish ... ok\ntest user/account/bootstrap ... ok\ntest user/account/validation ... ok\ntest user/session/lifecycle ... ok\n\ntest result: ok. 5 passed; 0 failed; 0 not run\n" ); }