use anyhow::{Context, Result, anyhow};
use dashmap::DashSet;
use log::{trace, warn};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::BufReader;
use std::path::PathBuf;
use std::sync::Arc;
use std::{fmt, fs};
use std::{hash::Hash, path::Path};
use tokio::process::Command;
use tracing::{Instrument as _, info_span, instrument};
use crate::cmd::ui::UiStage;
use crate::coverage::Tag;
use crate::coverage::commit_coverage_data::{CommitCoverageData, CoverageIdentifier, FileCoverage};
use crate::coverage::full_coverage_data::FullCoverageData;
use crate::errors::{
FailedTestResult, RunTestError, RunTestsErrors, SubcommandErrors, TestFailure,
};
use crate::network::NetworkDependency;
use super::TestReason;
use super::dotnet_cobertura::Coverage;
use super::util::spawn_limited_concurrency;
use super::{
ConcreteTestIdentifier, PlatformSpecificRelevantTestCaseData, TestDiscovery, TestIdentifier,
TestIdentifierCore, TestPlatform,
};
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone)]
pub struct DotnetTestIdentifier {
pub fully_qualified_name: String,
}
impl TestIdentifier for DotnetTestIdentifier {}
impl TestIdentifierCore for DotnetTestIdentifier {
fn lightly_unique_name(&self) -> String {
self.fully_qualified_name.clone()
}
}
impl fmt::Display for DotnetTestIdentifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.fully_qualified_name)
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone)]
pub enum DotnetCoverageIdentifier {
PackageDependency(DotnetPackageDependency),
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash, Clone)]
pub struct DotnetPackageDependency {
pub package_name: PackageName,
pub version: PackageVersion,
}
impl CoverageIdentifier for DotnetCoverageIdentifier {}
impl TryFrom<DotnetCoverageIdentifier> for NetworkDependency {
type Error = &'static str;
fn try_from(_value: DotnetCoverageIdentifier) -> std::result::Result<Self, Self::Error> {
Err("not supported")
}
}
#[derive(Eq, Hash, PartialEq, Debug, Clone)]
pub struct DotnetConcreteTestIdentifier {
pub test_identifier: DotnetTestIdentifier,
}
impl ConcreteTestIdentifier<DotnetTestIdentifier> for DotnetConcreteTestIdentifier {
fn test_identifier(&self) -> &DotnetTestIdentifier {
&self.test_identifier
}
}
pub struct DotnetTestDiscovery {
all_test_cases: HashSet<DotnetConcreteTestIdentifier>,
}
impl TestDiscovery<DotnetConcreteTestIdentifier, DotnetTestIdentifier> for DotnetTestDiscovery {
fn all_test_cases(&self) -> &HashSet<DotnetConcreteTestIdentifier> {
&self.all_test_cases
}
fn map_ti_to_cti(
&self,
test_identifier: DotnetTestIdentifier,
) -> Option<DotnetConcreteTestIdentifier> {
Some(DotnetConcreteTestIdentifier { test_identifier })
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone)]
pub struct PackageName(pub String);
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Clone)]
pub struct PackageVersion(pub String);
pub struct DotnetTestPlatform;
impl DotnetTestPlatform {
#[must_use]
pub fn autodetect(project_dir: &Path) -> bool {
let slns = Self::find_sln_file(project_dir)
.expect("autodetect test project type failed when checking for *.sln files");
if slns.is_empty() {
false
} else {
trace!("Detected one-or-more .sln files; auto-detect result: .NET test project");
true
}
}
async fn get_all_test_cases(
project_dir: &Path,
) -> Result<HashSet<DotnetConcreteTestIdentifier>> {
let mut result: HashSet<DotnetConcreteTestIdentifier> = HashSet::new();
let args = [
"test",
"--list-tests",
"--disable-build-servers",
"--",
"NUnit.DisplayName=FullName",
];
let output = Command::new("dotnet")
.args(args)
.current_dir(project_dir)
.output()
.instrument(info_span!("dotnet test",
ui_stage = Into::<u64>::into(UiStage::Compiling),
subcommand = true,
subcommand_binary = "dotnet",
subcommand_args = ?args
))
.await
.map_err(|e| SubcommandErrors::UnableToStart {
command: "dotnet test --list-tests".to_string(),
error: e,
})?;
if !output.status.success() {
return Err(SubcommandErrors::SubcommandFailed {
command: String::from("dotnet test --list-tests"),
status: output.status,
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
}
.into());
}
let stdout = String::from_utf8(output.stdout).expect("Invalid UTF-8 output");
info_span!(
"dotnet test parse",
ui_stage = Into::<u64>::into(UiStage::ListingTests),
)
.in_scope(|| {
let mut found_intro = false;
for line in stdout.lines() {
if found_intro {
result.insert(DotnetConcreteTestIdentifier {
test_identifier: DotnetTestIdentifier {
fully_qualified_name: String::from(line.trim_start()),
},
});
}
if line.starts_with("The following Tests are available:") {
found_intro = true;
}
}
});
Ok(result)
}
async fn run_test(
project_dir: &Path,
test_case: &DotnetConcreteTestIdentifier,
_tmp_path: &Path,
_binaries: &DashSet<PathBuf>,
) -> Result<CommitCoverageData<DotnetTestIdentifier, DotnetCoverageIdentifier>, RunTestError>
{
let mut coverage_data = CommitCoverageData::new();
coverage_data.add_executed_test(test_case.test_identifier.clone());
let output = Command::new("dotnet")
.args([
"test",
"--no-build",
"--disable-build-servers",
"--collect:\"XPlat Code Coverage;Format=cobertura",
"--filter",
&format!(
"FullyQualifiedName={}",
test_case.test_identifier.fully_qualified_name
),
"--",
"DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.IncludeTestAssembly=true",
"DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.DeterministicReport=true",
])
.current_dir(project_dir)
.output()
.instrument(info_span!("execute-test", perftrace = "run-test"))
.await
.map_err(|e| SubcommandErrors::UnableToStart {
command: "dotnet test --filter ...".to_string(),
error: e,
})?;
if !output.status.success() {
return Err(RunTestError::TestExecutionFailure(FailedTestResult {
test_identifier: Box::new(test_case.test_identifier.clone()),
failure: TestFailure::NonZeroExitCode {
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
},
}));
}
trace!("Successfully ran test {:?}!", test_case.test_identifier);
let stdout = String::from_utf8(output.stdout).expect("Invalid UTF-8 output");
let mut found_intro = false;
for line in stdout.lines() {
if found_intro {
if line.starts_with(' ') {
let path = PathBuf::from(line.trim_start());
Self::parse_profiling_data(
project_dir,
test_case,
&path,
&mut coverage_data,
)?;
}
}
if line.starts_with("Attachments:") {
found_intro = true;
}
}
Ok(coverage_data)
}
#[instrument(skip_all, fields(perftrace = "parse-test-data"))]
fn parse_profiling_data(
project_dir: &Path,
test_case: &DotnetConcreteTestIdentifier,
profile_file: &PathBuf,
coverage_data: &mut CommitCoverageData<DotnetTestIdentifier, DotnetCoverageIdentifier>,
) -> Result<()> {
let reader = File::open(profile_file)
.context(format!("error opening {}", profile_file.display()))?;
let coverage: Coverage = quick_xml::de::from_reader(BufReader::new(reader))?;
for ref pkg in coverage.packages.package {
for cls in &pkg.classes.class {
if cls.line_rate > 0.0 {
let current_source_file = Path::new(&cls.filename);
trace!(
"test hit file {} {} {}; project_dir: {}",
pkg.name,
cls.name,
current_source_file.display(),
project_dir.display()
);
if let Ok(relative_path) =
Path::new(¤t_source_file).strip_prefix(project_dir)
{
trace!("test hit file relative path: {}", relative_path.display());
coverage_data.add_file_to_test(FileCoverage {
file_name: relative_path.to_path_buf(),
test_identifier: test_case.test_identifier.clone(),
});
}
}
}
}
Ok(())
}
fn find_sln_file(project_dir: &Path) -> Result<Vec<PathBuf>> {
let mut retval = vec![];
for entry in fs::read_dir(project_dir)? {
let path = entry?.path();
if path.extension().is_some_and(|ext| ext == "sln") && path.is_file() {
retval.push(path);
}
}
Ok(retval)
}
}
impl TestPlatform for DotnetTestPlatform {
type TI = DotnetTestIdentifier;
type CI = DotnetCoverageIdentifier;
type TD = DotnetTestDiscovery;
type CTI = DotnetConcreteTestIdentifier;
fn platform_identifier() -> &'static str {
"dotnet"
}
fn platform_tags() -> Vec<Tag> {
vec![Tag {
key: String::from("__testtrim_dotnet"),
value: String::from("1"),
}]
}
fn project_name(project_dir: &Path) -> Result<String> {
Ok(String::from(
project_dir
.file_name()
.ok_or_else(|| anyhow!("unable to find name of current directory"))?
.to_string_lossy(),
))
}
#[instrument(skip_all, fields(perftrace = "discover-tests"))]
async fn discover_tests(project_dir: &Path) -> Result<DotnetTestDiscovery> {
let all_test_cases = Self::get_all_test_cases(project_dir).await?;
trace!("all_test_cases: {all_test_cases:?}");
Ok(DotnetTestDiscovery { all_test_cases })
}
#[instrument(skip_all, fields(perftrace = "platform-specific-test-cases"))]
fn platform_specific_relevant_test_cases<
Commit: crate::scm::ScmCommit,
MyScm: crate::scm::Scm<Commit>,
>(
_eval_target_test_cases: &std::collections::HashSet<DotnetTestIdentifier>,
_eval_target_changed_files: &std::collections::HashSet<PathBuf>,
_scm: &MyScm,
_ancestor_commit: &Commit,
_coverage_data: &FullCoverageData<DotnetTestIdentifier, DotnetCoverageIdentifier>,
) -> anyhow::Result<
PlatformSpecificRelevantTestCaseData<DotnetTestIdentifier, DotnetCoverageIdentifier>,
> {
let test_cases: HashMap<
DotnetTestIdentifier,
HashSet<TestReason<DotnetCoverageIdentifier>>,
> = HashMap::new();
Ok(PlatformSpecificRelevantTestCaseData {
additional_test_cases: test_cases,
external_dependencies_changed: None,
})
}
async fn run_tests<'a, I>(
_test_discovery: &DotnetTestDiscovery,
project_dir: &Path,
test_cases: I,
_jobs: u16, ) -> Result<CommitCoverageData<DotnetTestIdentifier, DotnetCoverageIdentifier>, RunTestsErrors>
where
I: IntoIterator<Item = &'a DotnetConcreteTestIdentifier>,
DotnetConcreteTestIdentifier: 'a,
{
let tmp_dir = tempfile::Builder::new().prefix("testtrim").tempdir()?;
let mut coverage_data = CommitCoverageData::new();
let binaries = Arc::new(DashSet::new());
let mut futures = vec![];
for test_case in test_cases {
let tc = test_case.clone();
let tmp_path = PathBuf::from(tmp_dir.path());
let b = binaries.clone();
futures.push(async move {
Self::run_test(project_dir, &tc, &tmp_path, &b)
.instrument(info_span!("dotnet test",
ui_stage = Into::<u64>::into(UiStage::RunSingleTest),
test_case = %tc.test_identifier(),
))
.await
});
}
let concurrency = 1;
let results = spawn_limited_concurrency(concurrency, futures).await;
let mut failed_test_results = vec![];
for result in results {
match result {
Ok(res) => coverage_data.merge_in(res),
Err(RunTestError::TestExecutionFailure(failed_test_result)) => {
failed_test_results.push(failed_test_result);
}
Err(e) => return Err(e.into()),
}
}
if failed_test_results.is_empty() {
Ok(coverage_data)
} else {
Err(RunTestsErrors::TestExecutionFailures(failed_test_results))
}
}
fn analyze_changed_files(
_project_dir: &Path,
_d_files: &HashSet<PathBuf>,
_ge_data: &mut CommitCoverageData<DotnetTestIdentifier, DotnetCoverageIdentifier>,
) -> Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
}