소스 검색

Cleanup processes started by UseReactDevelopmentServer and UseAngularCliServer

Fixes #11597
AndriySvyryd 6 년 전
부모
커밋
db3d23b3af

+ 14 - 6
src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliBuilder.cs

@@ -1,15 +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;
+using System.Diagnostics;
+using System.IO;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
 using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.NodeServices.Npm;
 using Microsoft.AspNetCore.NodeServices.Util;
 using Microsoft.AspNetCore.SpaServices.Prerendering;
 using Microsoft.AspNetCore.SpaServices.Util;
-using System;
-using System.IO;
-using System.Text.RegularExpressions;
-using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
 
 namespace Microsoft.AspNetCore.SpaServices.AngularCli
 {
@@ -48,15 +51,20 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
                 throw new InvalidOperationException($"To use {nameof(AngularCliBuilder)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} when calling {nameof(SpaApplicationBuilderExtensions.UseSpa)}.");
             }
 
+            var appBuilder = spaBuilder.ApplicationBuilder;
+            var applicationStoppingToken = appBuilder.ApplicationServices.GetRequiredService<IHostApplicationLifetime>().ApplicationStopping;
             var logger = LoggerFinder.GetOrCreateLogger(
-                spaBuilder.ApplicationBuilder,
+                appBuilder,
                 nameof(AngularCliBuilder));
+            var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService<DiagnosticSource>();
             var scriptRunner = new NodeScriptRunner(
                 sourcePath,
                 _scriptName,
                 "--watch",
                 null,
-                pkgManagerCommand);
+                pkgManagerCommand,
+                diagnosticSource,
+                applicationStoppingToken);
             scriptRunner.AttachToLogger(logger);
 
             using (var stdOutReader = new EventedStreamStringReader(scriptRunner.StdOut))

+ 15 - 10
src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddleware.cs

@@ -1,18 +1,21 @@
 // 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 Microsoft.AspNetCore.Builder;
-using Microsoft.Extensions.Logging;
-using Microsoft.AspNetCore.NodeServices.Npm;
-using Microsoft.AspNetCore.NodeServices.Util;
-using Microsoft.AspNetCore.SpaServices.Util;
 using System;
+using System.Diagnostics;
 using System.IO;
+using System.Net.Http;
 using System.Text.RegularExpressions;
-using System.Threading.Tasks;
 using System.Threading;
-using System.Net.Http;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.NodeServices.Npm;
+using Microsoft.AspNetCore.NodeServices.Util;
 using Microsoft.AspNetCore.SpaServices.Extensions.Util;
+using Microsoft.AspNetCore.SpaServices.Util;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
 
 namespace Microsoft.AspNetCore.SpaServices.AngularCli
 {
@@ -40,8 +43,10 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
 
             // Start Angular CLI and attach to middleware pipeline
             var appBuilder = spaBuilder.ApplicationBuilder;
+            var applicationStoppingToken = appBuilder.ApplicationServices.GetRequiredService<IHostApplicationLifetime>().ApplicationStopping;
             var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName);
-            var angularCliServerInfoTask = StartAngularCliServerAsync(sourcePath, scriptName, pkgManagerCommand, devServerPort, logger);
+            var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService<DiagnosticSource>();
+            var angularCliServerInfoTask = StartAngularCliServerAsync(sourcePath, scriptName, pkgManagerCommand, devServerPort, logger, diagnosticSource, applicationStoppingToken);
 
             // Everything we proxy is hardcoded to target http://localhost because:
             // - the requests are always from the local machine (we're not accepting remote
