File: TestTriggerMap\TestTriggerMapTests.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 Aspire.SelectTests;
using Aspire.TestUtilities;
using System.Runtime.CompilerServices;
using Xunit;
using YamlDotNet.RepresentationModel;
 
namespace Infrastructure.Tests.TestTriggerMap;
 
/// <summary>
/// Exercises curated trigger-map routing through the production selector with repository inputs.
/// </summary>
[Collection("GraphAffectedProjects")] // MSBuildLocator registers process-wide; keep graph use serialized.
public sealed class TestTriggerMapTests
{
    [Fact]
    public void AppHostServerV2SelectsItsBundleConsumers()
    {
        var result = SelectWithRealMap("src/Aspire.Hosting.Native.Server/Program.cs", "Aspire.Hosting.Native.Server");
 
        Assert.False(result.SelectsAll);
        Assert.Contains("Aspire.Cli.EndToEnd.Tests", result.TestProjects);
        Assert.Contains("job:cli-starter-validation", result.Jobs);
        Assert.Contains("job:extension-e2e", result.Jobs);
        Assert.Contains("job:winget-installer", result.Jobs);
        Assert.Contains("job:homebrew-installer", result.Jobs);
    }
 
    [Theory]
    [InlineData("eng/scripts/generate-template-cgmanifest.ps1")]
    public void TemplateManifestInputsSelectInfrastructureCoverage(string path)
    {
        var result = SelectWithRealMap(path);
 
        Assert.False(result.SelectsAll);
        Assert.Equal(["Infrastructure.Tests"], result.TestProjects);
        Assert.Empty(result.Jobs);
    }
 
    [Fact]
    public void TemplateManifestBuildTargetSelectsInfrastructureCoverage()
    {
        var result = SelectWithRealMap("src/Aspire.ProjectTemplates/Aspire.ProjectTemplates.csproj");
 
        Assert.False(result.SelectsAll);
        Assert.Contains("Infrastructure.Tests", result.TestProjects);
    }
 
