From ce5ab24155e3f55e664b52cd1478e22542d67695 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 25 Feb 2026 20:59:11 -0800 Subject: [PATCH 1/2] feat: Add CI Detection Tests --- Directory.Packages.props | 2 + .../CIDetectionTests.cs | 86 +++++++++++++++++++ .../IntelliTect.Multitool.Tests.csproj | 2 + IntelliTect.Multitool.Tests/MSBuildFixture.cs | 21 +++++ 4 files changed, 111 insertions(+) create mode 100644 IntelliTect.Multitool.Tests/CIDetectionTests.cs create mode 100644 IntelliTect.Multitool.Tests/MSBuildFixture.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index ded4255..3d5dabf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,8 @@ false + + diff --git a/IntelliTect.Multitool.Tests/CIDetectionTests.cs b/IntelliTect.Multitool.Tests/CIDetectionTests.cs new file mode 100644 index 0000000..b70c7b6 --- /dev/null +++ b/IntelliTect.Multitool.Tests/CIDetectionTests.cs @@ -0,0 +1,86 @@ +using System.IO; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using System.Xml; +using Microsoft.Build.Construction; +using Microsoft.Build.Evaluation; +using Xunit; + +namespace IntelliTect.Multitool.Tests; + +[Collection(MSBuildCollection.CollectionName)] +public class CIDetectionTests +{ + private static readonly string TargetsPath = Path.Combine( + RepositoryPaths.GetDefaultRepoRoot(), + "IntelliTect.Multitool", "Build", "IntelliTect.Multitool.targets"); + + // Derived from the targets file itself — automatically stays in sync when new CI variables are added. + private static readonly Lazy> _ciVarNames = new(() => + { + var doc = XDocument.Load(TargetsPath); + var conditions = doc.Descendants() + .Where(e => e.Name.LocalName == "CI" && e.Attribute("Condition") != null) + .Select(e => e.Attribute("Condition")!.Value); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string condition in conditions) + foreach (Match m in Regex.Matches(condition, @"\$\((\w+)\)")) + names.Add(m.Groups[1].Value); + return names; + }); + + [Theory] + [InlineData("GITHUB_ACTIONS", "true")] + [InlineData("GITLAB_CI", "true")] + [InlineData("CIRCLECI", "true")] + [InlineData("CONTINUOUS_INTEGRATION", "true")] + [InlineData("TF_BUILD", "true")] + [InlineData("TEAMCITY_VERSION", "1.0")] + [InlineData("APPVEYOR", "True")] + [InlineData("BuildRunner", "MyGet")] + [InlineData("JENKINS_URL", "http://jenkins")] + [InlineData("TRAVIS", "true")] + [InlineData("BUDDY", "true")] + [InlineData("CODEBUILD_CI", "true")] + public void CiEnvVar_SetsCIPropertyToTrue(string envVar, string value) + { + string ci = EvaluateCIProperty(new Dictionary { [envVar] = value }); + Assert.Equal("true", ci); + } + + [Fact] + public void NoCiEnvVars_SetsCIPropertyToFalse() + { + string ci = EvaluateCIProperty([]); + Assert.Equal("false", ci); + } + + [Fact] + public void CiAlreadyTrue_IsNotOverridden() + { + string ci = EvaluateCIProperty(new Dictionary { ["CI"] = "true" }); + Assert.Equal("true", ci); + } + + private static string EvaluateCIProperty(Dictionary overrides) + { + // Clear all CI-related variables parsed from the targets file so that any env vars + // set on the host runner (e.g. GITHUB_ACTIONS=true on GitHub Actions) don't leak in. + // Global properties override process env vars in MSBuild evaluation. + var globalProperties = _ciVarNames.Value + .ToDictionary(v => v, _ => "", StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in overrides) + globalProperties[key] = value; + + using var collection = new ProjectCollection(globalProperties); + string xml = $""" + + + + """; + using var reader = XmlReader.Create(new StringReader(xml)); + ProjectRootElement rootElement = ProjectRootElement.Create(reader, collection); + var project = new Project(rootElement, null, null, collection); + return project.GetPropertyValue("CI"); + } +} diff --git a/IntelliTect.Multitool.Tests/IntelliTect.Multitool.Tests.csproj b/IntelliTect.Multitool.Tests/IntelliTect.Multitool.Tests.csproj index a734b6f..6474654 100644 --- a/IntelliTect.Multitool.Tests/IntelliTect.Multitool.Tests.csproj +++ b/IntelliTect.Multitool.Tests/IntelliTect.Multitool.Tests.csproj @@ -13,6 +13,8 @@ + + diff --git a/IntelliTect.Multitool.Tests/MSBuildFixture.cs b/IntelliTect.Multitool.Tests/MSBuildFixture.cs new file mode 100644 index 0000000..08140a6 --- /dev/null +++ b/IntelliTect.Multitool.Tests/MSBuildFixture.cs @@ -0,0 +1,21 @@ +using Microsoft.Build.Locator; +using Xunit; + +namespace IntelliTect.Multitool.Tests; + +[CollectionDefinition(CollectionName)] +public class MSBuildCollection : ICollectionFixture +{ + public const string CollectionName = "MSBuild"; +} + +public class MSBuildFixture : IDisposable +{ + public MSBuildFixture() + { + if (!MSBuildLocator.IsRegistered) + MSBuildLocator.RegisterDefaults(); + } + + public void Dispose() { } +} From fceb4f261db323d76a3c08e7a27783ceda8ddbaf Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 25 Feb 2026 21:57:00 -0800 Subject: [PATCH 2/2] Update --- .../CIDetectionTests.cs | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/IntelliTect.Multitool.Tests/CIDetectionTests.cs b/IntelliTect.Multitool.Tests/CIDetectionTests.cs index b70c7b6..42b1e09 100644 --- a/IntelliTect.Multitool.Tests/CIDetectionTests.cs +++ b/IntelliTect.Multitool.Tests/CIDetectionTests.cs @@ -72,15 +72,30 @@ private static string EvaluateCIProperty(Dictionary overrides) foreach (var (key, value) in overrides) globalProperties[key] = value; - using var collection = new ProjectCollection(globalProperties); - string xml = $""" - - - - """; - using var reader = XmlReader.Create(new StringReader(xml)); - ProjectRootElement rootElement = ProjectRootElement.Create(reader, collection); - var project = new Project(rootElement, null, null, collection); - return project.GetPropertyValue("CI"); + // CI itself is not in _ciVarNames (it's the output property, not an input condition + // variable), but GitHub Actions sets CI=true in the OS environment. If it leaks in, + // the outer guard short-circuits and the + // entire detection block is skipped. Temporarily remove it from the process environment + // so MSBuild doesn't see it, unless the caller is explicitly testing the CI=true case. + string? savedCI = Environment.GetEnvironmentVariable("CI"); + if (!overrides.ContainsKey("CI")) + Environment.SetEnvironmentVariable("CI", null); + try + { + using var collection = new ProjectCollection(globalProperties); + string xml = $""" + + + + """; + using var reader = XmlReader.Create(new StringReader(xml)); + ProjectRootElement rootElement = ProjectRootElement.Create(reader, collection); + var project = new Project(rootElement, null, null, collection); + return project.GetPropertyValue("CI"); + } + finally + { + Environment.SetEnvironmentVariable("CI", savedCI); + } } }