File: QuarantineScriptTests.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 QuarantineScriptTests
{
    [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 Quarantine_AddsAttribute_WhenMissing(string code, string fullName, string issue)
    {
        var updated = Quarantine(fullName, issue, code);
        Assert.Contains("QuarantinedTest", updated);
        Assert.Contains(issue, updated);
        // Attribute should be applied at method level and not duplicate facts
        Assert.Contains("[Fact]", updated);
    }
 
    [Fact]
    public void Quarantine_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;
        using Aspire.TestUtilities;
        public class C {
            [Fact]
            [QuarantinedTest("https://github.com/dotnet/aspire/issues/100")]
            public void M() { }
        }
        """;
 
        var updated = Quarantine("N.C.M", newUrl, code);
        var count = Regex.Matches(updated, "QuarantinedTest").Count;
        Assert.Equal(1, count);
        Assert.Contains(originalUrl, updated);
        Assert.DoesNotContain(newUrl, updated);
    }
 
    [Fact]
    public void Unquarantine_RemovesAttribute_WhenPresent()
    {
        const string code = """
        namespace N;
        using Xunit;
        using Aspire.TestUtilities;
        public class C {
            [Fact]
            [QuarantinedTest("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 = RemoveQuarantinedAttribute(method, out var removed);
        Assert.True(removed);
        var newRoot = root.ReplaceNode(method, updated);
        var text = newRoot.ToFullString();
        Assert.DoesNotContain("QuarantinedTest", text);
        Assert.Contains("[Fact]", text);
    }
 
    [Theory]
    [InlineData("http://example.com/issue/1", true)]
    [InlineData("https://github.com/dotnet/aspire/issues/123", true)]
    [InlineData("ftp://example.com/issue/1", false)]
    [InlineData("www.github.com/issue/1", false)]
    [InlineData("/relative/path", false)]
    [InlineData("", false)]
    public void UrlValidation_Works(string url, bool expected)
    {
        var result = IsHttpUrl(url);
        Assert.Equal(expected, result);
    }
 
    [Fact]
    public void Quarantine_AddsUsingDirective_WhenMissing()
    {
        const string code = """
        namespace N;
        using Xunit;
        public class C { [Fact] public void M() { } }
        """;
 
        var updated = Quarantine("N.C.M", "https://github.com/dotnet/aspire/issues/500", code);
        Assert.Contains("using Aspire.TestUtilities;", updated);
        Assert.Contains("[QuarantinedTest(\"https://github.com/dotnet/aspire/issues/500\")]", updated);
    }
 
    [Fact]
    public void Unquarantine_RemovesUsingDirective_WhenNoAttributesRemain()
    {
        const string code = """
        namespace N;
        using Xunit;
        using Aspire.TestUtilities;
        public class C {
            [Fact]
            [QuarantinedTest("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 updatedMethod = RemoveQuarantinedAttribute(method, out var removed);
        Assert.True(removed);
        var newRoot = root.ReplaceNode(method, updatedMethod);
        // After removal, simulate the same cleanup as the tool would
        bool anyQuarantinedLeft = newRoot.DescendantNodes().OfType<AttributeSyntax>().Any(IsQuarantinedAttribute);
        if (!anyQuarantinedLeft)
        {
            newRoot = RemoveUsingDirective(newRoot, "Aspire.TestUtilities");
        }
        var text = newRoot.ToFullString();
        Assert.DoesNotContain("using Aspire.TestUtilities;", text);
    }
 
    [Fact]
    public void Multiple_Targets_AreMatched_ByEnclosingNamesAndMethod()
    {
        const string code = """
        namespace N1;
        using Xunit;
        public class A { [Fact] public void M() { } }
        namespace N2 { public class B { [Fact] public void M() { } } }
        """;
 
        var updated1 = Quarantine("N1.A.M", "https://github.com/dotnet/aspire/issues/10", code);
        Assert.Contains("[QuarantinedTest(\"https://github.com/dotnet/aspire/issues/10\")]", updated1);
        // Ensure only the N1.A.M got the attribute, not N2.B.M
        var tree = CSharpSyntaxTree.ParseText(updated1);
        var root = tree.GetCompilationUnitRoot();
        var methods = root.DescendantNodes().OfType<MethodDeclarationSyntax>().ToList();
        var aM = methods.First(m => m.Identifier.ValueText == "M" && GetEnclosingNames(m).Namespace == "N1");
        var bM = methods.First(m => m.Identifier.ValueText == "M" && GetEnclosingNames(m).Namespace == "N2");
        Assert.Contains("QuarantinedTest", aM.ToFullString());
        Assert.DoesNotContain("QuarantinedTest", bM.ToFullString());
    }
 
    [Fact]
    public void Multiple_Targets_SameFile_UsingDirective_AddedOnce()
    {
        const string code = """
        namespace N;
        using Xunit;
        public class C {
            [Fact] public void M1() { }
            [Fact] public void M2() { }
        }
        """;
 
        var updated1 = Quarantine("N.C.M1", "https://github.com/dotnet/aspire/issues/11", code);
        var updated2 = Quarantine("N.C.M2", "https://github.com/dotnet/aspire/issues/11", updated1);
        var norm = NormalizeNewlines(updated2);
    // Only one using should be present
        var count = Regex.Matches(norm, "using Aspire.TestUtilities;").Count;
        Assert.Equal(1, count);
    // Both methods should be quarantined (ignore indentation)
    var rx1 = new Regex(@"\[QuarantinedTest\(""https://github.com/dotnet/aspire/issues/11""\)\]\n\s*public void M1\(\)", RegexOptions.Multiline);
    var rx2 = new Regex(@"\[QuarantinedTest\(""https://github.com/dotnet/aspire/issues/11""\)\]\n\s*public void M2\(\)", RegexOptions.Multiline);
    Assert.True(rx1.IsMatch(norm), $"Expected to match M1 pattern, but did not.\nPattern: {rx1}\nText:\n{norm}");
    Assert.True(rx2.IsMatch(norm), $"Expected to match M2 pattern, but did not.\nPattern: {rx2}\nText:\n{norm}");
    }
 
    [Fact]
    public void Quarantine_DoesNotInsertBlankLine_BetweenAttributes()
    {
        const string code = """
        namespace N;
        using Xunit;
        public class C {
            [Fact]
            public void M() { }
        }
        """;
 
        var updated = Quarantine("N.C.M", "https://github.com/dotnet/aspire/issues/99", code);
        var norm = NormalizeNewlines(updated);
    // Ensure there is no blank line between [Fact] and [QuarantinedTest]
    Assert.DoesNotMatch(new Regex(@"\[Fact\]\n\s*\n\s*\[QuarantinedTest", RegexOptions.Multiline), norm);
    // But [Fact] followed by [QuarantinedTest(...)] on the next line should exist (ignore indentation)
    Assert.Matches(new Regex(@"\[Fact\]\n\s+\[QuarantinedTest\(""https://github.com/dotnet/aspire/issues/99""\)\]", RegexOptions.Multiline), norm);
    }
 
    private static string Quarantine(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 = AddQuarantinedAttribute(method, issueUrl);
            root = root.ReplaceNode(method, updated);
            // Simulate the tool adding using directive for short attribute name
            root = EnsureUsingDirective(root, "Aspire.TestUtilities");
            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 IsQuarantinedAttribute(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, "QuarantinedTest", StringComparison.Ordinal)
            || string.Equals(lastId, "QuarantinedTestAttribute", StringComparison.Ordinal);
    }
 
    private static MethodDeclarationSyntax RemoveQuarantinedAttribute(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 => !IsQuarantinedAttribute(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 AddQuarantinedAttribute(MethodDeclarationSyntax method, string issueUrl)
    {
        foreach (var list in method.AttributeLists)
        {
            if (list.Attributes.Any(IsQuarantinedAttribute))
            {
                return method;
            }
        }
        // Use short attribute name and simulate the tool adding the using at file level
        var attrName = SyntaxFactory.ParseName("QuarantinedTest");
        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 bool IsHttpUrl(string url)
    {
        if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
        {
            return false;
        }
        return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps;
    }
 
    private static CompilationUnitSyntax EnsureUsingDirective(CompilationUnitSyntax root, string namespaceName)
    {
        if (root.Usings.Any(u => u.Name != null && u.Name.ToString() == namespaceName))
        {
            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 CompilationUnitSyntax RemoveUsingDirective(CompilationUnitSyntax root, string namespaceName)
    {
        // Remove matching using directives wherever they appear in the tree
        var nodesToRemove = root.DescendantNodes().OfType<UsingDirectiveSyntax>()
            .Where(u => u.Name != null && u.Name.ToString() == namespaceName)
            .ToList();
        CompilationUnitSyntax updated;
        if (nodesToRemove.Count > 0)
        {
            updated = (CompilationUnitSyntax)root.RemoveNodes(nodesToRemove, SyntaxRemoveOptions.KeepNoTrivia)!;
        }
        else
        {
            updated = root;
        }
 
        // Also ensure the compilation unit usings are filtered (in case any remain)
        if (updated.Usings.Count > 0)
        {
            var filtered = updated.Usings.Where(u => u.Name == null || u.Name.ToString() != namespaceName).ToList();
            updated = updated.WithUsings(SyntaxFactory.List(filtered));
        }
 
        // Fallback: if the text still contains the using (due to trivia/layout quirks), do a textual removal
        var text = updated.ToFullString();
        if (text.Contains($"using {namespaceName};"))
        {
            text = text.Replace($"using {namespaceName};\r\n", string.Empty)
                       .Replace($"using {namespaceName};\n", string.Empty);
            updated = CSharpSyntaxTree.ParseText(text).GetCompilationUnitRoot();
        }
 
        return updated;
    }
 
    private static string NormalizeNewlines(string text) => text.Replace("\r\n", "\n");
}