    [Theory]
    [InlineData("src/Aspire.Dashboard/Aspire.Dashboard.csproj")]
    [InlineData("src/Aspire.Cli/Resources/NewResource.resx")]
    [InlineData("src/Aspire.Hosting.Testing/Properties/NewResource.resx")]
    public void ResourceMetadataInputsSelectInfrastructureTests(string path)
    {
        var result = SelectWithRealMap(path);
 
        Assert.False(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
        Assert.Contains("Infrastructure.Tests", result.TestProjects);
    }
 
    [Theory]
    [InlineData("src/Aspire.Dashboard/Resources/NewResource.Designer.cs")]
    [InlineData("src/Aspire.Dashboard/Resources/xlf/NewResource.fr.xlf")]
    [InlineData("src/Aspire.Dashboard/Components/NewComponent.razor")]
    public void InputsNotReadByResourceMetadataTestsKeepExistingSelection(string path)
    {
        var result = SelectWithRealMap(path, "Aspire.Dashboard");
 
        Assert.False(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
        Assert.Equal(["Aspire.Templates.Tests"], result.TestProjects);
    }
 
    // Repo-relative '/'-separated project paths listed in Aspire.slnx (the Layer 1 graph root).
    private static IReadOnlySet<string> LoadSolutionProjectPaths()
    {
        var slnx = File.ReadAllText(Path.Combine(RepoRoot.Path, "Aspire.slnx"));
        // <Project Path="src/Foo/Foo.csproj" /> — paths use '/' in the slnx already.
        return System.Text.RegularExpressions.Regex.Matches(slnx, "Path=\"([^\"]+\\.csproj)\"")
            .Select(m => m.Groups[1].Value.Replace('\\', '/'))
            .ToHashSet(StringComparer.Ordinal);
    }
 
    public static TheoryData<string, string[]> AuditedLoosePathCases => new()
    {
        {
            "eng/github-ci/resolve-comparison-base.sh",
            ["test:Infrastructure.Tests"]
        },
        {
            "eng/scripts/pack-cli-npm-package.ps1",
            ["test:Aspire.Cli.Tests", "test:Infrastructure.Tests"]
        },
        {
            "eng/scripts/pack-cli-npm-package.pointer.README.md",
            ["test:Infrastructure.Tests"]
        },
        {
            "tools/CreateFailingTestIssue/Program.cs",
            ["test:Infrastructure.Tests"]
        },
        {
            "tools/CreateFailingTestIssue/CreateFailingTestIssue.csproj",
            ["test:Infrastructure.Tests"]
        },
        {
            ".github/workflows/prepare-installer-artifacts.yml",
            ["test:Infrastructure.Tests", "job:winget-installer", "job:homebrew-installer"]
        },
        {
            ".github/actionlint.yaml",
            ["test:Infrastructure.Tests"]
        },
        {
            ".github/actionlint-version.json",
            ["test:Infrastructure.Tests"]
        },
        {
            ".github/scripts/assert-extension-e2e-bridge-vsix.ps1",
            ["job:extension-unit"]
        },
        {
            ".vscode/launch.json",
            ["job:extension-unit"]
        },
        {
            ".vscode/tasks.json",
            ["job:extension-unit"]
        },
        {
            "eng/test-retry-patterns.json",
            ["test:Infrastructure.Tests"]
        },
        {
            "eng/scripts/aspire-skills-bundle.common.ps1",
            ["test:Infrastructure.Tests"]
        },
        {
            "eng/scripts/smoke-installed-cli.ps1",
            ["job:winget-installer"]
        },
        {
            "eng/scripts/smoke-installed-cli.sh",
            ["job:homebrew-installer"]
        },
        {
            // These paths do not exist intentionally: they prove new packaging inputs stay covered
            // without requiring this test to enumerate every current RID-specific project.
            "eng/dashboardpack/future-package-input.targets",
            [
                "test:Aspire.Hosting.Sdk.Tests",
                "test:Aspire.Templates.Tests",
                "test:Aspire.Cli.EndToEnd.Tests",
                "job:cli-starter-validation",
                "job:extension-e2e",
                "job:homebrew-installer",
                "job:native-dashboard-validation",
                "job:winget-installer"
            ]
        },
        {
            "eng/dashboardpack/Sdk.targets",
            [
                "test:Aspire.Hosting.Sdk.Tests",
                "test:Aspire.Templates.Tests",
                "test:Aspire.Cli.EndToEnd.Tests",
                "job:cli-starter-validation",
                "job:extension-e2e",
                "job:homebrew-installer",
                "job:native-dashboard-validation",
                "job:winget-installer"
            ]
        },
        {
            "eng/dashboardpack/Common.projitems",
            [
                "test:Aspire.Hosting.Sdk.Tests",
                "test:Aspire.Templates.Tests",
                "test:Aspire.Cli.EndToEnd.Tests",
                "job:cli-starter-validation",
                "job:extension-e2e",
                "job:homebrew-installer",
                "job:native-dashboard-validation",
                "job:winget-installer"
            ]
        },
        {
            "eng/dcppack/future-package-input.targets",
            ["test:Aspire.Cli.EndToEnd.Tests", "test:Aspire.Hosting.Sdk.Tests", "test:Aspire.Templates.Tests", "test:Aspire.TerminalHost.Tests", "job:extension-e2e"]
        },
        {
            "eng/scripts/load-cli-e2e-images.sh",
            ["test:Aspire.Cli.EndToEnd.Tests"]
        },
        {
            "eng/clipack/Common.projitems",
            [
                "test:Aspire.Cli.Tests",
                "test:Aspire.Cli.EndToEnd.Tests",
                "test:Infrastructure.Tests",
                "job:cli-starter-validation",
                "job:extension-e2e",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            "eng/clipack/Aspire.Cli.win-x64.csproj",
            [
                "test:Aspire.Cli.Tests",
                "test:Infrastructure.Tests",
                "job:cli-starter-validation",
                "job:extension-e2e",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            "eng/clipack/Aspire.Cli.linux-musl-x64.csproj",
            [
                "test:Aspire.Cli.Tests",
                "test:Infrastructure.Tests",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            "eng/clipack/Aspire.Cli.linux-arm64.csproj",
            [
                "test:Aspire.Cli.Tests",
                "test:Infrastructure.Tests",
                "job:cli-starter-validation",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            "eng/clipack/npm/aspire.js",
            [
                "test:Aspire.Cli.Tests",
                "test:Infrastructure.Tests",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            "eng/clipack/Aspire.Cli.NativeSymbols.proj",
            [
                "test:Aspire.Cli.Tests",
                "test:Infrastructure.Tests",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            "eng/scripts/cli-starter-validation.ps1",
            ["job:cli-starter-validation"]
        },
        {
            "eng/scripts/test-native-dashboard.ps1",
            ["job:native-dashboard-validation"]
        },
        {
            "eng/scripts/verify-native-dashboard-warnings.ps1",
            ["test:Infrastructure.Tests", "job:native-dashboard-validation"]
        },
        {
            ".github/workflows/native-dashboard-validation.yml",
            ["test:Infrastructure.Tests", "job:native-dashboard-validation"]
        },
        {
            "tests/Aspire.Dashboard.Tests/Aspire.Dashboard.Tests.csproj",
            ["test:Aspire.Dashboard.Tests", "job:native-dashboard-validation"]
        },
        {
            "tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/NativeAotDashboardTests.cs",
            ["test:Aspire.Dashboard.Playwright.Tests", "job:native-dashboard-validation"]
        },
        {
            "tests/Aspire.Dashboard.Tests/Integration/NativeAotDashboardTests.cs",
            ["test:Aspire.Dashboard.Tests", "job:native-dashboard-validation"]
        },
        {
            "tests/Aspire.Dashboard.Playwright.Tests/Integration/Playwright/Infrastructure/PlaywrightFixture.cs",
            ["test:Aspire.Dashboard.Playwright.Tests", "job:native-dashboard-validation"]
        },
        {
            "tests/Aspire.Dashboard.Playwright.Tests/Aspire.Dashboard.Playwright.Tests.csproj",
            ["test:Aspire.Dashboard.Playwright.Tests", "job:native-dashboard-validation"]
        },
        {
            "tests/Shared/Playwright/Playwright.targets",
            ["test:Aspire.Dashboard.Playwright.Tests", "test:Aspire.Templates.Tests", "job:native-dashboard-validation"]
        },
        {
            "eng/scripts/get-aspire-cli-pr.ps1",
            [
                "test:Aspire.Acquisition.Tests",
                "test:Aspire.Cli.EndToEnd.Tests",
                "job:cli-starter-validation",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            "eng/scripts/get-aspire-cli-pr.sh",
            [
                "test:Aspire.Acquisition.Tests",
                "test:Aspire.Cli.EndToEnd.Tests",
                "job:homebrew-installer",
                "job:winget-installer"
            ]
        },
        {
            ".github/workflows/cli-starter-validation.yml",
            ["test:Infrastructure.Tests", "job:cli-starter-validation"]
        },
        {
            "eng/clipack/Aspire.Cli.linux-x64.csproj",
            ["test:Aspire.Cli.Tests", "test:Aspire.Cli.EndToEnd.Tests", "test:Infrastructure.Tests", "job:cli-starter-validation", "job:extension-e2e", "job:winget-installer", "job:homebrew-installer"]
        },
        {
            "tools/CreateLayout/Program.cs",
            ["test:Aspire.Cli.EndToEnd.Tests", "test:Infrastructure.Tests", "job:cli-starter-validation", "job:extension-e2e", "job:winget-installer", "job:homebrew-installer"]
        },
        {
            "eng/Bundle.proj",
            ["test:Aspire.Cli.EndToEnd.Tests", "test:Infrastructure.Tests", "job:cli-starter-validation", "job:extension-e2e", "job:winget-installer", "job:homebrew-installer"]
        },
        {
            "tools/CreateLayout/verify-tray-payload.sh",
            ["test:Aspire.Cli.EndToEnd.Tests", "test:Infrastructure.Tests", "job:cli-starter-validation", "job:extension-e2e", "job:winget-installer", "job:homebrew-installer"]
        },
        {
            "tools/CreateLayout/verify-windows-tray-payload.ps1",
            ["test:Aspire.Cli.EndToEnd.Tests", "test:Infrastructure.Tests", "job:cli-starter-validation", "job:extension-e2e", "job:winget-installer", "job:homebrew-installer"]
        },
        {
            "src/Aspire.Tray/Windows/publish.ps1",
            ["test:Aspire.Cli.EndToEnd.Tests", "test:Infrastructure.Tests", "job:cli-starter-validation", "job:extension-e2e", "job:winget-installer", "job:homebrew-installer"]
        },
        {
            "playground/JavaSpringBoot/JavaSpringBoot.AppHost.Java/aspire.config.json",
            ["test:Aspire.Playground.Tests", "job:extension-e2e"]
        },
        {
            "playground/TsIntegrationSpike/deno-api/deno.json",
            ["test:Aspire.Playground.Tests", "job:polyglot", "job:typescript-sdk"]
        },
        {
            "playground/TsIntegrationSpike/deno-integration/integration.ts",
            ["test:Aspire.Playground.Tests", "job:polyglot", "job:typescript-sdk"]
        },
        {
            "playground/TsIntegrationSpike/kafka-integration/host-runtime.ts",
            ["test:Aspire.Playground.Tests", "test:Aspire.Cli.EndToEnd.Tests", "job:polyglot", "job:typescript-sdk"]
        },
        {
            "playground/TsIntegrationSpike/aspire.config.json",
            ["test:Aspire.Playground.Tests", "job:polyglot"]
        },
        {
            "playground/TsKafkaLib/packages/aspire-kafka/src/index.ts",
            ["test:Aspire.Playground.Tests", "job:polyglot"]
        },
        {
            "playground/TsKafkaLib/app/tsconfig.json",
            ["test:Aspire.Playground.Tests", "job:polyglot"]
        },
        {
            "src/Aspire.Hosting.Nuxt/src/integration.ts",
            ["job:polyglot", "job:typescript-sdk"]
        },
        {
            "playground/NuxtApp/web/app/app.vue",
            ["test:Aspire.Playground.Tests", "job:polyglot"]
        },
        {
            "playground/NuxtApp/verify.mjs",
            ["test:Aspire.Playground.Tests", "job:polyglot", "job:typescript-sdk"]
        },
        {
            ".gitignore",
            ["test:Infrastructure.Tests"]
        },
    };
 
    [Theory]
    [MemberData(nameof(AuditedLoosePathCases))]
    public void AuditedLoosePathSelectsExactConsumerSetWithoutRunAllFallback(string path, string[] expectedTargets)
    {
        var result = SelectWithRealMap(path);
 
        Assert.False(result.SelectsAll, $"{path} unexpectedly selected ALL: {result.EscalationReason}");
        Assert.Empty(result.UnmatchedFiles);
 
        var actualTargets = result.TestProjects.Select(name => $"test:{name}")
            .Concat(result.Jobs)
            .Order(StringComparer.Ordinal);
        Assert.Equal(expectedTargets.Order(StringComparer.Ordinal), actualTargets);
    }
 
    [Theory]
    [InlineData("src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/integration-host.mts")]
    [InlineData("src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/transport.mts")]
    [InlineData("src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/base.mts")]
    [InlineData("src/Aspire.Hosting.CodeGeneration.TypeScript/Resources/package.json")]
    public void IntegrationRuntimeResourcesSelectLifetimeE2eConsumer(string path)
    {
        var result = SelectWithRealMap(path, "Aspire.Hosting.CodeGeneration.TypeScript");
 
        Assert.False(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
        Assert.Contains("Aspire.Cli.EndToEnd.Tests", result.TestProjects);
        Assert.Contains(result.TestCauses["Aspire.Cli.EndToEnd.Tests"],
            cause => cause.Kind == CauseKind.PathRule && cause.Trigger == path);
        Assert.Contains("job:typescript-sdk", result.Jobs);
    }
 
    [Fact]
    [RequiresTools(["git"])]
    public void ExtensionJavaE2eDiscoveryInputsAreNotGitIgnored()
    {
        var startInfo = new System.Diagnostics.ProcessStartInfo("git")
        {
            WorkingDirectory = RepoRoot.Path,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
        };
        startInfo.ArgumentList.Add("--no-pager");
        startInfo.ArgumentList.Add("check-ignore");
        startInfo.ArgumentList.Add("--");
        startInfo.ArgumentList.Add("extension/java-e2e-workspace/AppHost.java");
        startInfo.ArgumentList.Add("extension/java-e2e-workspace/aspire.config.json");
        startInfo.ArgumentList.Add("extension/java-e2e-workspace/JavaSpringBoot.AppHost.Java/AppHost.java");
        startInfo.ArgumentList.Add("extension/java-e2e-workspace/JavaSpringBoot.AppHost.Java/aspire.config.json");
 
        using var process = System.Diagnostics.Process.Start(startInfo)
            ?? throw new InvalidOperationException("Failed to start git.");
        var ignoredPaths = process.StandardOutput.ReadToEnd();
        var stderr = process.StandardError.ReadToEnd();
        process.WaitForExit();
 
        Assert.True(
            process.ExitCode == 1,
            process.ExitCode == 0
                ? $"Git ignore rules must not exclude Java AppHost discovery inputs because aspire ls discovers them through git: {ignoredPaths}"
                : $"git check-ignore failed ({process.ExitCode}): {stderr}");
    }
 
    [Theory]
    [InlineData(".config/dotnet-tools.json")]
    [InlineData(".mcp.json")]
    [InlineData("src/Aspire.Dashboard/Components/Controls/TerminalTitle.razor.js")]
    [InlineData("src/Aspire.Dashboard/Components/Layout/TerminalDock.razor.js")]
    [InlineData("src/Aspire.Dashboard/wwwroot/js/app-terminalwindow.js")]
    [InlineData("tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalWindow.test.mjs")]
    [InlineData("tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalTitle.test.mjs")]
    [InlineData("tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalDock.test.mjs")]
    [InlineData("src/Aspire.Dashboard/wwwroot/js/app-resourcegraph.js")]
    [InlineData("tests/Aspire.Dashboard.Components.Tests/JavaScript/ResourceGraph.test.mjs")]
    public void DashboardScriptInputsSelectInfrastructureTests(string path)
    {
        var result = SelectWithRealMap(path);
 
        Assert.False(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
        // Infrastructure executes the scripts in addition to the consumers attributed by the project graph.
        Assert.Contains("Infrastructure.Tests", result.TestProjects);
    }
 
    [Theory]
    [InlineData("eng/scripts/gha-testreport.ps1")]
    [InlineData("eng/scripts/split-test-projects-for-ci.ps1")]
    [InlineData("tools/ExtractTestPartitions/Program.cs")]
    public void BroadCiInputHasAnExplicitRunAllRule(string path)
    {
        var result = SelectWithRealMap(path);
 
        Assert.True(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
    }
 
    [Fact]
    public void GitAttributesLineEndingRulesRunTheFullMatrix()
    {
        var result = SelectWithRealMap(".gitattributes");
 
        Assert.True(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
    }
 
    [Fact]
    public void SolutionEditRunsTheFullMatrix()
    {
        var soloEdit = SelectWithRealMap("Aspire.slnx");
 
        Assert.True(soloEdit.SelectsAll);
        Assert.Empty(soloEdit.UnmatchedFiles);
 
        // A project removed from the solution takes its files with it; nothing at head owns them, so the
        // run-all fallback still fires on the deleted project itself.
        var removal = SelectWithRealMap("src/Aspire.Hosting.Retired/Aspire.Hosting.Retired.csproj");
 
        Assert.True(removal.SelectsAll);
    }
 
    [Theory]
    [InlineData("eng/common/BuildConfiguration/build-configuration.json")]
    [InlineData("eng/common/loc/P22DotNetHtmlLocalization.lss")]
    [InlineData("eng/common/generate-locproject.ps1")]
    [InlineData("eng/common/native/NativeAotSupported.props")]
    public void EngCommonInputsRunTheFullMatrix(string path)
    {
        var result = SelectWithRealMap(path);
 
        Assert.True(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
    }
 
    [Theory]
    [InlineData("eng/common/templates/jobs/jobs.yml")]
    [InlineData("eng/common/templates-official/jobs/jobs.yml")]
    [InlineData("eng/common/core-templates/jobs/jobs.yml")]
    public void EngCommonPipelineTemplatesAlsoRunTheFullMatrix(string path)
    {
        var result = SelectWithRealMap(path);
 
        Assert.True(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
    }
 
    [Theory]
    [InlineData("eng/scripts/verify-cli-npm-package.ps1")]
    [InlineData("eng/scripts/verify-cli-tool-nupkg.ps1")]
    [InlineData("eng/scripts/stabilization-smoke-init-restore.sh")]
    [InlineData("eng/generate-catalog.ps1")]
    [InlineData("eng/scripts/update-aspire-skills-bundle.ps1")]
    [InlineData("eng/scripts/verify-aspire-skills-bundle.ps1")]
    public void PathHandledOutsideSelectorDoesNotForceTestSelection(string path)
    {
        var result = SelectWithRealMap(path);
 
        Assert.False(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
        Assert.Empty(result.TestProjects);
        Assert.Empty(result.Jobs);
    }
 
    [Theory]
    [InlineData("src/Aspire.Hosting.Dotnet/Aspire.Hosting.Dotnet.csproj", "Aspire.Hosting.Dotnet")]
    [InlineData("src/Aspire.Hosting.Foundry/Aspire.Hosting.Foundry.csproj", "Aspire.Hosting.Foundry")]
    public void DeploymentE2eIsSelectedWhenRuntimeHostingDependencyIsAffected(string path, string affectedProject)
    {
        var result = SelectWithRealMap(path, affectedProject);
 
        Assert.False(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
        Assert.Contains("job:deployment-e2e", result.Jobs);
        Assert.Contains(result.JobCauses["job:deployment-e2e"], cause =>
            cause.Kind == CauseKind.AffectedProject && cause.Trigger == affectedProject);
    }
 
    [Fact]
    public void DeploymentE2eIsNotSelectedForFoundryTestOnlyChange()
    {
        var result = SelectWithRealMap(
            "tests/Aspire.Hosting.Foundry.Tests/Aspire.Hosting.Foundry.Tests.csproj",
            "Aspire.Hosting.Foundry.Tests");
 
        Assert.False(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
        Assert.Equal("Aspire.Hosting.Foundry.Tests", Assert.Single(result.TestProjects));
        Assert.Empty(result.Jobs);
    }
 
    [Theory]
    [InlineData("src/Aspire.Cli/Commands/RunCommand.cs", "Aspire.Cli")]
    [InlineData("src/Aspire.Managed/Program.cs", "Aspire.Managed")]
    [InlineData("src/Aspire.AppHost.Sdk/Aspire.AppHost.Sdk.csproj", "Aspire.AppHost.Sdk")]
    [InlineData("src/Aspire.Hosting.JavaScript/JavaScriptAppResource.cs", "Aspire.Hosting.JavaScript")]
    [InlineData("src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptApiProjector.cs", "Aspire.Hosting.CodeGeneration.TypeScript")]
    [InlineData("src/Aspire.Hosting.AppHost/Aspire.Hosting.AppHost.csproj", "Aspire.Hosting.AppHost")]
    [InlineData("src/Aspire.Hosting.PostgreSQL/PostgresBuilderExtensions.cs", "Aspire.Hosting.PostgreSQL")]
    [InlineData("src/Aspire.Hosting.Dotnet/DotnetProjectResourceBuilderExtensions.cs", "Aspire.Hosting.Dotnet")]
    public void CliStarterValidationRunsWhenStarterRuntimeDependencyIsAffected(string path, string affectedProject)
    {
        var result = SelectWithRealMap(path, affectedProject);
 
        Assert.False(result.SelectsAll);
        Assert.Contains("job:cli-starter-validation", result.Jobs);
        Assert.Contains(result.JobCauses["job:cli-starter-validation"], cause => cause.Kind == CauseKind.AffectedProject);
    }
 
    [Theory]
    [InlineData("src/Aspire.ProjectTemplates/Aspire.ProjectTemplates.csproj")]
    [InlineData("src/Aspire.ProjectTemplates/templates/aspire-starter/Aspire-StarterApplication.1.AppHost/AppHost.cs")]
    [InlineData("eng/Bundle.proj")]
    public void CliStarterValidationRunsForLooseStarterConsumerChange(string path)
    {
        var result = SelectWithRealMap(path);
 
        Assert.False(result.SelectsAll);
        Assert.Contains("job:cli-starter-validation", result.Jobs);
    }
 
    [Theory]
    [InlineData("src/Aspire.ProjectTemplates/Aspire.ProjectTemplates.csproj")]
    // This path does not exist intentionally: it proves a newly added template is covered by the
    // package-wide rule instead of repeating the current template inventory in the test.
    [InlineData("src/Aspire.ProjectTemplates/templates/future-template/.template.config/template.json")]
    public void CliE2eRunsForProjectTemplatePackageInputs(string path)
    {
        var result = SelectWithRealMap(path);
 
        Assert.False(result.SelectsAll);
        Assert.Contains("Aspire.Cli.EndToEnd.Tests", result.TestProjects);
    }
 
    [Fact]
    public void CliBundleConsumersRunWhenCreateLayoutIsAffected()
    {
        var result = SelectWithRealMap("tools/CreateLayout/Program.cs", "CreateLayout");
 
        Assert.False(result.SelectsAll);
        Assert.Contains("Aspire.Cli.EndToEnd.Tests", result.TestProjects);
        Assert.Contains(
            result.TestCauses["Aspire.Cli.EndToEnd.Tests"],
            cause => cause.Kind == CauseKind.AffectedProject);
        Assert.Equal(
            ["job:cli-starter-validation", "job:extension-e2e", "job:homebrew-installer", "job:winget-installer"],
            result.Jobs.Order(StringComparer.Ordinal));
    }
 
    [Fact]
    public void CliBundleConsumersRunWhenStandaloneTerminalHostIsAffected()
    {
        var result = SelectWithRealMap("src/Aspire.TerminalHost/Program.cs", "Aspire.TerminalHost");
 
        Assert.False(result.SelectsAll);
        Assert.Contains("Aspire.Cli.EndToEnd.Tests", result.TestProjects);
        Assert.Equal(
            ["job:cli-starter-validation", "job:extension-e2e", "job:homebrew-installer", "job:winget-installer"],
            result.Jobs.Order(StringComparer.Ordinal));
    }
 
    [Theory]
    [InlineData("Aspire.Cli", "Aspire.Cli.EndToEnd.Tests")]
    [InlineData("Aspire.TerminalHost", "Aspire.Cli.EndToEnd.Tests")]
    [InlineData("Aspire.Dashboard", "Aspire.Templates.Tests")]
    public void TerminalPipelinePublishSettingsRunTheirInfrastructureCoverage(string projectName, string consumerProject)
    {
        var result = SelectWithRealMap(
            $"src/{projectName}/{projectName}.csproj", projectName);
 
        Assert.False(result.SelectsAll);
        Assert.Contains("Infrastructure.Tests", result.TestProjects);
        Assert.Contains(consumerProject, result.TestProjects);
    }
 
    [Fact]
    public void DashboardChangesRunTemplatesTestsThatStartAppHosts()
    {
        var result = SelectWithRealMap(
            "src/Aspire.Dashboard/Program.cs",
            "Aspire.Dashboard");
 
        Assert.False(result.SelectsAll);
        Assert.Contains("Aspire.Templates.Tests", result.TestProjects);
        Assert.Contains("job:native-dashboard-validation", result.Jobs);
    }
 
    [Fact]
    public void DotnetProjectResourceChangesRunGeneratedAppHostConsumers()
    {
        var result = SelectWithRealMap("src/Aspire.Hosting.Dotnet/Aspire.Hosting.Dotnet.csproj", "Aspire.Hosting.Dotnet");
 
        Assert.False(result.SelectsAll);
        Assert.Contains("Aspire.Templates.Tests", result.TestProjects);
        Assert.Contains("Aspire.Cli.EndToEnd.Tests", result.TestProjects);
        Assert.Contains("job:extension-e2e", result.Jobs);
        Assert.Contains("job:cli-starter-validation", result.Jobs);
    }
 
    [Fact]
    public void DotnetProjectResourceChangesRunGeneratedAppHostConsumersWithoutProjectGraph()
    {
        var result = SelectWithRealMap("src/Aspire.Hosting.Dotnet/Aspire.Hosting.Dotnet.csproj");
 
        Assert.False(result.SelectsAll);
        Assert.Contains("Aspire.Templates.Tests", result.TestProjects);
        Assert.Contains("Aspire.Cli.EndToEnd.Tests", result.TestProjects);
        Assert.Contains("job:extension-e2e", result.Jobs);
        Assert.Contains("job:cli-starter-validation", result.Jobs);
    }
 
    [Fact]
    public void CliStarterValidationDoesNotRunForUnrelatedHostingIntegrationChange()
    {
        var result = SelectWithRealMap(
            "src/Aspire.Hosting.Redis/RedisBuilderExtensions.cs",
            "Aspire.Hosting.Redis",
            "Aspire.Hosting.Redis.Tests",
            "Aspire.Hosting.Tests");
 
        Assert.False(result.SelectsAll);
        Assert.False(
            result.Jobs.Contains("job:cli-starter-validation", StringComparer.Ordinal),
            "Aggregate tests selected by Redis changes must not pull in all six starter-validation jobs.");
    }
 
    [Fact]
    public void CliStarterValidationDoesNotRunForNpgsqlClientChange()
    {
        var result = SelectWithRealMap(
            "src/Components/Aspire.Npgsql/NpgsqlCommon.cs",
            "Aspire.Npgsql",
            "Aspire.Npgsql.Tests",
            "Aspire.Hosting.PostgreSQL.Tests");
 
        Assert.False(result.SelectsAll);
        Assert.Empty(result.Jobs);
    }
 
    [Fact]
    public void UnrelatedProjectTemplateChangeRunsCliE2eAndTemplatesTestsWithoutStarterValidation()
    {
        var result = SelectWithRealMap(
            "src/Aspire.ProjectTemplates/templates/aspire-xunit/.template.config/template.json");
 
        Assert.False(result.SelectsAll);
        Assert.Equal(
            ["Aspire.Cli.EndToEnd.Tests", "Aspire.Templates.Tests"],
            result.TestProjects.Order(StringComparer.Ordinal));
        Assert.Equal(
            ["job:deployment-e2e", "job:homebrew-installer", "job:winget-installer"],
            result.Jobs.Order(StringComparer.Ordinal));
    }
 
    [Theory]
    [InlineData("src/Aspire.ProjectTemplates/templates/aspire-ts-cs-starter/frontend/package.json")]
    [InlineData("src/Aspire.ProjectTemplates/templates/aspire-ts-cs-starter/frontend/package-lock.json")]
    public void ProjectTemplateFrontendDependencyInputsRunCliSecurityGuards(string path)
    {
        var result = SelectWithRealMap(path);
 
        Assert.False(result.SelectsAll);
        Assert.Equal(
            ["Aspire.Cli.EndToEnd.Tests", "Aspire.Cli.Tests", "Aspire.Templates.Tests"],
            result.TestProjects.Order(StringComparer.Ordinal));
        Assert.Equal(
            ["job:deployment-e2e", "job:homebrew-installer", "job:winget-installer"],
            result.Jobs.Order(StringComparer.Ordinal));
    }
 
    [Fact]
    public void CliStarterValidationDoesNotRunForSharedTestUtilityChange()
    {
        var result = SelectWithRealMap(
            "tests/Aspire.TestUtilities/FileUtil.cs",
            "Aspire.TestUtilities",
            "Aspire.Cli.Tests",
            "Aspire.Cli.EndToEnd.Tests");
 
        Assert.False(result.SelectsAll);
        Assert.False(
            result.Jobs.Contains("job:cli-starter-validation", StringComparer.Ordinal),
            "Shared test utilities must not pull in all six starter-validation jobs.");
    }
 
    [Fact]
    public void CliStarterValidationDoesNotRunForHostingSdkTestFakeChange()
    {
        var result = SelectWithRealMap(
            "tests/Aspire.Hosting.Sdk.Tests.FakeCommand/Program.cs",
            "Aspire.Hosting.Sdk.Tests.FakeCommand",
            "Aspire.Hosting.Sdk.Tests");
 
        Assert.False(result.SelectsAll);
        Assert.Empty(result.Jobs);
    }
 
    [Fact]
    public void BuildPackagesWorkflowSelectsOnlyNuGetArtifactConsumers()
    {
        var result = SelectWithRealMap(".github/workflows/build-packages.yml");
 
        Assert.False(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
        Assert.Equal(
            ["Aspire.Cli.EndToEnd.Tests", "Aspire.Templates.Tests", "Infrastructure.Tests"],
            result.TestProjects.Order(StringComparer.Ordinal));
        Assert.Equal(
            ["job:cli-starter-validation", "job:extension-e2e", "job:homebrew-installer", "job:polyglot", "job:winget-installer"],
            result.Jobs.Order(StringComparer.Ordinal));
    }
 
    [Fact]
    public void BuildCliNativeArchivesWorkflowSelectsOnlyCliArchiveConsumers()
    {
        var result = SelectWithRealMap(".github/workflows/build-cli-native-archives.yml");
 
        Assert.False(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
        Assert.Equal(
            ["Aspire.Cli.EndToEnd.Tests", "Aspire.Templates.Tests", "Infrastructure.Tests"],
            result.TestProjects.Order(StringComparer.Ordinal));
        Assert.Equal(
            ["job:cli-starter-validation", "job:extension-e2e", "job:homebrew-installer", "job:native-dashboard-validation", "job:polyglot", "job:winget-installer"],
            result.Jobs.Order(StringComparer.Ordinal));
    }
 
    [Theory]
    [InlineData(".github/workflows/build-packages.yml")]
    [InlineData(".github/workflows/build-cli-native-archives.yml")]
    public void ArtifactProducerWorkflowSelectsEveryArtifactConsumingTestProject(string path)
    {
        // The narrowed routing above is only safe while the producer consumer groups still list every test
        // project that consumes a produced artifact in regular PR CI. Those projects declare artifact
        // consumption via RequiresNugets / RequiresCliArchive (read by eng/testing/CITestsProperties.props
        // into the runsheet, which is what puts them in the tests_requires_nugets_* /
        // tests_requires_cli_archive buckets that depend on the producer jobs). Adding a new regular-PR
        // consumer without updating the group would silently stop running it for producer-workflow changes
        // -- exactly the under-selection selective CI must never do.
        var consumers = LoadArtifactConsumingTestProjects()
            .Order(StringComparer.Ordinal)
            .ToList();
 
        Assert.NotEmpty(consumers);
 
        var result = SelectWithRealMap(path);
        foreach (var consumer in consumers)
        {
            Assert.Contains(consumer, result.TestProjects, StringComparer.Ordinal);
        }
    }
 
    [Fact]
    public void ArtifactProducerWorkflowSelectsEverySelectorGatedArtifactConsumingJob()
    {
        var workflow = new YamlStream();
        using (var reader = new StringReader(File.ReadAllText(Path.Combine(RepoRoot.Path, ".github", "workflows", "tests.yml"))))
        {
            workflow.Load(reader);
        }
 
        var root = Assert.IsType<YamlMappingNode>(workflow.Documents[0].RootNode);
        var jobs = Assert.IsType<YamlMappingNode>(root.Children[new YamlScalarNode("jobs")]);
        var packageSelectedJobs = SelectWithRealMap(".github/workflows/build-packages.yml").Jobs.ToHashSet(StringComparer.Ordinal);
        var cliArchiveSelectedJobs = SelectWithRealMap(".github/workflows/build-cli-native-archives.yml").Jobs.ToHashSet(StringComparer.Ordinal);
 
        var packageConsumerTargets = ArtifactConsumingWorkflowJobTargets(jobs, "build_packages").ToList();
        var cliArchiveConsumerTargets = ArtifactConsumingWorkflowJobTargets(jobs, "build_cli_archive_").ToList();
        Assert.Contains(packageConsumerTargets, target => target.WorkflowJobId == "polyglot_validation");
        Assert.Contains(packageConsumerTargets, target => target.WorkflowJobId == "extension_e2e_tests");
        Assert.Contains(packageConsumerTargets, target => target.WorkflowJobId == "prepare_winget_installer_artifacts");
        Assert.Contains(cliArchiveConsumerTargets, target => target.WorkflowJobId == "native_dashboard_validation_linux_x64");
        Assert.DoesNotContain(packageSelectedJobs, job => job == "job:native-dashboard-validation");
 
        foreach (var (workflowJobId, target) in packageConsumerTargets)
        {
            Assert.True(
                packageSelectedJobs.Contains(target),
                $"{workflowJobId} consumes build_packages artifacts, so {target} must be selected.");
        }
 
        foreach (var (workflowJobId, target) in cliArchiveConsumerTargets)
        {
            Assert.True(
                cliArchiveSelectedJobs.Contains(target),
                $"{workflowJobId} consumes build_cli_archive_* artifacts, so {target} must be selected.");
        }
    }
 
    [Theory]
    [InlineData(".github/actions/check-changed-files/action.yml", false, new[] { "Infrastructure.Tests" }, new string[] { })]
    [InlineData(".github/actions/select-tests/action.yml", false, new[] { "Infrastructure.Tests" }, new string[] { })]
    [InlineData(".github/actions/setup-deno/action.yml", false, new[] { "Aspire.Hosting.JavaScript.Tests" }, new[] { "job:extension-e2e" })]
    [InlineData(".github/actions/unlock-macos-keychain/action.yml", true, new string[] { }, new string[] { })]
    public void LocalActionRoutingMatchesItsRegularPrCiConsumers(
        string path,
        bool selectsAll,
        string[] expectedTestProjects,
        string[] expectedJobs)
    {
        var result = SelectWithRealMap(path);
 
        Assert.Equal(selectsAll, result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
 
        if (!selectsAll)
        {
            Assert.Equal(expectedTestProjects.Order(StringComparer.Ordinal), result.TestProjects.Order(StringComparer.Ordinal));
            Assert.Equal(expectedJobs.Order(StringComparer.Ordinal), result.Jobs.Order(StringComparer.Ordinal));
        }
    }
 
    [Fact]
    public void EnumerateTestsActionSelectsManagedTestProjectsButNotNonDotnetJobs()
    {
        var result = SelectWithRealMap(".github/actions/enumerate-tests/action.yml");
        var expectedTestProjects = LoadSolutionProjectPaths()
            .Where(projectPath => projectPath.StartsWith("tests/", StringComparison.Ordinal))
            .Select(projectPath => Path.GetFileNameWithoutExtension(projectPath)!)
            .Where(name => name.EndsWith(".Tests", StringComparison.Ordinal))
            .Order(StringComparer.Ordinal)
            .ToArray();
 
        Assert.False(result.SelectsAll);
        Assert.Empty(result.UnmatchedFiles);
        Assert.Equal(expectedTestProjects, result.TestProjects.Order(StringComparer.Ordinal));
        Assert.Empty(result.Jobs);
    }
 
    private static SelectionResult SelectWithRealMap(string path, params string[] layer1Affected)
    {
        var projectPaths = LoadSolutionProjectPaths();
        var testProjects = projectPaths
            .Where(projectPath => projectPath.StartsWith("tests/", StringComparison.Ordinal))
            .Select(projectPath => Path.GetFileNameWithoutExtension(projectPath)!)
            .Where(name => name.EndsWith(".Tests", StringComparison.Ordinal))
            .ToHashSet(StringComparer.Ordinal);
        var testProjectNames = Directory.EnumerateFiles(
                Path.Combine(RepoRoot.Path, "tests"), "*.csproj", SearchOption.AllDirectories)
            .Select(Path.GetFileNameWithoutExtension)
            .Where(name => name is not null)
            .Select(name => name!)
            .ToHashSet(StringComparer.Ordinal);
        var affectedTestProjectNames = layer1Affected
            .Where(testProjectNames.Contains)
            .ToHashSet(StringComparer.Ordinal);
        var projectDirectories = projectPaths
            .Select(projectPath => Path.GetDirectoryName(projectPath)!.Replace('\\', '/'))
            .ToHashSet(StringComparer.Ordinal);
        var mapPath = Path.Combine(RepoRoot.Path, "eng", "github-ci", "test-trigger-map.yml");
        var selector = new TestSelector(mapPath, testProjects, projectDirectories, affectedTestProjectNames);
 
        return selector.Select([path], layer1Affected, new SelectorOptions());
    }
 
    private static IEnumerable<(string WorkflowJobId, string Target)> ArtifactConsumingWorkflowJobTargets(YamlMappingNode jobs, string producerPrefix)
    {
        foreach (var (jobIdNode, jobNode) in jobs.Children)
        {
            var jobId = Assert.IsType<YamlScalarNode>(jobIdNode).Value;
            Assert.False(string.IsNullOrWhiteSpace(jobId), "tests.yml contains a job with an empty id.");
            var job = Assert.IsType<YamlMappingNode>(jobNode);
            if (jobId == "results")
            {
                continue;
            }
 
            if (!DependsOnArtifactProducer(job, producerPrefix)
                || !job.Children.TryGetValue(new YamlScalarNode("if"), out var ifNode))
            {
                continue;
            }
 
            var runVars = System.Text.RegularExpressions.Regex
                .Matches(ifNode.ToString(), @"needs\.setup_for_tests\.outputs\.(run_[a-z0-9_]+)")
                .Select(match => match.Groups[1].Value)
                .Distinct(StringComparer.Ordinal)
                .ToList();
 
            foreach (var runVar in runVars)
            {
                yield return (jobId!, "job:" + runVar["run_".Length..].Replace('_', '-'));
            }
        }
    }
 
    private static bool DependsOnArtifactProducer(YamlMappingNode job, string producerPrefix)
    {
        if (!job.Children.TryGetValue(new YamlScalarNode("needs"), out var needsNode))
        {
            return false;
        }
 
        return needsNode switch
        {
            YamlScalarNode scalar => IsArtifactProducer(scalar.Value, producerPrefix),
            YamlSequenceNode sequence => sequence.Children
                .OfType<YamlScalarNode>()
                .Any(node => IsArtifactProducer(node.Value, producerPrefix)),
            _ => false
        };
 
        static bool IsArtifactProducer(string? jobId, string producerPrefix) =>
            producerPrefix == "build_packages"
                ? jobId == producerPrefix
                : jobId?.StartsWith(producerPrefix, StringComparison.Ordinal) is true;
    }
 
    private static IEnumerable<string> LoadArtifactConsumingTestProjects()
    {
        GraphAffectedProjects.EnsureMSBuildRegistered();
 
        var solutionPath = Path.Combine(RepoRoot.Path, "Aspire.slnx");
        return LoadArtifactConsumingTestProjectsCore(solutionPath);
    }
 
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static IEnumerable<string> LoadArtifactConsumingTestProjectsCore(string solutionPath)
    {
        return LoadArtifactConsumingTestProjectMetadata(solutionPath)
            .Where(project => !project.SkipTests)
            .Select(project => project.Name);
    }
 
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static IEnumerable<ArtifactConsumingTestProject> LoadArtifactConsumingTestProjectMetadata(string solutionPath)
    {
        var graph = GraphAffectedProjects.BuildGraph(
            RepoRoot.Path,
            solutionPath,
            new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
            {
                ["IsGitHubActionsRunner"] = "true",
                ["IsGithubPullRequest"] = "true",
                ["RunOuterloopTests"] = "false",
                ["RunQuarantinedTests"] = "false",
            });
 
        return graph.ProjectNodes
            .Where(node => IsMatrixTestProject(node.ProjectInstance.FullPath))
            .Where(node =>
                IsTrue(node.ProjectInstance.GetPropertyValue("RequiresNugets"))
                || IsTrue(node.ProjectInstance.GetPropertyValue("RequiresCliArchive")))
            .Select(node => new ArtifactConsumingTestProject(
                Path.GetFileNameWithoutExtension(node.ProjectInstance.FullPath)!,
                IsTrue(node.ProjectInstance.GetPropertyValue("SkipTests"))))
            .GroupBy(project => project.Name, StringComparer.Ordinal)
            .Select(group => group.First());
    }
 
    private sealed record ArtifactConsumingTestProject(string Name, bool SkipTests);
 
    private static bool IsMatrixTestProject(string projectPath)
    {
        var relativePath = Path.GetRelativePath(RepoRoot.Path, projectPath).Replace('\\', '/');
        return relativePath.StartsWith("tests/", StringComparison.Ordinal)
            && Path.GetFileNameWithoutExtension(relativePath).EndsWith(".Tests", StringComparison.Ordinal);
    }
 
    private static bool IsTrue(string value) =>
        string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
}