File: ActiveIssueTests.cs
Web Access
Project: src\tests\QuarantineTools.Tests\QuarantineTools.Tests.csproj (QuarantineTools.Tests)
// 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;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Xunit;
using System.Text.RegularExpressions;
 
namespace QuarantineTools.Tests;
 
public class ActiveIssueTests
{
    [Theory]
    [InlineData(
        """
        namespace N1.N2;
        using Xunit;
        public class C {
            [Fact]
            public void M() { }
        }
        """,
        "N1.N2.C.M",
        "https://github.com/dotnet/aspire/issues/1")]
    [InlineData(
        """
        namespace N1.N2
        {
            using Xunit;
            public class Outer {
                public class Inner {
                    [Fact]
                    public void M() { }
                }
            }
        }
        """,
        "N1.N2.Outer+Inner.M",
        "https://github.com/dotnet/aspire/issues/2")]
    public void ActiveIssue_AddsAttribute_WhenMissing(string code, string fullName, string issue)
    {
        var updated = AddActiveIssue(fullName, issue, code);
        Assert.Contains("ActiveIssue", updated);
        Assert.Contains(issue, updated);
        // Attribute should be applied at method level and not duplicate facts
        Assert.Contains("[Fact]", updated);
    }
 
    [Fact]
    public void ActiveIssue_IsIdempotent_DoesNotDuplicateOrChangeReason()
    {
        const string originalUrl = "https://github.com/dotnet/aspire/issues/100";
        const string newUrl = "https://github.com/dotnet/aspire/issues/200";
        const string code = """
        namespace N;
        using Xunit;
        public class C {
            [Fact]
            [ActiveIssue("https://github.com/dotnet/aspire/issues/100")]
            public void M() { }
        }
        """;
 
        var updated = AddActiveIssue("N.C.M", newUrl, code);
        var count = Regex.Matches(updated, "ActiveIssue").Count;
        Assert.Equal(1, count);
        Assert.Contains(originalUrl, updated);
        Assert.DoesNotContain(newUrl, updated);
    }
 
    [Fact]
    public void ActiveIssue_RemovesAttribute_WhenPresent()
    {
        const string code = """
        namespace N;
        using Xunit;
        public class C {
            [Fact]
            [ActiveIssue("https://github.com/dotnet/aspire/issues/3")]
            public void M() { }
        }
        """;
 
        var tree = CSharpSyntaxTree.ParseText(code);
        var root = tree.GetCompilationUnitRoot();
        var method = root.DescendantNodes().OfType<MethodDeclarationSyntax>().Single(m => m.Identifier.ValueText == "M");
        var updated = RemoveActiveIssueAttribute(method, out var removed);
        Assert.True(removed);
        var newRoot = root.ReplaceNode(method, updated);
        var text = newRoot.ToFullString();
        Assert.DoesNotContain("ActiveIssue", text);
        Assert.Contains("[Fact]", text);
    }
 
    [Fact]
    public void ActiveIssue_AddsUsingDirective_WhenMissing()
    {
        const string code = """
        namespace N;
        public class C { public void M() { } }
        """;
 
        var updated = AddActiveIssue("N.C.M", "https://github.com/dotnet/aspire/issues/500", code);
        Assert.Contains("using Xunit;", updated);
        Assert.Contains("[ActiveIssue(\"https://github.com/dotnet/aspire/issues/500\")]", updated);
    }
 
    [Fact]
    public void ActiveIssue_DoesNotDuplicateUsingDirective_WhenAlreadyPresent()
    {
        const string code = """
        namespace N;
        using Xunit;
        public class C { 
            [Fact]
            public void M() { } 
        }
        """;
 
        var updated = AddActiveIssue("N.C.M", "https://github.com/dotnet/aspire/issues/501", code);
        var norm = NormalizeNewlines(updated);
        var count = Regex.Matches(norm, "using Xunit;").Count;
        Assert.Equal(1, count);
    }
 
    [Fact]
    public void ActiveIssue_WithConditionalArguments()
    {
        const string code = """
        namespace N;
        using Xunit;
        public class C {
            [Fact]
            public void M() { }
        }
        """;
 
        // ActiveIssue can have conditional arguments like typeof(PlatformDetection), nameof(PlatformDetection.IsRunningFromAzdo)
        // For simplicity, the tool adds just the URL, but the attribute should support additional arguments
        var updated = AddActiveIssue("N.C.M", "https://github.com/dotnet/aspire/issues/11820", code);
        Assert.Contains("[ActiveIssue(\"https://github.com/dotnet/aspire/issues/11820\")]", updated);
    }
 
