From 05d5ad7b68c2ac5ce677c0b2aec6a3bf43724843 Mon Sep 17 00:00:00 2001 From: Rami Date: Fri, 29 Sep 2023 14:34:33 -0700 Subject: [PATCH] Fixed test run hang if an error occurs when trying to send test results to the server Pretty print requests and responses for easier troubleshooting Added integration tests disable warning NETSDK1138: The target framework 'netcoreapp2.1' is out of support and will not receive security updates in the future. --- .github/workflows/build.yml | 32 ++- AzurePipelines.TestLogger.sln | 19 +- root | 0 src/AzurePipelines.TestLogger/ApiClient.cs | 2 +- src/AzurePipelines.TestLogger/ApiClientV5.cs | 1 - .../AzurePipelines.TestLogger.csproj | 52 ++--- .../AzurePipelinesTestLogger.cs | 28 ++- .../Json/JsonExtensions.cs | 41 ++++ src/AzurePipelines.TestLogger/LoggerQueue.cs | 77 +++---- .../AzurePipelines.TestLogger.Tests.csproj | 8 +- .../CaptureRequestsMiddleware.cs | 26 +++ .../ClientMessage.cs | 5 + .../IRequestStore.cs | 9 + .../IntegrationTests.cs | 210 ++++++++++++++++++ ...ockAzureDevOpsTestRunLogCollectorServer.cs | 23 ++ .../ProcessRunner.cs | 117 ++++++++++ .../RequestStore.cs | 9 + .../SampleUnitTestProject.csproj | 17 ++ tests/SampleUnitTestProject/UnitTest1.cs | 20 ++ 19 files changed, 601 insertions(+), 95 deletions(-) create mode 100644 root create mode 100644 tests/AzurePipelines.TestLogger.Tests/CaptureRequestsMiddleware.cs create mode 100644 tests/AzurePipelines.TestLogger.Tests/IRequestStore.cs create mode 100644 tests/AzurePipelines.TestLogger.Tests/IntegrationTests.cs create mode 100644 tests/AzurePipelines.TestLogger.Tests/MockAzureDevOpsTestRunLogCollectorServer.cs create mode 100644 tests/AzurePipelines.TestLogger.Tests/ProcessRunner.cs create mode 100644 tests/AzurePipelines.TestLogger.Tests/RequestStore.cs create mode 100644 tests/SampleUnitTestProject/SampleUnitTestProject.csproj create mode 100644 tests/SampleUnitTestProject/UnitTest1.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b5f26b..e4d0392 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: runs-on: windows-latest env: - VERSION: 1.2.2 + VERSION: 1.2.3 strategy: matrix: @@ -44,10 +44,10 @@ jobs: echo SEM_VERSION=${{ env.SEM_VERSION }} - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4.1.0 - name: Setup .NET Core 2.1 - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v3.2.0 with: dotnet-version: 2.1 @@ -63,23 +63,19 @@ jobs: copy ".\src\AzurePipelines.TestLogger\bin\${{ matrix.Configuration }}\netstandard1.5\*.dll" .\src\AzurePipelines.TestLogger\contentFiles\any\any - name: Pack - run: dotnet pack .\src\AzurePipelines.TestLogger\AzurePipelines.TestLogger.csproj --configuration ${{ matrix.Configuration }} -p:NuspecProperties="Version=${{ env.SEM_VERSION }}" --no-restore --no-build --output:.\build -p:NuspecFile=AzurePipelines.TestLogger.nuspec + run: dotnet pack .\src\AzurePipelines.TestLogger\AzurePipelines.TestLogger.csproj --configuration ${{ matrix.Configuration }} -p:NuspecProperties="Version=${{ env.SEM_VERSION }}" --no-restore --no-build --output:.\build -p:NuspecFile=..\AzurePipelines.TestLogger\AzurePipelines.TestLogger.nuspec - name: Test run: dotnet test --no-build --no-restore --verbosity normal --configuration ${{ matrix.Configuration }} - - name: Create zip - run: | - mkdir AzurePipelines.TestLogger.${{ env.SEM_VERSION }} - copy ".\src\AzurePipelines.TestLogger\bin\${{ matrix.Configuration }}\netstandard1.5\*.*" .\AzurePipelines.TestLogger.${{ env.SEM_VERSION }} - copy "~\.nuget\packages\semver\2.0.6\lib\netstandard1.1\*.*" .\AzurePipelines.TestLogger.${{ env.SEM_VERSION }} - copy ".\LICENSE" .\AzurePipelines.TestLogger.${{ env.SEM_VERSION }} - copy ".\README.md" .\AzurePipelines.TestLogger.${{ env.SEM_VERSION }} - copy ".\ReleaseNotes.md" .\AzurePipelines.TestLogger.${{ env.SEM_VERSION }} - tar -cf AzurePipelines.TestLogger.${{ env.SEM_VERSION }}.zip .\AzurePipelines.TestLogger.${{ env.SEM_VERSION }} + - name: Create Zip + uses: vimtor/action-zip@v1.1 + with: + files: LICENSE README.md ReleaseNotes.md .\src\AzurePipelines.TestLogger\bin\${{ matrix.Configuration }}\netstandard1.5 + dest: AzurePipelines.TestLogger.${{ env.SEM_VERSION }}.zip - name: Upload Build Artifact - uses: actions/upload-artifact@v2.3.1 + uses: actions/upload-artifact@v3.1.3 with: name: AzurePipelines.TestLogger ${{ env.SEM_VERSION }} ${{ matrix.Configuration }} path: AzurePipelines.TestLogger.${{ env.SEM_VERSION }}.zip @@ -95,15 +91,15 @@ jobs: fail_on_unmatched_files: true files: | AzurePipelines.TestLogger.${{ env.SEM_VERSION }}.zip - .\src\AzurePipelines.TestLogger\build\AzurePipelines.TestLogger.${{ env.SEM_VERSION }}.nupkg + .\build\AzurePipelines.TestLogger.${{ env.SEM_VERSION }}.nupkg - name: Upload NuGet Package - uses: actions/upload-artifact@v2.3.1 + uses: actions/upload-artifact@v3.1.3 with: name: AzurePipelines.TestLogger ${{ env.SEM_VERSION }} ${{ matrix.Configuration }}.nupkg - path: .\src\AzurePipelines.TestLogger\build\AzurePipelines.TestLogger.${{ env.SEM_VERSION }}.nupkg + path: .\build\AzurePipelines.TestLogger.${{ env.SEM_VERSION }}.nupkg if-no-files-found: error - name: Publish NuGet Package if: ${{ matrix.Configuration == 'Release' && github.event_name != 'pull_request' }} - run: dotnet nuget push .\src\AzurePipelines.TestLogger\build\AzurePipelines.TestLogger.${{ env.SEM_VERSION }}.nupkg --api-key ${{ secrets.NUGET_KEY }} --source https://api.nuget.org/v3/index.json \ No newline at end of file + run: dotnet nuget push .\build\AzurePipelines.TestLogger.${{ env.SEM_VERSION }}.nupkg --api-key ${{ secrets.NUGET_KEY }} --source https://api.nuget.org/v3/index.json diff --git a/AzurePipelines.TestLogger.sln b/AzurePipelines.TestLogger.sln index a170b6e..27c4992 100644 --- a/AzurePipelines.TestLogger.sln +++ b/AzurePipelines.TestLogger.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.31112.23 +# Visual Studio Version 17 +VisualStudioVersion = 17.7.34031.279 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C95ECC05-F3E8-49F4-B7C5-A29CD7EACFC1}" EndProject @@ -26,6 +26,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "misc", "misc", "{B8315F74-E stylecop.json = stylecop.json EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleUnitTestProject", "tests\SampleUnitTestProject\SampleUnitTestProject.csproj", "{AFB35FB3-F22D-436A-84F7-A5DEFD879D3D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -60,6 +62,18 @@ Global {8C42EBD4-FF36-44B6-A70E-7D83CB0626F8}.Release|x64.Build.0 = Release|Any CPU {8C42EBD4-FF36-44B6-A70E-7D83CB0626F8}.Release|x86.ActiveCfg = Release|Any CPU {8C42EBD4-FF36-44B6-A70E-7D83CB0626F8}.Release|x86.Build.0 = Release|Any CPU + {AFB35FB3-F22D-436A-84F7-A5DEFD879D3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AFB35FB3-F22D-436A-84F7-A5DEFD879D3D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AFB35FB3-F22D-436A-84F7-A5DEFD879D3D}.Debug|x64.ActiveCfg = Debug|Any CPU + {AFB35FB3-F22D-436A-84F7-A5DEFD879D3D}.Debug|x64.Build.0 = Debug|Any CPU + {AFB35FB3-F22D-436A-84F7-A5DEFD879D3D}.Debug|x86.ActiveCfg = Debug|Any CPU + {AFB35FB3-F22D-436A-84F7-A5DEFD879D3D}.Debug|x86.Build.0 = Debug|Any CPU + {AFB35FB3-F22D-436A-84F7-A5DEFD879D3D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AFB35FB3-F22D-436A-84F7-A5DEFD879D3D}.Release|Any CPU.Build.0 = Release|Any CPU + {AFB35FB3-F22D-436A-84F7-A5DEFD879D3D}.Release|x64.ActiveCfg = Release|Any CPU + {AFB35FB3-F22D-436A-84F7-A5DEFD879D3D}.Release|x64.Build.0 = Release|Any CPU + {AFB35FB3-F22D-436A-84F7-A5DEFD879D3D}.Release|x86.ActiveCfg = Release|Any CPU + {AFB35FB3-F22D-436A-84F7-A5DEFD879D3D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -67,6 +81,7 @@ Global GlobalSection(NestedProjects) = preSolution {77CA5040-B4A0-4D0B-ADDD-09853A385007} = {C95ECC05-F3E8-49F4-B7C5-A29CD7EACFC1} {8C42EBD4-FF36-44B6-A70E-7D83CB0626F8} = {FA92AD98-1291-4A90-A3AA-ED81A9BBC86E} + {AFB35FB3-F22D-436A-84F7-A5DEFD879D3D} = {FA92AD98-1291-4A90-A3AA-ED81A9BBC86E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A7517899-6171-4E6B-BDD7-DBE01B34E83A} diff --git a/root b/root new file mode 100644 index 0000000..e69de29 diff --git a/src/AzurePipelines.TestLogger/ApiClient.cs b/src/AzurePipelines.TestLogger/ApiClient.cs index 1b40b12..9bf9aa6 100644 --- a/src/AzurePipelines.TestLogger/ApiClient.cs +++ b/src/AzurePipelines.TestLogger/ApiClient.cs @@ -248,7 +248,7 @@ internal virtual async Task SendAsync(HttpMethod method, string endpoint if (Verbose) { - Console.WriteLine($"Request:\n{method} {requestUri}\n{body}\n\nResponse:\n{response.StatusCode}\n{responseBody}"); + Console.WriteLine($"Request:\n{method} {requestUri}\n{body.Indented()}\n\nResponse:\n{response.StatusCode}\n{responseBody.Indented()}\n"); } try diff --git a/src/AzurePipelines.TestLogger/ApiClientV5.cs b/src/AzurePipelines.TestLogger/ApiClientV5.cs index 67e8328..d704e6c 100644 --- a/src/AzurePipelines.TestLogger/ApiClientV5.cs +++ b/src/AzurePipelines.TestLogger/ApiClientV5.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using AzurePipelines.TestLogger.Json; using Microsoft.VisualStudio.TestPlatform.ObjectModel; diff --git a/src/AzurePipelines.TestLogger/AzurePipelines.TestLogger.csproj b/src/AzurePipelines.TestLogger/AzurePipelines.TestLogger.csproj index 33a95fa..b0f3b86 100644 --- a/src/AzurePipelines.TestLogger/AzurePipelines.TestLogger.csproj +++ b/src/AzurePipelines.TestLogger/AzurePipelines.TestLogger.csproj @@ -1,27 +1,27 @@ - - - netstandard1.5 - 1.0.0 - 1.0.0.0 - 1.0.0.0 - + + + netstandard1.5 + 1.0.0 + 1.0.0.0 + 1.0.0.0 + true + + - - - - - - - - - - - - - <_Parameter1>AzurePipelines.TestLogger.Tests - - - <_Parameter1>DynamicProxyGenAssembly2 - - - + + + + + + + <_Parameter1>AzurePipelines.TestLogger.Tests + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + + + + diff --git a/src/AzurePipelines.TestLogger/AzurePipelinesTestLogger.cs b/src/AzurePipelines.TestLogger/AzurePipelinesTestLogger.cs index b5db4a4..da5420a 100644 --- a/src/AzurePipelines.TestLogger/AzurePipelinesTestLogger.cs +++ b/src/AzurePipelines.TestLogger/AzurePipelinesTestLogger.cs @@ -28,6 +28,8 @@ public class AzurePipelinesTestLogger : ITestLoggerWithParameters public AzurePipelinesTestLogger() { + // For debugging purposes + // System.Diagnostics.Debugger.Launch(); _environmentVariableProvider = new EnvironmentVariableProvider(); _apiClientFactory = new ApiClientFactory(); } @@ -137,10 +139,28 @@ private void TestMessageHandler(object sender, TestRunMessageEventArgs e) // Add code to handle message } - private void TestResultHandler(object sender, TestResultEventArgs e) => - _queue.Enqueue(new VstpTestResult(e.Result)); + private void TestResultHandler(object sender, TestResultEventArgs e) + { + try + { + _queue.Enqueue(new VstpTestResult(e.Result)); + } + catch (Exception ex) + { + Console.WriteLine(ex.ToString()); + } + } - private void TestRunCompleteHandler(object sender, TestRunCompleteEventArgs e) => - _queue.Flush(new VstpTestRunComplete(e.IsAborted || e.IsCanceled, e.AttachmentSets)); + private void TestRunCompleteHandler(object sender, TestRunCompleteEventArgs e) + { + try + { + _queue.Flush(new VstpTestRunComplete(e.IsAborted || e.IsCanceled, e.AttachmentSets)); + } + catch (Exception ex) + { + Console.WriteLine(ex.ToString()); + } + } } } diff --git a/src/AzurePipelines.TestLogger/Json/JsonExtensions.cs b/src/AzurePipelines.TestLogger/Json/JsonExtensions.cs index 3a6bb34..f5dbd58 100644 --- a/src/AzurePipelines.TestLogger/Json/JsonExtensions.cs +++ b/src/AzurePipelines.TestLogger/Json/JsonExtensions.cs @@ -83,5 +83,46 @@ private static string JsonEscape(string value) return sb.ToString(); } + + /// + /// Indent JSON string. + /// + /// The JSON string. + /// The indented JSON string. + public static string Indented(this string input) + { + int level = 0; + StringBuilder result = new StringBuilder(); + + for (int i = 0; i < input.Length; i++) + { + char c = input[i]; + + if (c == '{' || c == '[') + { + result.Append(c); + result.AppendLine(); + result.Append(new string(' ', ++level * 2)); + } + else if (c == '}' || c == ']') + { + result.AppendLine(); + result.Append(new string(' ', --level * 2)); + result.Append(c); + } + else if (c == ',') + { + result.Append(c); + result.AppendLine(); + result.Append(new string(' ', level * 2)); + } + else + { + result.Append(c); + } + } + + return result.ToString(); + } } } diff --git a/src/AzurePipelines.TestLogger/LoggerQueue.cs b/src/AzurePipelines.TestLogger/LoggerQueue.cs index a5cc2eb..ca17eea 100644 --- a/src/AzurePipelines.TestLogger/LoggerQueue.cs +++ b/src/AzurePipelines.TestLogger/LoggerQueue.cs @@ -43,24 +43,17 @@ public void Flush(VstpTestRunComplete testRunComplete) // Cancel any idle consumers and let them return _queue.Cancel(); - try - { - // Any active consumer will circle back around and batch post the remaining queue - _consumeTask.Wait(TimeSpan.FromSeconds(60)); + // Any active consumer will circle back around and batch post the remaining queue + _consumeTask.Wait(TimeSpan.FromSeconds(60)); - // Update the run and parents to a completed state - SendTestsCompleted(testRunComplete, _consumeTaskCancellationSource.Token).Wait(TimeSpan.FromSeconds(60)); + // Update the run and parents to a completed state + SendTestsCompleted(testRunComplete, _consumeTaskCancellationSource.Token).Wait(TimeSpan.FromSeconds(60)); - // Cancel any active HTTP requests if still hasn't finished flushing - _consumeTaskCancellationSource.Cancel(); - if (!_consumeTask.Wait(TimeSpan.FromSeconds(10))) - { - throw new TimeoutException("Cancellation didn't happen quickly"); - } - } - catch (Exception ex) + // Cancel any active HTTP requests if still hasn't finished flushing + _consumeTaskCancellationSource.Cancel(); + if (!_consumeTask.Wait(TimeSpan.FromSeconds(10))) { - Console.WriteLine(ex); + throw new TimeoutException("Cancellation didn't happen quickly"); } } @@ -68,47 +61,47 @@ private async Task ConsumeItemsAsync(CancellationToken cancellationToken) { while (true) { - ITestResult[] nextItems = await _queue.TakeAsync().ConfigureAwait(false); - - if (nextItems == null || nextItems.Length == 0) + try { - // Queue is canceling and is empty - return; - } + ITestResult[] nextItems = await _queue.TakeAsync().ConfigureAwait(false); + + if (nextItems == null || nextItems.Length == 0) + { + // Queue is canceling and is empty + return; + } - await SendResultsAsync(nextItems, cancellationToken).ConfigureAwait(false); + await SendResultsAsync(nextItems, cancellationToken).ConfigureAwait(false); - if (cancellationToken.IsCancellationRequested) + if (cancellationToken.IsCancellationRequested) + { + return; + } + } + catch (Exception ex) { - return; + Console.WriteLine(ex); } } } private async Task SendResultsAsync(ITestResult[] testResults, CancellationToken cancellationToken) { - try + // Create a test run if we need it + if (RunId == 0) { - // Create a test run if we need it - if (RunId == 0) - { - Source = GetSource(testResults); - RunId = await CreateTestRun(cancellationToken).ConfigureAwait(false); - } + Source = GetSource(testResults); + RunId = await CreateTestRun(cancellationToken).ConfigureAwait(false); + } - // Group results by their parent - IEnumerable> testResultsByParent = GroupTestResultsByParent(testResults); + // Group results by their parent + IEnumerable> testResultsByParent = GroupTestResultsByParent(testResults); - // Create any required parent nodes - await CreateParents(testResultsByParent, cancellationToken).ConfigureAwait(false); + // Create any required parent nodes + await CreateParents(testResultsByParent, cancellationToken).ConfigureAwait(false); - // Update parents with the test results - await SendTestResults(testResultsByParent, cancellationToken).ConfigureAwait(false); - } - catch (Exception) - { - // Eat any communications exceptions - } + // Update parents with the test results + await SendTestResults(testResultsByParent, cancellationToken).ConfigureAwait(false); } // Internal for testing diff --git a/tests/AzurePipelines.TestLogger.Tests/AzurePipelines.TestLogger.Tests.csproj b/tests/AzurePipelines.TestLogger.Tests/AzurePipelines.TestLogger.Tests.csproj index fcb8ca3..6d83ca0 100644 --- a/tests/AzurePipelines.TestLogger.Tests/AzurePipelines.TestLogger.Tests.csproj +++ b/tests/AzurePipelines.TestLogger.Tests/AzurePipelines.TestLogger.Tests.csproj @@ -2,11 +2,16 @@ netcoreapp2.1 + false - + + + + + @@ -14,6 +19,7 @@ + diff --git a/tests/AzurePipelines.TestLogger.Tests/CaptureRequestsMiddleware.cs b/tests/AzurePipelines.TestLogger.Tests/CaptureRequestsMiddleware.cs new file mode 100644 index 0000000..55cd0e4 --- /dev/null +++ b/tests/AzurePipelines.TestLogger.Tests/CaptureRequestsMiddleware.cs @@ -0,0 +1,26 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace AzurePipelines.TestLogger.Tests +{ + public class CaptureRequestsMiddleware + { + private readonly RequestDelegate _next; + + public CaptureRequestsMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task Invoke(HttpContext context) + { + // Capture the incoming request and store it + IRequestStore requestStore = context.RequestServices.GetService(); + requestStore.Add(context.Request); + + // Call the next middleware in the pipeline + await _next(context); + } + } +} \ No newline at end of file diff --git a/tests/AzurePipelines.TestLogger.Tests/ClientMessage.cs b/tests/AzurePipelines.TestLogger.Tests/ClientMessage.cs index b8748f0..c51a544 100644 --- a/tests/AzurePipelines.TestLogger.Tests/ClientMessage.cs +++ b/tests/AzurePipelines.TestLogger.Tests/ClientMessage.cs @@ -47,5 +47,10 @@ public bool Equals(ClientMessage other) => private static string RemoveWhiteSpace(string str) => string.Concat(str.Split(default(string[]), StringSplitOptions.RemoveEmptyEntries)); + + public override string ToString() + { + return $"Method: {Method}, Endpoint: {Endpoint}, ApiVersion: {ApiVersion}, Body: {Body}"; + } } } diff --git a/tests/AzurePipelines.TestLogger.Tests/IRequestStore.cs b/tests/AzurePipelines.TestLogger.Tests/IRequestStore.cs new file mode 100644 index 0000000..2971d86 --- /dev/null +++ b/tests/AzurePipelines.TestLogger.Tests/IRequestStore.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Http; + +namespace AzurePipelines.TestLogger.Tests +{ + internal interface IRequestStore + { + void Add(HttpRequest item); + } +} \ No newline at end of file diff --git a/tests/AzurePipelines.TestLogger.Tests/IntegrationTests.cs b/tests/AzurePipelines.TestLogger.Tests/IntegrationTests.cs new file mode 100644 index 0000000..88ec029 --- /dev/null +++ b/tests/AzurePipelines.TestLogger.Tests/IntegrationTests.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using SampleUnitTestProject; + +namespace AzurePipelines.TestLogger.Tests +{ + [TestFixture] + public class IntegrationTests + { + private string _vsTestExeFilePath; + private string _sampleUnitTestProjectDllFilePath; + private string _vsTestLoggerDllPath; + + [OneTimeSetUp] + public void SetUpFixture() + { + _vsTestExeFilePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + "Microsoft Visual Studio", + "2022", + "Enterprise", + "Common7", + "IDE", + "CommonExtensions", + "Microsoft", + "TestWindow", + "vstest.console.exe"); + + string configuration = "Debug"; + +#if RELEASE + configuration = "Release"; +#endif + + string rootRepositoryPath = GetRootRepositoryPath(); + _sampleUnitTestProjectDllFilePath = Path.Combine(rootRepositoryPath, $@"tests\SampleUnitTestProject\bin\{configuration}\netcoreapp2.1\SampleUnitTestProject.dll"); + _vsTestLoggerDllPath = Path.Combine(rootRepositoryPath, $@"src\AzurePipelines.TestLogger\bin\{configuration}\netstandard1.5"); + } + + [Test] + public void ExecuteTest_WithInvalidAzureDevopsCollectionUri_ContinuesTestExecution() + { + // Given + string fullyQualifiedTestMethodName = GetFullyQualifiedTestMethodName( + typeof(UnitTest1), + nameof(UnitTest1.TestMethod)); + + const string collectionUri = "collectionUri"; + + // When + int exitCode = ExecuteUnitTestWithLogger( + testMethod: fullyQualifiedTestMethodName, + collectionUri: collectionUri); + + // Then + Assert.AreEqual(0, exitCode); + } + + [Test] + public async Task ExecuteTest_WithDataTestMethod_LogsEachDataRow() + { + // Given + string fullyQualifiedTestMethodName = GetFullyQualifiedTestMethodName( + typeof(UnitTest1), + nameof(UnitTest1.DataTestMethod)); + + // When + TestResults testResults = await StartServerAndExecuteUnitTestWithLoggerAsync( + fullyQualifiedTestMethodName); + + // Then + Assert.AreEqual(0, testResults.ExitCode); + Assert.AreEqual(2, testResults.CapturedRequests.Count); + } + + private async Task StartServerAndExecuteUnitTestWithLoggerAsync( + string fullyQualifiedTestMethodName) + { + // Create the Server + IRequestStore requestStore = new RequestStore(); + + IWebHost host = WebHost.CreateDefaultBuilder() + .UseKestrel() + .ConfigureServices(configureServices => + { + configureServices.AddSingleton(requestStore); + }) + .UseUrls("http://127.0.0.1:0") // listen on a random available port + .UseStartup() + .Build(); + + await host.StartAsync(); + + try + { + // Get the server's listening address + IServerAddressesFeature serverAddresses = host.Services + .GetRequiredService() + .Features.Get(); + + string serverUrl = serverAddresses.Addresses.First(); + + Console.WriteLine($"Server is listening on: {serverUrl}"); + + int exitCode = ExecuteUnitTestWithLogger( + testMethod: fullyQualifiedTestMethodName, + collectionUri: $"{serverUrl}/"); + + List capturedRequests = (List)requestStore; + + return new TestResults + { + ExitCode = exitCode, + CapturedRequests = capturedRequests, + }; + } + finally + { + await host.StopAsync(); + } + } + + private class TestResults + { + public int ExitCode { get; set; } + public List CapturedRequests { get; set; } + } + + private static string GetFullyQualifiedTestMethodName(Type type, string methodName) + { + MethodInfo methodInfo = type.GetMethod(methodName); + return $"{type.Namespace}.{type.Name}.{methodInfo.Name}"; + } + + private int ExecuteUnitTestWithLogger( + bool verbose = true, + bool useDefaultCredentials = true, + string apiVersion = "3.0-preview.2", + bool groupTestResultsByClassName = false, + string testMethod = "SampleUnitTestProject.UnitTest1.TestMethod1", + string collectionUri = "collectionUri", + string teamProject = "teamProject", + string buildId = "buildId", + string buildRequestedFor = "buildRequestedFor", + string agentName = "agentName", + string agentJobName = "jobName") + { + List loggerArguments = new List + { + "AzurePipelines", + $"Verbose={verbose}", + $"UseDefaultCredentials={useDefaultCredentials}", + $"ApiVersion={apiVersion}", + $"GroupTestResultsByClassName={groupTestResultsByClassName}" + }; + + List arguments = new List + { + $"\"{_sampleUnitTestProjectDllFilePath}\"", + $"/Tests:{testMethod}", + $"/logger:\"{string.Join(";", loggerArguments)}\"", + $"/TestAdapterPath:\"{_vsTestLoggerDllPath}\"" + }; + + Dictionary environmentVariables = new Dictionary + { + { EnvironmentVariableNames.TeamFoundationCollectionUri, collectionUri }, + { EnvironmentVariableNames.TeamProject, teamProject }, + { EnvironmentVariableNames.BuildId, buildId }, + { EnvironmentVariableNames.BuildRequestedFor, buildRequestedFor }, + { EnvironmentVariableNames.AgentName, agentName }, + { EnvironmentVariableNames.AgentJobName, agentJobName }, + }; + + ProcessRunner processRunner = new ProcessRunner(); + return processRunner.Run(_vsTestExeFilePath, arguments, environmentVariables); + } + + private static string GetRootRepositoryPath() + { + string currentDirectory = Directory.GetCurrentDirectory(); + string fileNameToFind = "root"; + + // Start from the current directory and move up the directory tree + while (!File.Exists(Path.Combine(currentDirectory, fileNameToFind))) + { + string parentDirectory = Directory.GetParent(currentDirectory)?.FullName; + if (parentDirectory == null || parentDirectory == currentDirectory) + { + throw new Exception($"Failed to find file '{fileNameToFind}' in the directory tree."); + } + + currentDirectory = parentDirectory; + } + + return currentDirectory; + } + } +} diff --git a/tests/AzurePipelines.TestLogger.Tests/MockAzureDevOpsTestRunLogCollectorServer.cs b/tests/AzurePipelines.TestLogger.Tests/MockAzureDevOpsTestRunLogCollectorServer.cs new file mode 100644 index 0000000..91a7edf --- /dev/null +++ b/tests/AzurePipelines.TestLogger.Tests/MockAzureDevOpsTestRunLogCollectorServer.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace AzurePipelines.TestLogger.Tests +{ + internal class MockAzureDevOpsTestRunLogCollectorServer + { + public void ConfigureServices(IServiceCollection services) + { + services.AddMvcCore(); + } + + public void Configure(IApplicationBuilder app) + { + app.UseMvc(routes => + { + routes.MapRoute(name: "default", template: "{controller=Home}/{action=Index}/{id?}"); + }); + + app.UseMiddleware(); + } + } +} \ No newline at end of file diff --git a/tests/AzurePipelines.TestLogger.Tests/ProcessRunner.cs b/tests/AzurePipelines.TestLogger.Tests/ProcessRunner.cs new file mode 100644 index 0000000..bb68367 --- /dev/null +++ b/tests/AzurePipelines.TestLogger.Tests/ProcessRunner.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading; + +namespace AzurePipelines.TestLogger.Tests +{ + internal class ProcessRunner + { + private StringBuilder _outputAndError; + + public int Run( + string fileName, + List arguments, + IEnumerable> environmentVariables) + { + _outputAndError?.Clear(); + _outputAndError = new StringBuilder(); + + Console.WriteLine($"\"{fileName}\" {string.Join(" ", arguments)}"); + + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = string.Join(" ", arguments), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + // Set environment variables + foreach (KeyValuePair environmentVariable in environmentVariables) + { + if (environmentVariable.Value != null) + { + startInfo.EnvironmentVariables[environmentVariable.Key] = environmentVariable.Value; + } + } + + StringBuilder output = new StringBuilder(); + StringBuilder error = new StringBuilder(); + StringBuilder outputAndError = new StringBuilder(); + + // Start the process + using (Process process = new Process { StartInfo = startInfo }) + { + process.Start(); + + Thread stdOutReaderThread = null; + Thread stdErrReaderThread = null; + + // Invoke stdOut and stdErr readers - each + // has its own thread to guarantee that they aren't + // blocked by, or cause a block to, the actual + // process running (or the gui). + stdOutReaderThread = new Thread(this.ReadStdOut); + stdOutReaderThread.Start(process); + stdErrReaderThread = new Thread(this.ReadStdErr); + stdErrReaderThread.Start(process); + + process.WaitForExit(); + int exitCode = process.ExitCode; + + if (stdOutReaderThread != null) + { + // wait for thread + stdOutReaderThread.Join(); + } + + if (stdErrReaderThread != null) + { + // wait for thread + stdErrReaderThread.Join(); + } + + Console.WriteLine($"Output:\n{_outputAndError.ToString()}"); + + // Check the exit code + Console.WriteLine($"Exit Code: {exitCode}"); + + return exitCode; + } + } + + private void ReadStdOut(object processObj) + { + try + { + string str; + while ((str = ((Process)processObj).StandardOutput.ReadLine()) != null) + { + _outputAndError.AppendLine(str); + } + } + catch + { + } + } + + private void ReadStdErr(object processObj) + { + try + { + string str; + while ((str = ((Process)processObj).StandardError.ReadLine()) != null) + { + _outputAndError.AppendLine(str); + } + } + catch + { + } + } + } +} diff --git a/tests/AzurePipelines.TestLogger.Tests/RequestStore.cs b/tests/AzurePipelines.TestLogger.Tests/RequestStore.cs new file mode 100644 index 0000000..5e23bd9 --- /dev/null +++ b/tests/AzurePipelines.TestLogger.Tests/RequestStore.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; + +namespace AzurePipelines.TestLogger.Tests +{ + internal class RequestStore : List, IRequestStore + { + } +} diff --git a/tests/SampleUnitTestProject/SampleUnitTestProject.csproj b/tests/SampleUnitTestProject/SampleUnitTestProject.csproj new file mode 100644 index 0000000..76f6681 --- /dev/null +++ b/tests/SampleUnitTestProject/SampleUnitTestProject.csproj @@ -0,0 +1,17 @@ + + + netcoreapp2.1 + false + Library + false + + + + + 2.2.10 + + + 2.2.10 + + + \ No newline at end of file diff --git a/tests/SampleUnitTestProject/UnitTest1.cs b/tests/SampleUnitTestProject/UnitTest1.cs new file mode 100644 index 0000000..8bc54dc --- /dev/null +++ b/tests/SampleUnitTestProject/UnitTest1.cs @@ -0,0 +1,20 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace SampleUnitTestProject +{ + [TestClass] + public class UnitTest1 + { + [TestMethod] + public void TestMethod() + { + } + + [DataTestMethod] + [DataRow(1)] + [DataRow(2)] + public void DataTestMethod(int parameter) + { + } + } +}