diff --git a/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliBuilder.cs b/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliBuilder.cs index 24f0ba430f..f23382691f 100644 --- a/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliBuilder.cs +++ b/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().ApplicationStopping; var logger = LoggerFinder.GetOrCreateLogger( - spaBuilder.ApplicationBuilder, + appBuilder, nameof(AngularCliBuilder)); + var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService(); var scriptRunner = new NodeScriptRunner( sourcePath, _scriptName, "--watch", null, - pkgManagerCommand); + pkgManagerCommand, + diagnosticSource, + applicationStoppingToken); scriptRunner.AttachToLogger(logger); using (var stdOutReader = new EventedStreamStringReader(scriptRunner.StdOut)) diff --git a/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddleware.cs b/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddleware.cs index ede248601d..013681d577 100644 --- a/src/Middleware/SpaServices.Extensions/src/AngularCli/AngularCliMiddleware.cs +++ b/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 System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; 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.Text.RegularExpressions; -using System.Threading.Tasks; -using System.Threading; -using System.Net.Http; 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().ApplicationStopping; var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName); - var angularCliServerInfoTask = StartAngularCliServerAsync(sourcePath, scriptName, pkgManagerCommand, devServerPort, logger); + var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService(); + 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 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; diff --git a/src/Middleware/SpaServices.Extensions/src/Npm/NodeScriptRunner.cs b/src/Middleware/SpaServices.Extensions/src/Npm/NodeScriptRunner.cs index f08abeb19c..9631b29ef2 100644 --- a/src/Middleware/SpaServices.Extensions/src/Npm/NodeScriptRunner.cs +++ b/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 script entries defined in a package.json file, /// capturing any output written to stdio. /// - 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 envVars, string pkgManagerCommand) + public NodeScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary 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; + } + } } } diff --git a/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs b/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs index b4cb361b54..eb103f03f9 100644 --- a/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddleware.cs +++ b/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 System; +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.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.Text.RegularExpressions; -using System.Threading.Tasks; 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().ApplicationStopping; var logger = LoggerFinder.GetOrCreateLogger(appBuilder, LogCategoryName); - var portTask = StartCreateReactAppServerAsync(sourcePath, scriptName, pkgManagerCommand, devServerPort, logger); + var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService(); + 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 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)) diff --git a/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddlewareExtensions.cs b/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddlewareExtensions.cs index 346e839046..0c560c0b75 100644 --- a/src/Middleware/SpaServices.Extensions/src/ReactDevelopmentServer/ReactDevelopmentServerMiddlewareExtensions.cs +++ b/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 { diff --git a/src/Middleware/SpaServices.Extensions/test/ListLoggerFactory.cs b/src/Middleware/SpaServices.Extensions/test/ListLoggerFactory.cs new file mode 100644 index 0000000000..123880fd14 --- /dev/null +++ b/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 _shouldLogCategory; + private bool _disposed; + + public ListLoggerFactory() + : this(_ => true) + { + } + + public ListLoggerFactory(Func 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( + LogLevel logLevel, EventId eventId, TState state, Exception exception, Func 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 state) => null; + } + } +} diff --git a/src/Middleware/SpaServices.Extensions/test/Microsoft.AspNetCore.SpaServices.Extensions.Tests.csproj b/src/Middleware/SpaServices.Extensions/test/Microsoft.AspNetCore.SpaServices.Extensions.Tests.csproj index 235edce687..ff68386156 100644 --- a/src/Middleware/SpaServices.Extensions/test/Microsoft.AspNetCore.SpaServices.Extensions.Tests.csproj +++ b/src/Middleware/SpaServices.Extensions/test/Microsoft.AspNetCore.SpaServices.Extensions.Tests.csproj @@ -1,17 +1,27 @@ - + $(DefaultNetCoreTargetFramework) true + + + false + + + + PreserveNewest + + + diff --git a/src/Middleware/SpaServices.Extensions/test/SpaServicesExtensionsTests.cs b/src/Middleware/SpaServices.Extensions/test/SpaServicesExtensionsTests.cs index 8d55dc3f35..9c5d8e1dc5 100644 --- a/src/Middleware/SpaServices.Extensions/test/SpaServicesExtensionsTests.cs +++ b/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(); + var diagnosticListener = serviceProvider.GetRequiredService(); + 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(); + var diagnosticListener = serviceProvider.GetRequiredService(); + 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(MockBehavior.Strict).Object; } @@ -39,13 +134,74 @@ namespace Microsoft.AspNetCore.SpaServices.Extensions.Tests return applicationbuilderMock.Object; } - private IServiceProvider GetServiceProvider() + private IServiceProvider GetServiceProvider(Action configuration = null) { var services = new ServiceCollection(); services.AddLogging(); - services.AddSpaStaticFiles(); + services.AddSpaStaticFiles(configuration); + services.AddSingleton(ListLoggerFactory); + services.AddSingleton(typeof(IHostApplicationLifetime), new TestHostApplicationLifetime()); + services.AddSingleton(typeof(IWebHostEnvironment), new TestWebHostEnvironment()); + + var listener = new DiagnosticListener("Microsoft.AspNetCore"); + services.AddSingleton(listener); + services.AddSingleton(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; } + } } } diff --git a/src/Middleware/SpaServices.Extensions/test/package.json b/src/Middleware/SpaServices.Extensions/test/package.json new file mode 100644 index 0000000000..604ef27fa0 --- /dev/null +++ b/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" + } +}