From 0b7bda6bccbe2c0eb7e4a86543ae6a85c64ffd9b Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 30 Oct 2025 07:59:39 -0500 Subject: [PATCH 1/7] Refactor JavaScript Integration - Obsolete AddNpmApp. - Add JavaScriptAppResource and AddJavaScriptApp extension method - Refactor AddViteApp to be a specialization of AddJavaScriptApp - Replace NodeInstallerResource with JavaScriptInstallerResource - Add JavaScriptPackageManagerAnnotation to handle different package managers - Replace JavaScriptRunCommandAnnotation with JavaScriptRunScriptAnnotation - Replace JavaScriptBuildCommandAnnotation with JavaScriptBuildScriptAnnotation Contributes to #8350 --- .../AspireJavaScript.AppHost/AppHost.cs | 11 +- .../AspireWithNode.AppHost/AppHost.cs | 2 +- .../JavaScriptAppResource.cs | 15 + .../JavaScriptBuildCommandAnnotation.cs | 24 -- .../JavaScriptBuildScriptAnnotation.cs | 24 ++ .../JavaScriptInstallCommandAnnotation.cs | 8 +- ...urce.cs => JavaScriptInstallerResource.cs} | 4 +- .../JavaScriptPackageManagerAnnotation.cs | 24 ++ .../JavaScriptRunCommandAnnotation.cs | 21 -- .../JavaScriptRunScriptAnnotation.cs | 24 ++ src/Aspire.Hosting.NodeJs/NodeAppResource.cs | 4 +- src/Aspire.Hosting.NodeJs/NodeExtensions.cs | 297 ++++++++++------ src/Aspire.Hosting.NodeJs/ViteAppResource.cs | 2 +- .../ResourceBuilderExtensions.cs | 38 +++ .../aspire-py-starter/13.0/apphost.cs | 1 - .../AddNodeAppTests.cs | 2 + .../IntegrationTests.cs | 12 +- .../NodeAppFixture.cs | 2 + .../NodeJsPublicApiTests.cs | 4 + .../PackageInstallationTests.cs | 323 +++++++++--------- .../ResourceCreationTests.cs | 31 +- .../PublishAsDockerfileTests.cs | 8 +- .../ResourceExtensionsTests.cs | 27 ++ .../WithReferenceTests.cs | 4 +- 24 files changed, 561 insertions(+), 351 deletions(-) create mode 100644 src/Aspire.Hosting.NodeJs/JavaScriptAppResource.cs delete mode 100644 src/Aspire.Hosting.NodeJs/JavaScriptBuildCommandAnnotation.cs create mode 100644 src/Aspire.Hosting.NodeJs/JavaScriptBuildScriptAnnotation.cs rename src/Aspire.Hosting.NodeJs/{NodeInstallerResource.cs => JavaScriptInstallerResource.cs} (74%) create mode 100644 src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs delete mode 100644 src/Aspire.Hosting.NodeJs/JavaScriptRunCommandAnnotation.cs create mode 100644 src/Aspire.Hosting.NodeJs/JavaScriptRunScriptAnnotation.cs diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs index b960eb39f0d..fe1e22e816b 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs @@ -3,16 +3,14 @@ var weatherApi = builder.AddProject("weatherapi") .WithExternalHttpEndpoints(); -builder.AddNpmApp("angular", "../AspireJavaScript.Angular") - .WithNpm(install: true) +builder.AddJavaScriptApp("angular", "../AspireJavaScript.Angular") .WithReference(weatherApi) .WaitFor(weatherApi) .WithHttpEndpoint(env: "PORT") .WithExternalHttpEndpoints() .PublishAsDockerFile(); -builder.AddNpmApp("react", "../AspireJavaScript.React") - .WithNpm(install: true) +builder.AddJavaScriptApp("react", "../AspireJavaScript.React") .WithReference(weatherApi) .WaitFor(weatherApi) .WithEnvironment("BROWSER", "none") // Disable opening browser on npm start @@ -20,8 +18,8 @@ .WithExternalHttpEndpoints() .PublishAsDockerFile(); -builder.AddNpmApp("vue", "../AspireJavaScript.Vue") - .WithInstallCommand("npm", ["ci"]) // Use 'npm ci' for clean install +builder.AddJavaScriptApp("vue", "../AspireJavaScript.Vue") + .WithNpm(installArgs: ["ci"]) // Use 'npm ci' for clean install .WithReference(weatherApi) .WaitFor(weatherApi) .WithHttpEndpoint(env: "PORT") @@ -29,7 +27,6 @@ .PublishAsDockerFile(); var reactvite = builder.AddViteApp("reactvite", "../AspireJavaScript.Vite") - .WithNpm(install: true) .WithReference(weatherApi) .WithEnvironment("BROWSER", "none") .WithExternalHttpEndpoints(); diff --git a/playground/AspireWithNode/AspireWithNode.AppHost/AppHost.cs b/playground/AspireWithNode/AspireWithNode.AppHost/AppHost.cs index 1b78eec857a..8512fd90a18 100644 --- a/playground/AspireWithNode/AspireWithNode.AppHost/AppHost.cs +++ b/playground/AspireWithNode/AspireWithNode.AppHost/AppHost.cs @@ -10,7 +10,7 @@ var weatherapi = builder.AddProject("weatherapi"); -var frontend = builder.AddNpmApp("frontend", "../NodeFrontend", "watch") +var frontend = builder.AddJavaScriptApp("frontend", "../NodeFrontend", "watch") .WithReference(weatherapi) .WaitFor(weatherapi) .WithReference(cache) diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptAppResource.cs b/src/Aspire.Hosting.NodeJs/JavaScriptAppResource.cs new file mode 100644 index 00000000000..af177eaa581 --- /dev/null +++ b/src/Aspire.Hosting.NodeJs/JavaScriptAppResource.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// A resource that represents a JavaScript application. +/// +/// The name of the resource. +/// The command to execute. +/// The working directory to use for the command. If null, the working directory of the current process is used. +public class JavaScriptAppResource(string name, string command, string workingDirectory) + : ExecutableResource(name, command, workingDirectory), IResourceWithServiceDiscovery, IResourceWithContainerFiles; diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptBuildCommandAnnotation.cs b/src/Aspire.Hosting.NodeJs/JavaScriptBuildCommandAnnotation.cs deleted file mode 100644 index e51f442433f..00000000000 --- a/src/Aspire.Hosting.NodeJs/JavaScriptBuildCommandAnnotation.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Hosting.ApplicationModel; - -namespace Aspire.Hosting.NodeJs; - -/// -/// Represents the annotation for the JavaScript package manager's build command. -/// -/// The executable command name -/// The command line arguments for the JavaScript package manager's install command. -public sealed class JavaScriptBuildCommandAnnotation(string command, string[] args) : IResourceAnnotation -{ - /// - /// Gets the executable command name. - /// - public string Command { get; } = command; - - /// - /// Gets the command-line arguments supplied to the application. - /// - public string[] Args { get; } = args; -} diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptBuildScriptAnnotation.cs b/src/Aspire.Hosting.NodeJs/JavaScriptBuildScriptAnnotation.cs new file mode 100644 index 00000000000..77d412efbf5 --- /dev/null +++ b/src/Aspire.Hosting.NodeJs/JavaScriptBuildScriptAnnotation.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.NodeJs; + +/// +/// Represents the annotation for the JavaScript package manager's build script. +/// +/// The name of the JavaScript package manager's build script. +/// The command line arguments for the JavaScript package manager's build script. +public sealed class JavaScriptBuildScriptAnnotation(string scriptName, string[]? args) : IResourceAnnotation +{ + /// + /// Gets the name of the script used to build. + /// + public string ScriptName { get; } = scriptName; + + /// + /// Gets the command-line arguments supplied to the build script. + /// + public string[] Args { get; } = args ?? []; +} diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptInstallCommandAnnotation.cs b/src/Aspire.Hosting.NodeJs/JavaScriptInstallCommandAnnotation.cs index de71f7841c6..1202847b559 100644 --- a/src/Aspire.Hosting.NodeJs/JavaScriptInstallCommandAnnotation.cs +++ b/src/Aspire.Hosting.NodeJs/JavaScriptInstallCommandAnnotation.cs @@ -8,15 +8,9 @@ namespace Aspire.Hosting.NodeJs; /// /// Represents the annotation for the JavaScript package manager's install command. /// -/// The executable command name /// The command line arguments for the JavaScript package manager's install command. -public sealed class JavaScriptInstallCommandAnnotation(string command, string[] args) : IResourceAnnotation +public sealed class JavaScriptInstallCommandAnnotation(string[] args) : IResourceAnnotation { - /// - /// Gets the executable command name. - /// - public string Command { get; } = command; - /// /// Gets the command-line arguments supplied to the application. /// diff --git a/src/Aspire.Hosting.NodeJs/NodeInstallerResource.cs b/src/Aspire.Hosting.NodeJs/JavaScriptInstallerResource.cs similarity index 74% rename from src/Aspire.Hosting.NodeJs/NodeInstallerResource.cs rename to src/Aspire.Hosting.NodeJs/JavaScriptInstallerResource.cs index c55bb5a6432..61209711538 100644 --- a/src/Aspire.Hosting.NodeJs/NodeInstallerResource.cs +++ b/src/Aspire.Hosting.NodeJs/JavaScriptInstallerResource.cs @@ -6,9 +6,9 @@ namespace Aspire.Hosting.NodeJs; /// -/// A resource that represents a package installer for a node app. +/// A resource that represents a package installer for a JavaScript app. /// /// The name of the resource. /// The working directory to use for the command. -public class NodeInstallerResource(string name, string workingDirectory) +public class JavaScriptInstallerResource(string name, string workingDirectory) : ExecutableResource(name, "node", workingDirectory); diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs b/src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs new file mode 100644 index 00000000000..98bd2cca53d --- /dev/null +++ b/src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.NodeJs; + +/// +/// Represents the annotation for the JavaScript package manager used in a resource. +/// +/// The name of the executable used to run the package manager. +/// The command used to run a script with the JavaScript package manager. +public sealed class JavaScriptPackageManagerAnnotation(string executableName, string? runScriptCommand) : IResourceAnnotation +{ + /// + /// Gets the executable used to run the JavaScript package manager. + /// + public string ExecutableName { get; } = executableName; + + /// + /// Gets the command used to run a script with the JavaScript package manager. + /// + public string? ScriptCommand { get; } = runScriptCommand; +} diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptRunCommandAnnotation.cs b/src/Aspire.Hosting.NodeJs/JavaScriptRunCommandAnnotation.cs deleted file mode 100644 index 5f2ce043837..00000000000 --- a/src/Aspire.Hosting.NodeJs/JavaScriptRunCommandAnnotation.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Aspire.Hosting.ApplicationModel; - -namespace Aspire.Hosting.NodeJs; - -/// -/// Represents the annotation for the JavaScript resource's initial run command line arguments. -/// -/// -/// The Resource contains the command name, while this annotation contains only the arguments. -/// These arguments are applied to the command before any user supplied arguments. -/// -public sealed class JavaScriptRunCommandAnnotation(string[] args) : IResourceAnnotation -{ - /// - /// Gets the command-line arguments supplied to the application. - /// - public string[] Args { get; } = args; -} diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptRunScriptAnnotation.cs b/src/Aspire.Hosting.NodeJs/JavaScriptRunScriptAnnotation.cs new file mode 100644 index 00000000000..e0b18ce1eb0 --- /dev/null +++ b/src/Aspire.Hosting.NodeJs/JavaScriptRunScriptAnnotation.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.NodeJs; + +/// +/// Represents the annotation for the script used during run mode in a JavaScript resource. +/// +/// The name of the JavaScript package manager's run script. +/// The command line arguments for the JavaScript package manager's run script. +public sealed class JavaScriptRunScriptAnnotation(string scriptName, string[]? args) : IResourceAnnotation +{ + /// + /// Gets the name of the script to run. + /// + public string ScriptName { get; } = scriptName; + + /// + /// Gets the name of the script to run. + /// + public string[] Args { get; } = args ?? []; +} diff --git a/src/Aspire.Hosting.NodeJs/NodeAppResource.cs b/src/Aspire.Hosting.NodeJs/NodeAppResource.cs index 668de8b3a13..31d9b206b2f 100644 --- a/src/Aspire.Hosting.NodeJs/NodeAppResource.cs +++ b/src/Aspire.Hosting.NodeJs/NodeAppResource.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Hosting.ApplicationModel; - namespace Aspire.Hosting; /// @@ -12,4 +10,4 @@ namespace Aspire.Hosting; /// The command to execute. /// The working directory to use for the command. If null, the working directory of the current process is used. public class NodeAppResource(string name, string command, string workingDirectory) - : ExecutableResource(name, command, workingDirectory), IResourceWithServiceDiscovery; + : JavaScriptAppResource(name, command, workingDirectory), IResourceWithServiceDiscovery; diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index 5284c46f09a..dc230ea7fa0 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -28,7 +28,7 @@ public static class NodeAppHostingExtension /// The to add the resource to. /// The name of the resource. /// The path to the script that Node will execute. - /// The working directory to use for the command. If null, the working directory of the current process is used. + /// The working directory to use for the command. If null, the directory of the is used. /// The arguments to pass to the command. /// A reference to the . public static IResourceBuilder AddNodeApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string scriptPath, string? workingDirectory = null, string[]? args = null) @@ -59,9 +59,9 @@ public static IResourceBuilder AddNodeApp(this IDistributedAppl /// The npm script to execute. Defaults to "start". /// The arguments to pass to the command. /// A reference to the . + [Obsolete("Use AddJavaScriptApp instead.")] public static IResourceBuilder AddNpmApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, string scriptName = "start", string[]? args = null) { - ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); ArgumentException.ThrowIfNullOrEmpty(workingDirectory); @@ -80,93 +80,74 @@ public static IResourceBuilder AddNpmApp(this IDistributedAppli .WithIconName("CodeJsRectangle"); } - private static IResourceBuilder WithNodeDefaults(this IResourceBuilder builder) where TResource : NodeAppResource => - builder.WithOtlpExporter() - .WithEnvironment("NODE_ENV", builder.ApplicationBuilder.Environment.IsDevelopment() ? "development" : "production") - .WithCertificateTrustConfiguration((ctx) => - { - if (ctx.Scope == CertificateTrustScope.Append) - { - ctx.EnvironmentVariables["NODE_EXTRA_CA_CERTS"] = ctx.CertificateBundlePath; - } - else - { - ctx.Arguments.Add("--use-openssl-ca"); - } - - return Task.CompletedTask; - }); - /// - /// Adds a Vite app to the distributed application builder. + /// Adds a JavaScript application resource to the distributed application using the specified working directory and + /// run script. /// - /// The to add the resource to. - /// The name of the Vite app. - /// The working directory of the Vite app. - /// The name of the script that runs the Vite app. Defaults to "dev". - /// A reference to the . + /// The distributed application builder to which the JavaScript application resource will be added. + /// The unique name of the JavaScript application resource. Cannot be null or empty. + /// The path to the directory containing the JavaScript application. + /// The name of the npm script to run when starting the application. Defaults to "start". Cannot be null or empty. + /// An optional array of additional arguments to pass to the run script. May be null. + /// A resource builder for the newly added JavaScript application resource. /// - /// - /// The following example creates a Vite app using npm as the package manager. - /// - /// var builder = DistributedApplication.CreateBuilder(args); - /// - /// builder.AddViteApp("frontend", "./frontend") - /// .WithNpm(); - /// - /// builder.Build().Run(); - /// - /// + /// If a Dockerfile does not exist in the application's directory, one will be generated + /// automatically when publishing. The method configures the resource with Node.js defaults and sets up npm + /// integration. /// - public static IResourceBuilder AddViteApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, string scriptName = "dev") + public static IResourceBuilder AddJavaScriptApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string runScriptName = "start", string[]? args = null) { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); - ArgumentException.ThrowIfNullOrEmpty(workingDirectory); + ArgumentException.ThrowIfNullOrEmpty(appDirectory); + ArgumentException.ThrowIfNullOrEmpty(runScriptName); - workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory)); - var resource = new ViteAppResource(name, "node", workingDirectory); + appDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, appDirectory)); + var resource = new JavaScriptAppResource(name, "npm", appDirectory); - return builder.AddResource(resource) + return builder.CreateDefaultJavaScriptAppBuilder(resource, appDirectory, runScriptName); + } + + private static IResourceBuilder CreateDefaultJavaScriptAppBuilder( + this IDistributedApplicationBuilder builder, + TResource resource, + string appDirectory, + string runScriptName, + Action? argsCallback = null) where TResource : JavaScriptAppResource + { + var resourceBuilder = builder.AddResource(resource) .WithNodeDefaults() - .WithIconName("CodeJsRectangle") .WithArgs(c => { - if (resource.TryGetLastAnnotation(out var packageManagerAnnotation)) + if (c.Resource.TryGetLastAnnotation(out var runCommand)) { - foreach (var arg in packageManagerAnnotation.Args) + if (c.Resource.TryGetLastAnnotation(out var packageManager) && + !string.IsNullOrEmpty(packageManager.ScriptCommand)) { - c.Args.Add(arg); + c.Args.Add(packageManager.ScriptCommand); } - } - c.Args.Add(scriptName); - c.Args.Add("--"); - var targetEndpoint = resource.GetEndpoint("https"); - if (!targetEndpoint.Exists) - { - targetEndpoint = resource.GetEndpoint("http"); + c.Args.Add(runCommand.ScriptName); } - c.Args.Add("--port"); - c.Args.Add(targetEndpoint.Property(EndpointProperty.TargetPort)); + argsCallback?.Invoke(c); }) - .WithHttpEndpoint(env: "PORT") + .WithIconName("CodeJsRectangle") .WithNpm() .PublishAsDockerFile(c => { // Only generate a Dockerfile if one doesn't already exist in the app directory - if (File.Exists(Path.Combine(resource.WorkingDirectory, "Dockerfile"))) + if (File.Exists(Path.Combine(appDirectory, "Dockerfile"))) { return; } - c.WithDockerfileBuilder(resource.WorkingDirectory, dockerfileContext => + c.WithDockerfileBuilder(appDirectory, dockerfileContext => { - if (c.Resource.TryGetLastAnnotation(out var buildCommand)) + if (c.Resource.TryGetLastAnnotation(out var packageManager)) { var logger = dockerfileContext.Services.GetService>() ?? NullLogger.Instance; - var nodeVersion = DetectNodeVersion(resource.WorkingDirectory, logger) ?? DefaultNodeVersion; + var nodeVersion = DetectNodeVersion(appDirectory, logger) ?? DefaultNodeVersion; var dockerBuilder = dockerfileContext.Builder .From($"node:{nodeVersion}-slim") .WorkDir("/app") @@ -174,14 +155,27 @@ public static IResourceBuilder AddViteApp(this IDistributedAppl if (c.Resource.TryGetLastAnnotation(out var installCommand)) { - dockerBuilder.Run($"{installCommand.Command} {string.Join(' ', installCommand.Args)}"); + dockerBuilder.Run($"{packageManager.ExecutableName} {string.Join(' ', installCommand.Args)}"); } - dockerBuilder.Run($"{buildCommand.Command} {string.Join(' ', buildCommand.Args)}"); + if (c.Resource.TryGetLastAnnotation(out var buildCommand)) + { + var command = packageManager.ExecutableName; + if (!string.IsNullOrEmpty(packageManager.ScriptCommand)) + { + command += $" {packageManager.ScriptCommand}"; + } + var args = string.Join(' ', buildCommand.Args); + if (args.Length > 0) + { + args = " " + args; + } + dockerBuilder.Run($"{command} {buildCommand.ScriptName}{args}"); + } } }); - // since Vite apps are typically served via a separate web server, we don't have an entrypoint + // Javascript apps don't have an entrypoint if (resource.TryGetLastAnnotation(out var dockerFileAnnotation)) { dockerFileAnnotation.HasEntrypoint = false; @@ -191,7 +185,91 @@ public static IResourceBuilder AddViteApp(this IDistributedAppl throw new InvalidOperationException("DockerfileBuildAnnotation should exist after calling PublishAsDockerFile."); } }) - .WithAnnotation(new ContainerFilesSourceAnnotation() { SourcePath = "/app/dist" }); + .WithAnnotation(new ContainerFilesSourceAnnotation() { SourcePath = "/app/dist" }) + .WithBuildScript("build") + .WithRunScript(runScriptName); + + // ensure the package manager command is set before starting the resource + if (builder.ExecutionContext.IsRunMode) + { + builder.Eventing.Subscribe((_, _) => + { + if (resourceBuilder.Resource.TryGetLastAnnotation(out var packageManager)) + { + resourceBuilder.WithCommand(packageManager.ExecutableName); + } + + return Task.CompletedTask; + }); + } + + return resourceBuilder; + } + + private static IResourceBuilder WithNodeDefaults(this IResourceBuilder builder) where TResource : JavaScriptAppResource => + builder.WithOtlpExporter() + .WithEnvironment("NODE_ENV", builder.ApplicationBuilder.Environment.IsDevelopment() ? "development" : "production") + .WithCertificateTrustConfiguration((ctx) => + { + if (ctx.Scope == CertificateTrustScope.Append) + { + ctx.EnvironmentVariables["NODE_EXTRA_CA_CERTS"] = ctx.CertificateBundlePath; + } + else + { + ctx.Arguments.Add("--use-openssl-ca"); + } + + return Task.CompletedTask; + }); + + /// + /// Adds a Vite app to the distributed application builder. + /// + /// The to add the resource to. + /// The name of the Vite app. + /// The path to the directory containing the Vite app. + /// The name of the script that runs the Vite app. Defaults to "dev". + /// A reference to the . + /// + /// + /// The following example creates a Vite app using npm as the package manager. + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// builder.AddViteApp("frontend", "./frontend"); + /// + /// builder.Build().Run(); + /// + /// + /// + public static IResourceBuilder AddViteApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string runScriptName = "dev") + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(appDirectory); + + appDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, appDirectory)); + var resource = new ViteAppResource(name, "npm", appDirectory); + + return builder.CreateDefaultJavaScriptAppBuilder( + resource, + appDirectory, + runScriptName, + argsCallback: c => + { + c.Args.Add("--"); + + var targetEndpoint = resource.GetEndpoint("https"); + if (!targetEndpoint.Exists) + { + targetEndpoint = resource.GetEndpoint("http"); + } + + c.Args.Add("--port"); + c.Args.Add(targetEndpoint.Property(EndpointProperty.TargetPort)); + }) + .WithHttpEndpoint(env: "PORT"); } /// @@ -199,13 +277,13 @@ public static IResourceBuilder AddViteApp(this IDistributedAppl /// /// The NodeAppResource. /// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource. + /// The command-line arguments (including the install command itself) passed to the JavaScript package manager to install dependencies. /// A reference to the . - public static IResourceBuilder WithNpm(this IResourceBuilder resource, bool install = true) where TResource : NodeAppResource + public static IResourceBuilder WithNpm(this IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScriptAppResource { - resource.WithCommand("npm") - .WithAnnotation(new JavaScriptInstallCommandAnnotation("npm", ["install"])) - .WithAnnotation(new JavaScriptRunCommandAnnotation(["run"])) - .WithAnnotation(new JavaScriptBuildCommandAnnotation("npm", ["run", "build"])); + resource + .WithAnnotation(new JavaScriptPackageManagerAnnotation("npm", runScriptCommand: "run")) + .WithAnnotation(new JavaScriptInstallCommandAnnotation(installArgs ?? ["install"])); // ci in publish, if there is a lock file? AddInstaller(resource, install); return resource; @@ -216,13 +294,13 @@ public static IResourceBuilder WithNpm(this IResourceBuild /// /// The NodeAppResource. /// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource. + /// The command-line arguments (including the install command itself) passed to the JavaScript package manager to install dependencies. /// A reference to the . - public static IResourceBuilder WithYarn(this IResourceBuilder resource, bool install = true) where TResource : NodeAppResource + public static IResourceBuilder WithYarn(this IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScriptAppResource { - resource.WithCommand("yarn") - .WithAnnotation(new JavaScriptInstallCommandAnnotation("yarn", ["install"])) - .WithAnnotation(new JavaScriptRunCommandAnnotation(["run"])) - .WithAnnotation(new JavaScriptBuildCommandAnnotation("yarn", ["run", "build"])); + resource + .WithAnnotation(new JavaScriptPackageManagerAnnotation("yarn", runScriptCommand: "run")) + .WithAnnotation(new JavaScriptInstallCommandAnnotation(installArgs ?? ["install"])); // --frozen-lockfile in publish AddInstaller(resource, install); return resource; @@ -233,53 +311,61 @@ public static IResourceBuilder WithYarn(this IResourceBuil /// /// The NodeAppResource. /// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource. + /// The command-line arguments (including the install command itself) passed to the JavaScript package manager to install dependencies. /// A reference to the . - public static IResourceBuilder WithPnpm(this IResourceBuilder resource, bool install = true) where TResource : NodeAppResource + public static IResourceBuilder WithPnpm(this IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScriptAppResource { - resource.WithCommand("pnpm") - .WithAnnotation(new JavaScriptInstallCommandAnnotation("pnpm", ["install"])) - .WithAnnotation(new JavaScriptRunCommandAnnotation(["run"])) - .WithAnnotation(new JavaScriptBuildCommandAnnotation("pnpm", ["run", "build"])); + resource + .WithAnnotation(new JavaScriptPackageManagerAnnotation("pnpm", runScriptCommand: "run")) + .WithAnnotation(new JavaScriptInstallCommandAnnotation(installArgs ?? ["install"])); // --frozen-lockfile in publish AddInstaller(resource, install); return resource; } /// - /// Configures the Node.js resource to run the command to install packages before the application starts. + /// Adds a build script annotation to the resource builder using the specified command-line arguments. /// - /// The NodeAppResource. - /// The executable command name - /// The command line arguments for the JavaScript package manager's install command. - /// A reference to the . - public static IResourceBuilder WithInstallCommand(this IResourceBuilder resource, string command, string[] args) where TResource : NodeAppResource + /// The type of JavaScript application resource being configured. + /// The resource builder to which the build script annotation will be added. + /// The name of the script to be executed when the resource is built. + /// An array of command-line arguments to use for the build script. + /// The same resource builder instance with the build script annotation applied. + /// + /// Use this method to specify custom build scripts for JavaScript application resources during + /// deployment. + /// + public static IResourceBuilder WithBuildScript(this IResourceBuilder resource, string scriptName, string[]? args = null) where TResource : JavaScriptAppResource { - resource.WithAnnotation(new JavaScriptInstallCommandAnnotation(command, args)); - - AddInstaller(resource, install: true); - return resource; + return resource.WithAnnotation(new JavaScriptBuildScriptAnnotation(scriptName, args)); } /// - /// Configures the Node.js resource to run the command to build the app during deployment. + /// Adds a run script annotation to the specified JavaScript application resource builder, specifying the script to + /// execute and its arguments during run mode. /// - /// The NodeAppResource. - /// The executable command name - /// The command line arguments for the JavaScript package manager's build command. - /// A reference to the . - public static IResourceBuilder WithBuildCommand(this IResourceBuilder resource, string command, string[] args) where TResource : NodeAppResource + /// The type of the JavaScript application resource being configured. Must inherit from JavaScriptAppResource. + /// The resource builder to which the run script annotation will be added. + /// The name of the script to be executed when the resource is run. + /// An array of arguments to pass to the script. + /// The same resource builder instance with the run script annotation applied, enabling further configuration. + /// + /// Use this method to specify a custom script and its arguments that should be executed when the resource is executed + /// in RunMode. + /// + public static IResourceBuilder WithRunScript(this IResourceBuilder resource, string scriptName, string[]? args = null) where TResource : JavaScriptAppResource { - return resource.WithAnnotation(new JavaScriptBuildCommandAnnotation(command, args)); + return resource.WithAnnotation(new JavaScriptRunScriptAnnotation(scriptName, args)); } - private static void AddInstaller(IResourceBuilder resource, bool install) where TResource : NodeAppResource + private static void AddInstaller(IResourceBuilder resource, bool install) where TResource : JavaScriptAppResource { - // Only install packages if not in publish mode - if (!resource.ApplicationBuilder.ExecutionContext.IsPublishMode) + // Only install packages if in run mode + if (resource.ApplicationBuilder.ExecutionContext.IsRunMode) { // Check if the installer resource already exists var installerName = $"{resource.Resource.Name}-installer"; - resource.ApplicationBuilder.TryCreateResourceBuilder(installerName, out var existingResource); + resource.ApplicationBuilder.TryCreateResourceBuilder(installerName, out var existingResource); if (!install) { @@ -288,7 +374,7 @@ private static void AddInstaller(IResourceBuilder resource // Remove existing installer resource if install is false resource.ApplicationBuilder.Resources.Remove(existingResource.Resource); resource.Resource.Annotations.OfType() - .Where(w => w.Resource == existingResource) + .Where(w => w.Resource == existingResource.Resource) .ToList() .ForEach(w => resource.Resource.Annotations.Remove(w)); resource.Resource.Annotations.OfType() @@ -308,22 +394,23 @@ private static void AddInstaller(IResourceBuilder resource return; } - var installer = new NodeInstallerResource(installerName, resource.Resource.WorkingDirectory); + var installer = new JavaScriptInstallerResource(installerName, resource.Resource.WorkingDirectory); var installerBuilder = resource.ApplicationBuilder.AddResource(installer) .WithParentRelationship(resource.Resource) .ExcludeFromManifest(); - resource.ApplicationBuilder.Eventing.Subscribe((e, _) => + resource.ApplicationBuilder.Eventing.Subscribe((_, _) => { // set the installer's working directory to match the resource's working directory // and set the install command and args based on the resource's annotations - if (!resource.Resource.TryGetLastAnnotation(out var installCommand)) + if (!resource.Resource.TryGetLastAnnotation(out var packageManager) || + !resource.Resource.TryGetLastAnnotation(out var installCommand)) { - throw new InvalidOperationException("JavaScriptInstallCommandAnnotation is required when installing packages."); + throw new InvalidOperationException("JavaScriptPackageManagerAnnotation and JavaScriptInstallCommandAnnotation are required when installing packages."); } installerBuilder - .WithCommand(installCommand.Command) + .WithCommand(packageManager.ExecutableName) .WithWorkingDirectory(resource.Resource.WorkingDirectory) .WithArgs(installCommand.Args); diff --git a/src/Aspire.Hosting.NodeJs/ViteAppResource.cs b/src/Aspire.Hosting.NodeJs/ViteAppResource.cs index c964cfa9b61..2fd8cf51a65 100644 --- a/src/Aspire.Hosting.NodeJs/ViteAppResource.cs +++ b/src/Aspire.Hosting.NodeJs/ViteAppResource.cs @@ -10,4 +10,4 @@ namespace Aspire.Hosting.NodeJs; /// The command to execute the Vite application, such as the script or entry point. /// The working directory from which the Vite application command is executed. public class ViteAppResource(string name, string command, string workingDirectory) - : NodeAppResource(name, command, workingDirectory), IResourceWithContainerFiles; + : JavaScriptAppResource(name, command, workingDirectory); diff --git a/src/Aspire.Hosting/ResourceBuilderExtensions.cs b/src/Aspire.Hosting/ResourceBuilderExtensions.cs index f348907e5aa..fd7eba1ba33 100644 --- a/src/Aspire.Hosting/ResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ResourceBuilderExtensions.cs @@ -1310,6 +1310,44 @@ public static IResourceBuilder PublishWithContainerFiles( }); } + /// + /// Adds a container files source annotation to the resource being built, specifying the path to the container files + /// source. + /// + /// The type of resource that supports container files and is being built. + /// The resource builder to which the container files source annotation will be added. Cannot be null. + /// The path to the container files source to associate with the resource. Cannot be null. + /// Specifies how to handle existing container files source annotations. Use Replace to remove existing annotations + /// before adding the new one; otherwise, the new annotation is appended. The default is Append. + /// The resource builder instance with the container files source annotation applied. + /// + /// If the behavior is set to Replace, any existing container files source annotations are + /// removed before the new annotation is added. + /// + public static IResourceBuilder WithContainerFilesSource( + this IResourceBuilder builder, + string sourcePath, + ResourceAnnotationMutationBehavior behavior = ResourceAnnotationMutationBehavior.Append) where T : IResourceWithContainerFiles + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(sourcePath); + + // Remove any existing annotations if behavior is Replace because + // WithAnnotation will throw if there are multiple existing annotations of the same type. + if (behavior == ResourceAnnotationMutationBehavior.Replace) + { + builder.Resource.Annotations + .OfType() + .ToList() + .ForEach(w => builder.Resource.Annotations.Remove(w)); + } + + return builder.WithAnnotation(new ContainerFilesSourceAnnotation() + { + SourcePath = sourcePath + }, behavior); + } + /// /// Excludes a resource from being published to the manifest. /// diff --git a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs index 6406b4e17c6..435e3ac6d9f 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs +++ b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs @@ -21,7 +21,6 @@ .WithHttpHealthCheck("/health"); var frontend = builder.AddViteApp("frontend", "./frontend") - .WithNpm(install: true) .WithReference(app) .WaitFor(app); diff --git a/tests/Aspire.Hosting.NodeJs.Tests/AddNodeAppTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/AddNodeAppTests.cs index cf02e3b9edb..d3b04d47597 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/AddNodeAppTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/AddNodeAppTests.cs @@ -42,8 +42,10 @@ public async Task VerifyManifest() """; Assert.Equal(expectedManifest, manifest.ToString()); +#pragma warning disable CS0618 // Type or member is obsolete var npmApp = builder.AddNpmApp("npmapp", workingDirectory) .WithHttpEndpoint(port: 5032, env: "PORT"); +#pragma warning restore CS0618 // Type or member is obsolete manifest = await ManifestUtils.GetManifest(npmApp.Resource); diff --git a/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs index cf970d49cd5..b2f7eeb51cc 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs @@ -25,11 +25,11 @@ public void ResourceBasedPackageInstallersAppearInApplicationModel() var appModel = app.Services.GetRequiredService(); // Verify all Node.js app resources are present - var nodeResources = appModel.Resources.OfType().ToList(); + var nodeResources = appModel.Resources.OfType().ToList(); Assert.Single(nodeResources); // Verify all installer resources are present as separate resources - var npmInstallers = appModel.Resources.OfType().ToList(); + var npmInstallers = appModel.Resources.OfType().ToList(); Assert.Single(npmInstallers); @@ -51,7 +51,7 @@ public void ResourceBasedPackageInstallersAppearInApplicationModel() Assert.Single(waitAnnotations); var waitedResource = waitAnnotations.First().Resource; - Assert.True(waitedResource is NodeInstallerResource); + Assert.True(waitedResource is JavaScriptInstallerResource); } } @@ -60,19 +60,19 @@ public void InstallerResourcesHaveCorrectExecutableConfiguration() { var builder = DistributedApplication.CreateBuilder(); - builder.AddNpmApp("test-app", "./test") + builder.AddJavaScriptApp("test-app", "./test") .WithNpm(install: true); using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var installer = Assert.Single(appModel.Resources.OfType()); + var installer = Assert.Single(appModel.Resources.OfType()); // Verify it's configured as an ExecutableResource Assert.IsAssignableFrom(installer); // Verify working directory matches parent - var parentApp = Assert.Single(appModel.Resources.OfType()); + var parentApp = Assert.Single(appModel.Resources.OfType()); Assert.Equal(parentApp.WorkingDirectory, installer.WorkingDirectory); // Verify parent-child relationship exists diff --git a/tests/Aspire.Hosting.NodeJs.Tests/NodeAppFixture.cs b/tests/Aspire.Hosting.NodeJs.Tests/NodeAppFixture.cs index 76d5cb94674..0b2cd8eb273 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/NodeAppFixture.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/NodeAppFixture.cs @@ -34,8 +34,10 @@ public async ValueTask InitializeAsync() NodeAppBuilder = _builder.AddNodeApp("nodeapp", scriptPath) .WithHttpEndpoint(port: 5031, env: "PORT"); +#pragma warning disable CS0618 // Type or member is obsolete NpmAppBuilder = _builder.AddNpmApp("npmapp", _nodeAppPath) .WithHttpEndpoint(port: 5032, env: "PORT"); +#pragma warning restore CS0618 // Type or member is obsolete _app = _builder.Build(); diff --git a/tests/Aspire.Hosting.NodeJs.Tests/NodeJsPublicApiTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/NodeJsPublicApiTests.cs index 8d8952ebd8c..25123c56450 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/NodeJsPublicApiTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/NodeJsPublicApiTests.cs @@ -102,6 +102,7 @@ public void AddNodeAppShouldThrowWhenScriptPathIsNullOrEmpty(bool isNull) } [Fact] + [Obsolete] public void AddNpmAppShouldThrowWhenBuilderIsNull() { IDistributedApplicationBuilder builder = null!; @@ -117,6 +118,7 @@ public void AddNpmAppShouldThrowWhenBuilderIsNull() [Theory] [InlineData(true)] [InlineData(false)] + [Obsolete] public void AddNpmAppShouldThrowWhenNameIsNullOrEmpty(bool isNull) { var builder = TestDistributedApplicationBuilder.Create(); @@ -132,6 +134,7 @@ public void AddNpmAppShouldThrowWhenNameIsNullOrEmpty(bool isNull) } [Fact] + [Obsolete] public void AddNpmAppShouldThrowWhenWorkingDirectoryIsNull() { var builder = TestDistributedApplicationBuilder.Create(); @@ -147,6 +150,7 @@ public void AddNpmAppShouldThrowWhenWorkingDirectoryIsNull() [Theory] [InlineData(true)] [InlineData(false)] + [Obsolete] public void AddNpmAppShouldThrowWhenScriptNameIsNullOrEmpty(bool isNull) { var builder = TestDistributedApplicationBuilder.Create(); diff --git a/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs index d10077ff9e0..4de30f764a4 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs @@ -19,8 +19,8 @@ public void WithNpm_CanBeConfiguredWithInstallAndCIOptions() { var builder = DistributedApplication.CreateBuilder(); - var nodeApp = builder.AddNpmApp("nodeApp", "./test-app"); - var nodeApp2 = builder.AddNpmApp("nodeApp2", "./test-app-ci"); + var nodeApp = builder.AddJavaScriptApp("nodeApp", "./test-app"); + var nodeApp2 = builder.AddJavaScriptApp("nodeApp2", "./test-app-ci"); // Test that both configurations can be set up without errors nodeApp.WithNpm(install: true); // Uses npm install @@ -29,8 +29,8 @@ public void WithNpm_CanBeConfiguredWithInstallAndCIOptions() using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var nodeResources = appModel.Resources.OfType().ToList(); - var installerResources = appModel.Resources.OfType().ToList(); + var nodeResources = appModel.Resources.OfType().ToList(); + var installerResources = appModel.Resources.OfType().ToList(); Assert.Equal(2, nodeResources.Count); Assert.Single(installerResources); @@ -50,19 +50,18 @@ public void WithNpm_ExcludedFromPublishMode() { var builder = DistributedApplication.CreateBuilder(["Publishing:Publisher=manifest", "Publishing:OutputPath=./publish"]); - var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); nodeApp.WithNpm(install: true); using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists - var nodeResource = Assert.Single(appModel.Resources.OfType()); - Assert.Equal("npm", nodeResource.Command); + // Verify the JavaScriptApp resource exists + var nodeResource = Assert.Single(appModel.Resources, r => r.Name == "test-app"); // Verify NO installer resource was created in publish mode - var installerResources = appModel.Resources.OfType().ToList(); + var installerResources = appModel.Resources.OfType().ToList(); Assert.Empty(installerResources); // Verify no wait annotations were added @@ -70,220 +69,230 @@ public void WithNpm_ExcludedFromPublishMode() } [Fact] - public void WithYarn_CreatesInstallerWhenInstallIsTrue() + public async Task WithYarn_CreatesInstallerWhenInstallIsTrue() { - var builder = DistributedApplication.CreateBuilder(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); nodeApp.WithYarn(install: true); using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists with yarn command - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // Verify the JavaScriptApp resource exists with yarn command + var nodeResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("yarn", nodeResource.Command); + // Verify the package manager annotation + Assert.True(nodeResource.TryGetLastAnnotation(out var packageManager)); + Assert.Equal("yarn", packageManager.ExecutableName); + Assert.Equal("run", packageManager.ScriptCommand); + // Verify the install command annotation Assert.True(nodeResource.TryGetLastAnnotation(out var installAnnotation)); - Assert.Equal("yarn", installAnnotation.Command); Assert.Equal(["install"], installAnnotation.Args); // Verify the run command annotation - Assert.True(nodeResource.TryGetLastAnnotation(out var runAnnotation)); - Assert.Equal(["run"], runAnnotation.Args); + Assert.True(nodeResource.TryGetLastAnnotation(out var runAnnotation)); + Assert.Equal("start", runAnnotation.ScriptName); // Verify the build command annotation - Assert.True(nodeResource.TryGetLastAnnotation(out var buildAnnotation)); - Assert.Equal("yarn", buildAnnotation.Command); - Assert.Equal(["run", "build"], buildAnnotation.Args); + Assert.True(nodeResource.TryGetLastAnnotation(out var buildAnnotation)); + Assert.Equal("build", buildAnnotation.ScriptName); // Verify the installer resource was created - var installerResource = Assert.Single(appModel.Resources.OfType()); + var installerResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("test-app-installer", installerResource.Name); } [Fact] - public void WithYarn_DoesNotCreateInstallerWhenInstallIsFalse() + public async Task WithYarn_DoesNotCreateInstallerWhenInstallIsFalse() { - var builder = DistributedApplication.CreateBuilder(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); nodeApp.WithYarn(install: false); using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists with yarn command - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // Verify the JavaScriptApp resource exists with yarn command + var nodeResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("yarn", nodeResource.Command); // Verify annotations are set Assert.True(nodeResource.TryGetLastAnnotation(out var _)); - Assert.True(nodeResource.TryGetLastAnnotation(out var _)); - Assert.True(nodeResource.TryGetLastAnnotation(out var _)); + Assert.True(nodeResource.TryGetLastAnnotation(out var _)); + Assert.True(nodeResource.TryGetLastAnnotation(out var _)); // Verify NO installer resource was created - var installerResources = appModel.Resources.OfType().ToList(); + var installerResources = appModel.Resources.OfType().ToList(); Assert.Empty(installerResources); } [Fact] - public void WithPnpm_CreatesInstallerWhenInstallIsTrue() + public async Task WithPnpm_CreatesInstallerWhenInstallIsTrue() { - var builder = DistributedApplication.CreateBuilder(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); nodeApp.WithPnpm(install: true); using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists with pnpm command - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // Verify the JavaScriptApp resource exists with pnpm command + var nodeResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("pnpm", nodeResource.Command); + Assert.True(nodeResource.TryGetLastAnnotation(out var packageManager)); + Assert.Equal("pnpm", packageManager.ExecutableName); + Assert.Equal("run", packageManager.ScriptCommand); + // Verify the install command annotation Assert.True(nodeResource.TryGetLastAnnotation(out var installAnnotation)); - Assert.Equal("pnpm", installAnnotation.Command); Assert.Equal(["install"], installAnnotation.Args); // Verify the run command annotation - Assert.True(nodeResource.TryGetLastAnnotation(out var runAnnotation)); - Assert.Equal(["run"], runAnnotation.Args); + Assert.True(nodeResource.TryGetLastAnnotation(out var runAnnotation)); + Assert.Equal("start", runAnnotation.ScriptName); // Verify the build command annotation - Assert.True(nodeResource.TryGetLastAnnotation(out var buildAnnotation)); - Assert.Equal("pnpm", buildAnnotation.Command); - Assert.Equal(["run", "build"], buildAnnotation.Args); + Assert.True(nodeResource.TryGetLastAnnotation(out var buildAnnotation)); + Assert.Equal("build", buildAnnotation.ScriptName); + Assert.Empty(buildAnnotation.Args); // Verify the installer resource was created - var installerResource = Assert.Single(appModel.Resources.OfType()); + var installerResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("test-app-installer", installerResource.Name); } [Fact] - public void WithPnpm_DoesNotCreateInstallerWhenInstallIsFalse() + public async Task WithPnpm_DoesNotCreateInstallerWhenInstallIsFalse() { - var builder = DistributedApplication.CreateBuilder(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); nodeApp.WithPnpm(install: false); using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists with pnpm command - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // Verify the JavaScriptApp resource exists with pnpm command + var nodeResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("pnpm", nodeResource.Command); // Verify annotations are set Assert.True(nodeResource.TryGetLastAnnotation(out var _)); - Assert.True(nodeResource.TryGetLastAnnotation(out var _)); - Assert.True(nodeResource.TryGetLastAnnotation(out var _)); + Assert.True(nodeResource.TryGetLastAnnotation(out var _)); + Assert.True(nodeResource.TryGetLastAnnotation(out var _)); // Verify NO installer resource was created - var installerResources = appModel.Resources.OfType().ToList(); + var installerResources = appModel.Resources.OfType().ToList(); Assert.Empty(installerResources); } - [Fact] - public void WithInstallCommand_CreatesInstallerWithCustomCommand() - { - var builder = DistributedApplication.CreateBuilder(); + //[Fact] + //public void WithInstallCommand_CreatesInstallerWithCustomCommand() + //{ + // var builder = DistributedApplication.CreateBuilder(); - var nodeApp = builder.AddNpmApp("test-app", "./test-app"); - nodeApp.WithInstallCommand("bun", ["install", "--frozen-lockfile"]); + // var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); + // nodeApp.WithInstallCommand("bun", ["install", "--frozen-lockfile"]); - using var app = builder.Build(); + // using var app = builder.Build(); - var appModel = app.Services.GetRequiredService(); + // var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // // Verify the JavaScriptApp resource exists + // var nodeResource = Assert.Single(appModel.Resources.OfType()); - // Verify the install command annotation with custom command - Assert.True(nodeResource.TryGetLastAnnotation(out var installAnnotation)); - Assert.Equal("bun", installAnnotation.Command); - Assert.Equal(["install", "--frozen-lockfile"], installAnnotation.Args); + // // Verify the install command annotation with custom command + // Assert.True(nodeResource.TryGetLastAnnotation(out var installAnnotation)); + // Assert.Equal("bun", installAnnotation.Command); + // Assert.Equal(["install", "--frozen-lockfile"], installAnnotation.Args); - // Verify the installer resource was created - var installerResource = Assert.Single(appModel.Resources.OfType()); - Assert.Equal("test-app-installer", installerResource.Name); - } + // // Verify the installer resource was created + // var installerResource = Assert.Single(appModel.Resources.OfType()); + // Assert.Equal("test-app-installer", installerResource.Name); + //} - [Fact] - public void WithBuildCommand_SetsCustomBuildCommand() - { - var builder = DistributedApplication.CreateBuilder(); + //[Fact] + //public void WithBuildCommand_SetsCustomBuildCommand() + //{ + // var builder = DistributedApplication.CreateBuilder(); - var nodeApp = builder.AddNpmApp("test-app", "./test-app"); - nodeApp.WithBuildCommand("bun", ["run", "build:prod"]); + // var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); + // nodeApp.WithBuildCommand("bun", ["run", "build:prod"]); - using var app = builder.Build(); + // using var app = builder.Build(); - var appModel = app.Services.GetRequiredService(); + // var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // // Verify the JavaScriptApp resource exists + // var nodeResource = Assert.Single(appModel.Resources.OfType()); - // Verify the build command annotation with custom command - Assert.True(nodeResource.TryGetLastAnnotation(out var buildAnnotation)); - Assert.Equal("bun", buildAnnotation.Command); - Assert.Equal(["run", "build:prod"], buildAnnotation.Args); - } + // // Verify the build command annotation with custom command + // Assert.True(nodeResource.TryGetLastAnnotation(out var buildAnnotation)); + // Assert.Equal("bun", buildAnnotation.Command); + // Assert.Equal(["run", "build:prod"], buildAnnotation.Args); + //} - [Fact] - public void WithInstallCommand_CanOverrideExistingInstallCommand() - { - var builder = DistributedApplication.CreateBuilder(); + //[Fact] + //public void WithInstallCommand_CanOverrideExistingInstallCommand() + //{ + // var builder = DistributedApplication.CreateBuilder(); - var nodeApp = builder.AddNpmApp("test-app", "./test-app"); - nodeApp.WithNpm(install: false); - nodeApp.WithInstallCommand("yarn", ["install", "--production"]); + // var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); + // nodeApp.WithNpm(install: false); + // nodeApp.WithInstallCommand("yarn", ["install", "--production"]); - using var app = builder.Build(); + // using var app = builder.Build(); - var appModel = app.Services.GetRequiredService(); + // var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // // Verify the JavaScriptApp resource exists + // var nodeResource = Assert.Single(appModel.Resources.OfType()); - // Verify the install command annotation was replaced - Assert.True(nodeResource.TryGetLastAnnotation(out var installAnnotation)); - Assert.Equal("yarn", installAnnotation.Command); - Assert.Equal(["install", "--production"], installAnnotation.Args); + // // Verify the install command annotation was replaced + // Assert.True(nodeResource.TryGetLastAnnotation(out var installAnnotation)); + // Assert.Equal("yarn", installAnnotation.Command); + // Assert.Equal(["install", "--production"], installAnnotation.Args); - // Verify the installer resource was created - var installerResource = Assert.Single(appModel.Resources.OfType()); - Assert.Equal("test-app-installer", installerResource.Name); - } + // // Verify the installer resource was created + // var installerResource = Assert.Single(appModel.Resources.OfType()); + // Assert.Equal("test-app-installer", installerResource.Name); + //} - [Fact] - public void WithBuildCommand_CanOverrideExistingBuildCommand() - { - var builder = DistributedApplication.CreateBuilder(); + //[Fact] + //public void WithBuildCommand_CanOverrideExistingBuildCommand() + //{ + // var builder = DistributedApplication.CreateBuilder(); - var nodeApp = builder.AddNpmApp("test-app", "./test-app"); - nodeApp.WithNpm(install: false); - nodeApp.WithBuildCommand("pnpm", ["build", "--watch"]); + // var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); + // nodeApp.WithNpm(install: false); + // nodeApp.WithBuildCommand("pnpm", ["build", "--watch"]); - using var app = builder.Build(); + // using var app = builder.Build(); - var appModel = app.Services.GetRequiredService(); + // var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // // Verify the JavaScriptApp resource exists + // var nodeResource = Assert.Single(appModel.Resources.OfType()); - // Verify the build command annotation was replaced - Assert.True(nodeResource.TryGetLastAnnotation(out var buildAnnotation)); - Assert.Equal("pnpm", buildAnnotation.Command); - Assert.Equal(["build", "--watch"], buildAnnotation.Args); - } + // // Verify the build command annotation was replaced + // Assert.True(nodeResource.TryGetLastAnnotation(out var buildAnnotation)); + // Assert.Equal("pnpm", buildAnnotation.Command); + // Assert.Equal(["build", "--watch"], buildAnnotation.Args); + //} [Fact] public void WithNpmInstallWithYarnNoInstall() @@ -298,15 +307,18 @@ public void WithNpmInstallWithYarnNoInstall() var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // Verify the JavaScriptApp resource exists + var nodeResource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(nodeResource.TryGetLastAnnotation(out var packageManager)); + Assert.Equal("yarn", packageManager.ExecutableName); // Verify the install command annotation is correct - it should still be there - Assert.True(nodeResource.TryGetLastAnnotation(out var buildAnnotation)); - Assert.Equal("yarn", buildAnnotation.Command); + Assert.True(nodeResource.TryGetLastAnnotation(out var installAnnotation)); + Assert.Equal(["install"], installAnnotation.Args); // the installer resource should NOT be created - Assert.Empty(appModel.Resources.OfType()); + Assert.Empty(appModel.Resources.OfType()); } [Fact] @@ -322,15 +334,18 @@ public void WithNpmNoInstallWithYarnInstall() var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // Verify the JavaScriptApp resource exists + var nodeResource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(nodeResource.TryGetLastAnnotation(out var packageManager)); + Assert.Equal("yarn", packageManager.ExecutableName); // Verify the install command annotation is correct - it should still be there - Assert.True(nodeResource.TryGetLastAnnotation(out var buildAnnotation)); - Assert.Equal("yarn", buildAnnotation.Command); + Assert.True(nodeResource.TryGetLastAnnotation(out var installAnnotation)); + Assert.Equal(["install"], installAnnotation.Args); // the installer resource should be created - Assert.Single(appModel.Resources.OfType()); + Assert.Single(appModel.Resources.OfType()); } [Fact] @@ -347,15 +362,18 @@ public async Task WithNpmInstallWithYarnInstall() var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // Verify the JavaScriptApp resource exists + var nodeResource = Assert.Single(appModel.Resources.OfType()); + + Assert.True(nodeResource.TryGetLastAnnotation(out var packageManager)); + Assert.Equal("yarn", packageManager.ExecutableName); // Verify the install command annotation is correct - it should still be there - Assert.True(nodeResource.TryGetLastAnnotation(out var buildAnnotation)); - Assert.Equal("yarn", buildAnnotation.Command); + Assert.True(nodeResource.TryGetLastAnnotation(out var installAnnotation)); + Assert.Equal(["install"], installAnnotation.Args); // a single installer resource should be created - var installer = Assert.Single(appModel.Resources.OfType()); + var installer = Assert.Single(appModel.Resources.OfType()); Assert.Equal("yarn", installer.Command); } @@ -364,61 +382,63 @@ public void WithNpm_DefaultInstallsPackages() { var builder = DistributedApplication.CreateBuilder(); - var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); nodeApp.WithNpm(); // Using default parameter (should be install: true) using var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists with npm command - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // Verify the JavaScriptApp resource exists with npm command + var nodeResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("npm", nodeResource.Command); // Verify the installer resource was created by default - var installerResource = Assert.Single(appModel.Resources.OfType()); + var installerResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("test-app-installer", installerResource.Name); } [Fact] - public void WithYarn_DefaultInstallsPackages() + public async Task WithYarn_DefaultInstallsPackages() { - var builder = DistributedApplication.CreateBuilder(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); nodeApp.WithYarn(); // Using default parameter (should be install: true) using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists with yarn command - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // Verify the JavaScriptApp resource exists with yarn command + var nodeResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("yarn", nodeResource.Command); // Verify the installer resource was created by default - var installerResource = Assert.Single(appModel.Resources.OfType()); + var installerResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("test-app-installer", installerResource.Name); } [Fact] - public void WithPnpm_DefaultInstallsPackages() + public async Task WithPnpm_DefaultInstallsPackages() { - var builder = DistributedApplication.CreateBuilder(); + var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run); - var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); nodeApp.WithPnpm(); // Using default parameter (should be install: true) using var app = builder.Build(); + await ExecuteBeforeStartHooksAsync(app, CancellationToken.None); var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists with pnpm command - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // Verify the JavaScriptApp resource exists with pnpm command + var nodeResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("pnpm", nodeResource.Command); // Verify the installer resource was created by default - var installerResource = Assert.Single(appModel.Resources.OfType()); + var installerResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("test-app-installer", installerResource.Name); } @@ -433,16 +453,15 @@ public void AddViteApp_DefaultInstallsPackages() var appModel = app.Services.GetRequiredService(); - // Verify the NodeApp resource exists with npm command (default package manager) - var nodeResource = Assert.Single(appModel.Resources.OfType()); + // Verify the JavaScriptApp resource exists with npm command (default package manager) + var nodeResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("npm", nodeResource.Command); // Verify the installer resource was created by default for ViteApp - var installerResource = Assert.Single(appModel.Resources.OfType()); + var installerResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("test-app-installer", installerResource.Name); } [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); - } diff --git a/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs index 456fa638cff..f1e6d96b9a7 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs @@ -19,7 +19,7 @@ public void DefaultViteAppUsesNpm() var appModel = app.Services.GetRequiredService(); - var resource = appModel.Resources.OfType().SingleOrDefault(); + var resource = appModel.Resources.OfType().SingleOrDefault(); Assert.NotNull(resource); @@ -37,7 +37,7 @@ public void ViteAppUsesSpecifiedWorkingDirectory() var appModel = app.Services.GetRequiredService(); - var resource = appModel.Resources.OfType().SingleOrDefault(); + var resource = appModel.Resources.OfType().SingleOrDefault(); Assert.NotNull(resource); @@ -55,7 +55,7 @@ public void ViteAppHasExposedHttpEndpoints() var appModel = app.Services.GetRequiredService(); - var resource = appModel.Resources.OfType().SingleOrDefault(); + var resource = appModel.Resources.OfType().SingleOrDefault(); Assert.NotNull(resource); @@ -75,7 +75,7 @@ public void ViteAppDoesNotExposeExternalHttpEndpointsByDefault() var appModel = app.Services.GetRequiredService(); - var resource = appModel.Resources.OfType().SingleOrDefault(); + var resource = appModel.Resources.OfType().SingleOrDefault(); Assert.NotNull(resource); @@ -89,7 +89,7 @@ public void WithNpmDefaultsToInstallCommand() { var builder = DistributedApplication.CreateBuilder(); - var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); // Add package installation with default settings (should use npm install) nodeApp.WithNpm(install: true); @@ -99,16 +99,17 @@ public void WithNpmDefaultsToInstallCommand() var appModel = app.Services.GetRequiredService(); // Verify the NodeApp resource exists - var nodeResource = Assert.Single(appModel.Resources.OfType()); + var nodeResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("npm", nodeResource.Command); // Verify the installer resource was created - var installerResource = Assert.Single(appModel.Resources.OfType()); + var installerResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("test-app-installer", installerResource.Name); // Verify the install command annotation + Assert.True(nodeResource.TryGetLastAnnotation(out var packageManager)); + Assert.Equal("npm", packageManager.ExecutableName); Assert.True(nodeResource.TryGetLastAnnotation(out var installAnnotation)); - Assert.Equal("npm", installAnnotation.Command); Assert.Equal(["install"], installAnnotation.Args); // Verify the parent-child relationship @@ -134,12 +135,12 @@ public void ViteAppConfiguresPortFromEnvironment() var appModel = app.Services.GetRequiredService(); - var resource = Assert.Single(appModel.Resources.OfType()); + var resource = Assert.Single(appModel.Resources.OfType()); // Verify that command line arguments callback is configured Assert.True(resource.TryGetAnnotationsOfType(out var argsCallbackAnnotations)); List args = []; - var ctx = new CommandLineArgsCallbackContext(args); + var ctx = new CommandLineArgsCallbackContext(args, resource); foreach (var annotation in argsCallbackAnnotations) { @@ -160,7 +161,7 @@ public void WithNpmInstallFalseDoesNotCreateInstaller() { var builder = DistributedApplication.CreateBuilder(); - var nodeApp = builder.AddNpmApp("test-app", "./test-app"); + var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); // Configure npm without installing packages nodeApp.WithNpm(install: false); @@ -170,16 +171,16 @@ public void WithNpmInstallFalseDoesNotCreateInstaller() var appModel = app.Services.GetRequiredService(); // Verify the NodeApp resource exists with npm command - var nodeResource = Assert.Single(appModel.Resources.OfType()); + var nodeResource = Assert.Single(appModel.Resources.OfType()); Assert.Equal("npm", nodeResource.Command); // Verify the package manager annotations are set Assert.True(nodeResource.TryGetLastAnnotation(out var _)); - Assert.True(nodeResource.TryGetLastAnnotation(out var _)); - Assert.True(nodeResource.TryGetLastAnnotation(out var _)); + Assert.True(nodeResource.TryGetLastAnnotation(out var _)); + Assert.True(nodeResource.TryGetLastAnnotation(out var _)); // Verify NO installer resource was created - var installerResources = appModel.Resources.OfType().ToList(); + var installerResources = appModel.Resources.OfType().ToList(); Assert.Empty(installerResources); // Verify no wait annotations were added diff --git a/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs b/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs index 12deb45e0a3..cef70243c8b 100644 --- a/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs +++ b/tests/Aspire.Hosting.Tests/PublishAsDockerfileTests.cs @@ -17,7 +17,7 @@ public async Task PublishAsDockerFileConfiguresManifestWithoutBuildArgs() var path = tempDir.Path; - var frontend = builder.AddNpmApp("frontend", path, "watch") + var frontend = builder.AddJavaScriptApp("frontend", path, "watch") .PublishAsDockerFile(); // There should be an equivalent container resource with the same name @@ -155,7 +155,7 @@ public async Task PublishAsDockerFileConfigureContainer() var secret = builder.AddParameter("secret", secret: true); - var frontend = builder.AddNpmApp("frontend", path, "watch") + var frontend = builder.AddJavaScriptApp("frontend", path, "watch") .WithArgs("/usr/foo") .PublishAsDockerFile(c => { @@ -362,7 +362,7 @@ public void PublishAsDockerFile_CalledMultipleTimes_IsIdempotent() using var tempDir = CreateDirectoryWithDockerFile(); var path = tempDir.Path; - var frontend = builder.AddNpmApp("frontend", path, "watch") + var frontend = builder.AddJavaScriptApp("frontend", path, "watch") .PublishAsDockerFile() .PublishAsDockerFile(); // Call again - should not throw @@ -380,7 +380,7 @@ public void PublishAsDockerFile_CalledMultipleTimesWithCallbacks_IsIdempotent() var path = tempDir.Path; var callbackCount = 0; - var frontend = builder.AddNpmApp("frontend", path, "watch") + var frontend = builder.AddJavaScriptApp("frontend", path, "watch") .PublishAsDockerFile(c => { callbackCount++; diff --git a/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs b/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs index 4a70e626e6e..3e61b23afe7 100644 --- a/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs +++ b/tests/Aspire.Hosting.Tests/ResourceExtensionsTests.cs @@ -452,6 +452,29 @@ public async Task WithDeploymentImageTag_WithAsyncCallback_AddsCorrectAnnotation Assert.Equal("async-tag-test-container", result); } + [Fact] + public async Task WithContainerFilesSource_Works() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var resource = builder.AddResource(new TestContainerFilesResource("test-container")) + .WithContainerFilesSource("src/path1") + .WithContainerFilesSource("src/path2"); + Assert.Collection(resource.Resource.Annotations.OfType(), + a => Assert.Equal("src/path1", a.SourcePath), + a => Assert.Equal("src/path2", a.SourcePath)); + + resource.WithContainerFilesSource("src/override", ResourceAnnotationMutationBehavior.Replace); + + var annotation = Assert.Single(resource.Resource.Annotations.OfType()); + Assert.Equal("src/override", annotation.SourcePath); + + resource.WithContainerFilesSource("src/override2"); + Assert.Collection(resource.Resource.Annotations.OfType(), + a => Assert.Equal("src/override", a.SourcePath), + a => Assert.Equal("src/override2", a.SourcePath)); + } + private sealed class ComputeEnvironmentResource(string name) : Resource(name), IComputeEnvironmentResource { } @@ -475,4 +498,8 @@ private sealed class AnotherDummyAnnotation : IResourceAnnotation { } + + private sealed class TestContainerFilesResource(string name) : ContainerResource(name), IResourceWithContainerFiles + { + } } diff --git a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs index bc7137c1a83..22d549dc8fa 100644 --- a/tests/Aspire.Hosting.Tests/WithReferenceTests.cs +++ b/tests/Aspire.Hosting.Tests/WithReferenceTests.cs @@ -514,7 +514,7 @@ public async Task ExecutableResourceWithReferenceGetsConnectionStringAndProperti } [Fact] - public async Task NpmAppResourceWithReferenceGetsConnectionStringAndProperties() + public async Task JavaScriptAppAppResourceWithReferenceGetsConnectionStringAndProperties() { using var builder = TestDistributedApplicationBuilder.Create(); @@ -524,7 +524,7 @@ public async Task NpmAppResourceWithReferenceGetsConnectionStringAndProperties() ConnectionString = "Server=localhost;Database=mydb" }); - var executable = builder.AddNpmApp("NpmApp", ".\\app") + var executable = builder.AddJavaScriptApp("NpmApp", ".\\app") .WithReference(resource); // Call environment variable callbacks. From 615c10b564f825b0785af2ca7d3efdbe0a651a65 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 30 Oct 2025 17:26:12 -0500 Subject: [PATCH 2/7] Default install command args in publish mode. --- src/Aspire.Hosting.NodeJs/NodeExtensions.cs | 25 ++- .../AddJavaScriptAppTests.cs | 74 ++++++++ .../AddViteAppTests.cs | 2 +- .../PackageInstallationTests.cs | 171 +++++++++--------- 4 files changed, 182 insertions(+), 90 deletions(-) create mode 100644 tests/Aspire.Hosting.NodeJs.Tests/AddJavaScriptAppTests.cs diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index dc230ea7fa0..81558b5a6dd 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -277,13 +277,18 @@ public static IResourceBuilder AddViteApp(this IDistributedAppl /// /// The NodeAppResource. /// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource. - /// The command-line arguments (including the install command itself) passed to the JavaScript package manager to install dependencies. + /// The install command itself passed to npm to install dependencies. + /// The command-line arguments passed to npm to install dependencies. /// A reference to the . - public static IResourceBuilder WithNpm(this IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScriptAppResource + public static IResourceBuilder WithNpm(this IResourceBuilder resource, bool install = true, string? installCommand = null, string[]? installArgs = null) where TResource : JavaScriptAppResource { + ArgumentNullException.ThrowIfNull(resource); + + installCommand ??= resource.ApplicationBuilder.ExecutionContext.IsPublishMode ? "ci" : "install"; + resource .WithAnnotation(new JavaScriptPackageManagerAnnotation("npm", runScriptCommand: "run")) - .WithAnnotation(new JavaScriptInstallCommandAnnotation(installArgs ?? ["install"])); // ci in publish, if there is a lock file? + .WithAnnotation(new JavaScriptInstallCommandAnnotation([installCommand, .. installArgs ?? []])); AddInstaller(resource, install); return resource; @@ -294,13 +299,17 @@ public static IResourceBuilder WithNpm(this IResourceBuild /// /// The NodeAppResource. /// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource. - /// The command-line arguments (including the install command itself) passed to the JavaScript package manager to install dependencies. + /// The command-line arguments passed to "yarn install". /// A reference to the . public static IResourceBuilder WithYarn(this IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScriptAppResource { + ArgumentNullException.ThrowIfNull(resource); + + installArgs ??= resource.ApplicationBuilder.ExecutionContext.IsPublishMode ? ["--immutable"] : []; + resource .WithAnnotation(new JavaScriptPackageManagerAnnotation("yarn", runScriptCommand: "run")) - .WithAnnotation(new JavaScriptInstallCommandAnnotation(installArgs ?? ["install"])); // --frozen-lockfile in publish + .WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])); AddInstaller(resource, install); return resource; @@ -315,9 +324,13 @@ public static IResourceBuilder WithYarn(this IResourceBuil /// A reference to the . public static IResourceBuilder WithPnpm(this IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScriptAppResource { + ArgumentNullException.ThrowIfNull(resource); + + installArgs ??= resource.ApplicationBuilder.ExecutionContext.IsPublishMode ? ["--frozen-lockfile"] : []; + resource .WithAnnotation(new JavaScriptPackageManagerAnnotation("pnpm", runScriptCommand: "run")) - .WithAnnotation(new JavaScriptInstallCommandAnnotation(installArgs ?? ["install"])); // --frozen-lockfile in publish + .WithAnnotation(new JavaScriptInstallCommandAnnotation(["install", .. installArgs])); AddInstaller(resource, install); return resource; diff --git a/tests/Aspire.Hosting.NodeJs.Tests/AddJavaScriptAppTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/AddJavaScriptAppTests.cs new file mode 100644 index 00000000000..d72b05e6147 --- /dev/null +++ b/tests/Aspire.Hosting.NodeJs.Tests/AddJavaScriptAppTests.cs @@ -0,0 +1,74 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.NodeJs.Tests; + +public class AddJavaScriptAppTests +{ + [Fact] + public async Task VerifyDockerfile() + { + using var tempDir = new TempDirectory(); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true); + + // Create directory to ensure manifest generates correct relative build context path + var appDir = Path.Combine(tempDir.Path, "js"); + Directory.CreateDirectory(appDir); + + var yarnApp = builder.AddJavaScriptApp("js", appDir) + .WithYarn(installArgs: ["--immutable"]) + .WithBuildScript("do", ["--build"]); + + await ManifestUtils.GetManifest(yarnApp.Resource, tempDir.Path); + + var dockerfilePath = Path.Combine(tempDir.Path, "js.Dockerfile"); + var dockerfileContents = File.ReadAllText(dockerfilePath); + var expectedDockerfile = $$""" + FROM node:22-slim + WORKDIR /app + COPY . . + RUN yarn install --immutable + RUN yarn run do --build + + """.Replace("\r\n", "\n"); + Assert.Equal(expectedDockerfile, dockerfileContents); + + var dockerBuildAnnotation = yarnApp.Resource.Annotations.OfType().Single(); + Assert.False(dockerBuildAnnotation.HasEntrypoint); + + var containerFilesSource = yarnApp.Resource.Annotations.OfType().Single(); + Assert.Equal("/app/dist", containerFilesSource.SourcePath); + } + + [Fact] + public async Task VerifyPnpmDockerfile() + { + using var tempDir = new TempDirectory(); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputPath: tempDir.Path).WithResourceCleanUp(true); + + // Create directory to ensure manifest generates correct relative build context path + var appDir = Path.Combine(tempDir.Path, "js"); + Directory.CreateDirectory(appDir); + + var pnpmApp = builder.AddJavaScriptApp("js", appDir) + .WithPnpm(installArgs: ["--prefer-frozen-lockfile"]) + .WithBuildScript("mybuild"); + + await ManifestUtils.GetManifest(pnpmApp.Resource, tempDir.Path); + + var dockerfilePath = Path.Combine(tempDir.Path, "js.Dockerfile"); + var dockerfileContents = File.ReadAllText(dockerfilePath); + var expectedDockerfile = $$""" + FROM node:22-slim + WORKDIR /app + COPY . . + RUN pnpm install --prefer-frozen-lockfile + RUN pnpm run mybuild + + """.Replace("\r\n", "\n"); + Assert.Equal(expectedDockerfile, dockerfileContents); + } +} diff --git a/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs index cc99ae71cd4..783af2fb0e7 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs @@ -52,7 +52,7 @@ public async Task VerifyDefaultDockerfile() FROM node:22-slim WORKDIR /app COPY . . - RUN npm install + RUN npm ci RUN npm run build """.Replace("\r\n", "\n"); diff --git a/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs index 4de30f764a4..b42e5c4f3fe 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs @@ -15,12 +15,12 @@ public class PackageInstallationTests /// installer resources with proper arguments and relationships. /// [Fact] - public void WithNpm_CanBeConfiguredWithInstallAndCIOptions() + public void WithNpm_CanBeConfiguredWithInstall() { var builder = DistributedApplication.CreateBuilder(); var nodeApp = builder.AddJavaScriptApp("nodeApp", "./test-app"); - var nodeApp2 = builder.AddJavaScriptApp("nodeApp2", "./test-app-ci"); + var nodeApp2 = builder.AddJavaScriptApp("nodeApp2", "./test-app-2"); // Test that both configurations can be set up without errors nodeApp.WithNpm(install: true); // Uses npm install @@ -200,99 +200,50 @@ public async Task WithPnpm_DoesNotCreateInstallerWhenInstallIsFalse() Assert.Empty(installerResources); } - //[Fact] - //public void WithInstallCommand_CreatesInstallerWithCustomCommand() - //{ - // var builder = DistributedApplication.CreateBuilder(); - - // var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); - // nodeApp.WithInstallCommand("bun", ["install", "--frozen-lockfile"]); - - // using var app = builder.Build(); - - // var appModel = app.Services.GetRequiredService(); - - // // Verify the JavaScriptApp resource exists - // var nodeResource = Assert.Single(appModel.Resources.OfType()); - - // // Verify the install command annotation with custom command - // Assert.True(nodeResource.TryGetLastAnnotation(out var installAnnotation)); - // Assert.Equal("bun", installAnnotation.Command); - // Assert.Equal(["install", "--frozen-lockfile"], installAnnotation.Args); - - // // Verify the installer resource was created - // var installerResource = Assert.Single(appModel.Resources.OfType()); - // Assert.Equal("test-app-installer", installerResource.Name); - //} - - //[Fact] - //public void WithBuildCommand_SetsCustomBuildCommand() - //{ - // var builder = DistributedApplication.CreateBuilder(); - - // var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); - // nodeApp.WithBuildCommand("bun", ["run", "build:prod"]); - - // using var app = builder.Build(); - - // var appModel = app.Services.GetRequiredService(); - - // // Verify the JavaScriptApp resource exists - // var nodeResource = Assert.Single(appModel.Resources.OfType()); - - // // Verify the build command annotation with custom command - // Assert.True(nodeResource.TryGetLastAnnotation(out var buildAnnotation)); - // Assert.Equal("bun", buildAnnotation.Command); - // Assert.Equal(["run", "build:prod"], buildAnnotation.Args); - //} - - //[Fact] - //public void WithInstallCommand_CanOverrideExistingInstallCommand() - //{ - // var builder = DistributedApplication.CreateBuilder(); + [Fact] + public void WithNpm_CreatesInstallerWithCustomCommand() + { + var builder = DistributedApplication.CreateBuilder(); - // var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); - // nodeApp.WithNpm(install: false); - // nodeApp.WithInstallCommand("yarn", ["install", "--production"]); + var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); + nodeApp.WithNpm(installCommand: "ci", installArgs: ["--no-fund"]); - // using var app = builder.Build(); + using var app = builder.Build(); - // var appModel = app.Services.GetRequiredService(); + var appModel = app.Services.GetRequiredService(); - // // Verify the JavaScriptApp resource exists - // var nodeResource = Assert.Single(appModel.Resources.OfType()); + // Verify the JavaScriptApp resource exists + var nodeResource = Assert.Single(appModel.Resources.OfType()); - // // Verify the install command annotation was replaced - // Assert.True(nodeResource.TryGetLastAnnotation(out var installAnnotation)); - // Assert.Equal("yarn", installAnnotation.Command); - // Assert.Equal(["install", "--production"], installAnnotation.Args); + // Verify the install command annotation with custom command + Assert.True(nodeResource.TryGetLastAnnotation(out var installAnnotation)); + Assert.Equal(["ci", "--no-fund"], installAnnotation.Args); - // // Verify the installer resource was created - // var installerResource = Assert.Single(appModel.Resources.OfType()); - // Assert.Equal("test-app-installer", installerResource.Name); - //} + // Verify the installer resource was created + var installerResource = Assert.Single(appModel.Resources.OfType()); + Assert.Equal("test-app-installer", installerResource.Name); + } - //[Fact] - //public void WithBuildCommand_CanOverrideExistingBuildCommand() - //{ - // var builder = DistributedApplication.CreateBuilder(); + [Fact] + public void WithBuildScript_SetsCustomBuildCommand() + { + var builder = DistributedApplication.CreateBuilder(); - // var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); - // nodeApp.WithNpm(install: false); - // nodeApp.WithBuildCommand("pnpm", ["build", "--watch"]); + var nodeApp = builder.AddJavaScriptApp("test-app", "./test-app"); + nodeApp.WithBuildScript("bun", ["run", "build:prod"]); - // using var app = builder.Build(); + using var app = builder.Build(); - // var appModel = app.Services.GetRequiredService(); + var appModel = app.Services.GetRequiredService(); - // // Verify the JavaScriptApp resource exists - // var nodeResource = Assert.Single(appModel.Resources.OfType()); + // Verify the JavaScriptApp resource exists + var nodeResource = Assert.Single(appModel.Resources.OfType()); - // // Verify the build command annotation was replaced - // Assert.True(nodeResource.TryGetLastAnnotation(out var buildAnnotation)); - // Assert.Equal("pnpm", buildAnnotation.Command); - // Assert.Equal(["build", "--watch"], buildAnnotation.Args); - //} + // Verify the build command annotation with custom command + Assert.True(nodeResource.TryGetLastAnnotation(out var buildAnnotation)); + Assert.Equal("bun", buildAnnotation.ScriptName); + Assert.Equal(["run", "build:prod"], buildAnnotation.Args); + } [Fact] public void WithNpmInstallWithYarnNoInstall() @@ -462,6 +413,60 @@ public void AddViteApp_DefaultInstallsPackages() Assert.Equal("test-app-installer", installerResource.Name); } + [Fact] + public void WithNpm_DefaultsArgsInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var app = builder.AddViteApp("test-app", "./test-app") + .WithNpm(); + + Assert.True(app.Resource.TryGetLastAnnotation(out var installCommand)); + Assert.Equal(["ci"], installCommand.Args); + } + + [Fact] + public void WithNpm_CanChangeInstallCommandAndArgs() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var app = builder.AddViteApp("test-app", "./test-app") + .WithNpm(installCommand: "myinstall", installArgs: ["--no-fund"]); + + Assert.True(app.Resource.TryGetLastAnnotation(out var installCommand)); + Assert.Equal(["myinstall", "--no-fund"], installCommand.Args); + } + + [Fact] + public void WithYarn_DefaultsArgsInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var app = builder.AddViteApp("test-app", "./test-app") + .WithYarn(); + + Assert.True(app.Resource.TryGetLastAnnotation(out var installCommand)); + Assert.Equal(["install", "--immutable"], installCommand.Args); + + var app2 = builder.AddViteApp("test-app2", "./test-app2") + .WithYarn(installArgs:["--immutable-cache"]); + + Assert.True(app2.Resource.TryGetLastAnnotation(out installCommand)); + Assert.Equal(["install", "--immutable-cache"], installCommand.Args); + } + + [Fact] + public void WithPnmp_DefaultsArgsInPublishMode() + { + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); + + var app = builder.AddViteApp("test-app", "./test-app") + .WithPnpm(); + + Assert.True(app.Resource.TryGetLastAnnotation(out var installCommand)); + Assert.Equal(["install", "--frozen-lockfile"], installCommand.Args); + } + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ExecuteBeforeStartHooksAsync")] private static extern Task ExecuteBeforeStartHooksAsync(DistributedApplication app, CancellationToken cancellationToken); } From a724921b18421f69d385b6228e8dab82c1f691c5 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 30 Oct 2025 17:52:54 -0500 Subject: [PATCH 3/7] Only use clean installs when the lock file exists in publish mode. --- .../AspireJavaScript.AppHost/AppHost.cs | 2 +- src/Aspire.Hosting.NodeJs/NodeExtensions.cs | 26 ++++++++++++++++--- .../AddViteAppTests.cs | 3 +++ .../PackageInstallationTests.cs | 17 +++++++++--- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs index fe1e22e816b..d64ad942d25 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs @@ -19,7 +19,7 @@ .PublishAsDockerFile(); builder.AddJavaScriptApp("vue", "../AspireJavaScript.Vue") - .WithNpm(installArgs: ["ci"]) // Use 'npm ci' for clean install + .WithNpm(installCommand: "ci") // Use 'npm ci' for clean install .WithReference(weatherApi) .WaitFor(weatherApi) .WithHttpEndpoint(env: "PORT") diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index 81558b5a6dd..a699ecb843c 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -81,7 +81,7 @@ public static IResourceBuilder AddNpmApp(this IDistributedAppli } /// - /// Adds a JavaScript application resource to the distributed application using the specified working directory and + /// Adds a JavaScript application resource to the distributed application using the specified app directory and /// run script. /// /// The distributed application builder to which the JavaScript application resource will be added. @@ -284,7 +284,7 @@ public static IResourceBuilder WithNpm(this IResourceBuild { ArgumentNullException.ThrowIfNull(resource); - installCommand ??= resource.ApplicationBuilder.ExecutionContext.IsPublishMode ? "ci" : "install"; + installCommand ??= GetDefaultNpmInstallCommand(resource); resource .WithAnnotation(new JavaScriptPackageManagerAnnotation("npm", runScriptCommand: "run")) @@ -294,6 +294,12 @@ public static IResourceBuilder WithNpm(this IResourceBuild return resource; } + private static string GetDefaultNpmInstallCommand(IResourceBuilder resource) => + resource.ApplicationBuilder.ExecutionContext.IsPublishMode && + File.Exists(Path.Combine(resource.Resource.WorkingDirectory, "package-lock.json")) + ? "ci" + : "install"; + /// /// Configures the Node.js resource to use yarn as the package manager and optionally installs packages before the application starts. /// @@ -305,7 +311,7 @@ public static IResourceBuilder WithYarn(this IResourceBuil { ArgumentNullException.ThrowIfNull(resource); - installArgs ??= resource.ApplicationBuilder.ExecutionContext.IsPublishMode ? ["--immutable"] : []; + installArgs ??= GetDefaultYarnInstallArgs(resource); resource .WithAnnotation(new JavaScriptPackageManagerAnnotation("yarn", runScriptCommand: "run")) @@ -315,6 +321,12 @@ public static IResourceBuilder WithYarn(this IResourceBuil return resource; } + private static string[] GetDefaultYarnInstallArgs(IResourceBuilder resource) => + resource.ApplicationBuilder.ExecutionContext.IsPublishMode && + File.Exists(Path.Combine(resource.Resource.WorkingDirectory, "yarn.lock")) + ? ["--immutable"] + : []; + /// /// Configures the Node.js resource to use pnmp as the package manager and optionally installs packages before the application starts. /// @@ -326,7 +338,7 @@ public static IResourceBuilder WithPnpm(this IResourceBuil { ArgumentNullException.ThrowIfNull(resource); - installArgs ??= resource.ApplicationBuilder.ExecutionContext.IsPublishMode ? ["--frozen-lockfile"] : []; + installArgs ??= GetDefaultPnpmInstallArgs(resource); resource .WithAnnotation(new JavaScriptPackageManagerAnnotation("pnpm", runScriptCommand: "run")) @@ -336,6 +348,12 @@ public static IResourceBuilder WithPnpm(this IResourceBuil return resource; } + private static string[] GetDefaultPnpmInstallArgs(IResourceBuilder resource) => + resource.ApplicationBuilder.ExecutionContext.IsPublishMode && + File.Exists(Path.Combine(resource.Resource.WorkingDirectory, "pnpm-lock.yaml")) + ? ["--frozen-lockfile"] + : []; + /// /// Adds a build script annotation to the resource builder using the specified command-line arguments. /// diff --git a/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs index 783af2fb0e7..e4977de5caa 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs @@ -18,6 +18,9 @@ public async Task VerifyDefaultDockerfile() var viteDir = Path.Combine(tempDir.Path, "vite"); Directory.CreateDirectory(viteDir); + // Create a lock file so npm ci is used in the Dockerfile + File.WriteAllText(Path.Combine(viteDir, "package-lock.json"), "empty"); + var nodeApp = builder.AddViteApp("vite", viteDir) .WithNpm(install: true); diff --git a/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs index b42e5c4f3fe..95bca80eec9 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs @@ -416,9 +416,12 @@ public void AddViteApp_DefaultInstallsPackages() [Fact] public void WithNpm_DefaultsArgsInPublishMode() { + using var tempDir = new TempDirectory(); + File.WriteAllText(Path.Combine(tempDir.Path, "package-lock.json"), "empty"); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); - var app = builder.AddViteApp("test-app", "./test-app") + var app = builder.AddViteApp("test-app", tempDir.Path) .WithNpm(); Assert.True(app.Resource.TryGetLastAnnotation(out var installCommand)); @@ -440,15 +443,18 @@ public void WithNpm_CanChangeInstallCommandAndArgs() [Fact] public void WithYarn_DefaultsArgsInPublishMode() { + using var tempDir = new TempDirectory(); + File.WriteAllText(Path.Combine(tempDir.Path, "yarn.lock"), "empty"); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); - var app = builder.AddViteApp("test-app", "./test-app") + var app = builder.AddViteApp("test-app", tempDir.Path) .WithYarn(); Assert.True(app.Resource.TryGetLastAnnotation(out var installCommand)); Assert.Equal(["install", "--immutable"], installCommand.Args); - var app2 = builder.AddViteApp("test-app2", "./test-app2") + var app2 = builder.AddViteApp("test-app2", tempDir.Path) .WithYarn(installArgs:["--immutable-cache"]); Assert.True(app2.Resource.TryGetLastAnnotation(out installCommand)); @@ -458,9 +464,12 @@ public void WithYarn_DefaultsArgsInPublishMode() [Fact] public void WithPnmp_DefaultsArgsInPublishMode() { + using var tempDir = new TempDirectory(); + File.WriteAllText(Path.Combine(tempDir.Path, "pnpm-lock.yaml"), "empty"); + using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish); - var app = builder.AddViteApp("test-app", "./test-app") + var app = builder.AddViteApp("test-app", tempDir.Path) .WithPnpm(); Assert.True(app.Resource.TryGetLastAnnotation(out var installCommand)); From c5a2ea4eba754c4f455ab707841c6605da02201e Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 30 Oct 2025 17:57:17 -0500 Subject: [PATCH 4/7] Address copilot feedback --- src/Aspire.Hosting.NodeJs/JavaScriptRunScriptAnnotation.cs | 2 +- src/Aspire.Hosting.NodeJs/NodeExtensions.cs | 2 +- tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptRunScriptAnnotation.cs b/src/Aspire.Hosting.NodeJs/JavaScriptRunScriptAnnotation.cs index e0b18ce1eb0..b9dfb9b9fa6 100644 --- a/src/Aspire.Hosting.NodeJs/JavaScriptRunScriptAnnotation.cs +++ b/src/Aspire.Hosting.NodeJs/JavaScriptRunScriptAnnotation.cs @@ -18,7 +18,7 @@ public sealed class JavaScriptRunScriptAnnotation(string scriptName, string[]? a public string ScriptName { get; } = scriptName; /// - /// Gets the name of the script to run. + /// Gets the command-line arguments for the script. /// public string[] Args { get; } = args ?? []; } diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index a699ecb843c..d55c595c002 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -332,7 +332,7 @@ private static string[] GetDefaultYarnInstallArgs(IResourceBuilder /// The NodeAppResource. /// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource. - /// The command-line arguments (including the install command itself) passed to the JavaScript package manager to install dependencies. + /// The command-line arguments passed to "pnpm install". /// A reference to the . public static IResourceBuilder WithPnpm(this IResourceBuilder resource, bool install = true, string[]? installArgs = null) where TResource : JavaScriptAppResource { diff --git a/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs index 95bca80eec9..d6e747e40a3 100644 --- a/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs +++ b/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs @@ -462,7 +462,7 @@ public void WithYarn_DefaultsArgsInPublishMode() } [Fact] - public void WithPnmp_DefaultsArgsInPublishMode() + public void WithPnpm_DefaultsArgsInPublishMode() { using var tempDir = new TempDirectory(); File.WriteAllText(Path.Combine(tempDir.Path, "pnpm-lock.yaml"), "empty"); From 427d88e50332ca5ab4fc880f12e4baa5e4530015 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 30 Oct 2025 18:11:51 -0500 Subject: [PATCH 5/7] More PR feedback --- .../JavaScriptAppResource.cs | 2 +- .../JavaScriptInstallCommandAnnotation.cs | 7 +++- src/Aspire.Hosting.NodeJs/NodeAppResource.cs | 2 +- src/Aspire.Hosting.NodeJs/NodeExtensions.cs | 39 +++++++++---------- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptAppResource.cs b/src/Aspire.Hosting.NodeJs/JavaScriptAppResource.cs index af177eaa581..b6f24fba70b 100644 --- a/src/Aspire.Hosting.NodeJs/JavaScriptAppResource.cs +++ b/src/Aspire.Hosting.NodeJs/JavaScriptAppResource.cs @@ -10,6 +10,6 @@ namespace Aspire.Hosting; /// /// The name of the resource. /// The command to execute. -/// The working directory to use for the command. If null, the working directory of the current process is used. +/// The working directory to use for the command. public class JavaScriptAppResource(string name, string command, string workingDirectory) : ExecutableResource(name, command, workingDirectory), IResourceWithServiceDiscovery, IResourceWithContainerFiles; diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptInstallCommandAnnotation.cs b/src/Aspire.Hosting.NodeJs/JavaScriptInstallCommandAnnotation.cs index 1202847b559..461eb8af3d7 100644 --- a/src/Aspire.Hosting.NodeJs/JavaScriptInstallCommandAnnotation.cs +++ b/src/Aspire.Hosting.NodeJs/JavaScriptInstallCommandAnnotation.cs @@ -8,11 +8,14 @@ namespace Aspire.Hosting.NodeJs; /// /// Represents the annotation for the JavaScript package manager's install command. /// -/// The command line arguments for the JavaScript package manager's install command. +/// +/// The command line arguments for the JavaScript package manager's install command. +/// This includes the command itself (i.e. "install"). +/// public sealed class JavaScriptInstallCommandAnnotation(string[] args) : IResourceAnnotation { /// - /// Gets the command-line arguments supplied to the application. + /// Gets the command-line arguments supplied to the JavaScript package manager. /// public string[] Args { get; } = args; } diff --git a/src/Aspire.Hosting.NodeJs/NodeAppResource.cs b/src/Aspire.Hosting.NodeJs/NodeAppResource.cs index 31d9b206b2f..65572d4da56 100644 --- a/src/Aspire.Hosting.NodeJs/NodeAppResource.cs +++ b/src/Aspire.Hosting.NodeJs/NodeAppResource.cs @@ -8,6 +8,6 @@ namespace Aspire.Hosting; /// /// The name of the resource. /// The command to execute. -/// The working directory to use for the command. If null, the working directory of the current process is used. +/// The working directory to use for the command. public class NodeAppResource(string name, string command, string workingDirectory) : JavaScriptAppResource(name, command, workingDirectory), IResourceWithServiceDiscovery; diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs index d55c595c002..0136d04a824 100644 --- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs +++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs @@ -55,7 +55,7 @@ public static IResourceBuilder AddNodeApp(this IDistributedAppl /// /// The to add the resource to. /// The name of the resource. - /// The working directory to use for the command. If null, the working directory of the current process is used. + /// The working directory to use for the command. /// The npm script to execute. Defaults to "start". /// The arguments to pass to the command. /// A reference to the . @@ -80,6 +80,23 @@ public static IResourceBuilder AddNpmApp(this IDistributedAppli .WithIconName("CodeJsRectangle"); } + private static IResourceBuilder WithNodeDefaults(this IResourceBuilder builder) where TResource : JavaScriptAppResource => + builder.WithOtlpExporter() + .WithEnvironment("NODE_ENV", builder.ApplicationBuilder.Environment.IsDevelopment() ? "development" : "production") + .WithCertificateTrustConfiguration((ctx) => + { + if (ctx.Scope == CertificateTrustScope.Append) + { + ctx.EnvironmentVariables["NODE_EXTRA_CA_CERTS"] = ctx.CertificateBundlePath; + } + else + { + ctx.Arguments.Add("--use-openssl-ca"); + } + + return Task.CompletedTask; + }); + /// /// Adds a JavaScript application resource to the distributed application using the specified app directory and /// run script. @@ -88,14 +105,13 @@ public static IResourceBuilder AddNpmApp(this IDistributedAppli /// The unique name of the JavaScript application resource. Cannot be null or empty. /// The path to the directory containing the JavaScript application. /// The name of the npm script to run when starting the application. Defaults to "start". Cannot be null or empty. - /// An optional array of additional arguments to pass to the run script. May be null. /// A resource builder for the newly added JavaScript application resource. /// /// If a Dockerfile does not exist in the application's directory, one will be generated /// automatically when publishing. The method configures the resource with Node.js defaults and sets up npm /// integration. /// - public static IResourceBuilder AddJavaScriptApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string runScriptName = "start", string[]? args = null) + public static IResourceBuilder AddJavaScriptApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string runScriptName = "start") { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(name); @@ -206,23 +222,6 @@ private static IResourceBuilder CreateDefaultJavaScriptAppBuilder WithNodeDefaults(this IResourceBuilder builder) where TResource : JavaScriptAppResource => - builder.WithOtlpExporter() - .WithEnvironment("NODE_ENV", builder.ApplicationBuilder.Environment.IsDevelopment() ? "development" : "production") - .WithCertificateTrustConfiguration((ctx) => - { - if (ctx.Scope == CertificateTrustScope.Append) - { - ctx.EnvironmentVariables["NODE_EXTRA_CA_CERTS"] = ctx.CertificateBundlePath; - } - else - { - ctx.Arguments.Add("--use-openssl-ca"); - } - - return Task.CompletedTask; - }); - /// /// Adds a Vite app to the distributed application builder. /// From 176ca5f8599e7bbfe47fc8505f9efd8de3faa277 Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Thu, 30 Oct 2025 20:22:55 -0500 Subject: [PATCH 6/7] Fix PublishAsDockerFile to always clear the args when it is called, even if it is called a second time. --- src/Aspire.Hosting/ExecutableResourceBuilderExtensions.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Aspire.Hosting/ExecutableResourceBuilderExtensions.cs b/src/Aspire.Hosting/ExecutableResourceBuilderExtensions.cs index da7efd03a4d..a1ce21a14e9 100644 --- a/src/Aspire.Hosting/ExecutableResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting/ExecutableResourceBuilderExtensions.cs @@ -125,6 +125,10 @@ public static IResourceBuilder PublishAsDockerFile(this IResourceBuilder(builder.Resource.Name, out var existingBuilder)) { + // Arguments to the executable often contain physical paths that are not valid in the container + // Clear them out so that the container can be set up with the correct arguments + existingBuilder.WithArgs(c => c.Args.Clear()); + // Resource has already been converted, just invoke the configure callback if provided configure?.Invoke(existingBuilder); return builder; From de7e5e0ebf88d1cc319baa8a2231f333ec170cd5 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Fri, 31 Oct 2025 00:05:56 -0700 Subject: [PATCH 7/7] Update npm install command comment in AppHost.cs Clarified npm install command requirement for clean install. --- .../AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs index d64ad942d25..9f6d789fcd8 100644 --- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs +++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs @@ -19,7 +19,7 @@ .PublishAsDockerFile(); builder.AddJavaScriptApp("vue", "../AspireJavaScript.Vue") - .WithNpm(installCommand: "ci") // Use 'npm ci' for clean install + .WithNpm(installCommand: "ci") // Use 'npm ci' for clean install, requires lock file .WithReference(weatherApi) .WaitFor(weatherApi) .WithHttpEndpoint(env: "PORT")