diff --git a/Changelog.md b/Changelog.md index 522bec9..925710e 100644 --- a/Changelog.md +++ b/Changelog.md @@ -3,7 +3,11 @@ This project uses [semantic versioning](http://semver.org/spec/v2.0.0.html). Ref *[Semantic Versioning in Practice](https://www.jering.tech/articles/semantic-versioning-in-practice)* for an overview of semantic versioning. -## [Unreleased](https://github.com/JeringTech/Javascript.NodeJS/compare/6.0.0-beta.2...HEAD) +## [Unreleased](https://github.com/JeringTech/Javascript.NodeJS/compare/6.0.0-beta.3...HEAD) + +## [6.0.0-beta.3](https://github.com/JeringTech/Javascript.NodeJS/compare/6.0.0-beta.2...6.0.0-beta.3) - Mar 31, 2021 +### Additions +- Added `NodeJSProcessOptions.ExecutablePath` option. ([#106](https://github.com/JeringTech/Javascript.NodeJS/pull/106)). ## [6.0.0-beta.2](https://github.com/JeringTech/Javascript.NodeJS/compare/6.0.0-beta.1...6.0.0-beta.2) - Feb 24, 2021 ### Additions diff --git a/ReadMe.md b/ReadMe.md index 2afe1f8..a7f398b 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -349,7 +349,8 @@ The next two sections list all available options. #### NodeJSProcessOptions | Option | Type | Description | Default | | ------ | ---- | ----------- | ------- | -| ProjectPath | `string` | The base path for resolving paths of NodeJS modules on disk. If this value is `null`, whitespace or an empty string and the application is an ASP.NET Core application, project path is `IHostingEnvironment.ContentRootPath` | The current directory (value returned by `Directory.GetCurrentDirectory()`) | +| ProjectPath | `string` | The base path for resolving paths of NodeJS modules on disk. If this value is `null`, whitespace or an empty string and the application is an ASP.NET Core application, project path is `IHostingEnvironment.ContentRootPath`. | The current directory (value returned by `Directory.GetCurrentDirectory()`) | +| ExecutablePath | `string` | The value used to locate the NodeJS executable. This value may be an absolute path, a relative path, or a file name. If this value is a relative path, the executable's path is resolved relative to `Directory.GetCurrentDirectory()`. If this value is a file name, the executable's path is resolved using the path environment variable. If this value is `null`, whitespace or an empty string, it is overridden with the file name "node". | `null` | | NodeAndV8Options | `string` | NodeJS and V8 options in the form "[NodeJS options] [V8 options]". The full list of NodeJS options can be found here: https://nodejs.org/api/cli.html#cli_options. | `null` | | Port | `int` | The port that the server running on NodeJS will listen on. If set to 0, the OS will choose the port. | `0` | | EnvironmentVariables | `IDictionary` | The environment variables for the NodeJS process. The full list of NodeJS environment variables can be found here: https://nodejs.org/api/cli.html#cli_environment_variables. If this value doesn't contain an element with key "NODE_ENV" and the application is an ASP.NET Core application, an element with key "NODE_ENV" is added with value "development" if `IHostingEnvironment.EnvironmentName` is `EnvironmentName.Development` or "production" otherwise. | An Empty `IDictionary` | diff --git a/src/NodeJS/NodeJSServiceImplementations/OutOfProcess/ConfigureNodeJSProcessOptions.cs b/src/NodeJS/NodeJSServiceImplementations/OutOfProcess/ConfigureNodeJSProcessOptions.cs index fc1cdfb..6b15be0 100644 --- a/src/NodeJS/NodeJSServiceImplementations/OutOfProcess/ConfigureNodeJSProcessOptions.cs +++ b/src/NodeJS/NodeJSServiceImplementations/OutOfProcess/ConfigureNodeJSProcessOptions.cs @@ -28,6 +28,12 @@ public ConfigureNodeJSProcessOptions(IServiceScopeFactory serviceScopeFactory) /// The target to configure. public void Configure(NodeJSProcessOptions options) { + // Set executable path if unspecified + if (string.IsNullOrWhiteSpace(options.ExecutablePath)) + { + options.ExecutablePath = "node"; + } + // Check whether project path already specified bool projectPathSpecified = !string.IsNullOrWhiteSpace(options.ProjectPath); diff --git a/src/NodeJS/NodeJSServiceImplementations/OutOfProcess/NodeJSProcessFactory.cs b/src/NodeJS/NodeJSServiceImplementations/OutOfProcess/NodeJSProcessFactory.cs index 1e55905..5cc0664 100644 --- a/src/NodeJS/NodeJSServiceImplementations/OutOfProcess/NodeJSProcessFactory.cs +++ b/src/NodeJS/NodeJSServiceImplementations/OutOfProcess/NodeJSProcessFactory.cs @@ -34,7 +34,7 @@ internal ProcessStartInfo CreateStartInfo(string nodeServerScript) nodeServerScript = EscapeCommandLineArg(nodeServerScript); // TODO can we escape before embedding? Would avoid an allocation every time we start a NodeJS process. int currentProcessPid = Process.GetCurrentProcess().Id; - var startInfo = new ProcessStartInfo("node") + var startInfo = new ProcessStartInfo(_nodeJSProcessOptions.ExecutablePath) { Arguments = $"{_nodeJSProcessOptions.NodeAndV8Options} -e \"{nodeServerScript}\" -- --parentPid {currentProcessPid} --port {_nodeJSProcessOptions.Port}", UseShellExecute = false, diff --git a/src/NodeJS/NodeJSServiceImplementations/OutOfProcess/NodeJSProcessOptions.cs b/src/NodeJS/NodeJSServiceImplementations/OutOfProcess/NodeJSProcessOptions.cs index c9136e5..2bd9d4b 100644 --- a/src/NodeJS/NodeJSServiceImplementations/OutOfProcess/NodeJSProcessOptions.cs +++ b/src/NodeJS/NodeJSServiceImplementations/OutOfProcess/NodeJSProcessOptions.cs @@ -16,6 +16,16 @@ public class NodeJSProcessOptions /// public string ProjectPath { get; set; } = Directory.GetCurrentDirectory(); + /// + /// The value used to locate the NodeJS executable. + /// This value may be an absolute path, a relative path, or a file name. + /// If this value is a relative path, the executable's path is resolved relative to . + /// If this value is a file name, the executable's path is resolved using the path environment variable. + /// If this value is null, whitespace or an empty string, it is overridden with the file name "node". + /// Defaults to null. + /// + public string ExecutablePath { get; set; } + /// /// NodeJS and V8 options in the form [NodeJS options] [V8 options]. /// The full list of NodeJS options can be found here: https://nodejs.org/api/cli.html#cli_options. diff --git a/test/NodeJS/ConfigureNodeJSProcessOptionsUnitTests.cs b/test/NodeJS/ConfigureNodeJSProcessOptionsUnitTests.cs index c8e24a3..4f95bf3 100644 --- a/test/NodeJS/ConfigureNodeJSProcessOptionsUnitTests.cs +++ b/test/NodeJS/ConfigureNodeJSProcessOptionsUnitTests.cs @@ -11,6 +11,37 @@ public class ConfigureNodeJSProcessOptionsUnitTests { private readonly MockRepository _mockRepository = new MockRepository(MockBehavior.Default); + [Theory] + [MemberData(nameof(Configure_SetsExecutablePathIfItIsNullWhitespaceOrAnEmptyString_Data))] + public void Configure_SetsExecutablePathIfItIsNullWhitespaceOrAnEmptyString(string dummyExecutablePath) + { + // Arrange + Mock mockServiceProvider = _mockRepository.Create(); + mockServiceProvider.Setup(s => s.GetService(typeof(IHostingEnvironment))).Returns(null); // Called by the extension method GetService + Mock mockServiceScope = _mockRepository.Create(); + mockServiceScope.Setup(s => s.ServiceProvider).Returns(mockServiceProvider.Object); + Mock mockServiceScopeFactory = _mockRepository.Create(); + mockServiceScopeFactory.Setup(s => s.CreateScope()).Returns(mockServiceScope.Object); + var dummyOptions = new NodeJSProcessOptions { ExecutablePath = dummyExecutablePath }; + ConfigureNodeJSProcessOptions testSubject = CreateConfigureNodeJSProcessOptions(mockServiceScopeFactory.Object); + + // Act + testSubject.Configure(dummyOptions); + + // AssertS + Assert.Equal("node", dummyOptions.ExecutablePath); + } + + public static IEnumerable Configure_SetsExecutablePathIfItIsNullWhitespaceOrAnEmptyString_Data() + { + return new object[][] + { + new object[]{null}, + new object[]{" "}, + new object[]{string.Empty} + }; + } + [Fact] public void Configure_DoesNothingIfProjectPathAndNodeEnvAlreadySpecified() { diff --git a/test/NodeJS/HttpNodeJSServiceIntegrationTests.cs b/test/NodeJS/HttpNodeJSServiceIntegrationTests.cs index 3e647cc..f3b1050 100644 --- a/test/NodeJS/HttpNodeJSServiceIntegrationTests.cs +++ b/test/NodeJS/HttpNodeJSServiceIntegrationTests.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using System.Threading; @@ -45,9 +46,60 @@ public HttpNodeJSServiceIntegrationTests(ITestOutputHelper testOutputHelper) _testOutputHelper = testOutputHelper; } + [Fact(Timeout = TIMEOUT_MS)] + public async void ExecutablePath_CanBeAbsolute() + { + // Arrange + const string dummyArg = "success"; + string absoluteExecutablePath = GetNodeAbsolutePath(); + Assert.NotNull(absoluteExecutablePath); // Node executable must be present on test machine + HttpNodeJSService testSubject = CreateHttpNodeJSService(projectPath: _projectPath, executablePath: absoluteExecutablePath); + + // Act + DummyResult result = await testSubject. + InvokeFromFileAsync(DUMMY_RETURNS_ARG_MODULE_FILE, args: new[] { dummyArg }).ConfigureAwait(false); + + // Assert + Assert.Equal(dummyArg, result.Result); + } + + [Fact(Timeout = TIMEOUT_MS)] + public async void ExecutablePath_CanBeRelative() + { + // Arrange + const string dummyArg = "success"; + string absoluteExecutablePath = GetNodeAbsolutePath(); + Assert.NotNull(absoluteExecutablePath); // Node executable must be present on test machine + string relativeExecutablePath = GetRelativePath(Directory.GetCurrentDirectory(), absoluteExecutablePath); + HttpNodeJSService testSubject = CreateHttpNodeJSService(projectPath: _projectPath, executablePath: relativeExecutablePath); + + // Act + DummyResult result = await testSubject. + InvokeFromFileAsync(DUMMY_RETURNS_ARG_MODULE_FILE, args: new[] { dummyArg }).ConfigureAwait(false); + + // Assert + Assert.Equal(dummyArg, result.Result); + } + + [Fact(Timeout = TIMEOUT_MS)] + public async void ExecutablePath_CanBeAFileName() + { + // Arrange + const string dummyArg = "success"; + HttpNodeJSService testSubject = CreateHttpNodeJSService(projectPath: _projectPath, executablePath: "node"); + + // Act + DummyResult result = await testSubject. + InvokeFromFileAsync(DUMMY_RETURNS_ARG_MODULE_FILE, args: new[] { dummyArg }).ConfigureAwait(false); + + // Assert + Assert.Equal(dummyArg, result.Result); + } + [Fact(Timeout = TIMEOUT_MS)] public async void InvokeFromFileAsync_WithTypeParameter_InvokesFromFile() { + // Arrange const string dummyArg = "success"; HttpNodeJSService testSubject = CreateHttpNodeJSService(projectPath: _projectPath); @@ -1115,6 +1167,7 @@ public async void NewProcessRetries_RetriesFailedInvocationInNewProcess() /// private HttpNodeJSService CreateHttpNodeJSService(StringBuilder loggerStringBuilder = default, string projectPath = default, + string executablePath = default, ServiceCollection services = default) { services ??= new ServiceCollection(); @@ -1123,6 +1176,10 @@ private HttpNodeJSService CreateHttpNodeJSService(StringBuilder loggerStringBuil { services.Configure(options => options.ProjectPath = projectPath); } + if (executablePath != null) + { + services.Configure(options => options.ExecutablePath = executablePath); + } services.AddLogging(lb => { lb. @@ -1192,5 +1249,57 @@ private void TryDeleteWatchDirectory() // Do nothing } } + + private string GetNodeAbsolutePath() + { +#if NETCOREAPP3_1 || NET5_0 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return GetNodeAbsolutePathCore("Path", ';', "node.exe"); + } + else + { + return GetNodeAbsolutePathCore("PATH", ':', "node"); + } +#else + // net461 + return GetNodeAbsolutePathCore("Path", ';', "node.exe"); +#endif + } + + private string GetNodeAbsolutePathCore(string environmentVariableName, char pathSeparator, string executableFile) + { + string pathEnvironmentVariable = Environment.GetEnvironmentVariable(environmentVariableName); + string[] directories = pathEnvironmentVariable.Split(new char[] { pathSeparator }, StringSplitOptions.RemoveEmptyEntries); + + foreach (string directory in directories) + { + string nodeAbsolutePath = Path.Combine(directory, executableFile); + if (File.Exists(nodeAbsolutePath)) + { + return nodeAbsolutePath; + } + } + + return null; + } + + private string GetRelativePath(string directoryRelativeTo, string path) + { +#if NETCOREAPP3_1 || NET5_0 + return Path.GetRelativePath(directoryRelativeTo, path); +#else + + // net461 doesn't support GetRelativePath. Make sure directory ends with '/'. + if (!directoryRelativeTo.EndsWith("/") && !directoryRelativeTo.EndsWith("\\")) + { + directoryRelativeTo += "/"; + } + var relativeToUri = new Uri(directoryRelativeTo); + var pathUri = new Uri(path); + Uri relativeUri = relativeToUri.MakeRelativeUri(pathUri); + return Uri.UnescapeDataString(relativeUri.ToString()); +#endif + } } } diff --git a/test/NodeJS/NodeJSProcessUnitTests.cs b/test/NodeJS/NodeJSProcessUnitTests.cs index e828e78..d01940e 100644 --- a/test/NodeJS/NodeJSProcessUnitTests.cs +++ b/test/NodeJS/NodeJSProcessUnitTests.cs @@ -375,6 +375,7 @@ private Process CreateProcess() { var dummyNodeJSProcessFactory = new NodeJSProcessFactory(null); ProcessStartInfo dummyProcessStartInfo = dummyNodeJSProcessFactory.CreateStartInfo(_dummyLongRunningScript); + dummyProcessStartInfo.FileName = "node"; return dummyNodeJSProcessFactory.CreateProcess(dummyProcessStartInfo); }