    [Fact]
    public void ActiveIssue_Multiple_Targets_SameFile_UsingDirective_AddedOnce()
    {
        const string code = """
        namespace N;
        public class C {
            public void M1() { }
            public void M2() { }
        }
        """;
 
        var updated1 = AddActiveIssue("N.C.M1", "https://github.com/dotnet/aspire/issues/11", code);
        var updated2 = AddActiveIssue("N.C.M2", "https://github.com/dotnet/aspire/issues/12", updated1);
        var norm = NormalizeNewlines(updated2);
        // Only one using should be present
        var count = Regex.Matches(norm, "using Xunit;").Count;
        Assert.Equal(1, count);
        // Both methods should have ActiveIssue
        Assert.Contains("M1", updated2);
        Assert.Contains("M2", updated2);
        var activeIssueCount = Regex.Matches(norm, @"\[ActiveIssue\(").Count;
        Assert.Equal(2, activeIssueCount);
    }
 
    [Fact]
    public void ActiveIssue_DoesNotInsertBlankLine_BetweenAttributes()
    {
        const string code = """
        namespace N;
        using Xunit;
        public class C {
            [Fact]
            public void M() { }
        }
        """;
 
        var updated = AddActiveIssue("N.C.M", "https://github.com/dotnet/aspire/issues/99", code);
        var norm = NormalizeNewlines(updated);
        // Ensure there is no blank line between [Fact] and [ActiveIssue]
        Assert.DoesNotMatch(new Regex(@"\[Fact\]\n\s*\n\s*\[ActiveIssue", RegexOptions.Multiline), norm);
        // But [Fact] followed by [ActiveIssue(...)] on the next line should exist (ignore indentation)
        Assert.Matches(new Regex(@"\[Fact\]\n\s+\[ActiveIssue\(""https://github.com/dotnet/aspire/issues/99""\)\]", RegexOptions.Multiline), norm);
    }
 
    [Fact]
    public void ActiveIssue_RecognizesAttributeWithOrWithoutSuffix()
    {
        const string codeWithSuffix = """
        namespace N;
        using Xunit;
        public class C {
            [Fact]
            [ActiveIssueAttribute("https://github.com/dotnet/aspire/issues/123")]
            public void M() { }
        }
        """;
 
        const string codeWithoutSuffix = """
        namespace N;
        using Xunit;
        public class C {
            [Fact]
            [ActiveIssue("https://github.com/dotnet/aspire/issues/124")]
            public void M() { }
        }
        """;
 
        // Both should be recognized as already having the attribute
        var updatedWithSuffix = AddActiveIssue("N.C.M", "https://github.com/dotnet/aspire/issues/999", codeWithSuffix);
        var updatedWithoutSuffix = AddActiveIssue("N.C.M", "https://github.com/dotnet/aspire/issues/999", codeWithoutSuffix);
 
        // Should not add another attribute (idempotent)
        var countWithSuffix = Regex.Matches(updatedWithSuffix, @"ActiveIssue").Count;
        var countWithoutSuffix = Regex.Matches(updatedWithoutSuffix, @"ActiveIssue").Count;
 
        Assert.Equal(1, countWithSuffix);
        Assert.Equal(1, countWithoutSuffix);
    }
 
    [Fact]
    public void ActiveIssue_RemovalDoesNotAffectOtherAttributes()
    {
        const string code = """
        namespace N;
        using Xunit;
        public class C {
            [Fact]
            [ActiveIssue("https://github.com/dotnet/aspire/issues/3")]
            [Trait("Category", "Integration")]
            public void M() { }
        }
        """;
 
        var tree = CSharpSyntaxTree.ParseText(code);
        var root = tree.GetCompilationUnitRoot();
        var method = root.DescendantNodes().OfType<MethodDeclarationSyntax>().Single(m => m.Identifier.ValueText == "M");
        var updated = RemoveActiveIssueAttribute(method, out var removed);
        Assert.True(removed);
        var newRoot = root.ReplaceNode(method, updated);
        var text = newRoot.ToFullString();
        
        Assert.DoesNotContain("ActiveIssue", text);
        Assert.Contains("[Fact]", text);
        Assert.Contains("[Trait(\"Category\", \"Integration\")]", text);
    }
 
