File: Diff.Disk.Tests.cs
Web Access
Project: ..\..\..\test\Microsoft.DotNet.ApiDiff.Tests\Microsoft.DotNet.ApiDiff.Tests.csproj (Microsoft.DotNet.ApiDiff.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.Emit;
using Microsoft.DotNet.ApiSymbolExtensions.Logging;
using Microsoft.DotNet.ApiSymbolExtensions.Tests;
using Moq;
 
namespace Microsoft.DotNet.ApiDiff.Tests;
 
public class DiffDiskTests
{
    private const string DefaultBeforeFriendlyName = "Before";
    private const string DefaultAfterFriendlyName = "After";
    private const string DefaultTableOfContentsTitle = "MyTitle";
    private const string DefaultAssemblyName = "MyAssembly";
 
    private const string ExpectedTableOfContents = $"""
        # API difference between {DefaultBeforeFriendlyName} and {DefaultAfterFriendlyName}
 
        API listing follows standard diff formatting.
        Lines preceded by a '+' are additions and a '-' indicates removal.
 
        * [{DefaultAssemblyName}]({DefaultTableOfContentsTitle}_{DefaultAssemblyName}.md)

        """;
 
    private const string ExpectedEmptyTableOfContents = $"""
        # API difference between {DefaultBeforeFriendlyName} and {DefaultAfterFriendlyName}
 
        API listing follows standard diff formatting.
        Lines preceded by a '+' are additions and a '-' indicates removal.
 

        """;
 
    private const string BeforeCode = """
        namespace MyNamespace
        {
            public class MyClass
            {
                public MyClass() { }
            }
        }
        """;
 
    private const string AfterCode = """
        namespace MyNamespace
        {
            public class MyClass
            {
                public MyClass() { }
                public void MyMethod() { }
            }
        }
        """;
 
    private const string DefaultExpectedMarkdown = $@"# {DefaultAssemblyName}
 
```diff
  namespace MyNamespace
  {{
      public class MyClass
      {{
+         public void MyMethod();
      }}
  }}
```
";
 
    private readonly Dictionary<string, string[]> DefaultBeforeAssemblyAndCodeFiles = new() { { DefaultAssemblyName, [BeforeCode] } };
    private readonly Dictionary<string, string[]> DefaultAfterAssemblyAndCodeFiles = new() { { DefaultAssemblyName, [AfterCode] } };
    private readonly Dictionary<string, string> DefaultExpectedAssemblyMarkdowns = new() { { DefaultAssemblyName, DefaultExpectedMarkdown } };
 
    /// <summary>
    /// This test reads two DLLs, where the only difference between the types in the assembly is one method that is added on the new type.
    /// The output goes to disk.
    /// </summary>
    [Fact]
    public async Task DiskRead_DiskWrite()
    {
        using TempDirectory inputFolderPath = new();
        using TempDirectory outputFolderPath = new();
 
        IDiffGenerator generator = TestDiskShared(
            inputFolderPath.DirPath,
            DefaultBeforeFriendlyName,
            DefaultAfterFriendlyName,
            DefaultTableOfContentsTitle,
            DefaultBeforeAssemblyAndCodeFiles,
            DefaultAfterAssemblyAndCodeFiles,
            outputFolderPath.DirPath,
            writeToDisk: true);
 
        await generator.RunAsync(CancellationToken.None);
 
        VerifyDiskWrite(outputFolderPath.DirPath, DefaultTableOfContentsTitle, ExpectedTableOfContents, DefaultExpectedAssemblyMarkdowns);
    }
 
    // Each assembly should have its own file. Confirm that namespaces spread throughout multiple assemblies does not accidentally overwrite any existing files.
    [Fact]
    public async Task DiskRead_DiskWrite_AssembliesWithRepeatedNamespaces()
    {
        string beforeAssembly1Code1 = """
        namespace MyNamespace
        {
            public class MyClass1
            {
                public MyClass1() { }
            }
        }
        """;
        string beforeAssembly1Code2 = """
        namespace MyNamespace
        {
            public class MyClass2
            {
                public MyClass2() { }
            }
        }
        """;
        string beforeAssembly2Code1 = """
        namespace MyNamespace
        {
            public class MyClass3
            {
                public MyClass3() { }
            }
        }
        """;
        string beforeAssembly2Code2 = """
        namespace MyNamespace
        {
            public class MyClass4
            {
                public MyClass4() { }
            }
        }
        """;
 
        string afterAssembly1Code1 = """
        namespace MyNamespace
        {
            public class MyClass1
            {
                public MyClass1() { }
                public void MyMethod() { }
            }
        }
        """;
        string afterAssembly1Code2 = """
        namespace MyNamespace
        {
            public class MyClass2
            {
                public MyClass2() { }
                public void MyMethod() { }
            }
        }
        """;
        string afterAssembly2Code1 = """
        namespace MyNamespace
        {
            public class MyClass3
            {
                public MyClass3() { }
                public void MyMethod() { }
            }
        }
        """;
        string afterAssembly2Code2 = """
        namespace MyNamespace
        {
            public class MyClass4
            {
                public MyClass4() { }
                public void MyMethod() { }
            }
        }
        """;
 
        Dictionary<string, string[]> beforeAssemblyAndCodeFiles = new()
        {
            { "Assembly1", [beforeAssembly1Code1, beforeAssembly1Code2] },
            { "Assembly2", [beforeAssembly2Code1, beforeAssembly2Code2] }
        };
 
        Dictionary<string, string[]> afterAssemblyAndCodeFiles = new()
        {
            { "Assembly1", [afterAssembly1Code1, afterAssembly1Code2] },
            { "Assembly2", [afterAssembly2Code1, afterAssembly2Code2] }
        };
 
        Dictionary<string, string> expectedAssemblyMarkdowns = new()
        {
            { "Assembly1", $@"# Assembly1
 
```diff
  namespace MyNamespace
  {{
      public class MyClass1
      {{
+         public void MyMethod();
      }}
      public class MyClass2
      {{
+         public void MyMethod();
      }}
  }}
```
" },
            { "Assembly2", $@"# Assembly2
 
```diff
  namespace MyNamespace
  {{
      public class MyClass3
      {{
+         public void MyMethod();
      }}
      public class MyClass4
      {{
+         public void MyMethod();
      }}
  }}
```
" }
 
        };
 
        string expectedTableOfContents = $@"# API difference between {DefaultBeforeFriendlyName} and {DefaultAfterFriendlyName}
 
API listing follows standard diff formatting.
Lines preceded by a '+' are additions and a '-' indicates removal.
 
* [Assembly1]({DefaultTableOfContentsTitle}_Assembly1.md)
* [Assembly2]({DefaultTableOfContentsTitle}_Assembly2.md)
";
 
        using TempDirectory inputFolderPath = new();
        using TempDirectory outputFolderPath = new();
 
        IDiffGenerator generator = TestDiskShared(
            inputFolderPath.DirPath,
            DefaultBeforeFriendlyName,
            DefaultAfterFriendlyName,
            DefaultTableOfContentsTitle,
            beforeAssemblyAndCodeFiles,
            afterAssemblyAndCodeFiles,
            outputFolderPath.DirPath,
            writeToDisk: true);
 
        await generator.RunAsync(CancellationToken.None);
 
        VerifyDiskWrite(outputFolderPath.DirPath, DefaultTableOfContentsTitle, expectedTableOfContents, expectedAssemblyMarkdowns);
    }
 
    /// <summary>
    /// This test reads two DLLs, where the assembly is explicitly excluded.
    /// The output is disk is simply the table of contents markdown file with no assembly list. No other files.
    /// </summary>
    [Fact]
    public async Task DiskRead_DiskWrite_ExcludeAssembly()
    {
        using TempDirectory root = new();
 
        DirectoryInfo inputFolderPath = new(Path.Join(root.DirPath, "inputFolder"));
        DirectoryInfo outputFolderPath = new(Path.Join(root.DirPath, "outputFolder"));
 
        inputFolderPath.Create();
        outputFolderPath.Create();
 
        IDiffGenerator generator = TestDiskShared(
            inputFolderPath.FullName,
            DefaultBeforeFriendlyName,
            DefaultAfterFriendlyName,
            DefaultTableOfContentsTitle,
            DefaultBeforeAssemblyAndCodeFiles,
            DefaultAfterAssemblyAndCodeFiles,
            outputFolderPath.FullName,
            writeToDisk: true,
            filesWithAssembliesToExclude: await GetFileWithListsAsync(root, [DefaultAssemblyName]));
 
        await generator.RunAsync(CancellationToken.None);
 
        VerifyDiskWrite(outputFolderPath.FullName, DefaultTableOfContentsTitle, ExpectedEmptyTableOfContents, []);
    }
 
    /// <summary>
    ///  Many namespaces belonging to a single assembly should go into the same output markdown file for that assembly.
    /// </summary>
    [Fact]
    public async Task DiskRead_DiskWrite_MultiNamespaces()
    {
        using TempDirectory inputFolderPath = new();
        using TempDirectory outputFolderPath = new();
 
        string beforeCode1 = """
        namespace MyNamespace
        {
            public class MyClass
            {
                public MyClass() { }
            }
        }
        """;
 
        string beforeCode2 = """
        namespace MyNamespace.MySubNamespace
        {
            public class MySubClass
            {
                public MySubClass() { }
            }
        }
        """;
 
        string afterCode1 = """
        namespace MyNamespace
        {
            public class MyClass
            {
                public MyClass() { }
                public void MyMethod() { }
            }
        }
        """;
 
        string afterCode2 = """
        namespace MyNamespace.MySubNamespace
        {
            public class MySubClass
            {
                public MySubClass() { }
                public void MySubMethod() { }
            }
        }
        """;
 
        string expectedMarkdown = $@"# {DefaultAssemblyName}
 
```diff
  namespace MyNamespace
  {{
      public class MyClass
      {{
+         public void MyMethod();
      }}
  }}
  namespace MyNamespace.MySubNamespace
  {{
      public class MySubClass
      {{
+         public void MySubMethod();
      }}
  }}
```
";
 
        Dictionary<string, string[]> beforeAssemblyAndCodeFiles = new() { { DefaultAssemblyName, [beforeCode1, beforeCode2] } };
        Dictionary<string, string[]> afterAssemblyAndCodeFiles = new() { { DefaultAssemblyName, [afterCode1, afterCode2] } };
        Dictionary<string, string> expectedAssemblyMarkdowns = new() { { DefaultAssemblyName, expectedMarkdown } };
 
        IDiffGenerator generator = TestDiskShared(
            inputFolderPath.DirPath,
            DefaultBeforeFriendlyName,
            DefaultAfterFriendlyName,
            DefaultTableOfContentsTitle,
            beforeAssemblyAndCodeFiles,
            afterAssemblyAndCodeFiles,
            outputFolderPath.DirPath,
            writeToDisk: true);
 
        await generator.RunAsync(CancellationToken.None);
 
        VerifyDiskWrite(outputFolderPath.DirPath, DefaultTableOfContentsTitle, ExpectedTableOfContents, expectedAssemblyMarkdowns);
    }
 
    /// <summary>
    /// This test reads two DLLs, where the only difference between the types in the assembly is one method that is added on the new type.
    /// The output is the Results dictionary.
    /// </summary>
    [Fact]
    public async Task DiskRead_MemoryWrite()
    {
        using TempDirectory inputFolderPath = new();
        using TempDirectory outputFolderPath = new();
 
        IDiffGenerator generator = TestDiskShared(
            inputFolderPath.DirPath,
            DefaultBeforeFriendlyName,
            DefaultAfterFriendlyName,
            DefaultTableOfContentsTitle,
            DefaultBeforeAssemblyAndCodeFiles,
            DefaultAfterAssemblyAndCodeFiles,
            outputFolderPath.DirPath,
            writeToDisk: false);
 
        await generator.RunAsync(CancellationToken.None);
 
        string tableOfContentsMarkdownFilePath = Path.Join(outputFolderPath.DirPath, $"{DefaultTableOfContentsTitle}.md");
        Assert.Contains(tableOfContentsMarkdownFilePath, generator.Results.Keys);
 
        Assert.Equal(ExpectedTableOfContents, generator.Results[tableOfContentsMarkdownFilePath]);
 
        string myAssemblyMarkdownFilePath = Path.Join(outputFolderPath.DirPath, $"{DefaultTableOfContentsTitle}_{DefaultAssemblyName}.md");
        Assert.Contains(myAssemblyMarkdownFilePath, generator.Results.Keys);
 
        Assert.Equal(DefaultExpectedMarkdown, generator.Results[myAssemblyMarkdownFilePath]);
    }
 
    /// <summary>
    /// This test reads two DLLs, where the assembly is explicitly excluded.
    /// The output is an empty Results dictionary.
    /// </summary>
    [Fact]
    public async Task DiskRead_MemoryWrite_ExcludeAssembly()
    {
        using TempDirectory root = new();
 
        DirectoryInfo inputFolderPath = new(Path.Join(root.DirPath, "inputFolder"));
        DirectoryInfo outputFolderPath = new(Path.Join(root.DirPath, "outputFolder"));
 
        inputFolderPath.Create();
        outputFolderPath.Create();
 
        IDiffGenerator generator = TestDiskShared(
            inputFolderPath.FullName,
            DefaultBeforeFriendlyName,
            DefaultAfterFriendlyName,
            DefaultTableOfContentsTitle,
            DefaultBeforeAssemblyAndCodeFiles,
            DefaultAfterAssemblyAndCodeFiles,
            outputFolderPath.FullName,
            writeToDisk: false,
            filesWithAssembliesToExclude: await GetFileWithListsAsync(root, [DefaultAssemblyName]));
 
        await generator.RunAsync(CancellationToken.None);
 
        string tableOfContentsMarkdownFilePath = Path.Join(outputFolderPath.FullName, $"{DefaultTableOfContentsTitle}.md");
        Assert.Contains(tableOfContentsMarkdownFilePath, generator.Results.Keys);
 
        Assert.Equal(ExpectedEmptyTableOfContents, generator.Results[tableOfContentsMarkdownFilePath]);
 
        string myAssemblyMarkdownFilePath = Path.Join(outputFolderPath.FullName, $"{DefaultTableOfContentsTitle}_{DefaultAssemblyName}.md");
        Assert.DoesNotContain(myAssemblyMarkdownFilePath, generator.Results.Keys);
    }
 
    private IDiffGenerator TestDiskShared(
        string inputFolderPath,
        string beforeFriendlyName,
        string afterFriendlyName,
        string tableOfContentsTitle,
        Dictionary<string, string[]> beforeAssemblyAndCodeFiles,
        Dictionary<string, string[]> afterAssemblyAndCodeFiles,
        string outputFolderPath,
        bool writeToDisk,
        FileInfo[]? filesWithAssembliesToExclude = null,
        FileInfo[]? filesWithAttributesToExclude = null,
        FileInfo[]? filesWithAPIsToExclude = null)
    {
        string beforeAssembliesFolderPath = Path.Join(inputFolderPath, DefaultBeforeFriendlyName);
        string afterAssembliesFolderPath = Path.Join(inputFolderPath, DefaultAfterFriendlyName);
 
        Directory.CreateDirectory(beforeAssembliesFolderPath);
        Directory.CreateDirectory(afterAssembliesFolderPath);
 
        WriteCodeToAssemblyInDisk(beforeAssembliesFolderPath, beforeAssemblyAndCodeFiles);
        WriteCodeToAssemblyInDisk(afterAssembliesFolderPath, afterAssemblyAndCodeFiles);
 
        Mock<ILog> log = new();
 
        return DiffGeneratorFactory.Create(log.Object,
            beforeAssembliesFolderPath,
            beforeAssemblyReferencesFolderPath: null,
            afterAssembliesFolderPath,
            afterAssemblyReferencesFolderPath: null,
            outputFolderPath,
            beforeFriendlyName,
            afterFriendlyName,
            tableOfContentsTitle,
            filesWithAssembliesToExclude ?? [],
            filesWithAttributesToExclude ?? [],
            filesWithAPIsToExclude ?? [],
            addPartialModifier: false,
            writeToDisk,
            DiffGeneratorFactory.DefaultDiagnosticOptions);
    }
 
    private void WriteCodeToAssemblyInDisk(string assemblyDirectoryPath, Dictionary<string, string[]> assemblyAndCodeFiles)
    {
        foreach ((string assemblyName, string[] codeFiles) in assemblyAndCodeFiles)
        {
            List<SyntaxTree> syntaxTrees = new();
            foreach (string codeFile in codeFiles)
            {
                syntaxTrees.Add(CSharpSyntaxTree.ParseText(codeFile));
            }
 
            IEnumerable<MetadataReference> references = AppDomain.CurrentDomain.GetAssemblies()
                .Where(a => !a.IsDynamic)
                .Select(a => MetadataReference.CreateFromFile(a.Location))
                .Cast<MetadataReference>();
 
            CSharpCompilation compilation = CSharpCompilation.Create(
                assemblyName,
                syntaxTrees,
                references,
                new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
 
            var assemblyPath = Path.Join(assemblyDirectoryPath, $"{assemblyName}.dll");
            EmitResult result = compilation.Emit(assemblyPath);
 
            if (!result.Success)
            {
                IEnumerable<Diagnostic> failures = result.Diagnostics.Where(diagnostic => diagnostic.IsWarningAsError || diagnostic.Severity == DiagnosticSeverity.Error);
                string failureMessages = string.Join(Environment.NewLine, failures.Select(f => $"{f.Id}: {f.GetMessage()}"));
                Assert.Fail($"Compilation failed: {failureMessages}");
            }
        }
    }
 
    private void VerifyDiskWrite(string outputFolderPath, string tableOfContentsTitle, string expectedTableOfContents, Dictionary<string, string> expectedAssemblyMarkdowns)
    {
        string tableOfContentsMarkdownFilePath = Path.Join(outputFolderPath, $"{tableOfContentsTitle}.md");
        Assert.True(File.Exists(tableOfContentsMarkdownFilePath), $"{tableOfContentsMarkdownFilePath} table of contents markdown file does not exist.");
 
        string actualTableOfContentsText = File.ReadAllText(tableOfContentsMarkdownFilePath);
        Assert.Equal(expectedTableOfContents, actualTableOfContentsText);
 
        foreach ((string expectedAssembly, string expectedMarkdown) in expectedAssemblyMarkdowns)
        {
            string myAssemblyMarkdownFilePath = Path.Join(outputFolderPath, $"{tableOfContentsTitle}_{expectedAssembly}.md");
            Assert.True(File.Exists(myAssemblyMarkdownFilePath), $"{myAssemblyMarkdownFilePath} assembly markdown file does not exist.");
 
            string actualCode = File.ReadAllText(myAssemblyMarkdownFilePath);
            Assert.Equal(expectedMarkdown, actualCode);
        }
    }
 
    private Task<FileInfo[]> GetFileWithListsAsync(TempDirectory root, string[] list) => GetFilesWithListsAsync(root, [list]);
 
    private async Task<FileInfo[]> GetFilesWithListsAsync(TempDirectory root, List<string[]> lists)
    {
        List<FileInfo> filesWithLists = [];
 
        foreach (string[] list in lists)
        {
            FileInfo file = new(Path.Join(root.DirPath, Path.GetRandomFileName()));
            await File.WriteAllLinesAsync(file.FullName, list);
            filesWithLists.Add(file);
        }
 
        return [.. filesWithLists];
    }
}