File: PowerShellScripts\BuildTestMatrixTests.cs
Web Access
Project: src\tests\Infrastructure.Tests\Infrastructure.Tests.csproj (Infrastructure.Tests)
// 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.Json;
using Aspire.TestUtilities;
using Xunit;
 
namespace Infrastructure.Tests;
 
/// <summary>
/// Tests for eng/scripts/build-test-matrix.ps1
/// </summary>
public class BuildTestMatrixTests : IDisposable
{
    private readonly TemporaryWorkspace _workspace;
    private readonly string _scriptPath;
    private readonly ITestOutputHelper _output;
 
    public BuildTestMatrixTests(ITestOutputHelper output)
    {
        _output = output;
        _workspace = TemporaryWorkspace.Create(output);
        _scriptPath = Path.Combine(RepoRoot.Path, "eng", "scripts", "build-test-matrix.ps1");
    }
 
    public void Dispose() => _workspace.Dispose();
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task GeneratesMatrixFromSingleProject()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateTestsMetadataJson(
            Path.Combine(artifactsDir, "MyProject.tests-metadata.json"),
            projectName: "MyProject",
            testProjectPath: "tests/MyProject/MyProject.csproj",
            shortName: "MyProj");
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful("build-test-matrix.ps1 failed");
 
        var matrix = ParseCanonicalMatrix(outputFile);
        var entry = Assert.Single(matrix.Tests);
        Assert.Equal("MyProject", entry.ProjectName);
        Assert.Equal("MyProj", entry.Name);
        Assert.Equal("regular", entry.Type);
        Assert.False(entry.Properties.GetValueOrDefault("requiresNugets"));
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task GeneratesMatrixFromMultipleProjects()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateTestsMetadataJson(
            Path.Combine(artifactsDir, "ProjectA.tests-metadata.json"),
            projectName: "ProjectA",
            testProjectPath: "tests/ProjectA/ProjectA.csproj");
 
        TestDataBuilder.CreateTestsMetadataJson(
            Path.Combine(artifactsDir, "ProjectB.tests-metadata.json"),
            projectName: "ProjectB",
            testProjectPath: "tests/ProjectB/ProjectB.csproj");
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        Assert.Equal(2, matrix.Tests.Length);
        Assert.Contains(matrix.Tests, e => e.ProjectName == "ProjectA");
        Assert.Contains(matrix.Tests, e => e.ProjectName == "ProjectB");
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task GeneratesPartitionEntries()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateSplitTestsMetadataJson(
            Path.Combine(artifactsDir, "SplitProject.tests-metadata.json"),
            projectName: "SplitProject",
            testProjectPath: "tests/SplitProject/SplitProject.csproj",
            shortName: "Split");
 
        TestDataBuilder.CreateTestsPartitionsJson(
            Path.Combine(artifactsDir, "SplitProject.tests-partitions.json"),
            "PartitionA", "PartitionB");
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        // Should have 3 entries: PartitionA, PartitionB, and uncollected
        Assert.Equal(3, matrix.Tests.Length);
 
        var partitionA = matrix.Tests.FirstOrDefault(e => e.Name == "Split-PartitionA");
        Assert.NotNull(partitionA);
        Assert.Equal("collection", partitionA.Type);
        Assert.Contains("--filter-trait", partitionA.ExtraTestArgs);
        Assert.Contains("Partition=PartitionA", partitionA.ExtraTestArgs);
 
        var uncollected = matrix.Tests.FirstOrDefault(e => e.Name == "Split");
        Assert.NotNull(uncollected);
        Assert.Contains("--filter-not-trait", uncollected.ExtraTestArgs);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task GeneratesClassEntries()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateSplitTestsMetadataJson(
            Path.Combine(artifactsDir, "ClassSplitProject.tests-metadata.json"),
            projectName: "ClassSplitProject",
            testProjectPath: "tests/ClassSplitProject/ClassSplitProject.csproj",
            shortName: "ClassSplit");
 
        TestDataBuilder.CreateClassBasedPartitionsJson(
            Path.Combine(artifactsDir, "ClassSplitProject.tests-partitions.json"),
            "MyNamespace.TestClassA", "MyNamespace.TestClassB");
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        Assert.Equal(2, matrix.Tests.Length);
 