    [Fact]
    public void ActiveIssue_CanCoexist_WithQuarantinedTest()
    {
        // Verify that having both attribute types in the same file works correctly
        const string code = """
        namespace N;
        using Xunit;
        using Aspire.TestUtilities;
        public class C {
            [Fact]
            [QuarantinedTest("https://github.com/dotnet/aspire/issues/1")]
            public void M1() { }
            
            [Fact]
            public void M2() { }
        }
        """;
 
        var updated = AddActiveIssue("N.C.M2", "https://github.com/dotnet/aspire/issues/2", code);
        
        // Should have both attributes for different methods
        Assert.Contains("QuarantinedTest", updated);
        Assert.Contains("ActiveIssue", updated);
        Assert.Contains("using Xunit;", updated);
        Assert.Contains("using Aspire.TestUtilities;", updated);
    }
 
    private static string AddActiveIssue(string fullMethodName, string issueUrl, string code)
    {
        var (pathParts, methodName) = ParseFullMethodName(fullMethodName);
        var tree = CSharpSyntaxTree.ParseText(code);
        var root = tree.GetCompilationUnitRoot();
        var methodNodes = root.DescendantNodes().OfType<MethodDeclarationSyntax>().Where(m => m.Identifier.ValueText == methodName).ToList();
 
        foreach (var method in methodNodes)
        {
            var (ns, typeChain) = GetEnclosingNames(method);
            var actualParts = new List<string>();
            if (!string.IsNullOrEmpty(ns))
            {
                actualParts.AddRange(ns.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
            }
            actualParts.AddRange(typeChain);
            if (!SequenceEquals(actualParts, pathParts))
            {
                continue;
            }
 
            var updated = AddActiveIssueAttribute(method, issueUrl);
            root = root.ReplaceNode(method, updated);
            // Simulate the tool adding using directive for Xunit
            root = EnsureUsingDirective(root, "Xunit");
            break;
        }
 
        return root.ToFullString();
    }
 
    // Helpers copied from the script to validate logic
    private static (List<string> PathPartsBeforeMethod, string Method) ParseFullMethodName(string input)
    {
        var parts = input.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        var method = parts[^1];
        var beforeMethod = parts.Take(parts.Length - 1)
            .SelectMany(p => p.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
            .ToList();
        return (beforeMethod, method);
    }
 
    private static (string Namespace, List<string> TypeChain) GetEnclosingNames(SyntaxNode node)
    {
        var typeNames = new List<string>();
        string ns = string.Empty;
 
        for (var current = node.Parent; current != null; current = current.Parent)
        {
            switch (current)
            {
                case ClassDeclarationSyntax cd:
                    typeNames.Insert(0, cd.Identifier.ValueText);
                    break;
                case StructDeclarationSyntax sd:
                    typeNames.Insert(0, sd.Identifier.ValueText);
                    break;
                case RecordDeclarationSyntax rd:
                    typeNames.Insert(0, rd.Identifier.ValueText);
                    break;
                case InterfaceDeclarationSyntax id:
                    typeNames.Insert(0, id.Identifier.ValueText);
                    break;
                case NamespaceDeclarationSyntax nd:
                    ns = nd.Name.ToString();
                    current = null;
                    break;
                case FileScopedNamespaceDeclarationSyntax fsn:
                    ns = fsn.Name.ToString();
                    current = null;
                    break;
            }
            if (current == null)
            {
                break;
            }
        }
        return (ns, typeNames);
    }
 
    private static bool SequenceEquals(List<string> a, List<string> b)
    {
        if (a.Count != b.Count)
        {
            return false;
        }
        for (int i = 0; i < a.Count; i++)
        {
            if (!string.Equals(a[i], b[i], StringComparison.Ordinal))
            {
                return false;
            }
        }
        return true;
    }
 
    private static bool IsActiveIssueAttribute(AttributeSyntax attr)
    {
        string lastId = attr.Name switch
        {
            IdentifierNameSyntax ins => ins.Identifier.ValueText,
            QualifiedNameSyntax qns => (qns.Right as IdentifierNameSyntax)?.Identifier.ValueText ?? qns.Right.ToString(),
            AliasQualifiedNameSyntax aqn => (aqn.Name as IdentifierNameSyntax)?.Identifier.ValueText ?? aqn.Name.ToString(),
            _ => attr.Name.ToString().Split('.').Last()
        };
        return string.Equals(lastId, "ActiveIssue", StringComparison.Ordinal)
            || string.Equals(lastId, "ActiveIssueAttribute", StringComparison.Ordinal);
    }
 
    private static MethodDeclarationSyntax RemoveActiveIssueAttribute(MethodDeclarationSyntax method, out bool removed)
    {
        removed = false;
        if (method.AttributeLists.Count == 0)
        {
            return method;
        }
 
        var newLists = new List<AttributeListSyntax>();
        foreach (var list in method.AttributeLists)
        {
            var remaining = list.Attributes.Where(a => !IsActiveIssueAttribute(a)).ToList();
            if (remaining.Count == list.Attributes.Count)
            {
                newLists.Add(list);
                continue;
            }
            removed = true;
            if (remaining.Count > 0)
            {
                var newList = list.WithAttributes(SyntaxFactory.SeparatedList(remaining));
                newLists.Add(newList);
            }
        }
 
        return removed ? method.WithAttributeLists(SyntaxFactory.List(newLists)) : method;
    }
 
    private static MethodDeclarationSyntax AddActiveIssueAttribute(MethodDeclarationSyntax method, string issueUrl)
    {
        foreach (var list in method.AttributeLists)
        {
            if (list.Attributes.Any(IsActiveIssueAttribute))
            {
                return method;
            }
        }
        // Use short attribute name (ActiveIssue, not ActiveIssueAttribute)
        var attrName = SyntaxFactory.ParseName("ActiveIssue");
        var attrArgs = string.IsNullOrWhiteSpace(issueUrl)
            ? null
            : SyntaxFactory.AttributeArgumentList(
                SyntaxFactory.SingletonSeparatedList(
                    SyntaxFactory.AttributeArgument(
                        SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(issueUrl)))));
 
        var attr = SyntaxFactory.Attribute(attrName, attrArgs);
        var newList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(attr));
 
        if (method.AttributeLists.Count > 0)
        {
            // Append after existing attributes ensuring exactly one newline between them.
            var last = method.AttributeLists[method.AttributeLists.Count - 1];
            var indentation = SyntaxFactory.TriviaList(last.GetLeadingTrivia().Where(t => !t.IsKind(SyntaxKind.EndOfLineTrivia)));
            bool lastEndsWithNewline = last.GetTrailingTrivia().Any(t => t.IsKind(SyntaxKind.EndOfLineTrivia));
            var leading = lastEndsWithNewline
                ? indentation
                : indentation.Add(SyntaxFactory.EndOfLine("\n"));
            newList = newList
                .WithLeadingTrivia(leading)
                .WithTrailingTrivia(SyntaxFactory.EndOfLine("\n"));
        }
        else
        {
            var leading = method.GetLeadingTrivia();
            newList = newList.WithLeadingTrivia(leading)
                             .WithTrailingTrivia(SyntaxFactory.EndOfLine("\n"));
        }
 
        var newLists = method.AttributeLists.Add(newList);
        var updated = method.WithAttributeLists(newLists);
        return updated;
    }
 
    private static CompilationUnitSyntax EnsureUsingDirective(CompilationUnitSyntax root, string namespaceName)
    {
        // Check if using already exists anywhere in the tree (compilation unit or namespace level)
        var allUsings = root.DescendantNodes().OfType<UsingDirectiveSyntax>()
            .Where(u => u.Name != null && u.Name.ToString() == namespaceName);
        
        if (allUsings.Any())
        {
            return root;
        }
        
        var usingDirective = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceName))
            .WithUsingKeyword(
                SyntaxFactory.Token(SyntaxKind.UsingKeyword)
                    .WithTrailingTrivia(SyntaxFactory.Space))
            .WithSemicolonToken(
                SyntaxFactory.Token(SyntaxKind.SemicolonToken)
                    .WithTrailingTrivia(SyntaxFactory.EndOfLine("\n")));
        return root.WithUsings(root.Usings.Add(usingDirective));
    }
 
    private static string NormalizeNewlines(string text) => text.Replace("\r\n", "\n");
}