@@ -64,7 +69,7 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
         }
 
         private static async Task<AngularCliServerInfo> StartAngularCliServerAsync(
-            string sourcePath, string scriptName, string pkgManagerCommand, int portNumber, ILogger logger)
+            string sourcePath, string scriptName, string pkgManagerCommand, int portNumber, ILogger logger, DiagnosticSource diagnosticSource, CancellationToken applicationStoppingToken)
         {
             if (portNumber == default(int))
             {
@@ -73,7 +78,7 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
             logger.LogInformation($"Starting @angular/cli on port {portNumber}...");
 
             var scriptRunner = new NodeScriptRunner(
-                sourcePath, scriptName, $"--port {portNumber}", null, pkgManagerCommand);
+                sourcePath, scriptName, $"--port {portNumber}", null, pkgManagerCommand, diagnosticSource, applicationStoppingToken);
             scriptRunner.AttachToLogger(logger);
 
             Match openBrowserLine;

+ 32 - 8
src/Middleware/SpaServices.Extensions/src/Npm/NodeScriptRunner.cs

@@ -1,13 +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 Microsoft.Extensions.Logging;
-using Microsoft.AspNetCore.NodeServices.Util;
 using System;
+using System.Collections.Generic;
 using System.Diagnostics;
 using System.Runtime.InteropServices;
 using System.Text.RegularExpressions;
-using System.Collections.Generic;
+using System.Threading;
+using Microsoft.AspNetCore.NodeServices.Util;
+using Microsoft.Extensions.Logging;
 
 // This is under the NodeServices namespace because post 2.1 it will be moved to that package
 namespace Microsoft.AspNetCore.NodeServices.Npm
@@ -16,14 +17,15 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
     /// Executes the <c>script</c> entries defined in a <c>package.json</c> file,
     /// capturing any output written to stdio.
     /// </summary>
-    internal class NodeScriptRunner
+    internal class NodeScriptRunner : IDisposable
     {
+        private Process _npmProcess;
         public EventedStreamReader StdOut { get; }
         public EventedStreamReader StdErr { get; }
 
         private static Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1));
 
-        public NodeScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary<string, string> envVars, string pkgManagerCommand)
+        public NodeScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary<string, string> envVars, string pkgManagerCommand, DiagnosticSource diagnosticSource, CancellationToken applicationStoppingToken)
         {
             if (string.IsNullOrEmpty(workingDirectory))
             {
@@ -69,9 +71,22 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
                 }
             }
 
-            var process = LaunchNodeProcess(processStartInfo, pkgManagerCommand);
-            StdOut = new EventedStreamReader(process.StandardOutput);
-            StdErr = new EventedStreamReader(process.StandardError);
+            _npmProcess = LaunchNodeProcess(processStartInfo, pkgManagerCommand);
+            StdOut = new EventedStreamReader(_npmProcess.StandardOutput);
+            StdErr = new EventedStreamReader(_npmProcess.StandardError);
+
+            applicationStoppingToken.Register(((IDisposable)this).Dispose);
+            
+            if (diagnosticSource.IsEnabled("Microsoft.AspNetCore.NodeServices.Npm.NpmStarted"))
+            {
+                diagnosticSource.Write(
+                    "Microsoft.AspNetCore.NodeServices.Npm.NpmStarted",
+                    new
+                    {
+                        processStartInfo = processStartInfo,
+                        process = _npmProcess
+                    });
+            }
         }
 
         public void AttachToLogger(ILogger logger)
@@ -132,5 +147,14 @@ namespace Microsoft.AspNetCore.NodeServices.Npm
                 throw new InvalidOperationException(message, ex);
             }
         }
+
+        void IDisposable.Dispose()
+        {
+            if (_npmProcess != null && !_npmProcess.HasExited)
+            {
+                _npmProcess.Kill(entireProcessTree: true);
+                _npmProcess = null;
+            }
+        }
     }
 }

+ 15 - 9
src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs

@@ -1,17 +1,21 @@
 // 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 Microsoft.AspNetCore.Builder;
-using Microsoft.Extensions.Logging;
-using Microsoft.AspNetCore.NodeServices.Npm;
-using Microsoft.AspNetCore.NodeServices.Util;
-using Microsoft.AspNetCore.SpaServices.Util;
 using System;
-using System.IO;
 using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
 using System.Text.RegularExpressions;
+using System.Threading;
 using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.NodeServices.Npm;
+using Microsoft.AspNetCore.NodeServices.Util;
 using Microsoft.AspNetCore.SpaServices.Extensions.Util;
+using Microsoft.AspNetCore.SpaServices.Util;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
 
 namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
 {
@@ -39,8 +43,10 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
 
             // Start create-react-app and attach to middleware pipeline
             var appBuilder = spaBuilder.ApplicationBuilder;
+            var applicationStoppingToken = appBuilder.ApplicationServices.GetRequiredService<IHostApplicationLifetime>().ApplicationStopping;
             var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName);
-            var portTask = StartCreateReactAppServerAsync(sourcePath, scriptName, pkgManagerCommand, devServerPort, logger);
+            var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService<DiagnosticSource>();
+            var portTask = StartCreateReactAppServerAsync(sourcePath, scriptName, pkgManagerCommand, devServerPort, logger, diagnosticSource, applicationStoppingToken);
 
             // Everything we proxy is hardcoded to target http://localhost because:
             // - the requests are always from the local machine (we're not accepting remote