        var classA = matrix.Tests.FirstOrDefault(e => e.Name == "ClassSplit-TestClassA");
        Assert.NotNull(classA);
        Assert.Equal("class", classA.Type);
        Assert.Contains("--filter-class", classA.ExtraTestArgs);
        Assert.Contains("MyNamespace.TestClassA", classA.ExtraTestArgs);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task DefaultsMtpBaseArgsToEmptyWhenNotSpecified()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        // Create metadata without explicit timeouts
        TestDataBuilder.CreateTestsMetadataJson(
            Path.Combine(artifactsDir, "NoTimeouts.tests-metadata.json"),
            projectName: "NoTimeouts",
            testProjectPath: "tests/NoTimeouts/NoTimeouts.csproj");
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        var entry = Assert.Single(matrix.Tests);
        Assert.Equal("", entry.MtpBaseArgs);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task PreservesCustomMtpBaseArgs()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateTestsMetadataJson(
            Path.Combine(artifactsDir, "CustomTimeouts.tests-metadata.json"),
            projectName: "CustomTimeouts",
            testProjectPath: "tests/CustomTimeouts/CustomTimeouts.csproj",
            mtpBaseArgs: "--hangdump-timeout 15m --timeout 45m");
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        var entry = Assert.Single(matrix.Tests);
        Assert.Equal("--hangdump-timeout 15m --timeout 45m", entry.MtpBaseArgs);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task PreservesRequiresNugetsProperty()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateTestsMetadataJson(
            Path.Combine(artifactsDir, "NeedsNugets.tests-metadata.json"),
            projectName: "NeedsNugets",
            testProjectPath: "tests/NeedsNugets/NeedsNugets.csproj",
            requiresNugets: true);
 
        TestDataBuilder.CreateTestsMetadataJson(
            Path.Combine(artifactsDir, "NoNugets.tests-metadata.json"),
            projectName: "NoNugets",
            testProjectPath: "tests/NoNugets/NoNugets.csproj",
            requiresNugets: false);
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        Assert.Equal(2, matrix.Tests.Length);
        Assert.Contains(matrix.Tests, e => e.ProjectName == "NeedsNugets" && e.Properties.GetValueOrDefault("requiresNugets") == true);
        Assert.Contains(matrix.Tests, e => e.ProjectName == "NoNugets" && e.Properties.GetValueOrDefault("requiresNugets") == false);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task GeneratesCorrectFilterArgs()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateSplitTestsMetadataJson(
            Path.Combine(artifactsDir, "FilterTest.tests-metadata.json"),
            projectName: "FilterTest",
            testProjectPath: "tests/FilterTest/FilterTest.csproj");
 
