diff --git a/eng/MSBuild/Generators.props b/eng/MSBuild/Generators.props index 2a99ec02f95..a5743854124 100644 --- a/eng/MSBuild/Generators.props +++ b/eng/MSBuild/Generators.props @@ -10,4 +10,8 @@ + + + + diff --git a/src/Generators/Microsoft.Gen.BuildMetadata/AnalyzerConfigOptionsExtensions.cs b/src/Generators/Microsoft.Gen.BuildMetadata/AnalyzerConfigOptionsExtensions.cs new file mode 100644 index 00000000000..1492b341de6 --- /dev/null +++ b/src/Generators/Microsoft.Gen.BuildMetadata/AnalyzerConfigOptionsExtensions.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.Gen.BuildMetadata; + +/// +/// Extension methods for to simplify MSBuild property access. +/// +internal static class AnalyzerConfigOptionsExtensions +{ + private const string PropertyPrefix = "build_property."; + + /// + /// Gets a boolean property value from MSBuild properties. + /// Supports "true"/"false" values (case-insensitive). + /// + /// The analyzer configuration options. + /// The property name (without "build_property." prefix). + /// True if the property value is "true", false otherwise. + public static bool GetBooleanProperty(this AnalyzerConfigOptions options, string propertyName) + { + var value = GetProperty(options, propertyName); + return string.Equals(value, "true", System.StringComparison.OrdinalIgnoreCase); + } + + /// + /// Gets a string property value from MSBuild properties. + /// + /// The analyzer configuration options. + /// The property name (without "build_property." prefix). + /// The property value, or null if not found. + public static string? GetProperty(this AnalyzerConfigOptions options, string propertyName) + { + var key = string.Concat(PropertyPrefix, propertyName); + return options.TryGetValue(key, out var value) ? value : null; + } +} diff --git a/src/Generators/Microsoft.Gen.BuildMetadata/BuildMetadata.cs b/src/Generators/Microsoft.Gen.BuildMetadata/BuildMetadata.cs new file mode 100644 index 00000000000..63943fae8ca --- /dev/null +++ b/src/Generators/Microsoft.Gen.BuildMetadata/BuildMetadata.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Gen.BuildMetadata; + +internal readonly record struct BuildMetadata(string? BuildId, string? BuildNumber, string? SourceBranchName, string? SourceVersion); diff --git a/src/Generators/Microsoft.Gen.BuildMetadata/BuildMetadataGenerator.cs b/src/Generators/Microsoft.Gen.BuildMetadata/BuildMetadataGenerator.cs new file mode 100644 index 00000000000..9c0656865ee --- /dev/null +++ b/src/Generators/Microsoft.Gen.BuildMetadata/BuildMetadataGenerator.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.Gen.BuildMetadata; + +/// +/// Source generator that creates build metadata extensions from MSBuild properties. +/// Supports both Azure DevOps and GitHub Actions build environments. +/// +[Generator] +public sealed class BuildMetadataGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var buildPropertiesPipeline = context.AnalyzerConfigOptionsProvider.Select((provider, ct) => + { + return CreateBuildMetadata(provider.GlobalOptions); + }); + + context.RegisterSourceOutput(buildPropertiesPipeline, Execute); + } + + private static BuildMetadata CreateBuildMetadata(AnalyzerConfigOptions globalOptions) + { + // Azure DevOps properties + var azureBuildId = globalOptions.GetProperty("BuildMetadataAzureBuildId"); + var azureBuildNumber = globalOptions.GetProperty("BuildMetadataAzureBuildNumber"); + var azureSourceBranchName = globalOptions.GetProperty("BuildMetadataAzureSourceBranchName"); + var azureSourceVersion = globalOptions.GetProperty("BuildMetadataAzureSourceVersion"); + var isAzureDevOps = globalOptions.GetBooleanProperty("BuildMetadataIsAzureDevOps"); + + // GitHub Actions properties + var gitHubRunId = globalOptions.GetProperty("BuildMetadataGitHubRunId"); + var gitHubRunNumber = globalOptions.GetProperty("BuildMetadataGitHubRunNumber"); + var gitHubRefName = globalOptions.GetProperty("BuildMetadataGitHubRefName"); + var gitHubSha = globalOptions.GetProperty("BuildMetadataGitHubSha"); + + return new BuildMetadata( + isAzureDevOps ? azureBuildId : gitHubRunId, + isAzureDevOps ? azureBuildNumber : gitHubRunNumber, + isAzureDevOps ? azureSourceBranchName : gitHubRefName, + isAzureDevOps ? azureSourceVersion : gitHubSha); + } + + private static void Execute(SourceProductionContext context, BuildMetadata buildMetadata) + { + var emitter = new Emitter(buildMetadata); + var result = emitter.Emit(); + context.AddSource("BuildMetadataExtensions.g.cs", SourceText.From(result, Encoding.UTF8)); + } +} diff --git a/src/Generators/Microsoft.Gen.BuildMetadata/Emitter.cs b/src/Generators/Microsoft.Gen.BuildMetadata/Emitter.cs new file mode 100644 index 00000000000..c7248695027 --- /dev/null +++ b/src/Generators/Microsoft.Gen.BuildMetadata/Emitter.cs @@ -0,0 +1,184 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Gen.Shared; + +namespace Microsoft.Gen.BuildMetadata; + +[SuppressMessage("Format", "S1199", Justification = "For better visualization of how the generated code will look like.")] +internal sealed class Emitter : EmitterBase +{ + private const string DependencyInjectionNamespace = "global::Microsoft.Extensions.DependencyInjection."; + private const string ConfigurationNamespace = "global::Microsoft.Extensions.Configuration."; + private const string HostingNamespace = "global::Microsoft.Extensions.Hosting."; + private readonly string? _buildId; + private readonly string? _buildNumber; + private readonly string? _sourceBranchName; + private readonly string? _sourceVersion; + + public Emitter(BuildMetadata buildMetadata) + { + _buildId = buildMetadata.BuildId; + _buildNumber = buildMetadata.BuildNumber; + _sourceBranchName = buildMetadata.SourceBranchName; + _sourceVersion = buildMetadata.SourceVersion; + } + + public string Emit() + { + GenerateBuildMetadataExtensions(); + return Capture(); + } + + private void GenerateBuildMetadataSource() + { + OutGeneratedCodeAttribute(); + OutLn("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"); + OutLn($"private sealed class BuildMetadataSource : {ConfigurationNamespace}IConfigurationSource"); + OutOpenBrace(); + { + OutLn("public string SectionName { get; }"); + OutLn(); + + OutLn("public BuildMetadataSource(string sectionName)"); + OutOpenBrace(); + { + OutNullGuards(checkBuilder: false); + OutLn("SectionName = sectionName;"); + } + + OutCloseBrace(); + OutLn(); + OutLn($"public {ConfigurationNamespace}IConfigurationProvider Build({ConfigurationNamespace}IConfigurationBuilder builder)"); + OutOpenBrace(); + { + OutLn($"return new {ConfigurationNamespace}Memory.MemoryConfigurationProvider(new {ConfigurationNamespace}Memory.MemoryConfigurationSource())"); + OutOpenBrace(); + { + OutLn($$"""{ $"{SectionName}:buildid", "{{_buildId}}" },"""); + OutLn($$"""{ $"{SectionName}:buildnumber", "{{_buildNumber}}" },"""); + OutLn($$"""{ $"{SectionName}:sourcebranchname", "{{_sourceBranchName}}" },"""); + OutLn($$"""{ $"{SectionName}:sourceversion", "{{_sourceVersion}}" },"""); + } + + OutCloseBraceWithExtra(";"); + } + + OutCloseBrace(); + } + + OutCloseBrace(); + } + + private void GenerateBuildMetadataExtensions() + { + OutLn("namespace Microsoft.Extensions.AmbientMetadata"); + OutOpenBrace(); + { + OutGeneratedCodeAttribute(); + OutLn("internal static class BuildMetadataGeneratedExtensions"); + OutOpenBrace(); + { + OutLn("private const string DefaultSectionName = \"ambientmetadata:build\";"); + OutLn(); + + GenerateBuildMetadataSource(); + OutLn(); + + OutLn($"public static {HostingNamespace}IHostBuilder UseBuildMetadata(this {HostingNamespace}IHostBuilder builder, string sectionName = DefaultSectionName)"); + OutOpenBrace(); + { + OutNullGuards(); + OutLn("_ = builder.ConfigureHostConfiguration(configBuilder => configBuilder.AddBuildMetadata(sectionName))"); + Indent(); + OutLn(".ConfigureServices((hostBuilderContext, serviceCollection) =>"); + Indent(); + OutLn($"{DependencyInjectionNamespace}BuildMetadataServiceCollectionExtensions.AddBuildMetadata(serviceCollection, hostBuilderContext.Configuration.GetSection(sectionName)));"); + Unindent(); + Unindent(); + OutLn(); + + OutLn("return builder;"); + } + + OutCloseBrace(); + OutLn(); + + OutLn("public static TBuilder UseBuildMetadata(this TBuilder builder, string sectionName = DefaultSectionName)"); + Indent(); + OutLn($"where TBuilder : {HostingNamespace}IHostApplicationBuilder"); + Unindent(); + OutOpenBrace(); + { + OutNullGuards(); + OutLn("_ = builder.Configuration.AddBuildMetadata(sectionName);"); + OutLn($"{DependencyInjectionNamespace}BuildMetadataServiceCollectionExtensions.AddBuildMetadata(builder.Services, builder.Configuration.GetSection(sectionName));"); + OutLn(); + + OutLn("return builder;"); + } + + OutCloseBrace(); + OutLn(); + +#pragma warning disable S103 // Lines should not be too long + OutLn($"public static {ConfigurationNamespace}IConfigurationBuilder AddBuildMetadata(this {ConfigurationNamespace}IConfigurationBuilder builder, string sectionName = DefaultSectionName)"); +#pragma warning restore S103 // Lines should not be too long + OutOpenBrace(); + { + OutNullGuards(); + OutLn("return builder.Add(new BuildMetadataSource(sectionName));"); + } + + OutCloseBrace(); + } + + OutCloseBrace(); + } + + OutCloseBrace(); + } + + private void OutNullGuards(bool checkBuilder = true) + { + OutPP("#if !NET"); + + if (checkBuilder) + { + OutLn("if (builder is null)"); + OutOpenBrace(); + OutLn("throw new global::System.ArgumentNullException(nameof(builder));"); + OutCloseBrace(); + OutLn(); + } + + OutLn("if (string.IsNullOrWhiteSpace(sectionName))"); + OutOpenBrace(); + { + OutLn("if (sectionName is null)"); + OutOpenBrace(); + { + OutLn("throw new global::System.ArgumentNullException(nameof(sectionName));"); + } + + OutCloseBrace(); + OutLn(); + OutLn("throw new global::System.ArgumentException(\"The value cannot be an empty string or composed entirely of whitespace.\", nameof(sectionName));"); + } + + OutCloseBrace(); + + OutPP("#else"); + + if (checkBuilder) + { + OutLn("global::System.ArgumentNullException.ThrowIfNull(builder);"); + } + + OutLn("global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);"); + + OutPP("#endif"); + OutLn(); + } +} diff --git a/src/Generators/Microsoft.Gen.BuildMetadata/Microsoft.Gen.BuildMetadata.csproj b/src/Generators/Microsoft.Gen.BuildMetadata/Microsoft.Gen.BuildMetadata.csproj new file mode 100644 index 00000000000..48e685b11d3 --- /dev/null +++ b/src/Generators/Microsoft.Gen.BuildMetadata/Microsoft.Gen.BuildMetadata.csproj @@ -0,0 +1,37 @@ + + + Microsoft.Gen.BuildMetadata + Code generator to support Microsoft.Extensions.AmbientMetadata.Build + Fundamentals + + + + cs + true + true + true + true + + $(NoWarn);EA0002;CA1852 + + + + normal + 100 + 75 + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/BuildMetadata.cs b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/BuildMetadata.cs new file mode 100644 index 00000000000..623a5fc7a96 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/BuildMetadata.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.AmbientMetadata; + +/// +/// Data model for the Build metadata. +/// +/// +/// The values are automatically grabbed from environment variables at build time in CI pipeline and saved in generated code. +/// At startup time, the class properties will be initialized from the generated code. +/// Currently supported CI pipelines: +/// +/// Azure DevOps +/// GitHub Actions +/// +/// +public class BuildMetadata +{ + /// + /// Gets or sets the ID of the record for the build, also known as the run ID. + /// + public string? BuildId { get; set; } + + /// + /// Gets or sets the name of the completed build, also known as the run number. + /// + public string? BuildNumber { get; set; } + + /// + /// Gets or sets the name of the branch in the triggering repo the build was queued for, also known as the ref name. + /// + public string? SourceBranchName { get; set; } + + /// + /// Gets or sets the latest version control change that is included in this build, also known as the commit SHA. + /// + public string? SourceVersion { get; set; } +} diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/BuildMetadataServiceCollectionExtensions.cs b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/BuildMetadataServiceCollectionExtensions.cs new file mode 100644 index 00000000000..55fa8a2c47d --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/BuildMetadataServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.AmbientMetadata; +using Microsoft.Extensions.Configuration; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extensions for Build metadata. +/// +public static class BuildMetadataServiceCollectionExtensions +{ + /// + /// Adds an instance of to the . + /// + /// The to add the services to. + /// The configuration section to bind the instance of against. + /// The for call chaining. + /// The argument or is . + public static IServiceCollection AddBuildMetadata(this IServiceCollection services, IConfigurationSection section) + { + _ = Throw.IfNull(services); + _ = Throw.IfNull(section); + + _ = services + .AddOptions() + .Bind(section); + + return services; + } + + /// + /// Adds an instance of to the . + /// + /// The to add the services to. + /// The delegate to configure with. + /// The for call chaining. + /// The argument or is . + public static IServiceCollection AddBuildMetadata(this IServiceCollection services, Action configure) + { + _ = Throw.IfNull(services); + _ = Throw.IfNull(configure); + + _ = services + .AddOptions() + .Configure(configure); + + return services; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/Microsoft.Extensions.AmbientMetadata.Build.csproj b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/Microsoft.Extensions.AmbientMetadata.Build.csproj new file mode 100644 index 00000000000..3fe3acddef4 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/Microsoft.Extensions.AmbientMetadata.Build.csproj @@ -0,0 +1,43 @@ + + + + Microsoft.Extensions.AmbientMetadata + Runtime information provider for Build ambient metadata. + Fundamentals + $(NetCoreTargetFrameworks) + + + + true + true + true + true + + + + dev + EXTEXP0009 + 100 + 100 + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/README.md b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/README.md new file mode 100644 index 00000000000..5fc308b7b9c --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/README.md @@ -0,0 +1,4 @@ +# About This Project + +This ambient metadata provider provides Build information at runtime. +See https://learn.microsoft.com/azure/devops/pipelines/build/variables for details. diff --git a/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/buildTransitive/Microsoft.Extensions.AmbientMetadata.Build.props b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/buildTransitive/Microsoft.Extensions.AmbientMetadata.Build.props new file mode 100644 index 00000000000..6ff6c318c33 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AmbientMetadata.Build/buildTransitive/Microsoft.Extensions.AmbientMetadata.Build.props @@ -0,0 +1,28 @@ + + + + $(Build_BuildId) + $(Build_BuildNumber) + $(Build_SourceBranchName) + $(Build_SourceVersion) + $(TF_BUILD) + + + $(GITHUB_RUN_ID) + $(GITHUB_RUN_NUMBER) + $(GITHUB_REF_NAME) + $(GITHUB_SHA) + + + + + + + + + + + + + + diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Generated/BuildMetadataConfigBuilderExtensionsTests.cs b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/BuildMetadataConfigBuilderExtensionsTests.cs new file mode 100644 index 00000000000..e09351c5ee7 --- /dev/null +++ b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/BuildMetadataConfigBuilderExtensionsTests.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.Extensions.AmbientMetadata; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Microsoft.Gen.BuildMetadata.Test; + +public class BuildMetadataConfigBuilderExtensionsTests +{ + [Fact] + public void GivenNullBuilder_ThrowsArgumentNullException() + { + // Arrange + IConfigurationBuilder? builder = null; + + // Act and Assert + Assert.Throws(() => builder!.AddBuildMetadata()); + } + + [Fact] + public void GivenNullSectionName_ThrowsArgumentNullException() + { + // Arrange + ConfigurationBuilder builder = new(); + + // Act and Assert + Assert.Throws(() => builder.AddBuildMetadata(sectionName: null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void GivenWhitespaceSectionName_ThrowsArgumentException(string sectionName) + { + // Arrange + ConfigurationBuilder builder = new(); + + // Act and Assert + Assert.Throws(() => builder.AddBuildMetadata(sectionName: sectionName)); + } + +} diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Generated/BuildMetadataHostBuilderExtensionsTests.cs b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/BuildMetadataHostBuilderExtensionsTests.cs new file mode 100644 index 00000000000..2c2cbd9b631 --- /dev/null +++ b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/BuildMetadataHostBuilderExtensionsTests.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using FluentAssertions; +using Microsoft.Extensions.AmbientMetadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting.Testing; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Gen.BuildMetadata.Test; + +public class BuildMetadataHostBuilderExtensionsTests +{ + [Fact] + public void Verify_HostBuilder() + { + var host = FakeHost.CreateBuilder() + .UseBuildMetadata() + .Build(); + + var buildMetadata = host.Services.GetRequiredService>(); + + buildMetadata.Value.Should().NotBeNull(); + buildMetadata.Value.BuildId.Should().Be("GeneratedTest_AzureBuildId"); + buildMetadata.Value.BuildNumber.Should().Be("GeneratedTest_AzureBuildNumber"); + buildMetadata.Value.SourceBranchName.Should().Be("GeneratedTest_AzureSourceBranchName"); + buildMetadata.Value.SourceVersion.Should().Be("GeneratedTest_AzureSourceVersion"); + } + + [Fact] + public void Verify_ApplicationHostBuilder() + { + var host = Host.CreateApplicationBuilder() + .UseBuildMetadata() + .Build(); + + var buildMetadata = host.Services.GetRequiredService>(); + + buildMetadata.Value.Should().NotBeNull(); + buildMetadata.Value.BuildId.Should().Be("GeneratedTest_AzureBuildId"); + buildMetadata.Value.BuildNumber.Should().Be("GeneratedTest_AzureBuildNumber"); + buildMetadata.Value.SourceBranchName.Should().Be("GeneratedTest_AzureSourceBranchName"); + buildMetadata.Value.SourceVersion.Should().Be("GeneratedTest_AzureSourceVersion"); + } +} diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Generated/Microsoft.Gen.BuildMetadata.Generated.Tests.csproj b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/Microsoft.Gen.BuildMetadata.Generated.Tests.csproj new file mode 100644 index 00000000000..0792e9a59e6 --- /dev/null +++ b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/Microsoft.Gen.BuildMetadata.Generated.Tests.csproj @@ -0,0 +1,30 @@ + + + Microsoft.Gen.BuildMetadata.Test + Tests for Microsoft.Gen.BuildMetadata. + + + + $(TestNetCoreTargetFrameworks) + true + true + $(NoWarn);IDE0161;S1144 + + + + + + + + + + + + + + + + + + + diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Generated/Microsoft.Gen.BuildMetadata.Generated.Tests.props b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/Microsoft.Gen.BuildMetadata.Generated.Tests.props new file mode 100644 index 00000000000..e1d31ac5d8b --- /dev/null +++ b/test/Generators/Microsoft.Gen.BuildMetadata/Generated/Microsoft.Gen.BuildMetadata.Generated.Tests.props @@ -0,0 +1,26 @@ + + + GeneratedTest_AzureBuildId + GeneratedTest_AzureBuildNumber + GeneratedTest_AzureSourceBranchName + GeneratedTest_AzureSourceVersion + true + + GeneratedTest_GitHubRunId + GeneratedTest_GitHubRunNumber + GeneratedTest_GitHubRefName + GeneratedTest_GitHubSha + + + + + + + + + + + + + + diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Unit/GeneratorTests.cs b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/GeneratorTests.cs new file mode 100644 index 00000000000..b71edd5d02b --- /dev/null +++ b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/GeneratorTests.cs @@ -0,0 +1,134 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.Gen.Shared; +using VerifyTests; +using VerifyXunit; +using Xunit; + +namespace Microsoft.Gen.BuildMetadata.Test; + +[Collection("BuildMetadataEmitterTests")] +public class GeneratorTests +{ + private readonly VerifySettings _verifySettings; + + public GeneratorTests() + { + _verifySettings = new VerifySettings(); + _verifySettings.UseDirectory("Verified"); + + _verifySettings.ScrubLinesWithReplace(value => + { + if (value.Contains(GeneratorUtilities.GeneratedCodeAttribute)) + { + return value.Replace(GeneratorUtilities.CurrentVersion, "VERSION"); + } + + return value; + }); + } + + [Theory] + [CombinatorialData] + public async Task BuildMetadataGenerator_ShouldGenerate([CombinatorialValues(true, false, null)] bool? isAzureDevOps) + { + var source = string.Empty; // Empty source, no attributes + + // Create test options based on the isAzureDevOps parameter + var optionsProvider = CreateTestOptionsProvider(isAzureDevOps); + + var (d, sources) = await RunGenerator(source, optionsProvider); + + d.Should().BeEmpty(); + sources.Should().HaveCount(1); + + var settings = new VerifySettings(_verifySettings); + settings.DisableRequireUniquePrefix(); + settings.UseParameters(isAzureDevOps); + + await Verifier.Verify(sources.Select(s => s.SourceText.ToString()), settings); + } + + private static TestAnalyzerConfigOptionsProvider CreateTestOptionsProvider(bool? isAzureDevOps) + { + return new TestAnalyzerConfigOptionsProvider(new Dictionary + { + { "build_property.BuildMetadataAzureBuildId", "TEST_AZURE_BUILDID" }, + { "build_property.BuildMetadataAzureBuildNumber", "TEST_AZURE_BUILDNUMBER" }, + { "build_property.BuildMetadataAzureSourceBranchName", "TEST_AZURE_SOURCEBRANCHNAME" }, + { "build_property.BuildMetadataAzureSourceVersion", "TEST_AZURE_SOURCEVERSION" }, + { "build_property.BuildMetadataIsAzureDevOps", isAzureDevOps?.ToString() ?? "false" }, + { "build_property.BuildMetadataGitHubRunId", "TEST_GITHUB_RUNID" }, + { "build_property.BuildMetadataGitHubRunNumber", "TEST_GITHUB_RUNNUMBER" }, + { "build_property.BuildMetadataGitHubRefName", "TEST_GITHUB_REFNAME" }, + { "build_property.BuildMetadataGitHubSha", "TEST_GITHUB_SHA" } + }); + } + + private static async Task<(IReadOnlyList diagnostics, IReadOnlyList sources)> RunGenerator( + string source, + TestAnalyzerConfigOptionsProvider optionsProvider) + { + // Create a test project and compilation + var proj = RoslynTestUtils.CreateTestProject(Array.Empty()); + proj = proj.WithDocument("source.cs", source); + proj.CommitChanges(); + + var comp = await proj.GetCompilationAsync(); + + // Create the generator driver with the options provider + var driver = Microsoft.CodeAnalysis.CSharp.CSharpGeneratorDriver.Create( + generators: new[] { new BuildMetadataGenerator().AsSourceGenerator() }, + parseOptions: CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview), + optionsProvider: optionsProvider); + + var result = driver.RunGeneratorsAndUpdateCompilation(comp!, out var outputCompilation, out var diagnostics); + var runResult = result.GetRunResult(); + + return (diagnostics, runResult.Results[0].GeneratedSources); + } + + private sealed class TestAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider + { + private readonly TestAnalyzerConfigOptions _globalOptions; + + public TestAnalyzerConfigOptionsProvider(Dictionary globalOptions) + { + _globalOptions = new TestAnalyzerConfigOptions(globalOptions); + } + + public override AnalyzerConfigOptions GlobalOptions => _globalOptions; + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => _globalOptions; + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => _globalOptions; + } + + private sealed class TestAnalyzerConfigOptions : AnalyzerConfigOptions + { + private readonly Dictionary _options; + + public TestAnalyzerConfigOptions(Dictionary options) + { + _options = options; + } + + public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) + { + return _options.TryGetValue(key, out value); + } + + public override IEnumerable Keys => _options.Keys; + } +} diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Microsoft.Gen.BuildMetadata.Unit.Tests.csproj b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Microsoft.Gen.BuildMetadata.Unit.Tests.csproj new file mode 100644 index 00000000000..f34950cbb88 --- /dev/null +++ b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Microsoft.Gen.BuildMetadata.Unit.Tests.csproj @@ -0,0 +1,23 @@ + + + Microsoft.Gen.BuildMetadata.Test + Tests for Microsoft.Gen.BuildMetadata + + + + true + + + + + + + + + + + + + + + diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=False.verified.txt b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=False.verified.txt new file mode 100644 index 00000000000..bf0810d20af --- /dev/null +++ b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=False.verified.txt @@ -0,0 +1,134 @@ +[ +// +#nullable enable +#pragma warning disable CS1591 // Compensate for https://github.com/dotnet/roslyn/issues/54103 +namespace Microsoft.Extensions.AmbientMetadata +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "VERSION")] + internal static class BuildMetadataGeneratedExtensions + { + private const string DefaultSectionName = "ambientmetadata:build"; + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "VERSION")] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + private sealed class BuildMetadataSource : global::Microsoft.Extensions.Configuration.IConfigurationSource + { + public string SectionName { get; } + + public BuildMetadataSource(string sectionName) + { +#if !NET + if (string.IsNullOrWhiteSpace(sectionName)) + { + if (sectionName is null) + { + throw new global::System.ArgumentNullException(nameof(sectionName)); + } + + throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName)); + } +#else + global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); +#endif + + SectionName = sectionName; + } + + public global::Microsoft.Extensions.Configuration.IConfigurationProvider Build(global::Microsoft.Extensions.Configuration.IConfigurationBuilder builder) + { + return new global::Microsoft.Extensions.Configuration.Memory.MemoryConfigurationProvider(new global::Microsoft.Extensions.Configuration.Memory.MemoryConfigurationSource()) + { + { $"{SectionName}:buildid", "TEST_GITHUB_RUNID" }, + { $"{SectionName}:buildnumber", "TEST_GITHUB_RUNNUMBER" }, + { $"{SectionName}:sourcebranchname", "TEST_GITHUB_REFNAME" }, + { $"{SectionName}:sourceversion", "TEST_GITHUB_SHA" }, + }; + } + } + + public static global::Microsoft.Extensions.Hosting.IHostBuilder UseBuildMetadata(this global::Microsoft.Extensions.Hosting.IHostBuilder builder, string sectionName = DefaultSectionName) + { +#if !NET + if (builder is null) + { + throw new global::System.ArgumentNullException(nameof(builder)); + } + + if (string.IsNullOrWhiteSpace(sectionName)) + { + if (sectionName is null) + { + throw new global::System.ArgumentNullException(nameof(sectionName)); + } + + throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName)); + } +#else + global::System.ArgumentNullException.ThrowIfNull(builder); + global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); +#endif + + _ = builder.ConfigureHostConfiguration(configBuilder => configBuilder.AddBuildMetadata(sectionName)) + .ConfigureServices((hostBuilderContext, serviceCollection) => + global::Microsoft.Extensions.DependencyInjection.BuildMetadataServiceCollectionExtensions.AddBuildMetadata(serviceCollection, hostBuilderContext.Configuration.GetSection(sectionName))); + + return builder; + } + + public static TBuilder UseBuildMetadata(this TBuilder builder, string sectionName = DefaultSectionName) + where TBuilder : global::Microsoft.Extensions.Hosting.IHostApplicationBuilder + { +#if !NET + if (builder is null) + { + throw new global::System.ArgumentNullException(nameof(builder)); + } + + if (string.IsNullOrWhiteSpace(sectionName)) + { + if (sectionName is null) + { + throw new global::System.ArgumentNullException(nameof(sectionName)); + } + + throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName)); + } +#else + global::System.ArgumentNullException.ThrowIfNull(builder); + global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); +#endif + + _ = builder.Configuration.AddBuildMetadata(sectionName); + global::Microsoft.Extensions.DependencyInjection.BuildMetadataServiceCollectionExtensions.AddBuildMetadata(builder.Services, builder.Configuration.GetSection(sectionName)); + + return builder; + } + + public static global::Microsoft.Extensions.Configuration.IConfigurationBuilder AddBuildMetadata(this global::Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string sectionName = DefaultSectionName) + { +#if !NET + if (builder is null) + { + throw new global::System.ArgumentNullException(nameof(builder)); + } + + if (string.IsNullOrWhiteSpace(sectionName)) + { + if (sectionName is null) + { + throw new global::System.ArgumentNullException(nameof(sectionName)); + } + + throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName)); + } +#else + global::System.ArgumentNullException.ThrowIfNull(builder); + global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); +#endif + + return builder.Add(new BuildMetadataSource(sectionName)); + } + } +} + +] diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=True.verified.txt b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=True.verified.txt new file mode 100644 index 00000000000..42f2fbc14d6 --- /dev/null +++ b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=True.verified.txt @@ -0,0 +1,134 @@ +[ +// +#nullable enable +#pragma warning disable CS1591 // Compensate for https://github.com/dotnet/roslyn/issues/54103 +namespace Microsoft.Extensions.AmbientMetadata +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "VERSION")] + internal static class BuildMetadataGeneratedExtensions + { + private const string DefaultSectionName = "ambientmetadata:build"; + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "VERSION")] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + private sealed class BuildMetadataSource : global::Microsoft.Extensions.Configuration.IConfigurationSource + { + public string SectionName { get; } + + public BuildMetadataSource(string sectionName) + { +#if !NET + if (string.IsNullOrWhiteSpace(sectionName)) + { + if (sectionName is null) + { + throw new global::System.ArgumentNullException(nameof(sectionName)); + } + + throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName)); + } +#else + global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); +#endif + + SectionName = sectionName; + } + + public global::Microsoft.Extensions.Configuration.IConfigurationProvider Build(global::Microsoft.Extensions.Configuration.IConfigurationBuilder builder) + { + return new global::Microsoft.Extensions.Configuration.Memory.MemoryConfigurationProvider(new global::Microsoft.Extensions.Configuration.Memory.MemoryConfigurationSource()) + { + { $"{SectionName}:buildid", "TEST_AZURE_BUILDID" }, + { $"{SectionName}:buildnumber", "TEST_AZURE_BUILDNUMBER" }, + { $"{SectionName}:sourcebranchname", "TEST_AZURE_SOURCEBRANCHNAME" }, + { $"{SectionName}:sourceversion", "TEST_AZURE_SOURCEVERSION" }, + }; + } + } + + public static global::Microsoft.Extensions.Hosting.IHostBuilder UseBuildMetadata(this global::Microsoft.Extensions.Hosting.IHostBuilder builder, string sectionName = DefaultSectionName) + { +#if !NET + if (builder is null) + { + throw new global::System.ArgumentNullException(nameof(builder)); + } + + if (string.IsNullOrWhiteSpace(sectionName)) + { + if (sectionName is null) + { + throw new global::System.ArgumentNullException(nameof(sectionName)); + } + + throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName)); + } +#else + global::System.ArgumentNullException.ThrowIfNull(builder); + global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); +#endif + + _ = builder.ConfigureHostConfiguration(configBuilder => configBuilder.AddBuildMetadata(sectionName)) + .ConfigureServices((hostBuilderContext, serviceCollection) => + global::Microsoft.Extensions.DependencyInjection.BuildMetadataServiceCollectionExtensions.AddBuildMetadata(serviceCollection, hostBuilderContext.Configuration.GetSection(sectionName))); + + return builder; + } + + public static TBuilder UseBuildMetadata(this TBuilder builder, string sectionName = DefaultSectionName) + where TBuilder : global::Microsoft.Extensions.Hosting.IHostApplicationBuilder + { +#if !NET + if (builder is null) + { + throw new global::System.ArgumentNullException(nameof(builder)); + } + + if (string.IsNullOrWhiteSpace(sectionName)) + { + if (sectionName is null) + { + throw new global::System.ArgumentNullException(nameof(sectionName)); + } + + throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName)); + } +#else + global::System.ArgumentNullException.ThrowIfNull(builder); + global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); +#endif + + _ = builder.Configuration.AddBuildMetadata(sectionName); + global::Microsoft.Extensions.DependencyInjection.BuildMetadataServiceCollectionExtensions.AddBuildMetadata(builder.Services, builder.Configuration.GetSection(sectionName)); + + return builder; + } + + public static global::Microsoft.Extensions.Configuration.IConfigurationBuilder AddBuildMetadata(this global::Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string sectionName = DefaultSectionName) + { +#if !NET + if (builder is null) + { + throw new global::System.ArgumentNullException(nameof(builder)); + } + + if (string.IsNullOrWhiteSpace(sectionName)) + { + if (sectionName is null) + { + throw new global::System.ArgumentNullException(nameof(sectionName)); + } + + throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName)); + } +#else + global::System.ArgumentNullException.ThrowIfNull(builder); + global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); +#endif + + return builder.Add(new BuildMetadataSource(sectionName)); + } + } +} + +] diff --git a/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=null.verified.txt b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=null.verified.txt new file mode 100644 index 00000000000..bf0810d20af --- /dev/null +++ b/test/Generators/Microsoft.Gen.BuildMetadata/Unit/Verified/GeneratorTests.BuildMetadataGenerator_ShouldGenerate_isAzureDevOps=null.verified.txt @@ -0,0 +1,134 @@ +[ +// +#nullable enable +#pragma warning disable CS1591 // Compensate for https://github.com/dotnet/roslyn/issues/54103 +namespace Microsoft.Extensions.AmbientMetadata +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "VERSION")] + internal static class BuildMetadataGeneratedExtensions + { + private const string DefaultSectionName = "ambientmetadata:build"; + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.BuildMetadata", "VERSION")] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + private sealed class BuildMetadataSource : global::Microsoft.Extensions.Configuration.IConfigurationSource + { + public string SectionName { get; } + + public BuildMetadataSource(string sectionName) + { +#if !NET + if (string.IsNullOrWhiteSpace(sectionName)) + { + if (sectionName is null) + { + throw new global::System.ArgumentNullException(nameof(sectionName)); + } + + throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName)); + } +#else + global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); +#endif + + SectionName = sectionName; + } + + public global::Microsoft.Extensions.Configuration.IConfigurationProvider Build(global::Microsoft.Extensions.Configuration.IConfigurationBuilder builder) + { + return new global::Microsoft.Extensions.Configuration.Memory.MemoryConfigurationProvider(new global::Microsoft.Extensions.Configuration.Memory.MemoryConfigurationSource()) + { + { $"{SectionName}:buildid", "TEST_GITHUB_RUNID" }, + { $"{SectionName}:buildnumber", "TEST_GITHUB_RUNNUMBER" }, + { $"{SectionName}:sourcebranchname", "TEST_GITHUB_REFNAME" }, + { $"{SectionName}:sourceversion", "TEST_GITHUB_SHA" }, + }; + } + } + + public static global::Microsoft.Extensions.Hosting.IHostBuilder UseBuildMetadata(this global::Microsoft.Extensions.Hosting.IHostBuilder builder, string sectionName = DefaultSectionName) + { +#if !NET + if (builder is null) + { + throw new global::System.ArgumentNullException(nameof(builder)); + } + + if (string.IsNullOrWhiteSpace(sectionName)) + { + if (sectionName is null) + { + throw new global::System.ArgumentNullException(nameof(sectionName)); + } + + throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName)); + } +#else + global::System.ArgumentNullException.ThrowIfNull(builder); + global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); +#endif + + _ = builder.ConfigureHostConfiguration(configBuilder => configBuilder.AddBuildMetadata(sectionName)) + .ConfigureServices((hostBuilderContext, serviceCollection) => + global::Microsoft.Extensions.DependencyInjection.BuildMetadataServiceCollectionExtensions.AddBuildMetadata(serviceCollection, hostBuilderContext.Configuration.GetSection(sectionName))); + + return builder; + } + + public static TBuilder UseBuildMetadata(this TBuilder builder, string sectionName = DefaultSectionName) + where TBuilder : global::Microsoft.Extensions.Hosting.IHostApplicationBuilder + { +#if !NET + if (builder is null) + { + throw new global::System.ArgumentNullException(nameof(builder)); + } + + if (string.IsNullOrWhiteSpace(sectionName)) + { + if (sectionName is null) + { + throw new global::System.ArgumentNullException(nameof(sectionName)); + } + + throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName)); + } +#else + global::System.ArgumentNullException.ThrowIfNull(builder); + global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); +#endif + + _ = builder.Configuration.AddBuildMetadata(sectionName); + global::Microsoft.Extensions.DependencyInjection.BuildMetadataServiceCollectionExtensions.AddBuildMetadata(builder.Services, builder.Configuration.GetSection(sectionName)); + + return builder; + } + + public static global::Microsoft.Extensions.Configuration.IConfigurationBuilder AddBuildMetadata(this global::Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string sectionName = DefaultSectionName) + { +#if !NET + if (builder is null) + { + throw new global::System.ArgumentNullException(nameof(builder)); + } + + if (string.IsNullOrWhiteSpace(sectionName)) + { + if (sectionName is null) + { + throw new global::System.ArgumentNullException(nameof(sectionName)); + } + + throw new global::System.ArgumentException("The value cannot be an empty string or composed entirely of whitespace.", nameof(sectionName)); + } +#else + global::System.ArgumentNullException.ThrowIfNull(builder); + global::System.ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); +#endif + + return builder.Add(new BuildMetadataSource(sectionName)); + } + } +} + +] diff --git a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/BuildMetadataServiceCollectionExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/BuildMetadataServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000000..1ea00d5bcb5 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/BuildMetadataServiceCollectionExtensionsTests.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Extensions.AmbientMetadata.Test; + +public class BuildMetadataServiceCollectionExtensionsTests +{ + [Fact] + public void GivenAnyNullArgument_ShouldThrowArgumentNullException() + { + var serviceCollection = new ServiceCollection(); + var config = new ConfigurationBuilder().Build(); + + serviceCollection.Invoking(x => ((IServiceCollection)null!).AddBuildMetadata(config.GetSection(string.Empty))) + .Should().Throw(); + + serviceCollection.Invoking(x => x.AddBuildMetadata((Action)null!)) + .Should().Throw(); + + serviceCollection.Invoking(x => ((IServiceCollection)null!).AddBuildMetadata(_ => { })) + .Should().Throw(); + + serviceCollection.Invoking(x => x.AddBuildMetadata((IConfigurationSection)null!)) + .Should().Throw(); + } + + [Fact] + public void GivenConfigurationSection_ShouldRegisterMetadataFromIt() + { + // Arrange + var testData = new BuildMetadata + { + BuildId = Guid.NewGuid().ToString(), + BuildNumber = "v1.2.3", + SourceBranchName = "main", + SourceVersion = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", + }; + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"{nameof(BuildMetadata)}:{nameof(BuildMetadata.BuildId)}"] = testData.BuildId, + [$"{nameof(BuildMetadata)}:{nameof(BuildMetadata.BuildNumber)}"] = testData.BuildNumber, + [$"{nameof(BuildMetadata)}:{nameof(BuildMetadata.SourceBranchName)}"] = testData.SourceBranchName, + [$"{nameof(BuildMetadata)}:{nameof(BuildMetadata.SourceVersion)}"] = testData.SourceVersion, + }) + .Build(); + + var configurationSection = config + .GetSection(nameof(BuildMetadata)); + + // Act + using var provider = new ServiceCollection() + .AddBuildMetadata(configurationSection) + .BuildServiceProvider(); + var metadata = provider + .GetRequiredService>().Value; + + // Assert + metadata.Should().BeEquivalentTo(testData); + } + + [Fact] + public void GivenActionDelegate_ShouldRegisterMetadataFromIt() + { + // Arrange + var testData = new BuildMetadata + { + BuildId = Guid.NewGuid().ToString(), + BuildNumber = "v1.2.3", + SourceBranchName = "main", + SourceVersion = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", + }; + + // Act + using var provider = new ServiceCollection() + .AddBuildMetadata(m => + { + m.BuildId = testData.BuildId; + m.BuildNumber = testData.BuildNumber; + m.SourceVersion = testData.SourceVersion; + m.SourceBranchName = testData.SourceBranchName; + }) + .BuildServiceProvider(); + var metadata = provider + .GetRequiredService>().Value; + + // Assert + metadata.Should().BeEquivalentTo(testData); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/BuildMetadataTests.cs b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/BuildMetadataTests.cs new file mode 100644 index 00000000000..af0ba390794 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/BuildMetadataTests.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq; +using System.Reflection; +using FluentAssertions; +using Xunit; + +namespace Microsoft.Extensions.AmbientMetadata.Test; + +public class BuildMetadataTests +{ + [Fact] + public void ShouldConstructObject() + { + var instance = new BuildMetadata(); + Assert.NotNull(instance); + } + + [Fact] + public void ShouldHaveAllPropertiesNull() + { + // Arrange + var obj = new BuildMetadata(); + var properties = obj.GetType().GetProperties().Select(f => f.GetValue(obj)).ToArray(); + + properties.Should().OnlyContain(x => x == null); + } + + [Fact] + public void BuildIdProperty_ShouldSetAndGetCorrectly() + { + // Arrange + var id = Guid.NewGuid().ToString(); + var metadata = new BuildMetadata + { + // Act + BuildId = id + }; + + // Assert + metadata.BuildId.Should().Be(id); + } + + [Fact] + public void BuildNumberProperty_ShouldSetAndGetCorrectly() + { + // Arrange + var metadata = new BuildMetadata + { + BuildNumber = "v1.2.3" + }; + + // Assert + metadata.BuildNumber.Should().Be("v1.2.3"); + } + + [Fact] + public void SourceBranchNameProperty_ShouldSetAndGetCorrectly() + { + // Arrange + var metadata = new BuildMetadata + { + SourceBranchName = "main" + }; + + // Assert + metadata.SourceBranchName.Should().Be("main"); + } + + [Fact] + public void SourceVersionProperty_ShouldSetAndGetCorrectly() + { + // Arrange + var metadata = new BuildMetadata + { + SourceVersion = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0" + }; + + // Assert + metadata.SourceVersion.Should().Be("a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/ConfigurationBindingQuirkBehaviorTests.cs b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/ConfigurationBindingQuirkBehaviorTests.cs new file mode 100644 index 00000000000..bd7f27c954c --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/ConfigurationBindingQuirkBehaviorTests.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting.Testing; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Extensions.AmbientMetadata.Test; + +public class ConfigurationBindingQuirkBehaviorTests +{ + [Theory] + [InlineData("ambientmetadata:build")] + [InlineData("customSection:ambientmetadata:build")] + public void GivenMetadata_RegistersOptions_HostBuilder(string sectionName) + { + // When configuration is not available, values are initialized to the default value of string?, which is null + var defaultMetadata = new BuildMetadata(); + + using var host = CreateUsingHostBuilder(sectionName); + + // We get a BuildMetadata instance with null values for all its properties, despite using empty strings in the configuration + // Related issue: https://github.com/dotnet/runtime/issues/62532 + host.Services.GetRequiredService>().Value.Should().BeEquivalentTo(defaultMetadata); + } + + [Theory] + [InlineData("ambientmetadata:build")] + [InlineData("customSection:ambientmetadata:build")] + public void GivenMetadata_RegistersOptions_HostApplicationBuilder(string sectionName) + { + // When configuration is not available, values are initialized to an empty string + var metadataWithEmptyStrings = new BuildMetadata + { + BuildId = string.Empty, + BuildNumber = string.Empty, + SourceBranchName = string.Empty, + SourceVersion = string.Empty + }; + + using var host = CreateUsingHostApplicationBuilder(sectionName); + + // We get a BuildMetadata instance with all properties populated with empty strings, as expected + host.Services.GetRequiredService>().Value.Should().BeEquivalentTo(metadataWithEmptyStrings); + } + + private static IConfigurationBuilder ConfigureInMemoryCollection(IConfigurationBuilder configuration, string sectionName) + { + return configuration.AddInMemoryCollection(new Dictionary + { + { $"{sectionName}:BuildId", string.Empty }, + { $"{sectionName}:BuildNumber", string.Empty }, + { $"{sectionName}:SourceBranchName", string.Empty }, + { $"{sectionName}:SourceVersion", string.Empty } + }); + } + + private static IHost CreateUsingHostBuilder(string sectionName) + { + return FakeHost.CreateBuilder() + .ConfigureHostConfiguration(configBuilder => + { + _ = ConfigureInMemoryCollection(configBuilder, sectionName); + }) + .ConfigureServices((context, services) => + { + _ = services.AddBuildMetadata(context.Configuration.GetSection(sectionName)); + }) + .Build(); + } + + private static IHost CreateUsingHostApplicationBuilder(string sectionName) + { + var hostBuilder = Host.CreateEmptyApplicationBuilder(new()); + _ = ConfigureInMemoryCollection(hostBuilder.Configuration, sectionName); + _ = hostBuilder.Services.AddBuildMetadata(hostBuilder.Configuration.GetSection(sectionName)); + return hostBuilder.Build(); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/Microsoft.Extensions.AmbientMetadata.Build.Tests.csproj b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/Microsoft.Extensions.AmbientMetadata.Build.Tests.csproj new file mode 100644 index 00000000000..7354b158585 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AmbientMetadata.Build.Tests/Microsoft.Extensions.AmbientMetadata.Build.Tests.csproj @@ -0,0 +1,17 @@ + + + Microsoft.Extensions.AmbientMetadata.Test + Unit tests for Microsoft.Extensions.AmbientMetadata.Build. + $(NetCoreTargetFrameworks) + + + + + + + + + + + +