@@ -63,7 +69,7 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
         }
 
         private static async Task<int> StartCreateReactAppServerAsync(
-            string sourcePath, string scriptName, string pkgManagerCommand, int portNumber, ILogger logger)
+            string sourcePath, string scriptName, string pkgManagerCommand, int portNumber, ILogger logger, DiagnosticSource diagnosticSource, CancellationToken applicationStoppingToken)
         {
             if (portNumber == default(int))
             {
@@ -77,7 +83,7 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
                 { "BROWSER", "none" }, // We don't want create-react-app to open its own extra browser window pointing to the internal dev server port
             };
             var scriptRunner = new NodeScriptRunner(
-                sourcePath, scriptName, null, envVars, pkgManagerCommand);
+                sourcePath, scriptName, null, envVars, pkgManagerCommand, diagnosticSource, applicationStoppingToken);
             scriptRunner.AttachToLogger(logger);
 
             using (var stdErrReader = new EventedStreamStringReader(scriptRunner.StdErr))

+ 1 - 1
src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddlewareExtensions.cs

@@ -1,8 +1,8 @@
 // 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 Microsoft.AspNetCore.Builder;
 using System;
+using Microsoft.AspNetCore.Builder;
 
 namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
 {

+ 101 - 0
src/Middleware/SpaServices.Extensions/test/ListLoggerFactory.cs

@@ -0,0 +1,101 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit.Abstractions;
+
+namespace Microsoft.AspNetCore.SpaServices.Extensions.Tests
+{
+    public class ListLoggerFactory : ILoggerFactory
+    {
+        private readonly Func<string, bool> _shouldLogCategory;
+        private bool _disposed;
+
+        public ListLoggerFactory()
+            : this(_ => true)
+        {
+        }
+
+        public ListLoggerFactory(Func<string, bool> shouldLogCategory)
+        {
+            _shouldLogCategory = shouldLogCategory;
+            Logger = new ListLogger();
+        }
+
+        public List<(LogLevel Level, EventId Id, string Message, object State, Exception Exception)> Log => Logger.LoggedEvents;
+        protected ListLogger Logger { get; set; }
+
+        public virtual void Clear() => Logger.Clear();
+
+        public void SetTestOutputHelper(ITestOutputHelper testOutputHelper)
+        {
+            Logger.TestOutputHelper = testOutputHelper;
+        }
+
+        public virtual ILogger CreateLogger(string name)
+        {
+            CheckDisposed();
+
+            return !_shouldLogCategory(name)
+                ? (ILogger)NullLogger.Instance
+                : Logger;
+        }
+
+        private void CheckDisposed()
+        {
+            if (_disposed)
+            {
+                throw new ObjectDisposedException(nameof(ListLoggerFactory));
+            }
+        }
+
+        public void AddProvider(ILoggerProvider provider)
+        {
+            CheckDisposed();
+        }
+
+        public void Dispose()
+        {
+            _disposed = true;
+        }
+
+        protected class ListLogger : ILogger
+        {
+            private readonly object _sync = new object();
+
+            public ITestOutputHelper TestOutputHelper { get; set; }
+
+            public List<(LogLevel, EventId, string, object, Exception)> LoggedEvents { get; }
+                = new List<(LogLevel, EventId, string, object, Exception)>();
+
+            public void Clear()
+            {
+                lock (_sync) // Guard against tests with explicit concurrency
+                {
+                    LoggedEvents.Clear();
+                }
+            }
+
+            public void Log<TState>(
+                LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
+            {
+                lock (_sync) // Guard against tests with explicit concurrency
+                {
+                    var message = formatter(state, exception)?.Trim();
+                    if (message != null)
+                    {
+                        TestOutputHelper?.WriteLine(message + Environment.NewLine);
+                    }
+
+                    LoggedEvents.Add((logLevel, eventId, message, state, exception));
+                }
+            }
+
+            public bool IsEnabled(LogLevel logLevel) => true;
+
+            public IDisposable BeginScope(object state) => null;
+
+            public IDisposable BeginScope<TState>(TState state) => null;
+        }
+    }
+}

+ 11 - 1
src/Middleware/SpaServices.Extensions/test/Microsoft.AspNetCore.SpaServices.Extensions.Tests.csproj

@@ -1,17 +1,27 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
     <TestDependsOnNode>true</TestDependsOnNode>
+	<!-- Depends on npm which is not picked up on helix -->
+    <!-- https://github.com/dotnet/aspnetcore/issues/18672 -->
+	<BuildHelixPayload>false</BuildHelixPayload>
   </PropertyGroup>
 
   <ItemGroup>
     <Reference Include="Microsoft.AspNetCore.SpaServices.Extensions" />
     <Reference Include="Microsoft.AspNetCore.Hosting" />
     <Reference Include="Microsoft.AspNetCore.TestHost" />
+    <Reference Include="Microsoft.Extensions.DiagnosticAdapter" />
     <Reference Include="Microsoft.Extensions.Hosting" />
     <Reference Include="Microsoft.Extensions.Logging.Testing" />
     <Content Include="js\**\*" />
   </ItemGroup>
 
+  <ItemGroup>
+    <None Update="package.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
+  </ItemGroup>
+
 </Project>

+ 159 - 3
src/Middleware/SpaServices.Extensions/test/SpaServicesExtensionsTests.cs

@@ -2,8 +2,22 @@
 // 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.Runtime.InteropServices;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
 using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.SpaServices.AngularCli;
+using Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer;
+using Microsoft.AspNetCore.SpaServices.StaticFiles;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DiagnosticAdapter;
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
 using Moq;
 using Xunit;
 
@@ -24,9 +38,90 @@ namespace Microsoft.AspNetCore.SpaServices.Extensions.Tests
             Assert.Equal("No RootPath was set on the SpaStaticFilesOptions.", exception.Message);
         }
 
+        [Fact]
+        public async Task UseSpa_KillsRds_WhenAppIsStopped()
+        {
+            var serviceProvider = GetServiceProvider(s => s.RootPath = "/");
+            var applicationbuilder = new ApplicationBuilder(serviceProvider);
+            var applicationLifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
+            var diagnosticListener = serviceProvider.GetRequiredService<DiagnosticListener>();
+            var listener = new NpmStartedDiagnosticListener();
+            diagnosticListener.SubscribeWithAdapter(listener);
+
+            applicationbuilder.UseSpa(b =>
+            {
+                b.Options.SourcePath = Directory.GetCurrentDirectory();
+                b.UseReactDevelopmentServer(GetPlatformSpecificWaitCommand());
+            });
+
+            await Assert_NpmKilled_WhenAppIsStopped(applicationLifetime, listener);
+        }
+
+        [Fact]
+        public async Task UseSpa_KillsAngularCli_WhenAppIsStopped()
+        {
+            var serviceProvider = GetServiceProvider(s => s.RootPath = "/");
+            var applicationbuilder = new ApplicationBuilder(serviceProvider);
+            var applicationLifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
+            var diagnosticListener = serviceProvider.GetRequiredService<DiagnosticListener>();
+            var listener = new NpmStartedDiagnosticListener();
+            diagnosticListener.SubscribeWithAdapter(listener);
+
+            applicationbuilder.UseSpa(b =>
+            {
+                b.Options.SourcePath = Directory.GetCurrentDirectory();
+                b.UseAngularCliServer(GetPlatformSpecificWaitCommand());
+            });
+
+            await Assert_NpmKilled_WhenAppIsStopped(applicationLifetime, listener);
+        }
+
+        private async Task Assert_NpmKilled_WhenAppIsStopped(IHostApplicationLifetime applicationLifetime, NpmStartedDiagnosticListener listener)
+        {
+            // Give node a moment to start up
+            await Task.WhenAny(listener.NpmStarted, Task.Delay(TimeSpan.FromSeconds(30)));
+
+            Process npmProcess = null;
+            var npmExitEvent = new ManualResetEventSlim();
+            if (listener.NpmStarted.IsCompleted)
+            {
+                npmProcess = listener.NpmStarted.Result.Process;
+                Assert.False(npmProcess.HasExited);
+                npmProcess.Exited += (_, __) => npmExitEvent.Set();
+            }
+
+            // Act
+            applicationLifetime.StopApplication();
+
+            // Assert
+            AssertNoErrors();
+            Assert.True(listener.NpmStarted.IsCompleted, "npm wasn't launched");
+
+            npmExitEvent.Wait(TimeSpan.FromSeconds(30));
+            Assert.True(npmProcess.HasExited, "npm wasn't killed");
+        }
+
+        private class NpmStartedDiagnosticListener
+        {
+            private readonly TaskCompletionSource<(ProcessStartInfo ProcessStartInfo, Process Process)> _npmStartedTaskCompletionSource
+                = new TaskCompletionSource<(ProcessStartInfo ProcessStartInfo, Process Process)>();
+
+            public Task<(ProcessStartInfo ProcessStartInfo, Process Process)> NpmStarted
+                => _npmStartedTaskCompletionSource.Task;
+
+            [DiagnosticName("Microsoft.AspNetCore.NodeServices.Npm.NpmStarted")]
+            public virtual void OnNpmStarted(ProcessStartInfo processStartInfo, Process process)
+            {
+                _npmStartedTaskCompletionSource.TrySetResult((processStartInfo, process));
+            }
+        }
+
+        private string GetPlatformSpecificWaitCommand()
+            => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "waitWindows" : "wait";
+
         private IApplicationBuilder GetApplicationBuilder(IServiceProvider serviceProvider = null)
         {
-            if(serviceProvider == null)
+            if (serviceProvider == null)
             {
                 serviceProvider = new Mock<IServiceProvider>(MockBehavior.Strict).Object;
             }
@@ -39,13 +134,74 @@ namespace Microsoft.AspNetCore.SpaServices.Extensions.Tests
             return applicationbuilderMock.Object;
         }
 
-        private IServiceProvider GetServiceProvider()
+        private IServiceProvider GetServiceProvider(Action<SpaStaticFilesOptions> configuration = null)
         {
             var services = new ServiceCollection();
             services.AddLogging();
-            services.AddSpaStaticFiles();
+            services.AddSpaStaticFiles(configuration);
+            services.AddSingleton<ILoggerFactory>(ListLoggerFactory);
+            services.AddSingleton(typeof(IHostApplicationLifetime), new TestHostApplicationLifetime());
+            services.AddSingleton(typeof(IWebHostEnvironment), new TestWebHostEnvironment());
+
+            var listener = new DiagnosticListener("Microsoft.AspNetCore");
+            services.AddSingleton(listener);
+            services.AddSingleton<DiagnosticSource>(listener);
 
             return services.BuildServiceProvider();
         }
+
+        private void AssertNoErrors()
+        {
+            var builder = new StringBuilder();
+            foreach (var line in ListLoggerFactory.Log)
+            {
+                if (line.Level < LogLevel.Error)
+                {
+                    continue;
+                }
+                builder.AppendLine(line.Message);
+            }
+
+            Assert.True(builder.Length == 0, builder.ToString());
+        }
+
+        private ListLoggerFactory ListLoggerFactory { get; } = new ListLoggerFactory(c => c == "Microsoft.AspNetCore.SpaServices");
+
+        private class TestHostApplicationLifetime : IHostApplicationLifetime
+        {
+            CancellationTokenSource _applicationStoppingSource;
+            CancellationTokenSource _applicationStoppedSource;
+
+            public TestHostApplicationLifetime()
+            {
+                _applicationStoppingSource = new CancellationTokenSource();
+                ApplicationStopping = _applicationStoppingSource.Token;
+
+                _applicationStoppedSource = new CancellationTokenSource();
+                ApplicationStopped = _applicationStoppedSource.Token;
+            }
+
+            public CancellationToken ApplicationStarted => CancellationToken.None;
+
+            public CancellationToken ApplicationStopping { get; }
+
+            public CancellationToken ApplicationStopped { get; }
+
+            public void StopApplication()
+            {
+                _applicationStoppingSource.Cancel();
+                _applicationStoppedSource.Cancel();
+            }
+        }
+
+        private class TestWebHostEnvironment : IWebHostEnvironment
+        {
+            public string EnvironmentName { get; set; }
+            public string ApplicationName { get; set; }
+            public string ContentRootPath { get; set; } = Directory.GetCurrentDirectory();
+            public string WebRootPath { get; set; } = Directory.GetCurrentDirectory();
+            public IFileProvider ContentRootFileProvider { get; set; }
+            public IFileProvider WebRootFileProvider { get; set; }
+        }
     }
 }

+ 6 - 0
src/Middleware/SpaServices.Extensions/test/package.json

@@ -0,0 +1,6 @@
+{
+  "scripts": {
+    "wait": "sleep 30 1>/dev/null && echo",
+    "waitWindows": "ping 127.0.0.1 -n 30 >nul && echo"
+  }
+}