        TestDataBuilder.CreateTestsPartitionsJson(
            Path.Combine(artifactsDir, "FilterTest.tests-partitions.json"),
            "MyPartition");
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        var partitionEntry = matrix.Tests.First(e => e.Collection == "MyPartition");
        Assert.Equal("--filter-trait \"Partition=MyPartition\"", partitionEntry.ExtraTestArgs);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task CreatesEmptyMatrixWhenNoMetadataFiles()
    {
        // Arrange
        var emptyArtifactsDir = Path.Combine(_workspace.Path, "empty-artifacts");
        Directory.CreateDirectory(emptyArtifactsDir);
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(emptyArtifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        Assert.Empty(matrix.Tests);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task UsesUncollectedMtpBaseArgsForUncollectedEntry()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateSplitTestsMetadataJson(
            Path.Combine(artifactsDir, "SplitProject.tests-metadata.json"),
            projectName: "SplitProject",
            testProjectPath: "tests/SplitProject/SplitProject.csproj",
            shortName: "Split",
            mtpBaseArgs: "--hangdump-timeout 15m --timeout 30m",
            uncollectedMtpBaseArgs: "--hangdump-timeout 20m --timeout 45m");
 
        TestDataBuilder.CreateTestsPartitionsJson(
            Path.Combine(artifactsDir, "SplitProject.tests-partitions.json"),
            "PartitionA");
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
 
        // The partitioned entry should have regular mtpBaseArgs
        var partitionEntry = matrix.Tests.FirstOrDefault(e => e.Name == "Split-PartitionA");
        Assert.NotNull(partitionEntry);
        Assert.Equal("--hangdump-timeout 15m --timeout 30m", partitionEntry.MtpBaseArgs);
 
        // The uncollected entry should have uncollected-specific mtpBaseArgs
        var uncollectedEntry = matrix.Tests.FirstOrDefault(e => e.Name == "Split");
        Assert.NotNull(uncollectedEntry);
        Assert.Equal("--hangdump-timeout 20m --timeout 45m", uncollectedEntry.MtpBaseArgs);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task PassesRequiresTestSdkProperty()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateTestsMetadataJson(
            Path.Combine(artifactsDir, "SdkProject.tests-metadata.json"),
            projectName: "SdkProject",
            testProjectPath: "tests/SdkProject/SdkProject.csproj",
            requiresTestSdk: true);
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        var entry = Assert.Single(matrix.Tests);
        Assert.True(entry.Properties.GetValueOrDefault("requiresTestSdk"));
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task PreservesSupportedOSes()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateTestsMetadataJson(
            Path.Combine(artifactsDir, "LinuxOnly.tests-metadata.json"),
            projectName: "LinuxOnly",
            testProjectPath: "tests/LinuxOnly/LinuxOnly.csproj",
            supportedOSes: ["linux"]);
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        var entry = Assert.Single(matrix.Tests);
        Assert.Single(entry.SupportedOSes);
        Assert.Contains("linux", entry.SupportedOSes);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task InheritsSupportedOSesToPartitionEntries()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateSplitTestsMetadataJson(
            Path.Combine(artifactsDir, "OsRestrictedSplit.tests-metadata.json"),
            projectName: "OsRestrictedSplit",
            testProjectPath: "tests/OsRestrictedSplit/OsRestrictedSplit.csproj",
            shortName: "OsRestrict",
            supportedOSes: ["windows", "linux"]);
 
        TestDataBuilder.CreateTestsPartitionsJson(
            Path.Combine(artifactsDir, "OsRestrictedSplit.tests-partitions.json"),
            "PartA");
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        // Both the partition entry and uncollected entry should have the same supportedOSes
        foreach (var entry in matrix.Tests)
        {
            Assert.Equal(2, entry.SupportedOSes.Length);
            Assert.Contains("windows", entry.SupportedOSes);
            Assert.Contains("linux", entry.SupportedOSes);
        }
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task PassesThroughRunnersForRegularTests()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateTestsMetadataJson(
            Path.Combine(artifactsDir, "CustomRunner.tests-metadata.json"),
            projectName: "CustomRunner",
            testProjectPath: "tests/CustomRunner/CustomRunner.csproj",
            runners: new Dictionary<string, string> { ["macos"] = "macos-latest-xlarge" });
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        var entry = Assert.Single(matrix.Tests);
        Assert.NotNull(entry.Runners);
        Assert.Single(entry.Runners);
        Assert.Equal("macos-latest-xlarge", entry.Runners["macos"]);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task OmitsRunnersWhenNotSet()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateTestsMetadataJson(
            Path.Combine(artifactsDir, "NoRunner.tests-metadata.json"),
            projectName: "NoRunner",
            testProjectPath: "tests/NoRunner/NoRunner.csproj");
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        var entry = Assert.Single(matrix.Tests);
        Assert.Null(entry.Runners);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task PassesThroughRunnersForPartitionEntries()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateSplitTestsMetadataJson(
            Path.Combine(artifactsDir, "SplitRunner.tests-metadata.json"),
            projectName: "SplitRunner",
            testProjectPath: "tests/SplitRunner/SplitRunner.csproj",
            shortName: "SplitR",
            runners: new Dictionary<string, string> { ["linux"] = "ubuntu-24.04" });
 
        TestDataBuilder.CreateTestsPartitionsJson(
            Path.Combine(artifactsDir, "SplitRunner.tests-partitions.json"),
            "PartA");
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        // All entries (partition + uncollected) should inherit the runners
        foreach (var entry in matrix.Tests)
        {
            Assert.NotNull(entry.Runners);
            Assert.Equal("ubuntu-24.04", entry.Runners["linux"]);
        }
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task PassesThroughRunnersForClassEntries()
    {
        // Arrange
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateSplitTestsMetadataJson(
            Path.Combine(artifactsDir, "ClassRunner.tests-metadata.json"),
            projectName: "ClassRunner",
            testProjectPath: "tests/ClassRunner/ClassRunner.csproj",
            shortName: "ClassR",
            runners: new Dictionary<string, string>
            {
                ["windows"] = "windows-2022",
                ["macos"] = "macos-latest-xlarge"
            });
 
        TestDataBuilder.CreateClassBasedPartitionsJson(
            Path.Combine(artifactsDir, "ClassRunner.tests-partitions.json"),
            "Ns.ClassA");
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        // Act
        var result = await RunScript(artifactsDir, outputFile);
 
        // Assert
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        var entry = Assert.Single(matrix.Tests);
        Assert.NotNull(entry.Runners);
        Assert.Equal(2, entry.Runners.Count);
        Assert.Equal("windows-2022", entry.Runners["windows"]);
        Assert.Equal("macos-latest-xlarge", entry.Runners["macos"]);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task AllCITestsPropertiesAppearInOutputWithDefaults()
    {
        // Verifies that every property defined in CITestsProperties.props
        // appears in the canonical matrix output with its default value
        // when the input metadata doesn't set any properties to true.
        var expectedProperties = ReadCITestsPropertyNames();
 
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateTestsMetadataJson(
            Path.Combine(artifactsDir, "DefaultProps.tests-metadata.json"),
            projectName: "DefaultProps",
            testProjectPath: "tests/DefaultProps/DefaultProps.csproj");
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        var result = await RunScript(artifactsDir, outputFile);
 
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        var entry = Assert.Single(matrix.Tests);
 
        foreach (var propName in expectedProperties)
        {
            Assert.True(entry.Properties.ContainsKey(propName),
                $"Expected property '{propName}' from CITestsProperties.props to be present in matrix output, but it was missing.");
            Assert.False(entry.Properties[propName],
                $"Expected property '{propName}' to have default value 'false', but it was 'true'.");
        }
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task DefaultsAreAppliedWhenPropertiesAreMissingFromMetadata()
    {
        // Create metadata JSON manually with a partial properties object
        // (only requiresNugets=true, everything else omitted) to verify
        // that the script fills in defaults from CITestsProperties.props.
        var expectedProperties = ReadCITestsPropertyNames();
 
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        var partialMetadata = """
            {
              "projectName": "PartialProps",
              "testProjectPath": "tests/PartialProps/PartialProps.csproj",
              "shortName": "PartialProps",
              "splitTests": "false",
              "properties": {
                "requiresNugets": true
              },
              "supportedOSes": ["windows", "linux"]
            }
            """;
        File.WriteAllText(
            Path.Combine(artifactsDir, "PartialProps.tests-metadata.json"),
            partialMetadata);
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        var result = await RunScript(artifactsDir, outputFile);
 
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        var entry = Assert.Single(matrix.Tests);
 
        // requiresNugets should be true (from input)
        Assert.True(entry.Properties["requiresNugets"]);
 
        // All other properties should be present with their default value (false)
        foreach (var propName in expectedProperties.Where(p => p != "requiresNugets"))
        {
            Assert.True(entry.Properties.ContainsKey(propName),
                $"Expected property '{propName}' to be filled in by defaults, but it was missing.");
            Assert.False(entry.Properties[propName],
                $"Expected property '{propName}' default to be 'false', but it was 'true'.");
        }
    }
 
    [Fact]
    public void CITestsPropertiesPropsFileIsValidAndComplete()
    {
        // Validates that CITestsProperties.props is well-formed XML
        // and contains the expected property definitions.
        var propsPath = Path.Combine(RepoRoot.Path, "eng", "testing", "CITestsProperties.props");
        Assert.True(File.Exists(propsPath), $"CITestsProperties.props not found at {propsPath}");
 
        var doc = new System.Xml.XmlDocument();
        doc.Load(propsPath);
 
        var items = doc.SelectNodes("/Project/ItemGroup/CITestsProperty");
        Assert.NotNull(items);
        Assert.True(items.Count > 0, "CITestsProperties.props should define at least one CITestsProperty item.");
 
        foreach (System.Xml.XmlElement item in items)
        {
            var include = item.GetAttribute("Include");
            var msbuildProp = item.GetAttribute("MSBuildProp");
            var defaultVal = item.GetAttribute("Default");
 
            Assert.False(string.IsNullOrWhiteSpace(include),
                "Each CITestsProperty must have an Include attribute (JSON key name).");
            Assert.False(string.IsNullOrWhiteSpace(msbuildProp),
                $"CITestsProperty '{include}' must have an MSBuildProp attribute.");
            Assert.False(string.IsNullOrWhiteSpace(defaultVal),
                $"CITestsProperty '{include}' must have a Default attribute.");
 
            // JSON keys should be camelCase (start with lowercase)
            Assert.True(char.IsLower(include[0]),
                $"CITestsProperty Include '{include}' should be camelCase (start with lowercase).");
 
            // MSBuild properties should be PascalCase (start with uppercase)
            Assert.True(char.IsUpper(msbuildProp[0]),
                $"CITestsProperty MSBuildProp '{msbuildProp}' should be PascalCase (start with uppercase).");
        }
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task SplitTestEntriesInheritAllCITestsProperties()
    {
        // Verifies that partition-based split test entries also get
        // all properties from CITestsProperties.props.
        var expectedProperties = ReadCITestsPropertyNames();
 
        var artifactsDir = Path.Combine(_workspace.Path, "artifacts");
        Directory.CreateDirectory(artifactsDir);
 
        TestDataBuilder.CreateSplitTestsMetadataJson(
            Path.Combine(artifactsDir, "SplitProps.tests-metadata.json"),
            projectName: "SplitProps",
            testProjectPath: "tests/SplitProps/SplitProps.csproj",
            shortName: "SplitP",
            requiresNugets: true);
 
        TestDataBuilder.CreateTestsPartitionsJson(
            Path.Combine(artifactsDir, "SplitProps.tests-partitions.json"),
            "PartA");
 
        var outputFile = Path.Combine(_workspace.Path, "matrix.json");
 
        var result = await RunScript(artifactsDir, outputFile);
 
        result.EnsureSuccessful();
 
        var matrix = ParseCanonicalMatrix(outputFile);
        Assert.True(matrix.Tests.Length >= 2, "Expected at least 2 entries (partition + uncollected).");
 
        foreach (var entry in matrix.Tests)
        {
            foreach (var propName in expectedProperties)
            {
                Assert.True(entry.Properties.ContainsKey(propName),
                    $"Split entry '{entry.Name}' is missing property '{propName}'.");
            }
            Assert.True(entry.Properties["requiresNugets"],
                $"Split entry '{entry.Name}' should have requiresNugets=true.");
        }
    }
 
    private async Task<CommandResult> RunScript(string artifactsDir, string outputFile)
    {
        using var cmd = new PowerShellCommand(_scriptPath, _output)
            .WithTimeout(TimeSpan.FromMinutes(2));
 
        return await cmd.ExecuteAsync(
            "-ArtifactsDir", $"\"{artifactsDir}\"",
            "-OutputMatrixFile", $"\"{outputFile}\"");
    }
 
    private static CanonicalMatrix ParseCanonicalMatrix(string path)
    {
        var json = File.ReadAllText(path);
        return JsonSerializer.Deserialize<CanonicalMatrix>(json)
            ?? throw new InvalidOperationException("Failed to parse matrix JSON");
    }
 
    /// <summary>
    /// Reads the CITestsProperties.props XML file and returns
    /// the list of property names (Include attributes).
    /// </summary>
    private static HashSet<string> ReadCITestsPropertyNames()
    {
        var propsPath = Path.Combine(RepoRoot.Path, "eng", "testing", "CITestsProperties.props");
        var doc = new System.Xml.XmlDocument();
        doc.Load(propsPath);
 
        var items = doc.SelectNodes("/Project/ItemGroup/CITestsProperty")
            ?? throw new InvalidOperationException("No CITestsProperty items found in CITestsProperties.props");
 
        var names = new HashSet<string>();
        foreach (System.Xml.XmlElement item in items)
        {
            names.Add(item.GetAttribute("Include"));
        }
        return names;
    }
}