diff --git a/.vscode/settings.json b/.vscode/settings.json index 89a3c7cca0..3a67def99f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,9 +1,9 @@ { - "files.trimTrailingWhitespace": true, - "files.associations": { - "*.*proj": "xml", - "*.props": "xml", - "*.targets": "xml", - "*.tasks": "xml" - } + "files.trimTrailingWhitespace": true, + "files.associations": { + "*.*proj": "xml", + "*.props": "xml", + "*.targets": "xml", + "*.tasks": "xml" + } } diff --git a/eng/Build.props b/eng/Build.props index e47402faf8..fa5e1d21d6 100644 --- a/eng/Build.props +++ b/eng/Build.props @@ -28,6 +28,7 @@ + + @@ -172,6 +174,7 @@ and are generated based on the last package release. + diff --git a/eng/Signing.props b/eng/Signing.props index 0dc432069d..f56fab785a 100644 --- a/eng/Signing.props +++ b/eng/Signing.props @@ -97,6 +97,12 @@ <_DotNetFilesToExclude Include="$(RedistNetCorePath)dotnet.exe" CertificateName="None" /> + + + 2.3.2 10.0.1 + 15.8.166 15.8.166 + 1.2.6 15.8.166 3.0.0 3.0.0 @@ -224,7 +226,7 @@ 4.2.1 4.2.1 3.8.0 - 0.1.22-pre3 + 0.2.23-pre1 3.0.0-preview7.33 3.0.0-preview7.33 3.0.0-preview7.33 @@ -235,6 +237,7 @@ 0.10.1 1.0.2 12.0.1 + 13.0.4 3.12.1 17.17134.0 2.43.0 diff --git a/src/Components/Components/src/Routing/RouteEntry.cs b/src/Components/Components/src/Routing/RouteEntry.cs index 3870ef2245..d2bca6d2a7 100644 --- a/src/Components/Components/src/Routing/RouteEntry.cs +++ b/src/Components/Components/src/Routing/RouteEntry.cs @@ -3,9 +3,11 @@ using System; using System.Collections.Generic; +using System.Diagnostics; namespace Microsoft.AspNetCore.Components.Routing { + [DebuggerDisplay("Handler = {Handler}, Template = {Template}")] internal class RouteEntry { public RouteEntry(RouteTemplate template, Type handler, string[] unusedRouteParameterNames) diff --git a/src/Components/Components/src/Routing/RouteTableFactory.cs b/src/Components/Components/src/Routing/RouteTableFactory.cs index fc148b708a..de6fe31c1d 100644 --- a/src/Components/Components/src/Routing/RouteTableFactory.cs +++ b/src/Components/Components/src/Routing/RouteTableFactory.cs @@ -114,6 +114,11 @@ namespace Microsoft.AspNetCore.Components /// internal static int RouteComparison(RouteEntry x, RouteEntry y) { + if (ReferenceEquals(x, y)) + { + return 0; + } + var xTemplate = x.Template; var yTemplate = y.Template; if (xTemplate.Segments.Length != y.Template.Segments.Length) diff --git a/src/Components/Components/src/Routing/RouteTemplate.cs b/src/Components/Components/src/Routing/RouteTemplate.cs index bb59be07ee..a79f0f911a 100644 --- a/src/Components/Components/src/Routing/RouteTemplate.cs +++ b/src/Components/Components/src/Routing/RouteTemplate.cs @@ -2,8 +2,11 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Diagnostics; + namespace Microsoft.AspNetCore.Components.Routing { + [DebuggerDisplay("{TemplateText}")] internal class RouteTemplate { public RouteTemplate(string templateText, TemplateSegment[] segments) diff --git a/src/Components/Components/test/Routing/RouteTableFactoryTests.cs b/src/Components/Components/test/Routing/RouteTableFactoryTests.cs index 89bf2db1cc..92bea90d79 100644 --- a/src/Components/Components/test/Routing/RouteTableFactoryTests.cs +++ b/src/Components/Components/test/Routing/RouteTableFactoryTests.cs @@ -366,6 +366,41 @@ namespace Microsoft.AspNetCore.Components.Test.Routing Assert.Equal("a/brilliant", routeTable.Routes[0].Template.TemplateText); } + [Fact] + public void DoesNotThrowIfStableSortComparesRouteWithItself() + { + // Test for https://github.com/aspnet/AspNetCore/issues/13313 + // Arrange & Act + var builder = new TestRouteTableBuilder(); + builder.AddRoute("r16"); + builder.AddRoute("r05"); + builder.AddRoute("r09"); + builder.AddRoute("r00"); + builder.AddRoute("r13"); + builder.AddRoute("r02"); + builder.AddRoute("r03"); + builder.AddRoute("r10"); + builder.AddRoute("r15"); + builder.AddRoute("r14"); + builder.AddRoute("r12"); + builder.AddRoute("r07"); + builder.AddRoute("r11"); + builder.AddRoute("r08"); + builder.AddRoute("r06"); + builder.AddRoute("r04"); + builder.AddRoute("r01"); + + var routeTable = builder.Build(); + + // Act + Assert.Equal(17, routeTable.Routes.Length); + for (var i = 0; i < 17; i++) + { + var templateText = "r" + i.ToString().PadLeft(2, '0'); + Assert.Equal(templateText, routeTable.Routes[i].Template.TemplateText); + } + } + [Theory] [InlineData("/literal", "/Literal/")] [InlineData("/{parameter}", "/{parameter}/")] diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index d1e851f090..0d7c7336dd 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -649,12 +649,12 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits { _intializationStarted = LoggerMessage.Define( LogLevel.Debug, - EventIds.InitializationFailed, + EventIds.InitializationStarted, "Circuit initialization started."); _intializationSucceded = LoggerMessage.Define( LogLevel.Debug, - EventIds.InitializationFailed, + EventIds.InitializationSucceeded, "Circuit initialization succeeded."); _intializationFailed = LoggerMessage.Define( diff --git a/src/Components/test/E2ETest/ServerExecutionTests/ComponentHubInvalidEventTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/ComponentHubInvalidEventTest.cs new file mode 100644 index 0000000000..8621107ec4 --- /dev/null +++ b/src/Components/test/E2ETest/ServerExecutionTests/ComponentHubInvalidEventTest.cs @@ -0,0 +1,138 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.E2ETest; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETests.ServerExecutionTests +{ + public class ComponentHubInvalidEventTest : IgnitorTest + { + public ComponentHubInvalidEventTest(AspNetSiteServerFixture serverFixture, ITestOutputHelper output) + : base(serverFixture, output) + { + } + + protected override void InitializeFixture(AspNetSiteServerFixture serverFixture) + { + serverFixture.BuildWebHostMethod = TestServer.Program.BuildWebHost; + } + + protected async override Task InitializeAsync() + { + var rootUri = ServerFixture.RootUri; + Assert.True(await Client.ConnectAsync(new Uri(rootUri, "/subdir")), "Couldn't connect to the app"); + Assert.Single(Batches); + + await Client.SelectAsync("test-selector-select", "BasicTestApp.CounterComponent"); + Assert.Equal(2, Batches.Count); + } + + [Fact] + public async Task DispatchingAnInvalidEventArgument_DoesNotProduceWarnings() + { + // Arrange + var expectedError = $"There was an unhandled exception on the current circuit, so this circuit will be terminated. For more details turn on " + + $"detailed exceptions in 'CircuitOptions.DetailedErrors'. Bad input data."; + + var eventDescriptor = Serialize(new WebEventDescriptor() + { + BrowserRendererId = 0, + EventHandlerId = 3, + EventArgsType = "mouse", + }); + + // Act + await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync( + "DispatchBrowserEvent", + eventDescriptor, + "{sadfadsf]")); + + // Assert + var actualError = Assert.Single(Errors); + Assert.Equal(expectedError, actualError); + Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); + Assert.Contains(Logs, l => (l.LogLevel, l.Exception?.Message) == (LogLevel.Debug, "There was an error parsing the event arguments. EventId: '3'.")); + } + + [Fact] + public async Task DispatchingAnInvalidEvent_DoesNotTriggerWarnings() + { + // Arrange + var expectedError = $"There was an unhandled exception on the current circuit, so this circuit will be terminated. For more details turn on " + + $"detailed exceptions in 'CircuitOptions.DetailedErrors'. Failed to dispatch event."; + + var eventDescriptor = Serialize(new WebEventDescriptor() + { + BrowserRendererId = 0, + EventHandlerId = 1990, + EventArgsType = "mouse", + }); + + var eventArgs = new MouseEventArgs + { + Type = "click", + Detail = 1, + ScreenX = 47, + ScreenY = 258, + ClientX = 47, + ClientY = 155, + }; + + // Act + await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync( + "DispatchBrowserEvent", + eventDescriptor, + Serialize(eventArgs))); + + // Assert + var actualError = Assert.Single(Errors); + Assert.Equal(expectedError, actualError); + Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); + Assert.Contains(Logs, l => (l.LogLevel, l.Message, l.Exception?.Message) == + (LogLevel.Debug, + "There was an error dispatching the event '1990' to the application.", + "There is no event handler associated with this event. EventId: '1990'. (Parameter 'eventHandlerId')")); + } + + [Fact] + public async Task DispatchingAnInvalidRenderAcknowledgement_DoesNotTriggerWarnings() + { + // Arrange + var expectedError = $"There was an unhandled exception on the current circuit, so this circuit will be terminated. For more details turn on " + + $"detailed exceptions in 'CircuitOptions.DetailedErrors'. Failed to complete render batch '1846'."; + + + Client.ConfirmRenderBatch = false; + await Client.ClickAsync("counter"); + + // Act + await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync( + "OnRenderCompleted", + 1846, + null)); + + // Assert + var actualError = Assert.Single(Errors); + Assert.Equal(expectedError, actualError); + Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); + + var entry = Assert.Single(Logs, l => l.EventId.Name == "OnRenderCompletedFailed"); + Assert.Equal(LogLevel.Debug, entry.LogLevel); + Assert.Matches("Failed to complete render batch '1846' in circuit host '.*'\\.", entry.Message); + Assert.Equal("Received an acknowledgement for batch with id '1846' when the last batch produced was '4'.", entry.Exception.Message); + } + + private string Serialize(T browserEventDescriptor) => + JsonSerializer.Serialize(browserEventDescriptor, TestJsonSerializerOptionsProvider.Options); + } +} diff --git a/src/Components/test/E2ETest/ServerExecutionTests/ComponentHubReliabilityTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/ComponentHubReliabilityTest.cs index a1401a4aca..8d770e64de 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/ComponentHubReliabilityTest.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/ComponentHubReliabilityTest.cs @@ -2,9 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -13,58 +11,22 @@ using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.SignalR.Client; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Testing; using Xunit; using Xunit.Abstractions; namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests { - public class ComponentHubReliabilityTest : IClassFixture, IDisposable + public class ComponentHubReliabilityTest : IgnitorTest { - private static readonly TimeSpan DefaultLatencyTimeout = Debugger.IsAttached ? TimeSpan.MaxValue : TimeSpan.FromSeconds(10); - private readonly AspNetSiteServerFixture _serverFixture; - public ComponentHubReliabilityTest(AspNetSiteServerFixture serverFixture, ITestOutputHelper output) + : base(serverFixture, output) { - _serverFixture = serverFixture; - Output = output; + } + protected override void InitializeFixture(AspNetSiteServerFixture serverFixture) + { serverFixture.BuildWebHostMethod = TestServer.Program.BuildWebHost; - CreateDefaultConfiguration(); - } - - public BlazorClient Client { get; set; } - public ITestOutputHelper Output { get; set; } - private IList Batches { get; set; } = new List(); - private IList Errors { get; set; } = new List(); - private ConcurrentQueue Logs { get; set; } = new ConcurrentQueue(); - - public TestSink TestSink { get; set; } - - private void CreateDefaultConfiguration() - { - Client = new BlazorClient() { DefaultLatencyTimeout = DefaultLatencyTimeout }; - Client.RenderBatchReceived += (id, data) => Batches.Add(new Batch(id, data)); - Client.OnCircuitError += (error) => Errors.Add(error); - Client.LoggerProvider = new XunitLoggerProvider(Output); - Client.FormatError = (error) => - { - var logs = string.Join(Environment.NewLine, Logs); - return new Exception(error + Environment.NewLine + logs); - }; - - _ = _serverFixture.RootUri; // this is needed for the side-effects of getting the URI. - TestSink = _serverFixture.Host.Services.GetRequiredService(); - TestSink.MessageLogged += LogMessages; - } - - private void LogMessages(WriteContext context) - { - var log = new LogMessage(context.LogLevel, context.EventId, context.Message, context.Exception); - Logs.Enqueue(log); - Output.WriteLine(log.ToString()); } [Fact] @@ -72,10 +34,11 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests { // Arrange var expectedError = "The circuit host '.*?' has already been initialized."; - var rootUri = _serverFixture.RootUri; + var rootUri = ServerFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri), "Couldn't connect to the app"); Assert.Single(Batches); + var descriptors = await Client.GetPrerenderDescriptors(baseUri); // Act @@ -96,7 +59,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests { // Arrange var expectedError = "The uris provided are invalid."; - var rootUri = _serverFixture.RootUri; + var rootUri = ServerFixture.RootUri; var uri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(uri, connectAutomatically: false), "Couldn't connect to the app"); var descriptors = await Client.GetPrerenderDescriptors(uri); @@ -117,7 +80,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests { // Arrange var expectedError = "The circuit failed to initialize."; - var rootUri = _serverFixture.RootUri; + var rootUri = ServerFixture.RootUri; var uri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(uri, connectAutomatically: false), "Couldn't connect to the app"); var descriptors = await Client.GetPrerenderDescriptors(uri); @@ -138,7 +101,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests { // Arrange var expectedError = "Circuit not initialized."; - var rootUri = _serverFixture.RootUri; + var rootUri = ServerFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, connectAutomatically: false)); Assert.Empty(Batches); @@ -164,7 +127,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests { // Arrange var expectedError = "Circuit not initialized."; - var rootUri = _serverFixture.RootUri; + var rootUri = ServerFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, connectAutomatically: false)); Assert.Empty(Batches); @@ -188,7 +151,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests { // Arrange var expectedError = "Circuit not initialized."; - var rootUri = _serverFixture.RootUri; + var rootUri = ServerFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, connectAutomatically: false)); Assert.Empty(Batches); @@ -206,125 +169,12 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests Assert.Contains(Logs, l => (l.LogLevel, l.Message) == (LogLevel.Debug, "Call to 'DispatchBrowserEvent' received before the circuit host initialization")); } - private async Task GoToTestComponent(IList batches) - { - var rootUri = _serverFixture.RootUri; - Assert.True(await Client.ConnectAsync(new Uri(rootUri, "/subdir")), "Couldn't connect to the app"); - Assert.Single(batches); - - await Client.SelectAsync("test-selector-select", "BasicTestApp.CounterComponent"); - Assert.Equal(2, batches.Count); - } - - [Fact] - public async Task DispatchingAnInvalidEventArgument_DoesNotProduceWarnings() - { - // Arrange - var expectedError = $"There was an unhandled exception on the current circuit, so this circuit will be terminated. For more details turn on " + - $"detailed exceptions in 'CircuitOptions.DetailedErrors'. Bad input data."; - - var eventDescriptor = Serialize(new WebEventDescriptor() - { - BrowserRendererId = 0, - EventHandlerId = 3, - EventArgsType = "mouse", - }); - - await GoToTestComponent(Batches); - Assert.Equal(2, Batches.Count); - - // Act - await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync( - "DispatchBrowserEvent", - eventDescriptor, - "{sadfadsf]")); - - // Assert - var actualError = Assert.Single(Errors); - Assert.Equal(expectedError, actualError); - Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); - Assert.Contains(Logs, l => (l.LogLevel, l.Exception?.Message) == (LogLevel.Debug, "There was an error parsing the event arguments. EventId: '3'.")); - } - - [Fact] - public async Task DispatchingAnInvalidEvent_DoesNotTriggerWarnings() - { - // Arrange - var expectedError = $"There was an unhandled exception on the current circuit, so this circuit will be terminated. For more details turn on " + - $"detailed exceptions in 'CircuitOptions.DetailedErrors'. Failed to dispatch event."; - - var eventDescriptor = Serialize(new WebEventDescriptor() - { - BrowserRendererId = 0, - EventHandlerId = 1990, - EventArgsType = "mouse", - }); - - var eventArgs = new MouseEventArgs - { - Type = "click", - Detail = 1, - ScreenX = 47, - ScreenY = 258, - ClientX = 47, - ClientY = 155, - }; - - await GoToTestComponent(Batches); - Assert.Equal(2, Batches.Count); - - // Act - await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync( - "DispatchBrowserEvent", - eventDescriptor, - Serialize(eventArgs))); - - // Assert - var actualError = Assert.Single(Errors); - Assert.Equal(expectedError, actualError); - Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); - Assert.Contains(Logs, l => (l.LogLevel, l.Message, l.Exception?.Message) == - (LogLevel.Debug, - "There was an error dispatching the event '1990' to the application.", - "There is no event handler associated with this event. EventId: '1990'. (Parameter 'eventHandlerId')")); - } - - [Fact] - public async Task DispatchingAnInvalidRenderAcknowledgement_DoesNotTriggerWarnings() - { - // Arrange - var expectedError = $"There was an unhandled exception on the current circuit, so this circuit will be terminated. For more details turn on " + - $"detailed exceptions in 'CircuitOptions.DetailedErrors'. Failed to complete render batch '1846'."; - - await GoToTestComponent(Batches); - Assert.Equal(2, Batches.Count); - - Client.ConfirmRenderBatch = false; - await Client.ClickAsync("counter"); - - // Act - await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync( - "OnRenderCompleted", - 1846, - null)); - - // Assert - var actualError = Assert.Single(Errors); - Assert.Equal(expectedError, actualError); - Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information); - - var entry = Assert.Single(Logs, l => l.EventId.Name == "OnRenderCompletedFailed"); - Assert.Equal(LogLevel.Debug, entry.LogLevel); - Assert.Matches("Failed to complete render batch '1846' in circuit host '.*'\\.", entry.Message); - Assert.Equal("Received an acknowledgement for batch with id '1846' when the last batch produced was '4'.", entry.Exception.Message); - } - [Fact] public async Task CannotInvokeOnRenderCompletedBeforeInitialization() { // Arrange var expectedError = "Circuit not initialized."; - var rootUri = _serverFixture.RootUri; + var rootUri = ServerFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, connectAutomatically: false)); Assert.Empty(Batches); @@ -347,7 +197,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests { // Arrange var expectedError = "Circuit not initialized."; - var rootUri = _serverFixture.RootUri; + var rootUri = ServerFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri, connectAutomatically: false)); Assert.Empty(Batches); @@ -373,7 +223,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests "For more details turn on detailed exceptions in 'CircuitOptions.DetailedErrors'. " + "Location change to 'http://example.com' failed."; - var rootUri = _serverFixture.RootUri; + var rootUri = ServerFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri), "Couldn't connect to the app"); Assert.Single(Batches); @@ -402,7 +252,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests "For more details turn on detailed exceptions in 'CircuitOptions.DetailedErrors'. " + "Location change failed."; - var rootUri = _serverFixture.RootUri; + var rootUri = ServerFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri), "Couldn't connect to the app"); Assert.Single(Batches); @@ -421,7 +271,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests var entry = Assert.Single(Logs, l => l.EventId.Name == "LocationChangeFailed"); Assert.Equal(LogLevel.Error, entry.LogLevel); - Assert.Matches($"Location change to '{new Uri(_serverFixture.RootUri, "/test")}' in circuit '.*' failed\\.", entry.Message); + Assert.Matches($"Location change to '{new Uri(ServerFixture.RootUri, "/test")}' in circuit '.*' failed\\.", entry.Message); } [Theory] @@ -436,7 +286,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests { // Arrange var expectedError = "Unhandled exception in circuit .*"; - var rootUri = _serverFixture.RootUri; + var rootUri = ServerFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri), "Couldn't connect to the app"); Assert.Single(Batches); @@ -467,7 +317,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests { // Arrange var expectedError = "Unhandled exception in circuit .*"; - var rootUri = _serverFixture.RootUri; + var rootUri = ServerFixture.RootUri; var baseUri = new Uri(rootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri), "Couldn't connect to the app"); Assert.Single(Batches); @@ -493,47 +343,5 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests Logs, e => LogLevel.Error == e.LogLevel && Regex.IsMatch(e.Message, expectedError)); } - - public void Dispose() - { - TestSink.MessageLogged -= LogMessages; - } - - private string Serialize(T browserEventDescriptor) => - JsonSerializer.Serialize(browserEventDescriptor, TestJsonSerializerOptionsProvider.Options); - - [DebuggerDisplay("{LogLevel.ToString(),nq} - {Message ?? \"null\",nq} - {Exception?.Message,nq}")] - private class LogMessage - { - public LogMessage(LogLevel logLevel, EventId eventId, string message, Exception exception) - { - LogLevel = logLevel; - EventId = eventId; - Message = message; - Exception = exception; - } - - public LogLevel LogLevel { get; set; } - public EventId EventId { get; set; } - public string Message { get; set; } - public Exception Exception { get; set; } - - public override string ToString() - { - return $"{LogLevel}: {EventId} {Message}{(Exception != null ? Environment.NewLine : "")}{Exception}"; - } - } - - private class Batch - { - public Batch(int id, byte[] data) - { - Id = id; - Data = data; - } - - public int Id { get; } - public byte[] Data { get; } - } } } diff --git a/src/Components/test/E2ETest/ServerExecutionTests/IgnitorTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/IgnitorTest.cs new file mode 100644 index 0000000000..ed9cd36c68 --- /dev/null +++ b/src/Components/test/E2ETest/ServerExecutionTests/IgnitorTest.cs @@ -0,0 +1,131 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Ignitor; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components +{ + // Base class for Ignitor-based tests. + public abstract class IgnitorTest : IClassFixture, IAsyncLifetime + where TFixture : ServerFixture + { + private static readonly TimeSpan DefaultTimeout = Debugger.IsAttached ? TimeSpan.MaxValue : TimeSpan.FromSeconds(30); + + protected IgnitorTest(TFixture serverFixture, ITestOutputHelper output) + { + ServerFixture = serverFixture; + Output = output; + } + + protected BlazorClient Client { get; private set; } + + protected ConcurrentQueue Logs { get; } = new ConcurrentQueue(); + + protected ITestOutputHelper Output { get; } + + protected TFixture ServerFixture { get; } + + protected TimeSpan Timeout { get; set; } = DefaultTimeout; + + private TestSink TestSink { get; set; } + + protected IReadOnlyCollection Batches => Client?.Operations?.Batches; + + protected IReadOnlyCollection DotNetCompletions => Client?.Operations?.DotNetCompletions; + + protected IReadOnlyCollection Errors => Client?.Operations?.Errors; + + protected IReadOnlyCollection JSInteropCalls => Client?.Operations?.JSInteropCalls; + + // Called to initialize the fixture as part of InitializeAsync. + protected virtual void InitializeFixture(TFixture serverFixture) + { + } + + async Task IAsyncLifetime.InitializeAsync() + { + Client = new BlazorClient() + { + CaptureOperations = true, + DefaultOperationTimeout = Timeout, + }; + Client.LoggerProvider = new XunitLoggerProvider(Output); + Client.FormatError = (error) => + { + var logs = string.Join(Environment.NewLine, Logs); + return new Exception(error + Environment.NewLine + logs); + }; + + InitializeFixture(ServerFixture); + _ = ServerFixture.RootUri; // This is needed for the side-effects of starting the server. + + if (ServerFixture is WebHostServerFixture hostFixture) + { + TestSink = hostFixture.Host.Services.GetRequiredService(); + TestSink.MessageLogged += TestSink_MessageLogged; + } + + await InitializeAsync(); + } + + async Task IAsyncLifetime.DisposeAsync() + { + if (TestSink != null) + { + TestSink.MessageLogged -= TestSink_MessageLogged; + } + + await DisposeAsync(); + } + + protected virtual Task InitializeAsync() + { + return Task.CompletedTask; + } + + protected virtual Task DisposeAsync() + { + return Task.CompletedTask; + } + + private void TestSink_MessageLogged(WriteContext context) + { + var log = new LogMessage(context.LogLevel, context.EventId, context.Message, context.Exception); + Logs.Enqueue(log); + Output.WriteLine(log.ToString()); + } + + [DebuggerDisplay("{LogLevel.ToString(),nq} - {Message ?? \"null\",nq} - {Exception?.Message,nq}")] + protected sealed class LogMessage + { + public LogMessage(LogLevel logLevel, EventId eventId, string message, Exception exception) + { + LogLevel = logLevel; + EventId = eventId; + Message = message; + Exception = exception; + } + + public LogLevel LogLevel { get; set; } + public EventId EventId { get; set; } + public string Message { get; set; } + public Exception Exception { get; set; } + + public override string ToString() + { + return $"{LogLevel}: {EventId} {Message}{(Exception != null ? Environment.NewLine : "")}{Exception}"; + } + } + } +} diff --git a/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs b/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs index ff81e5e337..a848669b78 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs @@ -2,7 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Text.Json; @@ -21,55 +20,26 @@ using Xunit.Abstractions; namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests { - [Flaky("https://github.com/aspnet/AspNetCore/issues/13086", FlakyOn.All)] - public class InteropReliabilityTests : IClassFixture, IDisposable + public class InteropReliabilityTests : IgnitorTest { - private static readonly TimeSpan DefaultLatencyTimeout = TimeSpan.FromSeconds(30); - private readonly AspNetSiteServerFixture _serverFixture; - public InteropReliabilityTests(AspNetSiteServerFixture serverFixture, ITestOutputHelper output) + : base(serverFixture, output) { - _serverFixture = serverFixture; - Output = output; + } + protected override void InitializeFixture(AspNetSiteServerFixture serverFixture) + { serverFixture.BuildWebHostMethod = TestServer.Program.BuildWebHost; - CreateDefaultConfiguration(); } - public BlazorClient Client { get; set; } - public ITestOutputHelper Output { get; set; } - private IList Batches { get; set; } = new List(); - private List DotNetCompletions = new List(); - private List JSInteropCalls = new List(); - private IList Errors { get; set; } = new List(); - private ConcurrentQueue Logs { get; set; } = new ConcurrentQueue(); - - public TestSink TestSink { get; set; } - - private void CreateDefaultConfiguration() + protected async override Task InitializeAsync() { - Client = new BlazorClient() { DefaultLatencyTimeout = DefaultLatencyTimeout }; - Client.RenderBatchReceived += (id, data) => Batches.Add(new Batch(id, data)); - Client.DotNetInteropCompletion += (method) => DotNetCompletions.Add(new DotNetCompletion(method)); - Client.JSInterop += (asyncHandle, identifier, argsJson) => JSInteropCalls.Add(new JSInteropCall(asyncHandle, identifier, argsJson)); - Client.OnCircuitError += (error) => Errors.Add(error); - Client.LoggerProvider = new XunitLoggerProvider(Output); - Client.FormatError = (error) => - { - var logs = string.Join(Environment.NewLine, Logs); - return new Exception(error + Environment.NewLine + logs); - }; + var rootUri = ServerFixture.RootUri; + Assert.True(await Client.ConnectAsync(new Uri(rootUri, "/subdir")), "Couldn't connect to the app"); + Assert.Single(Batches); - _ = _serverFixture.RootUri; // this is needed for the side-effects of getting the URI. - TestSink = _serverFixture.Host.Services.GetRequiredService(); - TestSink.MessageLogged += LogMessages; - } - - private void LogMessages(WriteContext context) - { - var log = new LogMessage(context.LogLevel, context.Message, context.Exception); - Logs.Enqueue(log); - Output.WriteLine(log.ToString()); + await Client.SelectAsync("test-selector-select", "BasicTestApp.ReliabilityComponent"); + Assert.Equal(2, Batches.Count); } [Fact] @@ -79,7 +49,6 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests var expectedError = "[\"1\"," + "false," + "\"There was an exception invoking \\u0027WriteAllText\\u0027 on assembly \\u0027System.IO.FileSystem\\u0027. For more details turn on detailed exceptions in \\u0027CircuitOptions.DetailedErrors\\u0027\"]"; - await GoToTestComponent(Batches); // Act await Client.InvokeDotNetMethod( @@ -90,7 +59,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests JsonSerializer.Serialize(new[] { ".\\log.txt", "log" })); // Assert - Assert.Single(DotNetCompletions, c => c.Message == expectedError); + Assert.Single(DotNetCompletions, c => c == expectedError); await ValidateClientKeepsWorking(Client, Batches); } @@ -102,8 +71,6 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests "false," + "\"There was an exception invoking \\u0027MadeUpMethod\\u0027 on assembly \\u0027BasicTestApp\\u0027. For more details turn on detailed exceptions in \\u0027CircuitOptions.DetailedErrors\\u0027\"]"; - await GoToTestComponent(Batches); - // Act await Client.InvokeDotNetMethod( "1", @@ -113,7 +80,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests JsonSerializer.Serialize(new[] { ".\\log.txt", "log" })); // Assert - Assert.Single(DotNetCompletions, c => c.Message == expectedError); + Assert.Single(DotNetCompletions, c => c == expectedError); await ValidateClientKeepsWorking(Client, Batches); } @@ -125,18 +92,16 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests "false," + "\"There was an exception invoking \\u0027NotifyLocationChanged\\u0027 on assembly \\u0027Microsoft.AspNetCore.Components.Server\\u0027. For more details turn on detailed exceptions in \\u0027CircuitOptions.DetailedErrors\\u0027\"]"; - await GoToTestComponent(Batches); - // Act await Client.InvokeDotNetMethod( "1", "Microsoft.AspNetCore.Components.Server", "NotifyLocationChanged", null, - JsonSerializer.Serialize(new[] { _serverFixture.RootUri })); + JsonSerializer.Serialize(new[] { ServerFixture.RootUri })); // Assert - Assert.Single(DotNetCompletions, c => c.Message == expectedError); + Assert.Single(DotNetCompletions, c => c == expectedError); await ValidateClientKeepsWorking(Client, Batches); } @@ -148,18 +113,16 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests "false," + "\"There was an exception invoking \\u0027NotifyLocationChanged\\u0027 on assembly \\u0027\\u0027. For more details turn on detailed exceptions in \\u0027CircuitOptions.DetailedErrors\\u0027\"]"; - await GoToTestComponent(Batches); - // Act await Client.InvokeDotNetMethod( "1", "", "NotifyLocationChanged", null, - JsonSerializer.Serialize(new object[] { _serverFixture.RootUri + "counter", false })); + JsonSerializer.Serialize(new object[] { ServerFixture.RootUri + "counter", false })); // Assert - Assert.Single(DotNetCompletions, c => c.Message == expectedError); + Assert.Single(DotNetCompletions, c => c == expectedError); await ValidateClientKeepsWorking(Client, Batches); } @@ -171,18 +134,16 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests "false," + "\"There was an exception invoking \\u0027\\u0027 on assembly \\u0027Microsoft.AspNetCore.Components.Server\\u0027. For more details turn on detailed exceptions in \\u0027CircuitOptions.DetailedErrors\\u0027\"]"; - await GoToTestComponent(Batches); - // Act await Client.InvokeDotNetMethod( "1", "Microsoft.AspNetCore.Components.Server", "", null, - JsonSerializer.Serialize(new object[] { _serverFixture.RootUri + "counter", false })); + JsonSerializer.Serialize(new object[] { ServerFixture.RootUri + "counter", false })); // Assert - Assert.Single(DotNetCompletions, c => c.Message == expectedError); + Assert.Single(DotNetCompletions, c => c == expectedError); await ValidateClientKeepsWorking(Client, Batches); } @@ -196,8 +157,6 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests "false," + "\"There was an exception invoking \\u0027Reverse\\u0027. For more details turn on detailed exceptions in \\u0027CircuitOptions.DetailedErrors\\u0027\"]"; - await GoToTestComponent(Batches); - // Act await Client.InvokeDotNetMethod( "1", @@ -206,7 +165,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests null, JsonSerializer.Serialize(Array.Empty())); - Assert.Single(DotNetCompletions, c => c.Message == expectedDotNetObjectRef); + Assert.Single(DotNetCompletions, c => c == expectedDotNetObjectRef); await Client.InvokeDotNetMethod( "1", @@ -216,7 +175,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests JsonSerializer.Serialize(Array.Empty())); // Assert - Assert.Single(DotNetCompletions, c => c.Message == "[\"1\",true,\"tnatropmI\"]"); + Assert.Single(DotNetCompletions, c => c == "[\"1\",true,\"tnatropmI\"]"); await Client.InvokeDotNetMethod( "1", @@ -225,7 +184,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests 3, // non existing ref JsonSerializer.Serialize(Array.Empty())); - Assert.Single(DotNetCompletions, c => c.Message == expectedError); + Assert.Single(DotNetCompletions, c => c == expectedError); await ValidateClientKeepsWorking(Client, Batches); } @@ -238,8 +197,6 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests "false," + "\"There was an exception invoking \\u0027ReceiveTrivial\\u0027 on assembly \\u0027BasicTestApp\\u0027. For more details turn on detailed exceptions in \\u0027CircuitOptions.DetailedErrors\\u0027\"]"; - await GoToTestComponent(Batches); - await Client.InvokeDotNetMethod( "1", "BasicTestApp", @@ -247,7 +204,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests null, JsonSerializer.Serialize(Array.Empty())); - Assert.Single(DotNetCompletions, c => c.Message == expectedImportantDotNetObjectRef); + Assert.Single(DotNetCompletions, c => c == expectedImportantDotNetObjectRef); // Act await Client.InvokeDotNetMethod( @@ -258,19 +215,16 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests JsonSerializer.Serialize(new object[] { new { __dotNetObject = 1 } })); // Assert - Assert.Single(DotNetCompletions, c => c.Message == expectedError); + Assert.Single(DotNetCompletions, c => c == expectedError); await ValidateClientKeepsWorking(Client, Batches); } [Fact] - [Flaky("https://github.com/aspnet/AspNetCore/issues/13086", FlakyOn.AzP.Windows)] public async Task ContinuesWorkingAfterInvalidAsyncReturnCallback() { // Arrange var expectedError = "An exception occurred executing JS interop: The JSON value could not be converted to System.Int32. Path: $ | LineNumber: 0 | BytePositionInLine: 3.. See InnerException for more details."; - await GoToTestComponent(Batches); - // Act await Client.ClickAsync("triggerjsinterop-malformed"); @@ -278,11 +232,14 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests Assert.NotEqual(default, call); var id = call.AsyncHandle; - await Client.HubConnection.InvokeAsync( - "EndInvokeJSFromDotNet", - id, - true, - $"[{id}, true, \"{{\"]"); + await Client.ExpectRenderBatch(async () => + { + await Client.HubConnection.InvokeAsync( + "EndInvokeJSFromDotNet", + id, + true, + $"[{id}, true, \"{{\"]"); + }); var text = Assert.Single( Client.FindElementById("errormessage-malformed").Children.OfType(), @@ -295,10 +252,6 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests public async Task JSInteropCompletionSuccess() { // Arrange - await GoToTestComponent(Batches); - var sink = _serverFixture.Host.Services.GetRequiredService(); - var logEvents = new List<(LogLevel logLevel, string)>(); - sink.MessageLogged += (wc) => logEvents.Add((wc.LogLevel, wc.EventId.Name)); // Act await Client.ClickAsync("triggerjsinterop-success"); @@ -307,27 +260,27 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests Assert.NotEqual(default, call); var id = call.AsyncHandle; - await Client.HubConnection.InvokeAsync( - "EndInvokeJSFromDotNet", - id++, - true, - $"[{id}, true, null]"); + await Client.ExpectRenderBatch(async () => + { + await Client.HubConnection.InvokeAsync( + "EndInvokeJSFromDotNet", + id, + true, + $"[{id}, true, null]"); + }); Assert.Single( Client.FindElementById("errormessage-success").Children.OfType(), e => "" == e.TextContent); - Assert.Contains((LogLevel.Debug, "EndInvokeJSSucceeded"), logEvents); + var entry = Assert.Single(Logs, l => l.EventId.Name == "EndInvokeJSSucceeded"); + Assert.Equal(LogLevel.Debug, entry.LogLevel); } [Fact] public async Task JSInteropThrowsInUserCode() { // Arrange - await GoToTestComponent(Batches); - var sink = _serverFixture.Host.Services.GetRequiredService(); - var logEvents = new List<(LogLevel logLevel, string)>(); - sink.MessageLogged += (wc) => logEvents.Add((wc.LogLevel, wc.EventId.Name)); // Act await Client.ClickAsync("triggerjsinterop-failure"); @@ -349,9 +302,10 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests Client.FindElementById("errormessage-failure").Children.OfType(), e => "There was an error invoking sendFailureCallbackReturn" == e.TextContent); - Assert.Contains((LogLevel.Debug, "EndInvokeJSFailed"), logEvents); + var entry = Assert.Single(Logs, l => l.EventId.Name == "EndInvokeJSFailed"); + Assert.Equal(LogLevel.Debug, entry.LogLevel); - Assert.DoesNotContain(logEvents, m => m.logLevel > LogLevel.Information); + Assert.DoesNotContain(Logs, m => m.LogLevel > LogLevel.Information); await ValidateClientKeepsWorking(Client, Batches); } @@ -360,10 +314,6 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests public async Task MalformedJSInteropCallbackDisposesCircuit() { // Arrange - await GoToTestComponent(Batches); - var sink = _serverFixture.Host.Services.GetRequiredService(); - var logEvents = new List<(LogLevel logLevel, string)>(); - sink.MessageLogged += (wc) => logEvents.Add((wc.LogLevel, wc.EventId.Name)); // Act await Client.ClickAsync("triggerjsinterop-malformed"); @@ -386,7 +336,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests Client.FindElementById("errormessage-malformed").Children.OfType(), e => "" == e.TextContent); - Assert.Contains((LogLevel.Debug, "EndInvokeDispatchException"), logEvents); + var entry = Assert.Single(Logs, l => l.EventId.Name == "EndInvokeDispatchException"); + Assert.Equal(LogLevel.Debug, entry.LogLevel); await Client.ExpectCircuitErrorAndDisconnect(async () => { @@ -402,8 +353,6 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests "false," + "\"There was an exception invoking \\u0027NotifyLocationChanged\\u0027 on assembly \\u0027Microsoft.AspNetCore.Components.Server\\u0027. For more details turn on detailed exceptions in \\u0027CircuitOptions.DetailedErrors\\u0027\"]"; - await GoToTestComponent(Batches); - // Act await Client.InvokeDotNetMethod( "1", @@ -413,7 +362,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests "[ \"invalidPayload\"}"); // Assert - Assert.Single(DotNetCompletions, c => c.Message == expectedError); + Assert.Single(DotNetCompletions, c => c == expectedError); await ValidateClientKeepsWorking(Client, Batches); } @@ -425,8 +374,6 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests "false," + "\"There was an exception invoking \\u0027ReceiveTrivial\\u0027 on assembly \\u0027BasicTestApp\\u0027. For more details turn on detailed exceptions in \\u0027CircuitOptions.DetailedErrors\\u0027\"]"; - await GoToTestComponent(Batches); - // Act await Client.InvokeDotNetMethod( "1", @@ -436,7 +383,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests "[ { \"data\": {\"}} ]"); // Assert - Assert.Single(DotNetCompletions, c => c.Message == expectedError); + Assert.Single(DotNetCompletions, c => c == expectedError); await ValidateClientKeepsWorking(Client, Batches); } @@ -444,10 +391,6 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests public async Task DispatchingEventsWithInvalidPayloadsShutsDownCircuitGracefully() { // Arrange - await GoToTestComponent(Batches); - var sink = _serverFixture.Host.Services.GetRequiredService(); - var logEvents = new List<(LogLevel logLevel, string)>(); - sink.MessageLogged += (wc) => logEvents.Add((wc.LogLevel, wc.EventId.Name)); // Act await Client.ExpectCircuitError(async () => @@ -458,9 +401,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests null); }); - Assert.Contains( - (LogLevel.Debug, "DispatchEventFailedToParseEventData"), - logEvents); + var entry = Assert.Single(Logs, l => l.EventId.Name == "DispatchEventFailedToParseEventData"); + Assert.Equal(LogLevel.Debug, entry.LogLevel); // Taking any other action will fail because the circuit is disposed. await Client.ExpectCircuitErrorAndDisconnect(async () => @@ -473,10 +415,6 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests public async Task DispatchingEventsWithInvalidEventDescriptor() { // Arrange - await GoToTestComponent(Batches); - var sink = _serverFixture.Host.Services.GetRequiredService(); - var logEvents = new List<(LogLevel logLevel, string)>(); - sink.MessageLogged += (wc) => logEvents.Add((wc.LogLevel, wc.EventId.Name)); // Act await Client.ExpectCircuitError(async () => @@ -487,9 +425,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests "{}"); }); - Assert.Contains( - (LogLevel.Debug, "DispatchEventFailedToParseEventData"), - logEvents); + var entry = Assert.Single(Logs, l => l.EventId.Name == "DispatchEventFailedToParseEventData"); + Assert.Equal(LogLevel.Debug, entry.LogLevel); // Taking any other action will fail because the circuit is disposed. await Client.ExpectCircuitErrorAndDisconnect(async () => @@ -502,10 +439,6 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests public async Task DispatchingEventsWithInvalidEventArgs() { // Arrange - await GoToTestComponent(Batches); - var sink = _serverFixture.Host.Services.GetRequiredService(); - var logEvents = new List<(LogLevel logLevel, string eventIdName, Exception exception)>(); - sink.MessageLogged += (wc) => logEvents.Add((wc.LogLevel, wc.EventId.Name, wc.Exception)); // Act var browserDescriptor = new WebEventDescriptor() @@ -524,9 +457,9 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests }); Assert.Contains( - logEvents, - e => e.eventIdName == "DispatchEventFailedToParseEventData" && e.logLevel == LogLevel.Debug && - e.exception.Message == "There was an error parsing the event arguments. EventId: '6'."); + Logs, + e => e.EventId.Name == "DispatchEventFailedToParseEventData" && e.LogLevel == LogLevel.Debug && + e.Exception.Message == "There was an error parsing the event arguments. EventId: '6'."); // Taking any other action will fail because the circuit is disposed. await Client.ExpectCircuitErrorAndDisconnect(async () => @@ -539,10 +472,6 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests public async Task DispatchingEventsWithInvalidEventHandlerId() { // Arrange - await GoToTestComponent(Batches); - var sink = _serverFixture.Host.Services.GetRequiredService(); - var logEvents = new List<(LogLevel logLevel, string eventIdName, Exception exception)>(); - sink.MessageLogged += (wc) => logEvents.Add((wc.LogLevel, wc.EventId.Name, wc.Exception)); // Act var mouseEventArgs = new MouseEventArgs() @@ -566,9 +495,9 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests }); Assert.Contains( - logEvents, - e => e.eventIdName == "DispatchEventFailedToDispatchEvent" && e.logLevel == LogLevel.Debug && - e.exception is ArgumentException ae && ae.Message.Contains("There is no event handler associated with this event. EventId: '1'.")); + Logs, + e => e.EventId.Name == "DispatchEventFailedToDispatchEvent" && e.LogLevel == LogLevel.Debug && + e.Exception is ArgumentException ae && ae.Message.Contains("There is no event handler associated with this event. EventId: '1'.")); // Taking any other action will fail because the circuit is disposed. await Client.ExpectCircuitErrorAndDisconnect(async () => @@ -581,21 +510,18 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests public async Task EventHandlerThrowsSyncExceptionTerminatesTheCircuit() { // Arrange - await GoToTestComponent(Batches); - var sink = _serverFixture.Host.Services.GetRequiredService(); - var logEvents = new List<(LogLevel logLevel, string eventIdName, Exception exception)>(); - sink.MessageLogged += (wc) => logEvents.Add((wc.LogLevel, wc.EventId.Name, wc.Exception)); // Act - await Client.ClickAsync("event-handler-throw-sync", expectRenderBatch: false); - - await Task.Delay(1000); + await Client.ExpectCircuitError(async () => + { + await Client.ClickAsync("event-handler-throw-sync", expectRenderBatch: false); + }); Assert.Contains( - logEvents, - e => LogLevel.Error == e.logLevel && - "CircuitUnhandledException" == e.eventIdName && - "Handler threw an exception" == e.exception.Message); + Logs, + e => LogLevel.Error == e.LogLevel && + "CircuitUnhandledException" == e.EventId.Name && + "Handler threw an exception" == e.Exception.Message); // Now if you try to click again, you will get *forcibly* disconnected for trying to talk to // a circuit that's gone. @@ -605,7 +531,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests }); } - private Task ValidateClientKeepsWorking(BlazorClient Client, IList batches) => + private Task ValidateClientKeepsWorking(BlazorClient Client, IReadOnlyCollection batches) => ValidateClientKeepsWorking(Client, () => batches.Count); private async Task ValidateClientKeepsWorking(BlazorClient Client, Func countAccessor) @@ -615,75 +541,5 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests Assert.Equal(currentBatches + 1, countAccessor()); } - - private async Task GoToTestComponent(IList batches) - { - var rootUri = _serverFixture.RootUri; - Assert.True(await Client.ConnectAsync(new Uri(rootUri, "/subdir")), "Couldn't connect to the app"); - Assert.Single(batches); - - await Client.SelectAsync("test-selector-select", "BasicTestApp.ReliabilityComponent"); - Assert.Equal(2, batches.Count); - } - - public void Dispose() - { - TestSink.MessageLogged -= LogMessages; - } - - private class LogMessage - { - public LogMessage(LogLevel logLevel, string message, Exception exception) - { - LogLevel = logLevel; - Message = message; - Exception = exception; - } - - public LogLevel LogLevel { get; set; } - public string Message { get; set; } - public Exception Exception { get; set; } - - public override string ToString() - { - return $"{LogLevel}: {Message}{(Exception != null ? Environment.NewLine : "")}{Exception}"; - } - } - - private class Batch - { - public Batch(int id, byte[] data) - { - Id = id; - Data = data; - } - - public int Id { get; } - public byte[] Data { get; } - } - - private class DotNetCompletion - { - public DotNetCompletion(string message) - { - Message = message; - } - - public string Message { get; } - } - - private class JSInteropCall - { - public JSInteropCall(int asyncHandle, string identifier, string argsJson) - { - AsyncHandle = asyncHandle; - Identifier = identifier; - ArgsJson = argsJson; - } - - public int AsyncHandle { get; } - public string Identifier { get; } - public string ArgsJson { get; } - } } } diff --git a/src/Components/test/E2ETest/ServerExecutionTests/RemoteRendererBufferLimitTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/RemoteRendererBufferLimitTest.cs index 360adc4b95..d77d2e6572 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/RemoteRendererBufferLimitTest.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/RemoteRendererBufferLimitTest.cs @@ -2,55 +2,33 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Ignitor; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Testing; using Xunit; +using Xunit.Abstractions; namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests { - public class RemoteRendererBufferLimitTest : IClassFixture, IDisposable + public class RemoteRendererBufferLimitTest : IgnitorTest { - private static readonly TimeSpan DefaultLatencyTimeout = Debugger.IsAttached ? TimeSpan.FromSeconds(60) : TimeSpan.FromMilliseconds(500); - - private AspNetSiteServerFixture _serverFixture; - - public RemoteRendererBufferLimitTest(AspNetSiteServerFixture serverFixture) + public RemoteRendererBufferLimitTest(AspNetSiteServerFixture serverFixture, ITestOutputHelper output) + : base(serverFixture, output) { - serverFixture.BuildWebHostMethod = TestServer.Program.BuildWebHost; - _serverFixture = serverFixture; - - // Needed here for side-effects - _ = _serverFixture.RootUri; - - Client = new BlazorClient() { DefaultLatencyTimeout = DefaultLatencyTimeout }; - Client.RenderBatchReceived += (id, data) => Batches.Add(new Batch(id, data)); - - Sink = _serverFixture.Host.Services.GetRequiredService(); - Sink.MessageLogged += LogMessages; } - public BlazorClient Client { get; set; } - - private IList Batches { get; set; } = new List(); - - // We use a stack so that we can search the logs in reverse order - private ConcurrentStack Logs { get; set; } = new ConcurrentStack(); - - public TestSink Sink { get; private set; } + protected override void InitializeFixture(AspNetSiteServerFixture serverFixture) + { + serverFixture.BuildWebHostMethod = TestServer.Program.BuildWebHost; + } [Fact] public async Task DispatchedEventsWillKeepBeingProcessed_ButUpdatedWillBeDelayedUntilARenderIsAcknowledged() { // Arrange - var baseUri = new Uri(_serverFixture.RootUri, "/subdir"); + var baseUri = new Uri(ServerFixture.RootUri, "/subdir"); Assert.True(await Client.ConnectAsync(baseUri), "Couldn't connect to the app"); Assert.Single(Batches); @@ -75,48 +53,11 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests Client.ConfirmRenderBatch = true; // This will resume the render batches. - await Client.ExpectRenderBatch(() => Client.ConfirmBatch(Batches[^1].Id)); + await Client.ExpectRenderBatch(() => Client.ConfirmBatch(Batches.Last().Id)); // Assert Assert.Equal("12", ((TextNode)Client.FindElementById("the-count").Children.Single()).TextContent); Assert.Equal(fullCount + 1, Batches.Count); } - - private void LogMessages(WriteContext context) => Logs.Push(new LogMessage(context.LogLevel, context.Message, context.Exception)); - - [DebuggerDisplay("{Message,nq}")] - private class LogMessage - { - public LogMessage(LogLevel logLevel, string message, Exception exception) - { - LogLevel = logLevel; - Message = message; - Exception = exception; - } - - public LogLevel LogLevel { get; set; } - public string Message { get; set; } - public Exception Exception { get; set; } - } - - private class Batch - { - public Batch(int id, byte[] data) - { - Id = id; - Data = data; - } - - public int Id { get; } - public byte[] Data { get; } - } - - public void Dispose() - { - if (Sink != null) - { - Sink.MessageLogged -= LogMessages; - } - } } } diff --git a/src/Components/test/testassets/BasicTestApp/ServerReliability/ReliabilityComponent.razor b/src/Components/test/testassets/BasicTestApp/ServerReliability/ReliabilityComponent.razor index 8557fa95c0..13256fb7bc 100644 --- a/src/Components/test/testassets/BasicTestApp/ServerReliability/ReliabilityComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/ServerReliability/ReliabilityComponent.razor @@ -77,7 +77,7 @@ { int currentCount = 0; string errorMalformed = ""; - string errorSuccess = ""; + string errorSuccess = "(this will be cleared)"; string errorFailure = ""; bool showConstructorThrow; bool showAttachThrow; @@ -116,6 +116,10 @@ var result = await JSRuntime.InvokeAsync( "sendSuccessCallbackReturn", Array.Empty()); + + // Make sure we trigger a render when the interop call completes. + // The test uses a render to synchronize. + errorSuccess = ""; } catch (Exception e) { diff --git a/src/Components/test/testassets/Ignitor/BlazorClient.cs b/src/Components/test/testassets/Ignitor/BlazorClient.cs index 4985af2207..3f96604574 100644 --- a/src/Components/test/testassets/Ignitor/BlazorClient.cs +++ b/src/Components/test/testassets/Ignitor/BlazorClient.cs @@ -2,8 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Diagnostics; -using System.IO; using System.Linq; using System.Net.Http; using System.Text.Json; @@ -33,8 +31,20 @@ namespace Ignitor }); } - public TimeSpan? DefaultLatencyTimeout { get; set; } = TimeSpan.FromMilliseconds(500); - public TimeSpan? DefaultConnectTimeout { get; set; } = TimeSpan.FromSeconds(10); + public TimeSpan? DefaultConnectionTimeout { get; set; } = TimeSpan.FromSeconds(10); + public TimeSpan? DefaultOperationTimeout { get; set; } = TimeSpan.FromMilliseconds(500); + + /// + /// Gets or sets a value that determines whether the client will capture data such + /// as render batches, interop calls, and errors for later inspection. + /// + public bool CaptureOperations { get; set; } + + /// + /// Gets the collections of operation results that are captured when + /// is true. + /// + public Operations Operations { get; private set; } public Func FormatError { get; set; } @@ -44,23 +54,23 @@ namespace Ignitor private TaskCompletionSource TaskCompletionSource { get; } - private CancellableOperation NextBatchReceived { get; set; } + private CancellableOperation NextBatchReceived { get; set; } - private CancellableOperation NextErrorReceived { get; set; } + private CancellableOperation NextErrorReceived { get; set; } - private CancellableOperation NextDisconnect { get; set; } + private CancellableOperation NextDisconnect { get; set; } - private CancellableOperation NextJSInteropReceived { get; set; } + private CancellableOperation NextJSInteropReceived { get; set; } - private CancellableOperation NextDotNetInteropCompletionReceived { get; set; } + private CancellableOperation NextDotNetInteropCompletionReceived { get; set; } public ILoggerProvider LoggerProvider { get; set; } public bool ConfirmRenderBatch { get; set; } = true; - public event Action JSInterop; + public event Action JSInterop; - public event Action RenderBatchReceived; + public event Action RenderBatchReceived; public event Action DotNetInteropCompletion; @@ -70,66 +80,66 @@ namespace Ignitor public ElementHive Hive { get; set; } = new ElementHive(); - public bool ImplicitWait => DefaultLatencyTimeout != null; + public bool ImplicitWait => DefaultOperationTimeout != null; public HubConnection HubConnection { get; set; } - public Task PrepareForNextBatch(TimeSpan? timeout) + public Task PrepareForNextBatch(TimeSpan? timeout) { if (NextBatchReceived?.Completion != null) { throw new InvalidOperationException("Invalid state previous task not completed"); } - NextBatchReceived = new CancellableOperation(timeout); + NextBatchReceived = new CancellableOperation(timeout); return NextBatchReceived.Completion.Task; } - public Task PrepareForNextJSInterop(TimeSpan? timeout) + public Task PrepareForNextJSInterop(TimeSpan? timeout) { if (NextJSInteropReceived?.Completion != null) { throw new InvalidOperationException("Invalid state previous task not completed"); } - NextJSInteropReceived = new CancellableOperation(timeout); + NextJSInteropReceived = new CancellableOperation(timeout); return NextJSInteropReceived.Completion.Task; } - public Task PrepareForNextDotNetInterop(TimeSpan? timeout) + public Task PrepareForNextDotNetInterop(TimeSpan? timeout) { if (NextDotNetInteropCompletionReceived?.Completion != null) { throw new InvalidOperationException("Invalid state previous task not completed"); } - NextDotNetInteropCompletionReceived = new CancellableOperation(timeout); + NextDotNetInteropCompletionReceived = new CancellableOperation(timeout); return NextDotNetInteropCompletionReceived.Completion.Task; } - public Task PrepareForNextCircuitError(TimeSpan? timeout) + public Task PrepareForNextCircuitError(TimeSpan? timeout) { if (NextErrorReceived?.Completion != null) { throw new InvalidOperationException("Invalid state previous task not completed"); } - NextErrorReceived = new CancellableOperation(timeout); + NextErrorReceived = new CancellableOperation(timeout); return NextErrorReceived.Completion.Task; } - public Task PrepareForNextDisconnect(TimeSpan? timeout) + public Task PrepareForNextDisconnect(TimeSpan? timeout) { if (NextDisconnect?.Completion != null) { throw new InvalidOperationException("Invalid state previous task not completed"); } - NextDisconnect = new CancellableOperation(timeout); + NextDisconnect = new CancellableOperation(timeout); return NextDisconnect.Completion.Task; } @@ -160,115 +170,162 @@ namespace Ignitor return ExpectRenderBatch(() => elementNode.SelectAsync(HubConnection, value)); } - public async Task ExpectRenderBatch(Func action, TimeSpan? timeout = null) + public async Task ExpectRenderBatch(Func action, TimeSpan? timeout = null) { var task = WaitForRenderBatch(timeout); await action(); - await task; + return await task; } - public async Task ExpectJSInterop(Func action, TimeSpan? timeout = null) + public async Task ExpectJSInterop(Func action, TimeSpan? timeout = null) { var task = WaitForJSInterop(timeout); await action(); - await task; + return await task; } - public async Task ExpectDotNetInterop(Func action, TimeSpan? timeout = null) + public async Task ExpectDotNetInterop(Func action, TimeSpan? timeout = null) { var task = WaitForDotNetInterop(timeout); await action(); - await task; + return await task; } - public async Task ExpectCircuitError(Func action, TimeSpan? timeout = null) + public async Task ExpectCircuitError(Func action, TimeSpan? timeout = null) { var task = WaitForCircuitError(timeout); await action(); - await task; + return await task; } - public async Task ExpectCircuitErrorAndDisconnect(Func action, TimeSpan? timeout = null) - { - // NOTE: timeout is used for each operation individually. - await ExpectDisconnect(async () => - { - await ExpectCircuitError(action, timeout); - }, timeout); - } - - public async Task ExpectDisconnect(Func action, TimeSpan? timeout = null) + public async Task ExpectDisconnect(Func action, TimeSpan? timeout = null) { var task = WaitForDisconnect(timeout); await action(); - await task; + return await task; } - private Task WaitForRenderBatch(TimeSpan? timeout = null) + public async Task<(string error, Exception exception)> ExpectCircuitErrorAndDisconnect(Func action, TimeSpan? timeout = null) + { + string error = null; + + // NOTE: timeout is used for each operation individually. + var exception = await ExpectDisconnect(async () => + { + error = await ExpectCircuitError(action, timeout); + }, timeout); + + return (error, exception); + } + + private async Task WaitForRenderBatch(TimeSpan? timeout = null) { if (ImplicitWait) { - if (DefaultLatencyTimeout == null && timeout == null) + if (DefaultOperationTimeout == null && timeout == null) { throw new InvalidOperationException("Implicit wait without DefaultLatencyTimeout is not allowed."); } - return PrepareForNextBatch(timeout ?? DefaultLatencyTimeout); + try + { + return await PrepareForNextBatch(timeout ?? DefaultOperationTimeout); + } + catch (OperationCanceledException) + { + throw FormatError("Timed out while waiting for batch."); + } } - return Task.CompletedTask; + return null; } - private async Task WaitForJSInterop(TimeSpan? timeout = null) + private async Task WaitForJSInterop(TimeSpan? timeout = null) { if (ImplicitWait) { - if (DefaultLatencyTimeout == null && timeout == null) + if (DefaultOperationTimeout == null && timeout == null) { throw new InvalidOperationException("Implicit wait without DefaultLatencyTimeout is not allowed."); } - await PrepareForNextJSInterop(timeout ?? DefaultLatencyTimeout); + try + { + return await PrepareForNextJSInterop(timeout ?? DefaultOperationTimeout); + } + catch (OperationCanceledException) + { + throw FormatError("Timed out while waiting for JS Interop."); + } } + + return null; } - private async Task WaitForDotNetInterop(TimeSpan? timeout = null) + private async Task WaitForDotNetInterop(TimeSpan? timeout = null) { if (ImplicitWait) { - if (DefaultLatencyTimeout == null && timeout == null) + if (DefaultOperationTimeout == null && timeout == null) { throw new InvalidOperationException("Implicit wait without DefaultLatencyTimeout is not allowed."); } - await PrepareForNextDotNetInterop(timeout ?? DefaultLatencyTimeout); + try + { + return await PrepareForNextDotNetInterop(timeout ?? DefaultOperationTimeout); + } + catch (OperationCanceledException) + { + throw FormatError("Timed out while waiting for .NET interop."); + } } + + return null; } - private async Task WaitForCircuitError(TimeSpan? timeout = null) + private async Task WaitForCircuitError(TimeSpan? timeout = null) { if (ImplicitWait) { - if (DefaultLatencyTimeout == null && timeout == null) + if (DefaultOperationTimeout == null && timeout == null) { throw new InvalidOperationException("Implicit wait without DefaultLatencyTimeout is not allowed."); } - await PrepareForNextCircuitError(timeout ?? DefaultLatencyTimeout); - } - } - - private async Task WaitForDisconnect(TimeSpan? timeout = null) - { - if (ImplicitWait) - { - if (DefaultLatencyTimeout == null && timeout == null) + try { - throw new InvalidOperationException("Implicit wait without DefaultLatencyTimeout is not allowed."); + return await PrepareForNextCircuitError(timeout ?? DefaultOperationTimeout); + } + catch (OperationCanceledException) + { + throw FormatError("Timed out while waiting for circuit error."); } } - await PrepareForNextDisconnect(timeout ?? DefaultLatencyTimeout); + return null; + } + + private async Task WaitForDisconnect(TimeSpan? timeout = null) + { + if (ImplicitWait) + { + if (DefaultOperationTimeout == null && timeout == null) + { + throw new InvalidOperationException("Implicit wait without DefaultLatencyTimeout is not allowed."); + } + + try + { + return await PrepareForNextDisconnect(timeout ?? DefaultOperationTimeout); + } + catch (OperationCanceledException) + { + throw FormatError("Timed out while waiting for disconnect."); + } + } + + return null; } public async Task ConnectAsync(Uri uri, bool connectAutomatically = true) @@ -294,6 +351,11 @@ namespace Ignitor HubConnection.On("JS.Error", OnError); HubConnection.Closed += OnClosedAsync; + if (CaptureOperations) + { + Operations = new Operations(); + } + if (!connectAutomatically) { return true; @@ -302,35 +364,41 @@ namespace Ignitor var descriptors = await GetPrerenderDescriptors(uri); await ExpectRenderBatch( async () => CircuitId = await HubConnection.InvokeAsync("StartCircuit", uri, uri, descriptors), - DefaultConnectTimeout); + DefaultConnectionTimeout); return CircuitId != null; } - private void OnEndInvokeDotNet(string completion) + private void OnEndInvokeDotNet(string message) { - DotNetInteropCompletion?.Invoke(completion); + Operations?.DotNetCompletions.Enqueue(message); + DotNetInteropCompletion?.Invoke(message); NextDotNetInteropCompletionReceived?.Completion?.TrySetResult(null); } private void OnBeginInvokeJS(int asyncHandle, string identifier, string argsJson) { - JSInterop?.Invoke(asyncHandle, identifier, argsJson); + var call = new CapturedJSInteropCall(asyncHandle, identifier, argsJson); + Operations?.JSInteropCalls.Enqueue(call); + JSInterop?.Invoke(call); NextJSInteropReceived?.Completion?.TrySetResult(null); } - private void OnRenderBatch(int batchId, byte[] batchData) + private void OnRenderBatch(int id, byte[] data) { - RenderBatchReceived?.Invoke(batchId, batchData); + var capturedBatch = new CapturedRenderBatch(id, data); - var batch = RenderBatchReader.Read(batchData); + Operations?.Batches.Enqueue(capturedBatch); + RenderBatchReceived?.Invoke(capturedBatch); + + var batch = RenderBatchReader.Read(data); Hive.Update(batch); if (ConfirmRenderBatch) { - _ = ConfirmBatch(batchId); + _ = ConfirmBatch(id); } NextBatchReceived?.Completion?.TrySetResult(null); @@ -343,8 +411,12 @@ namespace Ignitor private void OnError(string error) { + Operations?.Errors.Enqueue(error); OnCircuitError?.Invoke(error); + // If we get an error, forcibly terminate anything else we're waiting for. These + // tests should only encounter errors in specific situations, and this ensures that + // we fail with a good message. var exception = FormatError?.Invoke(error) ?? new Exception(error); NextBatchReceived?.Completion?.TrySetException(exception); NextDotNetInteropCompletionReceived?.Completion.TrySetException(exception); @@ -415,7 +487,7 @@ namespace Ignitor return element; } - private class CancellableOperation + private class CancellableOperation { public CancellableOperation(TimeSpan? timeout) { @@ -423,24 +495,32 @@ namespace Ignitor Initialize(); } + public TimeSpan? Timeout { get; } + + public TaskCompletionSource Completion { get; set; } + + public CancellationTokenSource Cancellation { get; set; } + + public CancellationTokenRegistration CancellationRegistration { get; set; } + private void Initialize() { - Completion = new TaskCompletionSource(TaskContinuationOptions.RunContinuationsAsynchronously); + Completion = new TaskCompletionSource(TaskContinuationOptions.RunContinuationsAsynchronously); Completion.Task.ContinueWith( (task, state) => { - var operation = (CancellableOperation)state; + var operation = (CancellableOperation)state; operation.Dispose(); }, this, TaskContinuationOptions.ExecuteSynchronously); // We need to execute synchronously to clean-up before anything else continues - if (Timeout != null) + if (Timeout != null && Timeout != System.Threading.Timeout.InfiniteTimeSpan && Timeout != TimeSpan.MaxValue) { Cancellation = new CancellationTokenSource(Timeout.Value); CancellationRegistration = Cancellation.Token.Register( (self) => { - var operation = (CancellableOperation)self; + var operation = (CancellableOperation)self; operation.Completion.TrySetCanceled(operation.Cancellation.Token); operation.Cancellation.Dispose(); operation.CancellationRegistration.Dispose(); @@ -455,14 +535,6 @@ namespace Ignitor Cancellation.Dispose(); CancellationRegistration.Dispose(); } - - public TimeSpan? Timeout { get; } - - public TaskCompletionSource Completion { get; set; } - - public CancellationTokenSource Cancellation { get; set; } - - public CancellationTokenRegistration CancellationRegistration { get; set; } } private string[] ReadMarkers(string content) diff --git a/src/Components/test/testassets/Ignitor/CapturedJSInteropCall.cs b/src/Components/test/testassets/Ignitor/CapturedJSInteropCall.cs new file mode 100644 index 0000000000..4af491a58a --- /dev/null +++ b/src/Components/test/testassets/Ignitor/CapturedJSInteropCall.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Ignitor +{ + public class CapturedJSInteropCall + { + public CapturedJSInteropCall(int asyncHandle, string identifier, string argsJson) + { + AsyncHandle = asyncHandle; + Identifier = identifier; + ArgsJson = argsJson; + } + + public int AsyncHandle { get; } + public string Identifier { get; } + public string ArgsJson { get; } + } +} diff --git a/src/Components/test/testassets/Ignitor/CapturedRenderBatch.cs b/src/Components/test/testassets/Ignitor/CapturedRenderBatch.cs new file mode 100644 index 0000000000..df2de87e9f --- /dev/null +++ b/src/Components/test/testassets/Ignitor/CapturedRenderBatch.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Ignitor +{ + public class CapturedRenderBatch + { + public CapturedRenderBatch(int id, byte[] data) + { + Id = id; + Data = data; + } + + public int Id { get; } + public byte[] Data { get; } + } +} diff --git a/src/Components/test/testassets/Ignitor/Operations.cs b/src/Components/test/testassets/Ignitor/Operations.cs new file mode 100644 index 0000000000..4dcf8798b5 --- /dev/null +++ b/src/Components/test/testassets/Ignitor/Operations.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Concurrent; + +namespace Ignitor +{ + public sealed class Operations + { + public ConcurrentQueue Batches { get; } = new ConcurrentQueue(); + + public ConcurrentQueue DotNetCompletions { get; } = new ConcurrentQueue(); + + public ConcurrentQueue Errors { get; } = new ConcurrentQueue(); + + public ConcurrentQueue JSInteropCalls { get; } = new ConcurrentQueue(); + } +} diff --git a/src/Components/test/testassets/Ignitor/Program.cs b/src/Components/test/testassets/Ignitor/Program.cs index 8f5785e7d3..43486d490e 100644 --- a/src/Components/test/testassets/Ignitor/Program.cs +++ b/src/Components/test/testassets/Ignitor/Program.cs @@ -2,10 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Net.Http; -using System.Runtime.InteropServices; -using System.Text.Json; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; @@ -38,9 +34,9 @@ namespace Ignitor var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // Click the counter button 1000 times - client.RenderBatchReceived += (int batchId, byte[] data) => + client.RenderBatchReceived += (batch) => { - if (batchId < 1000) + if (batch.Id < 1000) { var _ = client.ClickAsync("thecounter"); } @@ -56,8 +52,8 @@ namespace Ignitor return 0; } - private static void OnJSInterop(int callId, string identifier, string argsJson) => - Console.WriteLine("JS Invoke: " + identifier + " (" + argsJson + ")"); + private static void OnJSInterop(CapturedJSInteropCall call) => + Console.WriteLine("JS Invoke: " + call.Identifier + " (" + call.ArgsJson + ")"); public Program() { diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V4/_Layout.cshtml b/src/Identity/UI/src/Areas/Identity/Pages/V4/_Layout.cshtml index ef570db19f..21f5ba6ee3 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V4/_Layout.cshtml +++ b/src/Identity/UI/src/Areas/Identity/Pages/V4/_Layout.cshtml @@ -56,7 +56,14 @@
- © @DateTime.Now.Year - @Environment.ApplicationName - Privacy + © @DateTime.Now.Year - @Environment.ApplicationName - + @{ + var foundPrivacy = Url.Page("/Privacy", new { area = "" }); + } + @if (foundPrivacy != null) + { + Privacy + }
diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Pages/Shared/_Layout.cshtml b/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Pages/Shared/_Layout.cshtml index 03faa34a5d..ccc07b30f6 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Pages/Shared/_Layout.cshtml +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Pages/Shared/_Layout.cshtml @@ -4,7 +4,7 @@ @ViewData["Title"] - Company.WebApplication1 - + @@ -46,8 +46,8 @@ - - + + @RenderSection("Scripts", required: false) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Pages/Shared/_ValidationScriptsPartial.cshtml b/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Pages/Shared/_ValidationScriptsPartial.cshtml index 21638b6044..5a16d80a9a 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Pages/Shared/_ValidationScriptsPartial.cshtml +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Pages/Shared/_ValidationScriptsPartial.cshtml @@ -1,2 +1,2 @@ - - + + diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Views/Shared/_Layout.cshtml b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Views/Shared/_Layout.cshtml index d8d08c1a35..892d6b9b5d 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Views/Shared/_Layout.cshtml +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Views/Shared/_Layout.cshtml @@ -4,7 +4,7 @@ @ViewData["Title"] - Company.WebApplication1 - + @@ -45,8 +45,8 @@ © copyrightYear - Company.WebApplication1 - Privacy - - + + @RenderSection("Scripts", required: false) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Views/Shared/_ValidationScriptsPartial.cshtml b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Views/Shared/_ValidationScriptsPartial.cshtml index 754029c03a..5a16d80a9a 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Views/Shared/_ValidationScriptsPartial.cshtml +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Views/Shared/_ValidationScriptsPartial.cshtml @@ -1,2 +1,2 @@ - - \ No newline at end of file + + diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-FSharp/Views/Shared/_Layout.cshtml b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-FSharp/Views/Shared/_Layout.cshtml index e4a016e02e..6dbe6964a7 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-FSharp/Views/Shared/_Layout.cshtml +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-FSharp/Views/Shared/_Layout.cshtml @@ -5,7 +5,7 @@ @ViewData["Title"] - Company.WebApplication1 - + @@ -42,8 +42,8 @@ - - + + diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-FSharp/Views/Shared/_ValidationScriptsPartial.cshtml b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-FSharp/Views/Shared/_ValidationScriptsPartial.cshtml index bc03630978..010de00023 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-FSharp/Views/Shared/_ValidationScriptsPartial.cshtml +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-FSharp/Views/Shared/_ValidationScriptsPartial.cshtml @@ -1,3 +1,3 @@ - - + + diff --git a/src/Shared/Process/ProcessExtensions.cs b/src/Shared/Process/ProcessExtensions.cs index c6cbd1f970..5fbefcdb24 100644 --- a/src/Shared/Process/ProcessExtensions.cs +++ b/src/Shared/Process/ProcessExtensions.cs @@ -1,8 +1,9 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; @@ -41,42 +42,56 @@ namespace Microsoft.Extensions.Internal private static void GetAllChildIdsUnix(int parentId, ISet children, TimeSpan timeout) { - RunProcessAndWaitForExit( - "pgrep", - $"-P {parentId}", - timeout, - out var stdout); - - if (!string.IsNullOrEmpty(stdout)) + try { - using (var reader = new StringReader(stdout)) - { - while (true) - { - var text = reader.ReadLine(); - if (text == null) - { - return; - } + RunProcessAndWaitForExit( + "pgrep", + $"-P {parentId}", + timeout, + out var stdout); - if (int.TryParse(text, out var id)) + if (!string.IsNullOrEmpty(stdout)) + { + using (var reader = new StringReader(stdout)) + { + while (true) { - children.Add(id); - // Recursively get the children - GetAllChildIdsUnix(id, children, timeout); + var text = reader.ReadLine(); + if (text == null) + { + return; + } + + if (int.TryParse(text, out var id)) + { + children.Add(id); + // Recursively get the children + GetAllChildIdsUnix(id, children, timeout); + } } } } } + catch (Win32Exception ex) when (ex.Message.Contains("No such file or directory")) + { + // This probably means that pgrep isn't installed. Nothing to be done? + } } private static void KillProcessUnix(int processId, TimeSpan timeout) { - RunProcessAndWaitForExit( - "kill", - $"-TERM {processId}", - timeout, - out var stdout); + try + { + RunProcessAndWaitForExit( + "kill", + $"-TERM {processId}", + timeout, + out var stdout); + } + catch (Win32Exception ex) when (ex.Message.Contains("No such file or directory")) + { + // This probably means that the process is already dead + } } private static void RunProcessAndWaitForExit(string fileName, string arguments, TimeSpan timeout, out string stdout) diff --git a/src/SignalR/server/Core/src/Internal/DefaultHubProtocolResolver.cs b/src/SignalR/server/Core/src/Internal/DefaultHubProtocolResolver.cs index 5475fd3f07..546a2f988b 100644 --- a/src/SignalR/server/Core/src/Internal/DefaultHubProtocolResolver.cs +++ b/src/SignalR/server/Core/src/Internal/DefaultHubProtocolResolver.cs @@ -23,13 +23,12 @@ namespace Microsoft.AspNetCore.SignalR.Internal _logger = logger ?? NullLogger.Instance; _availableProtocols = new Dictionary(StringComparer.OrdinalIgnoreCase); - // We might get duplicates in _hubProtocols, but we're going to check it and overwrite in just a sec. - _hubProtocols = availableProtocols.ToList(); - foreach (var protocol in _hubProtocols) + foreach (var protocol in availableProtocols) { Log.RegisteredSignalRProtocol(_logger, protocol.Name, protocol.GetType()); _availableProtocols[protocol.Name] = protocol; } + _hubProtocols = _availableProtocols.Values.ToList(); } public virtual IHubProtocol GetProtocol(string protocolName, IReadOnlyList supportedProtocols) diff --git a/src/SignalR/server/SignalR/test/Internal/DefaultHubProtocolResolverTests.cs b/src/SignalR/server/SignalR/test/Internal/DefaultHubProtocolResolverTests.cs index ae18f59b66..418385ff0c 100644 --- a/src/SignalR/server/SignalR/test/Internal/DefaultHubProtocolResolverTests.cs +++ b/src/SignalR/server/SignalR/test/Internal/DefaultHubProtocolResolverTests.cs @@ -84,6 +84,22 @@ namespace Microsoft.AspNetCore.SignalR.Common.Protocol.Tests Assert.Same(jsonProtocol2, resolvedProtocol); } + [Fact] + public void AllProtocolsOnlyReturnsLatestOfSameType() + { + var jsonProtocol1 = new NewtonsoftJsonHubProtocol(); + var jsonProtocol2 = new NewtonsoftJsonHubProtocol(); + var resolver = new DefaultHubProtocolResolver(new[] { + jsonProtocol1, + jsonProtocol2 + }, NullLogger.Instance); + + var hubProtocols = resolver.AllProtocols; + Assert.Equal(1, hubProtocols.Count); + + Assert.Same(jsonProtocol2, hubProtocols[0]); + } + public static IEnumerable HubProtocolNames => HubProtocolHelpers.AllProtocols.Select(p => new object[] {p.Name}); } } diff --git a/src/Tools/Microsoft.dotnet-openapi/README.md b/src/Tools/Microsoft.dotnet-openapi/README.md new file mode 100644 index 0000000000..9ad333bddc --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/README.md @@ -0,0 +1,84 @@ +# Microsoft.dotnet-openapi + +`Microsoft.dotnet-openapi` is a tool for managing OpenAPI references within your project. + +## Commands + +### Add Commands + + + +#### Add File + +##### Options + +| Short option| Long option| Description | Example | +|-------|------|-------|---------| +| -v|--verbose | Show verbose output. |dotnet openapi add file *-v* .\OpenAPI.json | +| -p|--updateProject | The project to operate on. |dotnet openapi add file *--updateProject .\Ref.csproj* .\OpenAPI.json | + +##### Arguments + +| Argument | Description | Example | +|-------------|-------------|---------| +| source-file | The source to create a reference from. Must be an OpenAPI file. |dotnet openapi add file *.\OpenAPI.json* | + +#### Add URL + +##### Options + +| Short option| Long option| Description | Example | +|-------|------|-------------|---------| +| -v|--verbose | Show verbose output. |dotnet openapi add url *-v* | +| -p|--updateProject | The project to operate on. |dotnet openapi add url *--updateProject .\Ref.csproj* | +| -o|--output-file | Where to place the local copy of the OpenAPI file. |dotnet openapi add url *--output-file myclient.json* | + +##### Arguments + +| Argument | Description | Example | +|-------------|-------------|---------| +| source-file | The source to create a reference from. Must be a URL. |dotnet openapi add url | + +### Remove + +##### Options + +| Short option| Long option| Description| Example | +|-------|------|------------|---------| +| -v|--verbose | Show verbose output. |dotnet openapi remove *-v*| +| -p|--updateProject | The project to operate on. |dotnet openapi remove *--updateProject .\Ref.csproj* .\OpenAPI.json | + +#### Arguments + +| Argument | Description| Example | +| ------------|------------|---------| +| source-file | The source to remove the reference to. |dotnet openapi remove *.\OpenAPI.json* | + +### Refresh + +#### Options + +| Short option| Long option| Description | Example | +|-------|------|-------------|---------| +| -v|--verbose | Show verbose output. | dotnet openapi refresh *-v* | +| -p|--updateProject | The project to operate on. | dotnet openapi refresh *--updateProject .\Ref.csproj* | + +#### Arguments + +| Argument | Description | Example | +| ------------|-------------|---------| +| source-file | The URL to refresh the reference from. | dotnet openapi refresh ** | diff --git a/src/Tools/Microsoft.dotnet-openapi/src/Application.cs b/src/Tools/Microsoft.dotnet-openapi/src/Application.cs new file mode 100644 index 0000000000..52e25445b1 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/Application.cs @@ -0,0 +1,104 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Reflection; +using Microsoft.Build.Locator; +using Microsoft.DotNet.Openapi.Tools; +using Microsoft.DotNet.OpenApi.Commands; +using Microsoft.Extensions.CommandLineUtils; + +namespace Microsoft.DotNet.OpenApi +{ + internal class Application : CommandLineApplication + { + static Application() + { + MSBuildLocator.RegisterDefaults(); + } + + public Application( + string workingDirectory, + IHttpClientWrapper httpClient, + TextWriter output = null, + TextWriter error = null) + { + Out = output ?? Out; + Error = error ?? Error; + + WorkingDirectory = workingDirectory; + + Name = "openapi"; + FullName = "OpenApi reference management tool"; + Description = "OpenApi reference management operations."; + ShortVersionGetter = GetInformationalVersion; + + Help = HelpOption("-?|-h|--help"); + Help.Inherited = true; + + Invoke = () => + { + ShowHelp(); + return 0; + }; + + Commands.Add(new AddCommand(this, httpClient)); + Commands.Add(new RemoveCommand(this, httpClient)); + Commands.Add(new RefreshCommand(this, httpClient)); + } + + public string WorkingDirectory { get; } + + public CommandOption Help { get; } + + public new int Execute(params string[] args) + { + try + { + return base.Execute(args); + } + catch (AggregateException ex) when (ex.InnerException != null) + { + foreach (var innerException in ex.InnerExceptions) + { + Error.WriteLine(ex.InnerException.Message); + } + return 1; + } + + catch (ArgumentException ex) + { + // Don't show a call stack when we have unneeded arguments, just print the error message. + // The code that throws this exception will print help, so no need to do it here. + Error.WriteLine(ex.Message); + return 1; + } + catch (CommandParsingException ex) + { + // Don't show a call stack when we have unneeded arguments, just print the error message. + // The code that throws this exception will print help, so no need to do it here. + Error.WriteLine(ex.Message); + return 1; + } + catch (OperationCanceledException) + { + // This is a cancellation, not a failure. + Error.WriteLine("Cancelled"); + return 1; + } + catch (Exception ex) + { + Error.WriteLine(ex); + return 1; + } + } + + private string GetInformationalVersion() + { + var assembly = typeof(Application).GetTypeInfo().Assembly; + var attribute = assembly.GetCustomAttribute(); + return attribute.InformationalVersion; + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/CodeGenerator.cs b/src/Tools/Microsoft.dotnet-openapi/src/CodeGenerator.cs new file mode 100644 index 0000000000..f885993c29 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/CodeGenerator.cs @@ -0,0 +1,11 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.DotNet.OpenApi +{ + public enum CodeGenerator + { + NSwagCSharp, + NSwagTypeScript + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/Commands/AddCommand.cs b/src/Tools/Microsoft.dotnet-openapi/src/Commands/AddCommand.cs new file mode 100644 index 0000000000..bf879c8b20 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/Commands/AddCommand.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Microsoft.DotNet.Openapi.Tools; + +namespace Microsoft.DotNet.OpenApi.Commands +{ + internal class AddCommand : BaseCommand + { + private const string CommandName = "add"; + + public AddCommand(Application parent, IHttpClientWrapper httpClient) + : base(parent, CommandName, httpClient) + { + Commands.Add(new AddFileCommand(this, httpClient)); + //TODO: Add AddprojectComand here: https://github.com/aspnet/AspNetCore/issues/12738 + Commands.Add(new AddURLCommand(this, httpClient)); + } + + internal new Application Parent => (Application)base.Parent; + + protected override Task ExecuteCoreAsync() + { + ShowHelp(); + return Task.FromResult(0); + } + + protected override bool ValidateArguments() + { + return true; + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/Commands/AddFileCommand.cs b/src/Tools/Microsoft.dotnet-openapi/src/Commands/AddFileCommand.cs new file mode 100644 index 0000000000..b7f6cc9f00 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/Commands/AddFileCommand.cs @@ -0,0 +1,81 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.DotNet.Openapi.Tools; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.OpenApi.Commands +{ + internal class AddFileCommand : BaseCommand + { + private const string CommandName = "file"; + + private const string SourceFileArgName = "source-file"; + + public AddFileCommand(AddCommand parent, IHttpClientWrapper httpClient) + : base(parent, CommandName, httpClient) + { + _codeGeneratorOption = Option("-c|--code-generator", "The code generator to use. Defaults to 'NSwagCSharp'.", CommandOptionType.SingleValue); + _sourceFileArg = Argument(SourceFileArgName, $"The OpenAPI file to add. This must be a path to local OpenAPI file(s)", multipleValues: true); + } + + internal readonly CommandArgument _sourceFileArg; + internal readonly CommandOption _codeGeneratorOption; + + private readonly string[] ApprovedExtensions = new[] { ".json", ".yaml", ".yml" }; + + protected override async Task ExecuteCoreAsync() + { + var projectFilePath = ResolveProjectFile(ProjectFileOption); + + Ensure.NotNullOrEmpty(_sourceFileArg.Value, SourceFileArgName); + var codeGenerator = GetCodeGenerator(_codeGeneratorOption); + + foreach (var sourceFile in _sourceFileArg.Values) + { + if (!ApprovedExtensions.Any(e => sourceFile.EndsWith(e))) + { + await Warning.WriteLineAsync($"The extension for the given file '{sourceFile}' should have been one of: {string.Join(",", ApprovedExtensions)}."); + await Warning.WriteLineAsync($"The reference has been added, but may fail at build-time if the format is not correct."); + } + await AddOpenAPIReference(OpenApiReference, projectFilePath, sourceFile, codeGenerator); + } + + return 0; + } + + private bool IsLocalFile(string file) + { + return File.Exists(GetFullPath(file)); + } + + protected override bool ValidateArguments() + { + ValidateCodeGenerator(_codeGeneratorOption); + + try + { + Ensure.NotNullOrEmpty(_sourceFileArg.Value, SourceFileArgName); + } + catch(ArgumentException ex) + { + Error.Write(ex.Message); + return false; + } + + foreach (var sourceFile in _sourceFileArg.Values) + { + if (!IsLocalFile(sourceFile)) + { + Error.Write($"{SourceFileArgName} of '{sourceFile}' could not be found."); + } + } + return true; + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/Commands/AddProjectCommand.cs b/src/Tools/Microsoft.dotnet-openapi/src/Commands/AddProjectCommand.cs new file mode 100644 index 0000000000..a2a73794f8 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/Commands/AddProjectCommand.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.DotNet.Openapi.Tools; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.OpenApi.Commands +{ + internal class AddProjectCommand : BaseCommand + { + private const string CommandName = "project"; + + private const string SourceProjectArgName = "source-project"; + + public AddProjectCommand(BaseCommand parent, IHttpClientWrapper httpClient) + : base(parent, CommandName, httpClient) + { + _codeGeneratorOption = Option("-c|--code-generator", "The code generator to use. Defaults to 'NSwagCSharp'.", CommandOptionType.SingleValue); + _sourceProjectArg = Argument(SourceProjectArgName, $"The OpenAPI project to add. This must be the path to project file(s) containing OpenAPI endpoints", multipleValues: true); + } + + internal readonly CommandArgument _sourceProjectArg; + internal readonly CommandOption _codeGeneratorOption; + + protected override async Task ExecuteCoreAsync() + { + var projectFilePath = ResolveProjectFile(ProjectFileOption); + + var codeGenerator = GetCodeGenerator(_codeGeneratorOption); + + foreach (var sourceFile in _sourceProjectArg.Values) + { + await AddOpenAPIReference(OpenApiProjectReference, projectFilePath, sourceFile, codeGenerator); + } + + return 0; + } + + protected override bool ValidateArguments() + { + ValidateCodeGenerator(_codeGeneratorOption); + foreach (var sourceFile in _sourceProjectArg.Values) + { + if (!IsProjectFile(sourceFile)) + { + throw new ArgumentException($"{SourceProjectArgName} of '{sourceFile}' was not valid. Valid values must be project file(s)"); + } + } + + Ensure.NotNullOrEmpty(_sourceProjectArg.Value, SourceProjectArgName); + return true; + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/Commands/AddURLCommand.cs b/src/Tools/Microsoft.dotnet-openapi/src/Commands/AddURLCommand.cs new file mode 100644 index 0000000000..600584c0fb --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/Commands/AddURLCommand.cs @@ -0,0 +1,59 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.DotNet.Openapi.Tools; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.OpenApi.Commands +{ + internal class AddURLCommand : BaseCommand + { + private const string CommandName = "url"; + + private const string OutputFileName = "--output-file"; + private const string SourceUrlArgName = "source-URL"; + + public AddURLCommand(AddCommand parent, IHttpClientWrapper httpClient) + : base(parent, CommandName, httpClient) + { + _codeGeneratorOption = Option("-c|--code-generator", "The code generator to use. Defaults to 'NSwagCSharp'.", CommandOptionType.SingleValue); + _outputFileOption = Option(OutputFileName, "The destination to download the remote OpenAPI file to.", CommandOptionType.SingleValue); + _sourceFileArg = Argument(SourceUrlArgName, $"The OpenAPI file to add. This must be a URL to a remote OpenAPI file.", multipleValues: true); + } + + internal readonly CommandOption _outputFileOption; + + internal readonly CommandArgument _sourceFileArg; + internal readonly CommandOption _codeGeneratorOption; + + protected override async Task ExecuteCoreAsync() + { + var projectFilePath = ResolveProjectFile(ProjectFileOption); + + var sourceFile = Ensure.NotNullOrEmpty(_sourceFileArg.Value, SourceUrlArgName); + var codeGenerator = GetCodeGenerator(_codeGeneratorOption); + + // We have to download the file from that URL, save it to a local file, then create a OpenApiReference + var outputFile = await DownloadGivenOption(sourceFile, _outputFileOption); + + await AddOpenAPIReference(OpenApiReference, projectFilePath, outputFile, codeGenerator, sourceFile); + + return 0; + } + + protected override bool ValidateArguments() + { + ValidateCodeGenerator(_codeGeneratorOption); + var sourceFile = Ensure.NotNullOrEmpty(_sourceFileArg.Value, SourceUrlArgName); + if (!IsUrl(sourceFile)) + { + Error.Write($"{SourceUrlArgName} was not valid. Valid values are URLs"); + return false; + } + return true; + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/Commands/BaseCommand.cs b/src/Tools/Microsoft.dotnet-openapi/src/Commands/BaseCommand.cs new file mode 100644 index 0000000000..7123f0d04e --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/Commands/BaseCommand.cs @@ -0,0 +1,538 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Build.Evaluation; +using Microsoft.DotNet.Openapi.Tools; +using Microsoft.DotNet.Openapi.Tools.Internal; +using Microsoft.Extensions.CommandLineUtils; + +namespace Microsoft.DotNet.OpenApi.Commands +{ + internal abstract class BaseCommand : CommandLineApplication + { + protected string WorkingDirectory; + + protected readonly IHttpClientWrapper _httpClient; + + public const string OpenApiReference = "OpenApiReference"; + public const string OpenApiProjectReference = "OpenApiProjectReference"; + protected const string SourceUrlAttrName = "SourceUrl"; + + public const string ContentDispositionHeaderName = "Content-Disposition"; + private const string CodeGeneratorAttrName = "CodeGenerator"; + private const string DefaultExtension = ".json"; + + internal const string PackageVersionUrl = "https://go.microsoft.com/fwlink/?linkid=2099561"; + + public BaseCommand(CommandLineApplication parent, string name, IHttpClientWrapper httpClient) + { + Parent = parent; + Name = name; + Out = parent.Out ?? Out; + Error = parent.Error ?? Error; + _httpClient = httpClient; + + ProjectFileOption = Option("-p|--updateProject", "The project file update.", CommandOptionType.SingleValue); + + if (Parent is Application) + { + WorkingDirectory = ((Application)Parent).WorkingDirectory; + } + else + { + WorkingDirectory = ((Application)Parent.Parent).WorkingDirectory; + } + + OnExecute(ExecuteAsync); + } + + public CommandOption ProjectFileOption { get; } + + public TextWriter Warning + { + get { return Out; } + } + + protected abstract Task ExecuteCoreAsync(); + + protected abstract bool ValidateArguments(); + + private async Task ExecuteAsync() + { + if (GetApplication().Help.HasValue()) + { + ShowHelp(); + return 0; + } + + if (!ValidateArguments()) + { + ShowHelp(); + return 1; + } + + return await ExecuteCoreAsync(); + } + + private Application GetApplication() + { + var parent = Parent; + while(!(parent is Application)) + { + parent = parent.Parent; + } + return (Application)parent; + } + + internal FileInfo ResolveProjectFile(CommandOption projectOption) + { + string project; + if (projectOption.HasValue()) + { + project = projectOption.Value(); + project = GetFullPath(project); + if (!File.Exists(project)) + { + throw new ArgumentException($"The project '{project}' does not exist."); + } + } + else + { + var projects = Directory.GetFiles(WorkingDirectory, "*.csproj", SearchOption.TopDirectoryOnly); + if (projects.Length == 0) + { + throw new ArgumentException("No project files were found in the current directory. Either move to a new directory or provide the project explicitly"); + } + if (projects.Length > 1) + { + throw new ArgumentException("More than one project was found in this directory, either remove a duplicate or explicitly provide the project."); + } + + project = projects[0]; + } + + return new FileInfo(project); + } + + protected Project LoadProject(FileInfo projectFile) + { + var project = ProjectCollection.GlobalProjectCollection.LoadProject( + projectFile.FullName, + globalProperties: null, + toolsVersion: null); + project.ReevaluateIfNecessary(); + return project; + } + + internal bool IsProjectFile(string file) + { + return File.Exists(Path.GetFullPath(file)) && file.EndsWith(".csproj"); + } + + internal bool IsUrl(string file) + { + return Uri.TryCreate(file, UriKind.Absolute, out var _) && file.StartsWith("http"); + } + + internal async Task AddOpenAPIReference( + string tagName, + FileInfo projectFile, + string sourceFile, + CodeGenerator? codeGenerator, + string sourceUrl = null) + { + // EnsurePackagesInProjectAsync MUST happen before LoadProject, because otherwise the global state set by ProjectCollection doesn't pick up the nuget edits, and we end up losing them. + await EnsurePackagesInProjectAsync(projectFile, codeGenerator); + var project = LoadProject(projectFile); + var items = project.GetItems(tagName); + var fileItems = items.Where(i => string.Equals(GetFullPath(i.EvaluatedInclude), GetFullPath(sourceFile), StringComparison.Ordinal)); + + if (fileItems.Count() > 0) + { + Warning.Write($"One or more references to {sourceFile} already exist in '{project.FullPath}'. Duplicate references could lead to unexpected behavior."); + return; + } + + if (sourceUrl != null) + { + if (items.Any(i => string.Equals(i.GetMetadataValue(SourceUrlAttrName), sourceUrl))) + { + Warning.Write($"A reference to '{sourceUrl}' already exists in '{project.FullPath}'."); + return; + } + } + + var metadata = new Dictionary(); + + if (!string.IsNullOrEmpty(sourceUrl)) + { + metadata[SourceUrlAttrName] = sourceUrl; + } + + if (codeGenerator != null) + { + metadata[CodeGeneratorAttrName] = codeGenerator.ToString(); + } + + project.AddElementWithAttributes(tagName, sourceFile, metadata); + project.Save(); + } + + private async Task EnsurePackagesInProjectAsync(FileInfo projectFile, CodeGenerator? codeGenerator) + { + var urlPackages = await LoadPackageVersionsFromURLAsync(); + var attributePackages = GetServicePackages(codeGenerator); + + foreach (var kvp in attributePackages) + { + var packageId = kvp.Key; + var version = urlPackages != null && urlPackages.ContainsKey(packageId) ? urlPackages[packageId] : kvp.Value; + + var args = new[] { + "add", + "package", + packageId, + "--version", + version, + "--no-restore" + }; + + var muxer = DotNetMuxer.MuxerPathOrDefault(); + if (string.IsNullOrEmpty(muxer)) + { + throw new ArgumentException($"dotnet was not found on the path."); + } + + var startInfo = new ProcessStartInfo + { + FileName = muxer, + Arguments = string.Join(" ", args), + WorkingDirectory = projectFile.Directory.FullName, + RedirectStandardError = true, + RedirectStandardOutput = true, + }; + + var process = Process.Start(startInfo); + + var timeout = 20; + if (!process.WaitForExit(timeout * 1000)) + { + throw new ArgumentException($"Adding package `{packageId}` to `{projectFile.Directory}` took longer than {timeout} seconds."); + } + + if (process.ExitCode != 0) + { + Out.Write(process.StandardOutput.ReadToEnd()); + Error.Write(process.StandardError.ReadToEnd()); + throw new ArgumentException($"Could not add package `{packageId}` to `{projectFile.Directory}`"); + } + } + } + + internal async Task DownloadToFileAsync(string url, string destinationPath, bool overwrite) + { + using var response = await _httpClient.GetResponseAsync(url); + await WriteToFileAsync(await response.Stream, destinationPath, overwrite); + } + + internal async Task DownloadGivenOption(string url, CommandOption fileOption) + { + using var response = await _httpClient.GetResponseAsync(url); + + if (response.IsSuccessCode()) + { + string destinationPath; + if (fileOption.HasValue()) + { + destinationPath = fileOption.Value(); + } + else + { + var fileName = GetFileNameFromResponse(response, url); + var fullPath = GetFullPath(fileName); + var directory = Path.GetDirectoryName(fullPath); + destinationPath = GetUniqueFileName(directory, Path.GetFileNameWithoutExtension(fileName), Path.GetExtension(fileName)); + } + await WriteToFileAsync(await response.Stream, GetFullPath(destinationPath), overwrite: false); + + return destinationPath; + } + else + { + throw new ArgumentException($"The given url returned '{response.StatusCode}', indicating failure. The url might be wrong, or there might be a networking issue."); + } + } + + private string GetUniqueFileName(string directory, string fileName, string extension) + { + var uniqueName = fileName; + + var filePath = Path.Combine(directory, fileName + extension); + var exists = true; + var count = 0; + + do + { + if (!File.Exists(filePath)) + { + exists = false; + } + else + { + count++; + uniqueName = fileName + count; + filePath = Path.Combine(directory, uniqueName + extension); + } + } + while (exists); + + return uniqueName + extension; + } + + private string GetFileNameFromResponse(IHttpResponseMessageWrapper response, string url) + { + var contentDisposition = response.ContentDisposition(); + string result; + if (contentDisposition != null && contentDisposition.FileName != null) + { + var fileName = Path.GetFileName(contentDisposition.FileName); + if (!Path.HasExtension(fileName)) + { + fileName += DefaultExtension; + } + + result = fileName; + } + else + { + var uri = new Uri(url); + if (uri.Segments.Count() > 0 && uri.Segments.Last() != "/") + { + var lastSegment = uri.Segments.Last(); + if (!Path.HasExtension(lastSegment)) + { + lastSegment += DefaultExtension; + } + + result = lastSegment; + } + else + { + var parts = uri.Host.Split('.'); + + // There's no segment, use the domain name. + string domain; + switch (parts.Length) + { + case 1: + case 2: + // It's localhost if 1, no www if 2 + domain = parts.First(); + break; + case 3: + domain = parts[1]; + break; + default: + throw new NotImplementedException("We don't handle the case that the Host has more than three segments"); + } + + result = domain + DefaultExtension; + } + } + + return result; + } + + internal CodeGenerator? GetCodeGenerator(CommandOption codeGeneratorOption) + { + CodeGenerator? codeGenerator; + if (codeGeneratorOption.HasValue()) + { + codeGenerator = Enum.Parse(codeGeneratorOption.Value()); + } + else + { + codeGenerator = null; + } + + return codeGenerator; + } + + internal void ValidateCodeGenerator(CommandOption codeGeneratorOption) + { + if (codeGeneratorOption.HasValue()) + { + var value = codeGeneratorOption.Value(); + if (!Enum.TryParse(value, out CodeGenerator _)) + { + throw new ArgumentException($"Invalid value '{value}' given as code generator."); + } + } + } + + internal string GetFullPath(string path) + { + return Path.IsPathFullyQualified(path) + ? path + : Path.GetFullPath(path, WorkingDirectory); + } + + private async Task> LoadPackageVersionsFromURLAsync() + { + /* Example Json content + { + "Version" : "1.0", + "Packages" : { + "Microsoft.Azure.SignalR": "1.1.0-preview1-10442", + "Grpc.AspNetCore.Server": "0.1.22-pre2", + "Grpc.Net.ClientFactory": "0.1.22-pre2", + "Google.Protobuf": "3.8.0", + "Grpc.Tools": "1.22.0", + "NSwag.ApiDescription.Client": "13.0.3", + "Microsoft.Extensions.ApiDescription.Client": "0.3.0-preview7.19365.7", + "Newtonsoft.Json": "12.0.2" + } + }*/ + try + { + using var packageVersionStream = await (await _httpClient.GetResponseAsync(PackageVersionUrl)).Stream; + using var packageVersionDocument = await JsonDocument.ParseAsync(packageVersionStream); + var packageVersionsElement = packageVersionDocument.RootElement.GetProperty("Packages"); + var packageVersionsDictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var packageVersion in packageVersionsElement.EnumerateObject()) + { + packageVersionsDictionary[packageVersion.Name] = packageVersion.Value.GetString(); + } + + return packageVersionsDictionary; + } + catch + { + // TODO (johluo): Consider logging a message indicating what went wrong and actions, if any, to be taken to resolve possible issues. + // Currently not logging anything since the fwlink is not published yet. + return null; + } + } + + private static IDictionary GetServicePackages(CodeGenerator? type) + { + CodeGenerator generator = type ?? CodeGenerator.NSwagCSharp; + var name = Enum.GetName(typeof(CodeGenerator), generator); + var attributes = typeof(Program).Assembly.GetCustomAttributes(); + + var packages = attributes.Where(a => a.CodeGenerators.Contains(generator)); + var result = new Dictionary(); + if (packages != null) + { + foreach (var package in packages) + { + result[package.Name] = package.Version; + } + } + + return result; + } + + private static byte[] GetHash(Stream stream) + { + SHA256 algorithm; + try + { + algorithm = SHA256.Create(); + } + catch (TargetInvocationException) + { + // SHA256.Create is documented to throw this exception on FIPS-compliant machines. See + // https://msdn.microsoft.com/en-us/library/z08hz7ad Fall back to a FIPS-compliant SHA256 algorithm. + algorithm = new SHA256CryptoServiceProvider(); + } + + using (algorithm) + { + return algorithm.ComputeHash(stream); + } + } + + private async Task WriteToFileAsync(Stream content, string destinationPath, bool overwrite) + { + if (content.CanSeek) + { + content.Seek(0, SeekOrigin.Begin); + } + + destinationPath = GetFullPath(destinationPath); + var destinationExists = File.Exists(destinationPath); + if (destinationExists && !overwrite) + { + throw new ArgumentException($"File '{destinationPath}' already exists. Aborting to avoid conflicts. Provide the '--output-file' argument with an unused file to resolve."); + } + + await Out.WriteLineAsync($"Downloading to '{destinationPath}'."); + var reachedCopy = false; + try + { + if (destinationExists) + { + // Check hashes before using the downloaded information. + var downloadHash = GetHash(content); + + byte[] destinationHash; + using (var destinationStream = File.OpenRead(destinationPath)) + { + destinationHash = GetHash(destinationStream); + } + + var sameHashes = downloadHash.Length == destinationHash.Length; + for (var i = 0; sameHashes && i < downloadHash.Length; i++) + { + sameHashes = downloadHash[i] == destinationHash[i]; + } + + if (sameHashes) + { + await Out.WriteLineAsync($"Not overwriting existing and matching file '{destinationPath}'."); + return; + } + } + else + { + // May need to create directory to hold the file. + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDirectory) && !Directory.Exists(destinationDirectory)) + { + Directory.CreateDirectory(destinationDirectory); + } + } + + // Create or overwrite the destination file. + reachedCopy = true; + using var fileStream = new FileStream(destinationPath, FileMode.OpenOrCreate, FileAccess.Write); + fileStream.Seek(0, SeekOrigin.Begin); + if (content.CanSeek) + { + content.Seek(0, SeekOrigin.Begin); + } + await content.CopyToAsync(fileStream); + } + catch (Exception ex) + { + await Error.WriteLineAsync($"Downloading failed."); + await Error.WriteLineAsync(ex.ToString()); + if (reachedCopy) + { + File.Delete(destinationPath); + } + } + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/Commands/RefreshCommand.cs b/src/Tools/Microsoft.dotnet-openapi/src/Commands/RefreshCommand.cs new file mode 100644 index 0000000000..f21196f5f4 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/Commands/RefreshCommand.cs @@ -0,0 +1,67 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Build.Evaluation; +using Microsoft.DotNet.Openapi.Tools; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.OpenApi.Commands +{ + internal class RefreshCommand : BaseCommand + { + private const string CommandName = "refresh"; + + private const string SourceURLArgName = "source-URL"; + + public RefreshCommand(Application parent, IHttpClientWrapper httpClient) : base(parent, CommandName, httpClient) + { + _sourceFileArg = Argument(SourceURLArgName, $"The OpenAPI reference to refresh."); + } + + internal readonly CommandArgument _sourceFileArg; + + protected override async Task ExecuteCoreAsync() + { + var projectFile = ResolveProjectFile(ProjectFileOption); + + var sourceFile = Ensure.NotNullOrEmpty(_sourceFileArg.Value, SourceURLArgName); + + var destination = FindReferenceFromUrl(projectFile, sourceFile); + await DownloadToFileAsync(sourceFile, destination, overwrite: true); + + return 0; + } + + private string FindReferenceFromUrl(FileInfo projectFile, string url) + { + var project = LoadProject(projectFile); + var openApiReferenceItems = project.GetItems(OpenApiReference); + + foreach (ProjectItem item in openApiReferenceItems) + { + var attrUrl = item.GetMetadataValue(SourceUrlAttrName); + if (string.Equals(attrUrl, url, StringComparison.Ordinal)) + { + return item.EvaluatedInclude; + } + } + + throw new ArgumentException("There was no OpenAPI reference to refresh with the given URL."); + } + + protected override bool ValidateArguments() + { + var sourceFile = Ensure.NotNullOrEmpty(_sourceFileArg.Value, SourceURLArgName); + if (!IsUrl(sourceFile)) + { + throw new ArgumentException($"'dotnet openapi refresh' must be given a URL"); + } + + return true; + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/Commands/RemoveCommand.cs b/src/Tools/Microsoft.dotnet-openapi/src/Commands/RemoveCommand.cs new file mode 100644 index 0000000000..e8e45d2b32 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/Commands/RemoveCommand.cs @@ -0,0 +1,78 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Build.Evaluation; +using Microsoft.DotNet.Openapi.Tools; +using Microsoft.Extensions.CommandLineUtils; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.OpenApi.Commands +{ + internal class RemoveCommand : BaseCommand + { + private const string CommandName = "remove"; + + private const string SourceArgName = "soruce"; + + public RemoveCommand(Application parent, IHttpClientWrapper httpClient) : base(parent, CommandName, httpClient) + { + _sourceProjectArg = Argument(SourceArgName, $"The OpenAPI reference to remove. Must represent a reference which is already in this project", multipleValues: true); + } + + internal readonly CommandArgument _sourceProjectArg; + + protected override Task ExecuteCoreAsync() + { + var projectFile = ResolveProjectFile(ProjectFileOption); + + var sourceFile = Ensure.NotNullOrEmpty(_sourceProjectArg.Value, SourceArgName); + + if (IsProjectFile(sourceFile)) + { + RemoveServiceReference(OpenApiProjectReference, projectFile, sourceFile); + } + else + { + var file = RemoveServiceReference(OpenApiReference, projectFile, sourceFile); + + if (file != null) + { + File.Delete(GetFullPath(file)); + } + } + + return Task.FromResult(0); + } + + private string RemoveServiceReference(string tagName, FileInfo projectFile, string sourceFile) + { + var project = LoadProject(projectFile); + var openApiReferenceItems = project.GetItems(tagName); + + foreach (ProjectItem item in openApiReferenceItems) + { + var include = item.EvaluatedInclude; + var sourceUrl = item.HasMetadata(SourceUrlAttrName) ? item.GetMetadataValue(SourceUrlAttrName) : null; + if (string.Equals(include, sourceFile, StringComparison.OrdinalIgnoreCase) + || string.Equals(sourceUrl, sourceFile, StringComparison.OrdinalIgnoreCase)) + { + project.RemoveItem(item); + project.Save(); + return include; + } + } + + Warning.Write($"No OpenAPI reference was found with the file '{sourceFile}'"); + return null; + } + + protected override bool ValidateArguments() + { + Ensure.NotNullOrEmpty(_sourceProjectArg.Value, SourceArgName); + return true; + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/DebugMode.cs b/src/Tools/Microsoft.dotnet-openapi/src/DebugMode.cs new file mode 100644 index 0000000000..5619fe0178 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/DebugMode.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading; + +namespace Microsoft.DotNet.OpenApi +{ + internal static class DebugMode + { + public static void HandleDebugSwitch(ref string[] args) + { + if (args.Length > 0 && string.Equals("--debug", args[0], StringComparison.OrdinalIgnoreCase)) + { + args = args.Skip(1).ToArray(); + + Console.WriteLine("Waiting for debugger in pid: {0}", Process.GetCurrentProcess().Id); + while (!Debugger.IsAttached) + { + Thread.Sleep(TimeSpan.FromSeconds(3)); + } + } + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/HttpClientWrapper.cs b/src/Tools/Microsoft.dotnet-openapi/src/HttpClientWrapper.cs new file mode 100644 index 0000000000..4ca5a9ae26 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/HttpClientWrapper.cs @@ -0,0 +1,72 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Microsoft.DotNet.OpenApi; +using Microsoft.DotNet.OpenApi.Commands; + +namespace Microsoft.DotNet.Openapi.Tools +{ + public class HttpClientWrapper : IHttpClientWrapper + { + private readonly HttpClient _client; + + public HttpClientWrapper(HttpClient client) + { + _client = client; + } + + public void Dispose() + { + _client.Dispose(); + } + + public async Task GetResponseAsync(string url) + { + var response = await _client.GetAsync(url); + + return new HttpResponseMessageWrapper(response); + } + + public Task GetStreamAsync(string url) + { + return _client.GetStreamAsync(url); + } + } + + public class HttpResponseMessageWrapper : IHttpResponseMessageWrapper + { + private HttpResponseMessage _response; + + public HttpResponseMessageWrapper(HttpResponseMessage response) + { + _response = response; + } + + public Task Stream => _response.Content.ReadAsStreamAsync(); + + public HttpStatusCode StatusCode => _response.StatusCode; + + public bool IsSuccessCode() => _response.IsSuccessStatusCode; + + public ContentDispositionHeaderValue ContentDisposition() + { + if (_response.Headers.TryGetValues(BaseCommand.ContentDispositionHeaderName, out var disposition)) + { + return new ContentDispositionHeaderValue(disposition.First()); + } + + return null; + } + + public void Dispose() + { + _response.Dispose(); + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/IHttpClientWrapper.cs b/src/Tools/Microsoft.dotnet-openapi/src/IHttpClientWrapper.cs new file mode 100644 index 0000000000..1cdd358942 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/IHttpClientWrapper.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.DotNet.OpenApi; + +namespace Microsoft.DotNet.Openapi.Tools +{ + internal interface IHttpClientWrapper : IDisposable + { + Task GetResponseAsync(string url); + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/IHttpResponseMessageWrapper.cs b/src/Tools/Microsoft.dotnet-openapi/src/IHttpResponseMessageWrapper.cs new file mode 100644 index 0000000000..07a4cf6355 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/IHttpResponseMessageWrapper.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Net; +using System.Net.Http.Headers; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.OpenApi +{ + public interface IHttpResponseMessageWrapper : IDisposable + { + Task Stream { get; } + ContentDispositionHeaderValue ContentDisposition(); + HttpStatusCode StatusCode { get; } + bool IsSuccessCode(); + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/Internal/OpenapiDependencyAttribute.cs b/src/Tools/Microsoft.dotnet-openapi/src/Internal/OpenapiDependencyAttribute.cs new file mode 100644 index 0000000000..ae21fcec0b --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/Internal/OpenapiDependencyAttribute.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.DotNet.OpenApi; + +namespace Microsoft.DotNet.Openapi.Tools.Internal +{ + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + internal class OpenApiDependencyAttribute : Attribute + { + public OpenApiDependencyAttribute(string name, string version, string codeGenerators) + { + Name = name; + Version = version; + CodeGenerators = codeGenerators.Split(';', StringSplitOptions.RemoveEmptyEntries).Select(c => Enum.Parse(c)).ToArray(); + } + + public string Name { get; set; } + public string Version { get; set; } + public IEnumerable CodeGenerators { get; set; } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/Microsoft.dotnet-openapi.csproj b/src/Tools/Microsoft.dotnet-openapi/src/Microsoft.dotnet-openapi.csproj new file mode 100644 index 0000000000..ca416997da --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/Microsoft.dotnet-openapi.csproj @@ -0,0 +1,36 @@ + + + netcoreapp3.0 + exe + Command line tool to add an OpenAPI service reference + Microsoft.DotNet.Openapi.Tools + dotnet-openapi + Microsoft.dotnet-openapi + true + + false + + + + + + + + + + + + + + + <_Parameter1>NSwag.ApiDescription.Client + <_Parameter2>$(NSwagApiDescriptionClientPackageVersion) + <_Parameter3>NSwagCSharp;NSwagTypeScript + + + <_Parameter1>Newtonsoft.Json + <_Parameter2>$(NewtonsoftJsonPackageVersion) + <_Parameter3>NSwagCSharp;NSwagTypeScript + + + diff --git a/src/Tools/Microsoft.dotnet-openapi/src/Program.cs b/src/Tools/Microsoft.dotnet-openapi/src/Program.cs new file mode 100644 index 0000000000..1dc4e5c747 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/Program.cs @@ -0,0 +1,53 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Net.Http; +using Microsoft.DotNet.Openapi.Tools; + +namespace Microsoft.DotNet.OpenApi +{ + public class Program + { + public static int Main(string[] args) + { + var outputWriter = new StringWriter(); + var errorWriter = new StringWriter(); + + DebugMode.HandleDebugSwitch(ref args); + + try + { + using var httpClient = new HttpClientWrapper(new HttpClient()); + var application = new Application( + Directory.GetCurrentDirectory(), + httpClient, + outputWriter, + errorWriter); + + var result = application.Execute(args); + + return result; + } + catch (Exception ex) + { + errorWriter.Write("Unexpected error:"); + errorWriter.WriteLine(ex.ToString()); + } + finally + { + var output = outputWriter.ToString(); + var error = errorWriter.ToString(); + + outputWriter.Dispose(); + errorWriter.Dispose(); + + Console.WriteLine(output); + Console.Error.WriteLine(error); + } + + return 1; + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/ProjectExtensions.cs b/src/Tools/Microsoft.dotnet-openapi/src/ProjectExtensions.cs new file mode 100644 index 0000000000..3428c82839 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/ProjectExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Build.Evaluation; + +namespace Microsoft.DotNet.OpenApi +{ + public static class ProjectExtensions + { + public static void AddElementWithAttributes(this Project project, string tagName, string include, IDictionary metadata) + { + var item = project.AddItem(tagName, include).Single(); + foreach (var kvp in metadata) + { + item.Xml.AddMetadata(kvp.Key, kvp.Value, expressAsAttribute: true); + } + + project.Save(); + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/src/Properties/AssemblyInfo.cs b/src/Tools/Microsoft.dotnet-openapi/src/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..0df0f149da --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/src/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.DotNet.Open.Api.Tools.Tests, PublicKey = 0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Tools/Microsoft.dotnet-openapi/test/OpenApiAddFileTests.cs b/src/Tools/Microsoft.dotnet-openapi/test/OpenApiAddFileTests.cs new file mode 100644 index 0000000000..257a0fc82d --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/test/OpenApiAddFileTests.cs @@ -0,0 +1,254 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using Microsoft.DotNet.OpenApi.Tests; +using Microsoft.Extensions.Internal; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.OpenApi.Add.Tests +{ + public class OpenApiAddFileTests : OpenApiTestBase + { + public OpenApiAddFileTests(ITestOutputHelper output) : base(output) { } + + [Fact] + public void OpenApi_Empty_ShowsHelp() + { + var app = GetApplication(); + var run = app.Execute(new string[] { }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + Assert.Contains("Usage: openapi ", _output.ToString()); + } + + [Fact] + public void OpenApi_NoProjectExists() + { + var app = GetApplication(); + _tempDir.Create(); + var run = app.Execute(new string[] { "add", "file", "randomfile.json" }); + + Assert.Contains("No project files were found in the current directory", _error.ToString()); + Assert.Equal(1, run); + } + + [Fact] + public void OpenApi_ExplicitProject_Missing() + { + var app = GetApplication(); + _tempDir.Create(); + var csproj = "fake.csproj"; + var run = app.Execute(new string[] { "add", "file", "--updateProject", csproj, "randomfile.json" }); + + Assert.Contains($"The project '{Path.Combine(_tempDir.Root, csproj)}' does not exist.", _error.ToString()); + Assert.Equal(1, run); + } + + [Fact] + public void OpenApi_Add_Empty_ShowsHelp() + { + var app = GetApplication(); + var run = app.Execute(new string[] { "add" }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + Assert.Contains("Usage: openapi add", _output.ToString()); + } + + [Fact] + public void OpenApi_Add_File_Empty_ShowsHelp() + { + var app = GetApplication(); + var run = app.Execute(new string[] { "add", "file", "--help" }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + Assert.Contains("Usage: openapi ", _output.ToString()); + } + + [Fact] + public async Task OpenApi_Add_ReuseItemGroup() + { + var project = CreateBasicProject(withOpenApi: true); + + var app = GetApplication(); + var run = app.Execute(new[] { "add", "file", project.NSwagJsonFile }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + var secondRun = app.Execute(new[] { "add", "url", FakeOpenApiUrl }); + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, secondRun); + + var csproj = new FileInfo(project.Project.Path); + string content; + using (var csprojStream = csproj.OpenRead()) + using (var reader = new StreamReader(csprojStream)) + { + content = await reader.ReadToEndAsync(); + Assert.Contains("", content); + } + + // Build project and make sure it compiles + using var buildProc = ProcessEx.Run(_outputHelper, _tempDir.Root, "dotnet", "build"); + await buildProc.Exited; + Assert.True(buildProc.ExitCode == 0, $"Build failed: {buildProc.Output}"); + + + // Run project and make sure it doesn't crash + using var runProc = ProcessEx.Run(_outputHelper, _tempDir.Root, "dotnet", "run"); + Thread.Sleep(100); + Assert.False(runProc.HasExited, $"Run failed with: {runProc.Output}"); + } + + [Fact] + public async Task OpenApi_Add_FromJson() + { + var project = CreateBasicProject(withOpenApi: true); + var nswagJsonFile = project.NSwagJsonFile; + + var app = GetApplication(); + var run = app.Execute(new[] { "add", "file", nswagJsonFile }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + // csproj contents + var csproj = new FileInfo(project.Project.Path); + using (var csprojStream = csproj.OpenRead()) + using (var reader = new StreamReader(csprojStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Contains("", content); + } + + var jsonFile = Path.Combine(_tempDir.Root, expectedJsonName); + Assert.True(File.Exists(jsonFile)); + using (var jsonStream = new FileInfo(jsonFile).OpenRead()) + using (var reader = new StreamReader(jsonStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Equal(Content, content); + } + } + + [Fact] + public async Task OpenAPI_Add_Url_NoContentDisposition() + { + var project = CreateBasicProject(withOpenApi: false); + var url = NoDispositionUrl; + + var app = GetApplication(); + var run = app.Execute(new[] { "add", "url", url}); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + var expectedJsonName = "nodisposition.yaml"; + + // csproj contents + using (var csprojStream = new FileInfo(project.Project.Path).OpenRead()) + using (var reader = new StreamReader(csprojStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Contains("", content); + } + + var jsonFile = Path.Combine(_tempDir.Root, expectedJsonName); + Assert.True(File.Exists(jsonFile)); + using (var jsonStream = new FileInfo(jsonFile).OpenRead()) + using (var reader = new StreamReader(jsonStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Equal(Content, content); + } + } + + [Fact] + public async Task OpenAPI_Add_Url_NoExtension_AssumesJson() + { + var project = CreateBasicProject(withOpenApi: false); + var url = NoExtensionUrl; + + var app = GetApplication(); + var run = app.Execute(new[] { "add", "url", url }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + var expectedJsonName = "filename.json"; + + // csproj contents + using (var csprojStream = new FileInfo(project.Project.Path).OpenRead()) + using (var reader = new StreamReader(csprojStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Contains("", content); + } + + var jsonFile = Path.Combine(_tempDir.Root, expectedJsonName); + Assert.True(File.Exists(jsonFile)); + using (var jsonStream = new FileInfo(jsonFile).OpenRead()) + using (var reader = new StreamReader(jsonStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Equal(Content, content); + } + } + + [Fact] + public async Task OpenApi_Add_Url_NoSegment() + { + var project = CreateBasicProject(withOpenApi: false); + var url = NoSegmentUrl; + + var app = GetApplication(); + var run = app.Execute(new[] { "add", "url", url }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + var expectedJsonName = "contoso.json"; + + // csproj contents + using (var csprojStream = new FileInfo(project.Project.Path).OpenRead()) + using (var reader = new StreamReader(csprojStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Contains("", content); + } + + var jsonFile = Path.Combine(_tempDir.Root, expectedJsonName); + Assert.True(File.Exists(jsonFile)); + using (var jsonStream = new FileInfo(jsonFile).OpenRead()) + using (var reader = new StreamReader(jsonStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Equal(Content, content); + } + } + + [Fact] + public async Task OpenApi_Add_Url() + { + var project = CreateBasicProject(withOpenApi: false); + + var app = GetApplication(); + var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + var expectedJsonName = "filename.json"; + + // csproj contents + using (var csprojStream = new FileInfo(project.Project.Path).OpenRead()) + using (var reader = new StreamReader(csprojStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Contains("", content); + } + + var jsonFile = Path.Combine(_tempDir.Root, expectedJsonName); + Assert.True(File.Exists(jsonFile)); + using (var jsonStream = new FileInfo(jsonFile).OpenRead()) + using (var reader = new StreamReader(jsonStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Equal(Content, content); + } + } + + [Fact] + public async Task OpenApi_Add_Url_SameName_UniqueFile() + { + var project = CreateBasicProject(withOpenApi: false); + + var app = GetApplication(); + var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + var firstExpectedJsonName = "filename.json"; + + // csproj contents + using (var csprojStream = new FileInfo(project.Project.Path).OpenRead()) + using (var reader = new StreamReader(csprojStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Contains("", content); + } + + var firstJsonFile = Path.Combine(_tempDir.Root, firstExpectedJsonName); + Assert.True(File.Exists(firstJsonFile)); + using (var jsonStream = new FileInfo(firstJsonFile).OpenRead()) + using (var reader = new StreamReader(jsonStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Equal(Content, content); + } + + app = GetApplication(); + run = app.Execute(new[] { "add", "url", NoExtensionUrl }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + var secondExpectedJsonName = "filename1.json"; + + // csproj contents + using (var csprojStream = new FileInfo(project.Project.Path).OpenRead()) + using (var reader = new StreamReader(csprojStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Contains("", content); + Assert.Contains( + $@"", content); + } + + var secondJsonFile = Path.Combine(_tempDir.Root, secondExpectedJsonName); + Assert.True(File.Exists(secondJsonFile)); + using (var jsonStream = new FileInfo(secondJsonFile).OpenRead()) + using (var reader = new StreamReader(jsonStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Equal(Content, content); + } + } + + [Fact] + public async Task OpenApi_Add_Url_NSwagCSharp() + { + var project = CreateBasicProject(withOpenApi: false); + + var app = GetApplication(); + var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl, "--code-generator", "NSwagCSharp" }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + var expectedJsonName = "filename.json"; + + // csproj contents + using (var csprojStream = new FileInfo(project.Project.Path).OpenRead()) + using (var reader = new StreamReader(csprojStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Contains("", content); + } + + var resultFile = Path.Combine(_tempDir.Root, expectedJsonName); + Assert.True(File.Exists(resultFile)); + using (var jsonStream = new FileInfo(resultFile).OpenRead()) + using (var reader = new StreamReader(jsonStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Equal(Content, content); + } + } + + [Fact] + public async Task OpenApi_Add_Url_NSwagTypeScript() + { + var project = CreateBasicProject(withOpenApi: false); + + var app = GetApplication(); + var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl, "--code-generator", "NSwagTypeScript" }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + var expectedJsonName = "filename.json"; + + // csproj contents + using (var csprojStream = new FileInfo(project.Project.Path).OpenRead()) + using (var reader = new StreamReader(csprojStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Contains("", content); + } + + var resultFile = Path.Combine(_tempDir.Root, expectedJsonName); + Assert.True(File.Exists(resultFile)); + using (var jsonStream = new FileInfo(resultFile).OpenRead()) + using (var reader = new StreamReader(jsonStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Equal(Content, content); + } + } + + [Fact] + public async Task OpenApi_Add_Url_OutputFile() + { + var project = CreateBasicProject(withOpenApi: false); + + var app = GetApplication(); + var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl, "--output-file", Path.Combine("outputdir", "file.yaml") }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + var expectedJsonName = Path.Combine("outputdir", "file.yaml"); + + // csproj contents + using (var csprojStream = new FileInfo(project.Project.Path).OpenRead()) + using (var reader = new StreamReader(csprojStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Contains("", content); + } + + var resultFile = Path.Combine(_tempDir.Root, expectedJsonName); + Assert.True(File.Exists(resultFile)); + using (var jsonStream = new FileInfo(resultFile).OpenRead()) + using (var reader = new StreamReader(jsonStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Equal(Content, content); + } + } + + [Fact] + public async Task OpenApi_Add_URL_FileAlreadyExists_Fail() + { + var project = CreateBasicProject(withOpenApi: false); + + var app = GetApplication(); + var outputFile = Path.Combine("outputdir", "file.yaml"); + var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl, "--output-file", outputFile }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + var expectedJsonName = Path.Combine("outputdir", "file.yaml"); + + // csproj contents + using (var csprojStream = new FileInfo(project.Project.Path).OpenRead()) + using (var reader = new StreamReader(csprojStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Contains("", content); + } + + var resultFile = Path.Combine(_tempDir.Root, expectedJsonName); + Assert.True(File.Exists(resultFile)); + using (var jsonStream = new FileInfo(resultFile).OpenRead()) + using (var reader = new StreamReader(jsonStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Equal(Content, content); + } + + // Second reference, same output + app = GetApplication(); + run = app.Execute(new[] { "add", "url", DifferentUrl, "--output-file", outputFile}); + Assert.Equal(1, run); + Assert.True(_error.ToString().Contains("Aborting to avoid conflicts."), $"Should have aborted to avoid conflicts"); + + // csproj contents + using (var csprojStream = new FileInfo(project.Project.Path).OpenRead()) + using (var reader = new StreamReader(csprojStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Contains("", content); + Assert.DoesNotContain( + $@"", content); + } + + using (var jsonStream = new FileInfo(resultFile).OpenRead()) + using (var reader = new StreamReader(jsonStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Equal(Content, content); + } + } + + [Fact] + public void OpenApi_Add_URL_MultipleTimes_OnlyOneReference() + { + var project = CreateBasicProject(withOpenApi: false); + + var app = GetApplication(); + var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + app = GetApplication(); + run = app.Execute(new[] { "add", "url", "--output-file", "openapi.yaml", FakeOpenApiUrl }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + // csproj contents + var csproj = new FileInfo(project.Project.Path); + using var csprojStream = csproj.OpenRead(); + using var reader = new StreamReader(csprojStream); + var content = reader.ReadToEnd(); + var escapedPkgRef = Regex.Escape("", content); + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/test/OpenApiRefreshTests.cs b/src/Tools/Microsoft.dotnet-openapi/test/OpenApiRefreshTests.cs new file mode 100644 index 0000000000..d63d5015f6 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/test/OpenApiRefreshTests.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DotNet.OpenApi.Tests; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.OpenApi.Refresh.Tests +{ + public class OpenApiRefreshTests : OpenApiTestBase + { + public OpenApiRefreshTests(ITestOutputHelper output) : base(output) { } + + [Fact] + public async Task OpenApi_Refresh_Basic() + { + CreateBasicProject(withOpenApi: false); + + var app = GetApplication(); + var run = app.Execute(new[] { "add", "url", FakeOpenApiUrl }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + var expectedJsonPath = Path.Combine(_tempDir.Root, "filename.json"); + var json = await File.ReadAllTextAsync(expectedJsonPath); + json += "trash"; + await File.WriteAllTextAsync(expectedJsonPath, json); + + var firstWriteTime = File.GetLastWriteTime(expectedJsonPath); + + Thread.Sleep(TimeSpan.FromSeconds(1)); + + app = GetApplication(); + run = app.Execute(new[] { "refresh", FakeOpenApiUrl }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + var secondWriteTime = File.GetLastWriteTime(expectedJsonPath); + Assert.True(firstWriteTime < secondWriteTime, $"File wasn't updated! {firstWriteTime} {secondWriteTime}"); + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/test/OpenApiRemoveTests.cs b/src/Tools/Microsoft.dotnet-openapi/test/OpenApiRemoveTests.cs new file mode 100644 index 0000000000..5a110a7c66 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/test/OpenApiRemoveTests.cs @@ -0,0 +1,201 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.DotNet.OpenApi.Tests; +using Microsoft.Extensions.Tools.Internal; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.DotNet.OpenApi.Remove.Tests +{ + public class OpenApiRemoveTests : OpenApiTestBase + { + public OpenApiRemoveTests(ITestOutputHelper output) : base(output) { } + + [Fact] + public async Task OpenApi_Remove_File() + { + var nswagJsonFile = "openapi.json"; + _tempDir + .WithCSharpProject("testproj") + .WithTargetFrameworks("netcoreapp3.0") + .Dir() + .WithContentFile(nswagJsonFile) + .WithContentFile("Startup.cs") + .Create(); + + var add = GetApplication(); + var run = add.Execute(new[] { "add", "file", nswagJsonFile }); + + Assert.True(string.IsNullOrEmpty(_error.ToString()), $"Threw error: {_error.ToString()}"); + Assert.Equal(0, run); + + // csproj contents + var csproj = new FileInfo(Path.Join(_tempDir.Root, "testproj.csproj")); + using (var csprojStream = csproj.OpenRead()) + using (var reader = new StreamReader(csprojStream)) + { + var content = await reader.ReadToEndAsync(); + Assert.Contains("> DownloadMock() + { + var noExtension = new ContentDispositionHeaderValue("attachment"); + noExtension.Parameters.Add(new NameValueHeaderValue("filename", "filename")); + var extension = new ContentDispositionHeaderValue("attachment"); + extension.Parameters.Add(new NameValueHeaderValue("filename", "filename.json")); + var justAttachments = new ContentDispositionHeaderValue("attachment"); + + return new Dictionary> { + { FakeOpenApiUrl, Tuple.Create(Content, extension)}, + { DifferentUrl, Tuple.Create(DifferentUrlContent, null) }, + { PackageUrl, Tuple.Create(PackageUrlContent, null) }, + { NoDispositionUrl, Tuple.Create(Content, null) }, + { NoExtensionUrl, Tuple.Create(Content, noExtension) }, + { NoSegmentUrl, Tuple.Create(Content, justAttachments) } + }; + } + + public void Dispose() + { + _outputHelper.WriteLine(_output.ToString()); + _tempDir.Dispose(); + } + } + + public class TestHttpClientWrapper : IHttpClientWrapper + { + private readonly IDictionary> _results; + + public TestHttpClientWrapper(IDictionary> results) + { + _results = results; + } + + public void Dispose() + { + } + + public Task GetResponseAsync(string url) + { + var result = _results[url]; + byte[] byteArray = Encoding.ASCII.GetBytes(result.Item1); + var stream = new MemoryStream(byteArray); + + return Task.FromResult(new TestHttpResponseMessageWrapper(stream, result.Item2)); + } + } + + public class TestHttpResponseMessageWrapper : IHttpResponseMessageWrapper + { + public Task Stream { get; } + + public HttpStatusCode StatusCode { get; } = HttpStatusCode.OK; + + public bool IsSuccessCode() + { + return true; + } + + private ContentDispositionHeaderValue _contentDisposition; + + public TestHttpResponseMessageWrapper( + MemoryStream stream, + ContentDispositionHeaderValue header) + { + Stream = Task.FromResult(stream); + _contentDisposition = header; + } + + public ContentDispositionHeaderValue ContentDisposition() + { + return _contentDisposition; + } + + public void Dispose() + { + } + } + + public class TemporaryNSwagProject + { + public TemporaryNSwagProject(TemporaryCSharpProject project, string jsonFile) + { + Project = project; + NSwagJsonFile = jsonFile; + } + + public TemporaryCSharpProject Project { get; set; } + public string NSwagJsonFile { get; set; } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/test/ProcessEx.cs b/src/Tools/Microsoft.dotnet-openapi/test/ProcessEx.cs new file mode 100644 index 0000000000..fddaac68a7 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/test/ProcessEx.cs @@ -0,0 +1,146 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Internal +{ + internal class ProcessEx : IDisposable + { + private readonly ITestOutputHelper _output; + private readonly Process _process; + private readonly StringBuilder _stderrCapture = new StringBuilder(); + private readonly StringBuilder _stdoutCapture = new StringBuilder(); + private readonly object _pipeCaptureLock = new object(); + private BlockingCollection _stdoutLines = new BlockingCollection(); + private TaskCompletionSource _exited; + + private ProcessEx(ITestOutputHelper output, Process proc) + { + _output = output; + + _process = proc; + proc.EnableRaisingEvents = true; + proc.OutputDataReceived += OnOutputData; + proc.ErrorDataReceived += OnErrorData; + proc.Exited += OnProcessExited; + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + + _exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public Task Exited => _exited.Task; + + public bool HasExited => _process.HasExited; + + public string Output + { + get + { + lock (_pipeCaptureLock) + { + return _stdoutCapture.ToString(); + } + } + } + + public int ExitCode => _process.ExitCode; + + public static ProcessEx Run(ITestOutputHelper output, string workingDirectory, string command, string args = null, IDictionary envVars = null) + { + var startInfo = new ProcessStartInfo(command, args) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory + }; + + if (envVars != null) + { + foreach (var envVar in envVars) + { + startInfo.EnvironmentVariables[envVar.Key] = envVar.Value; + } + } + + output.WriteLine($"==> {startInfo.FileName} {startInfo.Arguments} [{startInfo.WorkingDirectory}]"); + var proc = Process.Start(startInfo); + + return new ProcessEx(output, proc); + } + + private void OnErrorData(object sender, DataReceivedEventArgs e) + { + if (e.Data == null) + { + return; + } + + lock (_pipeCaptureLock) + { + _stderrCapture.AppendLine(e.Data); + } + + _output.WriteLine("[ERROR] " + e.Data); + } + + private void OnOutputData(object sender, DataReceivedEventArgs e) + { + if (e.Data == null) + { + return; + } + + lock (_pipeCaptureLock) + { + _stdoutCapture.AppendLine(e.Data); + } + + _output.WriteLine(e.Data); + + if (_stdoutLines != null) + { + _stdoutLines.Add(e.Data); + } + } + + private void OnProcessExited(object sender, EventArgs e) + { + _process.WaitForExit(); + _stdoutLines.CompleteAdding(); + _stdoutLines = null; + _exited.TrySetResult(_process.ExitCode); + } + + public void Dispose() + { + if (_process != null && !_process.HasExited) + { + _process.KillTree(); + } + + _process.CancelOutputRead(); + _process.CancelErrorRead(); + + _process.ErrorDataReceived -= OnErrorData; + _process.OutputDataReceived -= OnOutputData; + _process.Exited -= OnProcessExited; + _process.Dispose(); + + if(_stdoutLines != null) + { + _stdoutLines.Dispose(); + } + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/test/Properties/AssemblyInfo.cs b/src/Tools/Microsoft.dotnet-openapi/test/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..76cbce868d --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/test/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] \ No newline at end of file diff --git a/src/Tools/Microsoft.dotnet-openapi/test/TestContent/Startup.cs.txt b/src/Tools/Microsoft.dotnet-openapi/test/TestContent/Startup.cs.txt new file mode 100644 index 0000000000..edea5132c8 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/test/TestContent/Startup.cs.txt @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Net.Http.Headers; + +namespace SimpleWebSite +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + // Example 1 + services + .AddMvcCore() + .AddAuthorization() + .AddFormatterMappings(m => m.SetMediaTypeMappingForFormat("js", new MediaTypeHeaderValue("application/json"))) + .SetCompatibilityVersion(CompatibilityVersion.Latest); + } + + public void Configure(IApplicationBuilder app) + { + app.UseMvcWithDefaultRoute(); + } + + public static void Main(string[] args) + { + var host = CreateWebHostBuilder(args) + .Build(); + + host.Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => + new WebHostBuilder() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseStartup() + .UseKestrel() + .UseIISIntegration(); + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/test/TestContent/openapi.json.txt b/src/Tools/Microsoft.dotnet-openapi/test/TestContent/openapi.json.txt new file mode 100644 index 0000000000..dff10b9817 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/test/TestContent/openapi.json.txt @@ -0,0 +1,514 @@ +{ + "x-generator": "NSwag v11.17.15.0 (NJsonSchema v9.10.53.0 (Newtonsoft.Json v10.0.0.0))", + "openapi": "2.0", + "info": { + "title": "My Title", + "version": "1.0.0" + }, + "host": "localhost:44370", + "schemes": [ + "https" + ], + "consumes": [ + "application/json", + "application/json-patch+json", + "text/json", + "application/*+json", + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "paths": { + "/pet": { + "post": { + "tags": [ + "Pet" + ], + "operationId": "Pet_AddPet", + "consumes": [ + "application/json" + ], + "parameters": [ + { + "name": "pet", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Pet" + }, + "x-nullable": true + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "x-nullable": true, + "description": "", + "schema": { + "$ref": "#/definitions/SerializableError" + } + } + } + }, + "put": { + "tags": [ + "Pet" + ], + "operationId": "Pet_EditPet", + "consumes": [ + "application/json" + ], + "parameters": [ + { + "name": "pet", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Pet" + }, + "x-nullable": true + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "x-nullable": true, + "description": "", + "schema": { + "$ref": "#/definitions/SerializableError" + } + } + } + } + }, + "/pet/findByStatus": { + "get": { + "tags": [ + "Pet" + ], + "operationId": "Pet_FindByStatus", + "consumes": [ + "application/json-patch+json", + "application/json", + "text/json", + "application/*+json" + ], + "parameters": [ + { + "name": "status", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "x-nullable": true + } + ], + "responses": { + "200": { + "x-nullable": true, + "description": "", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + } + } + } + } + }, + "/pet/findByCategory": { + "get": { + "tags": [ + "Pet" + ], + "operationId": "Pet_FindByCategory", + "parameters": [ + { + "type": "string", + "name": "category", + "in": "query", + "required": true, + "x-nullable": true + } + ], + "responses": { + "200": { + "x-nullable": true, + "description": "", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + } + }, + "400": { + "x-nullable": true, + "description": "", + "schema": { + "$ref": "#/definitions/SerializableError" + } + } + } + } + }, + "/pet/{petId}": { + "get": { + "tags": [ + "Pet" + ], + "operationId": "Pet_FindById", + "parameters": [ + { + "type": "integer", + "name": "petId", + "in": "path", + "required": true, + "format": "int32", + "x-nullable": false + } + ], + "responses": { + "200": { + "x-nullable": true, + "description": "", + "schema": { + "$ref": "#/definitions/Pet" + } + }, + "400": { + "x-nullable": true, + "description": "", + "schema": { + "$ref": "#/definitions/SerializableError" + } + }, + "404": { + "description": "" + } + } + }, + "post": { + "tags": [ + "Pet" + ], + "operationId": "Pet_EditPet2", + "parameters": [ + { + "type": "integer", + "name": "petId", + "in": "path", + "required": true, + "format": "int32", + "x-nullable": false + }, + { + "type": "integer", + "name": "Id", + "in": "formData", + "required": true, + "format": "int32", + "x-nullable": false + }, + { + "type": "integer", + "name": "Age", + "in": "formData", + "required": true, + "format": "int32", + "x-nullable": false + }, + { + "type": "integer", + "name": "Category.Id", + "in": "formData", + "required": true, + "format": "int32", + "x-nullable": false + }, + { + "type": "string", + "name": "Category.Name", + "in": "formData", + "required": true, + "x-nullable": true + }, + { + "type": "boolean", + "name": "HasVaccinations", + "in": "formData", + "required": true, + "x-nullable": false + }, + { + "type": "string", + "name": "Name", + "in": "formData", + "required": true, + "x-nullable": true + }, + { + "type": "array", + "name": "Images", + "in": "formData", + "required": true, + "collectionFormat": "multi", + "x-nullable": true, + "items": { + "$ref": "#/definitions/Image" + } + }, + { + "type": "array", + "name": "Tags", + "in": "formData", + "required": true, + "collectionFormat": "multi", + "x-nullable": true, + "items": { + "$ref": "#/definitions/Tag" + } + }, + { + "type": "string", + "name": "Status", + "in": "formData", + "required": true, + "x-nullable": true + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "x-nullable": true, + "description": "", + "schema": { + "$ref": "#/definitions/SerializableError" + } + }, + "404": { + "description": "" + } + } + }, + "delete": { + "tags": [ + "Pet" + ], + "operationId": "Pet_DeletePet", + "parameters": [ + { + "type": "integer", + "name": "petId", + "in": "path", + "required": true, + "format": "int32", + "x-nullable": false + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "x-nullable": true, + "description": "", + "schema": { + "$ref": "#/definitions/SerializableError" + } + }, + "404": { + "description": "" + } + } + } + }, + "/pet/{petId}/uploadImage": { + "post": { + "tags": [ + "Pet" + ], + "operationId": "Pet_UploadImage", + "consumes": [ + "multipart/form-data" + ], + "parameters": [ + { + "type": "integer", + "name": "petId", + "in": "path", + "required": true, + "format": "int32", + "x-nullable": false + }, + { + "type": "file", + "name": "file", + "in": "formData", + "required": true, + "x-nullable": true + } + ], + "responses": { + "200": { + "x-nullable": true, + "description": "", + "schema": { + "$ref": "#/definitions/ApiResponse" + } + }, + "400": { + "x-nullable": true, + "description": "", + "schema": { + "$ref": "#/definitions/SerializableError" + } + }, + "404": { + "description": "" + } + } + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "age", + "hasVaccinations", + "name", + "status" + ], + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "age": { + "type": "integer", + "format": "int32", + "maximum": 150.0, + "minimum": 0.0 + }, + "category": { + "$ref": "#/definitions/Category" + }, + "hasVaccinations": { + "type": "boolean" + }, + "name": { + "type": "string", + "maxLength": 50, + "minLength": 2 + }, + "images": { + "type": "array", + "items": { + "$ref": "#/definitions/Image" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/Tag" + } + }, + "status": { + "type": "string" + } + } + }, + "Category": { + "type": "object", + "additionalProperties": false, + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + } + }, + "Image": { + "type": "object", + "additionalProperties": false, + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "url": { + "type": "string" + } + } + }, + "Tag": { + "type": "object", + "additionalProperties": false, + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + } + }, + "SerializableError": { + "type": "object", + "additionalProperties": false, + "allOf": [ + { + "type": "object", + "additionalProperties": {} + } + ] + }, + "ApiResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "code" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + } +} diff --git a/src/Tools/Microsoft.dotnet-openapi/test/dotnet-microsoft.openapi.Tests.csproj b/src/Tools/Microsoft.dotnet-openapi/test/dotnet-microsoft.openapi.Tests.csproj new file mode 100644 index 0000000000..75e36320f9 --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/test/dotnet-microsoft.openapi.Tests.csproj @@ -0,0 +1,51 @@ + + + netcoreapp3.0 + Microsoft.DotNet.Open.Api.Tools.Tests + $(DefaultItemExcludes);TestProjects\**\* + DotNetAddOpenAPIReferenceToolsTests + + + + ..\src\Microsoft.dotnet-openapi.csproj + + + + + + + + + + + + + + + + + <_Parameter1>TestSettings:RestoreSources + <_Parameter2>$(RestoreSources) + + + <_Parameter1>TestSettings:RuntimeFrameworkVersion + <_Parameter2>$(RuntimeFrameworkVersion) + + + <_Parameter1>RepoRoot + <_Parameter2>$(RepoRoot) + + + + + + + + + + + + + + + diff --git a/src/Tools/Microsoft.dotnet-openapi/test/xunit.runner.json b/src/Tools/Microsoft.dotnet-openapi/test/xunit.runner.json new file mode 100644 index 0000000000..d25ea9035a --- /dev/null +++ b/src/Tools/Microsoft.dotnet-openapi/test/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "longRunningTestSeconds": 30, + "diagnosticMessages": true, + "maxParallelThreads": -1 +} diff --git a/src/Tools/README.md b/src/Tools/README.md index 3800c623f9..2341848303 100644 --- a/src/Tools/README.md +++ b/src/Tools/README.md @@ -1,18 +1,24 @@ # DotNetTools -## Projects +## Bundled tools -The folder contains command-line tools for ASP.NET Core that are bundled* in the .NET Core CLI. Follow the links below for more details on each tool. +The folder contains command-line tools for ASP.NET Core. The following tools are bundled* in the .NET Core CLI. Follow the links below for more details on each tool. - - [dotnet-watch](dotnet-watch/README.md) - - [dotnet-user-secrets](dotnet-user-secrets/README.md) - - [dotnet-sql-cache](dotnet-sql-cache/README.md) - - [dotnet-dev-certs](dotnet-dev-certs/README.md) +- [dotnet-watch](dotnet-watch/README.md) +- [dotnet-user-secrets](dotnet-user-secrets/README.md) +- [dotnet-sql-cache](dotnet-sql-cache/README.md) +- [dotnet-dev-certs](dotnet-dev-certs/README.md) *\*This applies to .NET Core CLI 2.1.300-preview2 and up. For earlier versions of the CLI, these tools must be installed separately.* *For 2.0 CLI and earlier, see for details.* +## Non-bundled tools + +The following tools are produced by us but not bundled in the .NET Core CLI. They must be aquired independently. + +- [Microsoft.dotnet-openapi](Microsoft.dotnet-openapi/README.md) + This folder also contains the infrastructure for our partners' service reference features: - [Extensions.ApiDescription.Client](Extensions.ApiDescription.Client/README.md) MSBuild glue for OpenAPI code generation. @@ -29,10 +35,11 @@ dotnet watch dotnet user-secrets dotnet sql-cache dotnet dev-certs +dotnet openapi ``` Add `--help` to see more details. For example, -``` +```sh dotnet watch --help ``` diff --git a/src/Tools/Shared/CommandLine/CommandLineApplicationExtensions.cs b/src/Tools/Shared/CommandLine/CommandLineApplicationExtensions.cs index 97189d0f38..d54db4d0fd 100644 --- a/src/Tools/Shared/CommandLine/CommandLineApplicationExtensions.cs +++ b/src/Tools/Shared/CommandLine/CommandLineApplicationExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Reflection; +using System.Threading.Tasks; namespace Microsoft.Extensions.CommandLineUtils { diff --git a/src/Tools/Shared/CommandLine/Ensure.cs b/src/Tools/Shared/CommandLine/Ensure.cs index 5cb8ff7ec7..df94e8d01c 100644 --- a/src/Tools/Shared/CommandLine/Ensure.cs +++ b/src/Tools/Shared/CommandLine/Ensure.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; diff --git a/src/Tools/dotnet-watch/test/Utilities/TemporaryCSharpProject.cs b/src/Tools/Shared/TestHelpers/TemporaryCSharpProject.cs similarity index 91% rename from src/Tools/dotnet-watch/test/Utilities/TemporaryCSharpProject.cs rename to src/Tools/Shared/TestHelpers/TemporaryCSharpProject.cs index 7156cf85cf..aeb305c737 100644 --- a/src/Tools/dotnet-watch/test/Utilities/TemporaryCSharpProject.cs +++ b/src/Tools/Shared/TestHelpers/TemporaryCSharpProject.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; @@ -6,12 +6,12 @@ using System.Collections.Generic; using System.Diagnostics; using System.Text; -namespace Microsoft.DotNet.Watcher.Tools.Tests +namespace Microsoft.Extensions.Tools.Internal { public class TemporaryCSharpProject { private const string Template = - @" + @" {0} Exe @@ -23,19 +23,22 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests private readonly string _filename; private readonly TemporaryDirectory _directory; - private List _items = new List(); - private List _properties = new List(); + private readonly List _items = new List(); + private readonly List _properties = new List(); - public TemporaryCSharpProject(string name, TemporaryDirectory directory) + public TemporaryCSharpProject(string name, TemporaryDirectory directory, string sdk) { Name = name; _filename = name + ".csproj"; _directory = directory; + Sdk = sdk; } public string Name { get; } public string Path => System.IO.Path.Combine(_directory.Root, _filename); + public string Sdk { get; } + public TemporaryCSharpProject WithTargetFrameworks(params string[] tfms) { Debug.Assert(tfms.Length > 0); @@ -95,7 +98,7 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests public void Create() { - _directory.CreateFile(_filename, string.Format(Template, string.Join("\r\n", _properties), string.Join("\r\n", _items))); + _directory.CreateFile(_filename, string.Format(Template, string.Join("\r\n", _properties), string.Join("\r\n", _items), Sdk)); } public class ItemSpec diff --git a/src/Tools/dotnet-watch/test/Utilities/TemporaryDirectory.cs b/src/Tools/Shared/TestHelpers/TemporaryDirectory.cs similarity index 78% rename from src/Tools/dotnet-watch/test/Utilities/TemporaryDirectory.cs rename to src/Tools/Shared/TestHelpers/TemporaryDirectory.cs index 692899817e..7cdef082f2 100644 --- a/src/Tools/dotnet-watch/test/Utilities/TemporaryDirectory.cs +++ b/src/Tools/Shared/TestHelpers/TemporaryDirectory.cs @@ -5,7 +5,7 @@ using System; using System.Collections.Generic; using System.IO; -namespace Microsoft.DotNet.Watcher.Tools.Tests +namespace Microsoft.Extensions.Tools.Internal { public class TemporaryDirectory : IDisposable { @@ -16,7 +16,7 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests public TemporaryDirectory() { - Root = Path.Combine(Path.GetTempPath(), "dotnet-watch-tests", Guid.NewGuid().ToString("N")); + Root = Path.Combine(Path.GetTempPath(), "dotnet-tool-tests", Guid.NewGuid().ToString("N")); } private TemporaryDirectory(string path, TemporaryDirectory parent) @@ -34,16 +34,16 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests public string Root { get; } - public TemporaryCSharpProject WithCSharpProject(string name) + public TemporaryCSharpProject WithCSharpProject(string name, string sdk = "Microsoft.NET.Sdk") { - var project = new TemporaryCSharpProject(name, this); + var project = new TemporaryCSharpProject(name, this, sdk); _projects.Add(project); return project; } - public TemporaryCSharpProject WithCSharpProject(string name, out TemporaryCSharpProject project) + public TemporaryCSharpProject WithCSharpProject(string name, out TemporaryCSharpProject project, string sdk = "Microsoft.NET.Sdk") { - project = WithCSharpProject(name); + project = WithCSharpProject(name, sdk); return project; } @@ -53,6 +53,16 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests return this; } + public TemporaryDirectory WithContentFile(string name) + { + using (var stream = File.OpenRead(Path.Combine("TestContent", $"{name}.txt"))) + using (var streamReader = new StreamReader(stream)) + { + _files[name] = streamReader.ReadToEnd(); + } + return this; + } + public TemporaryDirectory Up() { if (_parent == null) diff --git a/src/Tools/Tools.sln b/src/Tools/Tools.sln index 4af0386aeb..1de511f5cc 100644 --- a/src/Tools/Tools.sln +++ b/src/Tools/Tools.sln @@ -7,13 +7,21 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-watch", "dotnet-watc EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-watch.Tests", "dotnet-watch\test\dotnet-watch.Tests.csproj", "{63F7E822-D1E2-4C41-8ABF-60B9E3A9C54C}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-dev-certs", "dotnet-dev-certs\src\dotnet-dev-certs.csproj", "{0D6D5693-7E0C-4FE8-B4AA-21207B2650AA}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{E01EE27B-6CF9-4707-9849-5BA2ABA825F2}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.DeveloperCertificates.XPlat", "FirstRunCertGenerator\src\Microsoft.AspNetCore.DeveloperCertificates.XPlat.csproj", "{7BBDBDA2-299F-4C36-8338-23C525901DE0}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{2C485EAF-E4DE-4D14-8AE1-330641E17D44}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.DeveloperCertificates.XPlat.Tests", "FirstRunCertGenerator\test\Microsoft.AspNetCore.DeveloperCertificates.XPlat.Tests.csproj", "{1EC6FA27-40A5-433F-8CA1-636E7ED8863E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-dev-certs", "dotnet-dev-certs\src\dotnet-dev-certs.csproj", "{98550159-E04E-44EB-A969-E5BF12444B94}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-sql-cache", "dotnet-sql-cache\src\dotnet-sql-cache.csproj", "{15FB0E39-1A28-4325-AD3C-76352516C80D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-sql-cache", "dotnet-sql-cache\src\dotnet-sql-cache.csproj", "{216AF7F1-5B05-477E-B8D3-86F6059F268A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-user-secrets", "dotnet-user-secrets\src\dotnet-user-secrets.csproj", "{5FE62357-2915-4890-813A-D82656BDC4DD}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-user-secrets.Tests", "dotnet-user-secrets\test\dotnet-user-secrets.Tests.csproj", "{25F8DCC4-4571-42F7-BA0F-5C2D5A802297}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.dotnet-openapi", "Microsoft.dotnet-openapi\src\Microsoft.dotnet-openapi.csproj", "{C806041C-30F2-4B27-918A-5FF3576B833B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-microsoft.openapi.Tests", "Microsoft.dotnet-openapi\test\dotnet-microsoft.openapi.Tests.csproj", "{26BBA8A7-0F69-4C5F-B1C2-16B3320FFE3F}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions.ApiDescription.Client", "Extensions.ApiDescription.Client", "{78610083-1FCE-47F5-AB4D-AF0E1313B351}" EndProject @@ -77,6 +85,30 @@ Global {EB63AECB-B898-475D-90F7-FE61F9C1CCC6}.Debug|Any CPU.Build.0 = Debug|Any CPU {EB63AECB-B898-475D-90F7-FE61F9C1CCC6}.Release|Any CPU.ActiveCfg = Release|Any CPU {EB63AECB-B898-475D-90F7-FE61F9C1CCC6}.Release|Any CPU.Build.0 = Release|Any CPU + {98550159-E04E-44EB-A969-E5BF12444B94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98550159-E04E-44EB-A969-E5BF12444B94}.Debug|Any CPU.Build.0 = Debug|Any CPU + {98550159-E04E-44EB-A969-E5BF12444B94}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98550159-E04E-44EB-A969-E5BF12444B94}.Release|Any CPU.Build.0 = Release|Any CPU + {216AF7F1-5B05-477E-B8D3-86F6059F268A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {216AF7F1-5B05-477E-B8D3-86F6059F268A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {216AF7F1-5B05-477E-B8D3-86F6059F268A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {216AF7F1-5B05-477E-B8D3-86F6059F268A}.Release|Any CPU.Build.0 = Release|Any CPU + {5FE62357-2915-4890-813A-D82656BDC4DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5FE62357-2915-4890-813A-D82656BDC4DD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5FE62357-2915-4890-813A-D82656BDC4DD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5FE62357-2915-4890-813A-D82656BDC4DD}.Release|Any CPU.Build.0 = Release|Any CPU + {25F8DCC4-4571-42F7-BA0F-5C2D5A802297}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25F8DCC4-4571-42F7-BA0F-5C2D5A802297}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25F8DCC4-4571-42F7-BA0F-5C2D5A802297}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25F8DCC4-4571-42F7-BA0F-5C2D5A802297}.Release|Any CPU.Build.0 = Release|Any CPU + {C806041C-30F2-4B27-918A-5FF3576B833B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C806041C-30F2-4B27-918A-5FF3576B833B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C806041C-30F2-4B27-918A-5FF3576B833B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C806041C-30F2-4B27-918A-5FF3576B833B}.Release|Any CPU.Build.0 = Release|Any CPU + {26BBA8A7-0F69-4C5F-B1C2-16B3320FFE3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {26BBA8A7-0F69-4C5F-B1C2-16B3320FFE3F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {26BBA8A7-0F69-4C5F-B1C2-16B3320FFE3F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {26BBA8A7-0F69-4C5F-B1C2-16B3320FFE3F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -88,6 +120,14 @@ Global {160A445F-7E1F-430D-9403-41F7F6F4A16E} = {4110117E-3C28-4064-A7A3-B112BD6F8CB9} {233119FC-E4C1-421C-89AE-1A445C5A947F} = {4110117E-3C28-4064-A7A3-B112BD6F8CB9} {EB63AECB-B898-475D-90F7-FE61F9C1CCC6} = {4110117E-3C28-4064-A7A3-B112BD6F8CB9} + {E16F10C8-5FC3-420B-AB33-D6E5CBE89B75} = {E01EE27B-6CF9-4707-9849-5BA2ABA825F2} + {63F7E822-D1E2-4C41-8ABF-60B9E3A9C54C} = {2C485EAF-E4DE-4D14-8AE1-330641E17D44} + {98550159-E04E-44EB-A969-E5BF12444B94} = {E01EE27B-6CF9-4707-9849-5BA2ABA825F2} + {216AF7F1-5B05-477E-B8D3-86F6059F268A} = {E01EE27B-6CF9-4707-9849-5BA2ABA825F2} + {5FE62357-2915-4890-813A-D82656BDC4DD} = {E01EE27B-6CF9-4707-9849-5BA2ABA825F2} + {25F8DCC4-4571-42F7-BA0F-5C2D5A802297} = {2C485EAF-E4DE-4D14-8AE1-330641E17D44} + {C806041C-30F2-4B27-918A-5FF3576B833B} = {E01EE27B-6CF9-4707-9849-5BA2ABA825F2} + {26BBA8A7-0F69-4C5F-B1C2-16B3320FFE3F} = {2C485EAF-E4DE-4D14-8AE1-330641E17D44} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EC668D8E-97B9-4758-9E5C-2E5DD6B9137B} diff --git a/src/Tools/build.cmd b/src/Tools/build.cmd new file mode 100644 index 0000000000..033fe6f614 --- /dev/null +++ b/src/Tools/build.cmd @@ -0,0 +1,3 @@ +@ECHO OFF +SET RepoRoot=%~dp0..\.. +%RepoRoot%\build.cmd -projects %~dp0\**\*.*proj %* diff --git a/src/Tools/build.sh b/src/Tools/build.sh new file mode 100755 index 0000000000..7046bb98a0 --- /dev/null +++ b/src/Tools/build.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +repo_root="$DIR/../.." +"$repo_root/build.sh" --projects "$DIR/**/*.*proj" "$@" diff --git a/src/Tools/dotnet-dev-certs/src/Program.cs b/src/Tools/dotnet-dev-certs/src/Program.cs index 3854c4c59c..6ecb1912cd 100644 --- a/src/Tools/dotnet-dev-certs/src/Program.cs +++ b/src/Tools/dotnet-dev-certs/src/Program.cs @@ -104,10 +104,10 @@ namespace Microsoft.AspNetCore.DeveloperCertificates.Tools app.HelpOption("-h|--help"); app.OnExecute(() => - { - app.ShowHelp(); - return Success; - }); + { + app.ShowHelp(); + return Success; + }); return app.Execute(args); } diff --git a/src/Tools/dotnet-watch/src/PrefixConsoleReporter.cs b/src/Tools/dotnet-watch/src/PrefixConsoleReporter.cs index b2453276ef..cf13cd0733 100644 --- a/src/Tools/dotnet-watch/src/PrefixConsoleReporter.cs +++ b/src/Tools/dotnet-watch/src/PrefixConsoleReporter.cs @@ -11,18 +11,20 @@ namespace Microsoft.DotNet.Watcher { private object _lock = new object(); - public PrefixConsoleReporter(IConsole console, bool verbose, bool quiet) + private readonly string _prefix; + + public PrefixConsoleReporter(string prefix, IConsole console, bool verbose, bool quiet) : base(console, verbose, quiet) - { } + { + _prefix = prefix; + } protected override void WriteLine(TextWriter writer, string message, ConsoleColor? color) { - const string prefix = "watch : "; - lock (_lock) { Console.ForegroundColor = ConsoleColor.DarkGray; - writer.Write(prefix); + writer.Write(_prefix); Console.ResetColor(); base.WriteLine(writer, message, color); diff --git a/src/Tools/dotnet-watch/src/Program.cs b/src/Tools/dotnet-watch/src/Program.cs index 25317fb6b2..f27ffd878f 100644 --- a/src/Tools/dotnet-watch/src/Program.cs +++ b/src/Tools/dotnet-watch/src/Program.cs @@ -15,17 +15,17 @@ namespace Microsoft.DotNet.Watcher public class Program : IDisposable { private readonly IConsole _console; - private readonly string _workingDir; + private readonly string _workingDirectory; private readonly CancellationTokenSource _cts; private IReporter _reporter; - public Program(IConsole console, string workingDir) + public Program(IConsole console, string workingDirectory) { Ensure.NotNull(console, nameof(console)); - Ensure.NotNullOrEmpty(workingDir, nameof(workingDir)); + Ensure.NotNullOrEmpty(workingDirectory, nameof(workingDirectory)); _console = console; - _workingDir = workingDir; + _workingDirectory = workingDirectory; _cts = new CancellationTokenSource(); _console.CancelKeyPress += OnCancelKeyPress; _reporter = CreateReporter(verbose: true, quiet: false, console: _console); @@ -134,7 +134,7 @@ namespace Microsoft.DotNet.Watcher string projectFile; try { - projectFile = MsBuildProjectFinder.FindMsBuildProject(_workingDir, project); + projectFile = MsBuildProjectFinder.FindMsBuildProject(_workingDirectory, project); } catch (FileNotFoundException ex) { @@ -177,7 +177,7 @@ namespace Microsoft.DotNet.Watcher string projectFile; try { - projectFile = MsBuildProjectFinder.FindMsBuildProject(_workingDir, project); + projectFile = MsBuildProjectFinder.FindMsBuildProject(_workingDirectory, project); } catch (FileNotFoundException ex) { @@ -205,7 +205,7 @@ namespace Microsoft.DotNet.Watcher } private static IReporter CreateReporter(bool verbose, bool quiet, IConsole console) - => new PrefixConsoleReporter(console, verbose || CliContext.IsGlobalVerbose(), quiet); + => new PrefixConsoleReporter("watch : ", console, verbose || CliContext.IsGlobalVerbose(), quiet); public void Dispose() { diff --git a/src/Tools/dotnet-watch/src/dotnet-watch.csproj b/src/Tools/dotnet-watch/src/dotnet-watch.csproj index b289e14287..e50c349324 100644 --- a/src/Tools/dotnet-watch/src/dotnet-watch.csproj +++ b/src/Tools/dotnet-watch/src/dotnet-watch.csproj @@ -1,4 +1,4 @@ - + netcoreapp3.0 @@ -12,7 +12,7 @@ - + diff --git a/src/Tools/dotnet-watch/test/AssertEx.cs b/src/Tools/dotnet-watch/test/AssertEx.cs index 4d897058fd..d55406d8e9 100644 --- a/src/Tools/dotnet-watch/test/AssertEx.cs +++ b/src/Tools/dotnet-watch/test/AssertEx.cs @@ -1,11 +1,9 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; using System.Collections.Generic; using System.IO; using System.Linq; -using Xunit; using Xunit.Sdk; namespace Microsoft.DotNet.Watcher.Tools.Tests diff --git a/src/Tools/dotnet-watch/test/MsBuildFileSetFactoryTest.cs b/src/Tools/dotnet-watch/test/MsBuildFileSetFactoryTest.cs index c34111b239..0d69f49d2e 100644 --- a/src/Tools/dotnet-watch/test/MsBuildFileSetFactoryTest.cs +++ b/src/Tools/dotnet-watch/test/MsBuildFileSetFactoryTest.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; @@ -113,17 +113,20 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests public async Task MultiTfm() { _tempDir - .SubDir("src") - .SubDir("Project1") - .WithCSharpProject("Project1", out var target) - .WithTargetFrameworks("netcoreapp1.0", "net451") - .WithProperty("EnableDefaultCompileItems", "false") - .WithItem("Compile", "Class1.netcore.cs", "'$(TargetFramework)'=='netcoreapp1.0'") - .WithItem("Compile", "Class1.desktop.cs", "'$(TargetFramework)'=='net451'") - .Dir() - .WithFile("Class1.netcore.cs") - .WithFile("Class1.desktop.cs") - .WithFile("Class1.notincluded.cs"); + .SubDir("src") + .SubDir("Project1") + .WithCSharpProject("Project1", out var target) + .WithTargetFrameworks("netcoreapp1.0", "net451") + .WithProperty("EnableDefaultCompileItems", "false") + .WithItem("Compile", "Class1.netcore.cs", "'$(TargetFramework)'=='netcoreapp1.0'") + .WithItem("Compile", "Class1.desktop.cs", "'$(TargetFramework)'=='net451'") + .Dir() + .WithFile("Class1.netcore.cs") + .WithFile("Class1.desktop.cs") + .WithFile("Class1.notincluded.cs") + .Up() + .Up() + .Create(); var fileset = await GetFileSet(target); @@ -155,7 +158,10 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests .WithTargetFrameworks("netcoreapp1.0", "net451") .WithProjectReference(proj2) .Dir() - .WithFile("Class1.cs"); + .WithFile("Class1.cs") + .Up() + .Up() + .Create(); var fileset = await GetFileSet(target); diff --git a/src/Tools/dotnet-watch/test/Utilities/TestProjectGraph.cs b/src/Tools/dotnet-watch/test/Utilities/TestProjectGraph.cs index a8e615d3c9..7e44ea643e 100644 --- a/src/Tools/dotnet-watch/test/Utilities/TestProjectGraph.cs +++ b/src/Tools/dotnet-watch/test/Utilities/TestProjectGraph.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using Microsoft.Extensions.Tools.Internal; namespace Microsoft.DotNet.Watcher.Tools.Tests { @@ -10,7 +11,7 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests { private readonly TemporaryDirectory _directory; private Action _onCreate; - private Dictionary _projects = new Dictionary(); + private readonly Dictionary _projects = new Dictionary(); public TestProjectGraph(TemporaryDirectory directory) { _directory = directory; @@ -28,8 +29,7 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests public TemporaryCSharpProject GetOrCreate(string projectName) { - TemporaryCSharpProject sourceProj; - if (!_projects.TryGetValue(projectName, out sourceProj)) + if (!_projects.TryGetValue(projectName, out TemporaryCSharpProject sourceProj)) { sourceProj = _directory.SubDir(projectName).WithCSharpProject(projectName); _onCreate?.Invoke(sourceProj); @@ -38,4 +38,4 @@ namespace Microsoft.DotNet.Watcher.Tools.Tests return sourceProj; } } -} \ No newline at end of file +} diff --git a/src/Tools/dotnet-watch/test/dotnet-watch.Tests.csproj b/src/Tools/dotnet-watch/test/dotnet-watch.Tests.csproj index 913332b68d..b51c978aa7 100644 --- a/src/Tools/dotnet-watch/test/dotnet-watch.Tests.csproj +++ b/src/Tools/dotnet-watch/test/dotnet-watch.Tests.csproj @@ -1,4 +1,4 @@ - + netcoreapp3.0