diff --git a/Aspire.slnx b/Aspire.slnx
index d2ce1f43147..2dab93a8f6a 100644
--- a/Aspire.slnx
+++ b/Aspire.slnx
@@ -106,6 +106,11 @@
+
+
+
+
+
@@ -176,8 +181,8 @@
-
+
@@ -199,11 +204,6 @@
-
-
-
-
-
@@ -234,8 +234,8 @@
-
+
diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs
index 86b7914f5b1..15c89c54b9e 100644
--- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs
+++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AppHost.cs
@@ -1,9 +1,10 @@
-var builder = DistributedApplication.CreateBuilder(args);
+var builder = DistributedApplication.CreateBuilder(args);
var weatherApi = builder.AddProject("weatherapi")
.WithExternalHttpEndpoints();
builder.AddNpmApp("angular", "../AspireJavaScript.Angular")
+ .WithNpmPackageManager()
.WithReference(weatherApi)
.WaitFor(weatherApi)
.WithHttpEndpoint(env: "PORT")
@@ -11,6 +12,7 @@
.PublishAsDockerFile();
builder.AddNpmApp("react", "../AspireJavaScript.React")
+ .WithNpmPackageManager()
.WithReference(weatherApi)
.WaitFor(weatherApi)
.WithEnvironment("BROWSER", "none") // Disable opening browser on npm start
@@ -19,17 +21,17 @@
.PublishAsDockerFile();
builder.AddNpmApp("vue", "../AspireJavaScript.Vue")
+ .WithNpmPackageManager()
.WithReference(weatherApi)
.WaitFor(weatherApi)
.WithHttpEndpoint(env: "PORT")
.WithExternalHttpEndpoints()
.PublishAsDockerFile();
-builder.AddNpmApp("reactvite", "../AspireJavaScript.Vite")
+builder.AddViteApp("reactvite", "../AspireJavaScript.Vite")
+ .WithNpmPackageManager()
.WithReference(weatherApi)
.WithEnvironment("BROWSER", "none")
- .WithHttpEndpoint(env: "VITE_PORT")
- .WithExternalHttpEndpoints()
- .PublishAsDockerFile();
+ .WithExternalHttpEndpoints();
builder.Build().Run();
diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj
index aef5911e35f..262a263f6d4 100644
--- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj
+++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/AspireJavaScript.AppHost.csproj
@@ -18,22 +18,4 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/aspire-manifest.json b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/aspire-manifest.json
index 4b9f100e483..8e2b284d216 100644
--- a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/aspire-manifest.json
+++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/aspire-manifest.json
@@ -27,12 +27,16 @@
}
},
"angular": {
- "type": "dockerfile.v0",
- "path": "../AspireJavaScript.Angular/Dockerfile",
- "context": "../AspireJavaScript.Angular",
+ "type": "container.v1",
+ "build": {
+ "context": "../AspireJavaScript.Angular",
+ "dockerfile": "../AspireJavaScript.Angular/Dockerfile"
+ },
"env": {
"NODE_ENV": "development",
+ "WEATHERAPI_HTTP": "{weatherapi.bindings.http.url}",
"services__weatherapi__http__0": "{weatherapi.bindings.http.url}",
+ "WEATHERAPI_HTTPS": "{weatherapi.bindings.https.url}",
"services__weatherapi__https__0": "{weatherapi.bindings.https.url}",
"PORT": "{angular.bindings.http.targetPort}"
},
@@ -47,12 +51,16 @@
}
},
"react": {
- "type": "dockerfile.v0",
- "path": "../AspireJavaScript.React/Dockerfile",
- "context": "../AspireJavaScript.React",
+ "type": "container.v1",
+ "build": {
+ "context": "../AspireJavaScript.React",
+ "dockerfile": "../AspireJavaScript.React/Dockerfile"
+ },
"env": {
"NODE_ENV": "development",
+ "WEATHERAPI_HTTP": "{weatherapi.bindings.http.url}",
"services__weatherapi__http__0": "{weatherapi.bindings.http.url}",
+ "WEATHERAPI_HTTPS": "{weatherapi.bindings.https.url}",
"services__weatherapi__https__0": "{weatherapi.bindings.https.url}",
"BROWSER": "none",
"PORT": "{react.bindings.http.targetPort}"
@@ -68,12 +76,16 @@
}
},
"vue": {
- "type": "dockerfile.v0",
- "path": "../AspireJavaScript.Vue/Dockerfile",
- "context": "../AspireJavaScript.Vue",
+ "type": "container.v1",
+ "build": {
+ "context": "../AspireJavaScript.Vue",
+ "dockerfile": "../AspireJavaScript.Vue/Dockerfile"
+ },
"env": {
"NODE_ENV": "development",
+ "WEATHERAPI_HTTP": "{weatherapi.bindings.http.url}",
"services__weatherapi__http__0": "{weatherapi.bindings.http.url}",
+ "WEATHERAPI_HTTPS": "{weatherapi.bindings.https.url}",
"services__weatherapi__https__0": "{weatherapi.bindings.https.url}",
"PORT": "{vue.bindings.http.targetPort}"
},
@@ -86,6 +98,31 @@
"external": true
}
}
+ },
+ "reactvite": {
+ "type": "container.v1",
+ "build": {
+ "context": "../AspireJavaScript.Vite",
+ "dockerfile": "reactvite.Dockerfile"
+ },
+ "env": {
+ "NODE_ENV": "development",
+ "PORT": "{reactvite.bindings.http.targetPort}",
+ "WEATHERAPI_HTTP": "{weatherapi.bindings.http.url}",
+ "services__weatherapi__http__0": "{weatherapi.bindings.http.url}",
+ "WEATHERAPI_HTTPS": "{weatherapi.bindings.https.url}",
+ "services__weatherapi__https__0": "{weatherapi.bindings.https.url}",
+ "BROWSER": "none"
+ },
+ "bindings": {
+ "http": {
+ "scheme": "http",
+ "protocol": "tcp",
+ "transport": "http",
+ "targetPort": 8003,
+ "external": true
+ }
+ }
}
}
}
\ No newline at end of file
diff --git a/playground/AspireWithJavaScript/AspireJavaScript.AppHost/reactvite.Dockerfile b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/reactvite.Dockerfile
new file mode 100644
index 00000000000..3bafae638df
--- /dev/null
+++ b/playground/AspireWithJavaScript/AspireJavaScript.AppHost/reactvite.Dockerfile
@@ -0,0 +1,5 @@
+FROM node:22-slim
+WORKDIR /app
+COPY . .
+RUN npm install
+RUN npm run build
diff --git a/playground/AspireWithJavaScript/AspireJavaScript.Vite/Dockerfile b/playground/AspireWithJavaScript/AspireJavaScript.Vite/Dockerfile
deleted file mode 100644
index 2ac3df971b3..00000000000
--- a/playground/AspireWithJavaScript/AspireJavaScript.Vite/Dockerfile
+++ /dev/null
@@ -1,23 +0,0 @@
-# DisableDockerDetector "Playground/demo application used for testing Aspire features"
-FROM node:20 as build
-
-WORKDIR /app
-
-COPY package.json package.json
-COPY package-lock.json package-lock.json
-
-RUN npm install
-
-COPY . .
-
-RUN npm run build
-
-FROM nginx:alpine
-
-COPY --from=build /app/default.conf.template /etc/nginx/templates/default.conf.template
-COPY --from=build /app/dist /usr/share/nginx/html
-
-# Expose the default nginx port
-EXPOSE 80
-
-CMD ["nginx", "-g", "daemon off;"]
diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptPackageInstallerAnnotation.cs b/src/Aspire.Hosting.NodeJs/JavaScriptPackageInstallerAnnotation.cs
new file mode 100644
index 00000000000..bdeb1ee3053
--- /dev/null
+++ b/src/Aspire.Hosting.NodeJs/JavaScriptPackageInstallerAnnotation.cs
@@ -0,0 +1,17 @@
+// 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 an annotation for a JavaScript installer resource.
+///
+public sealed class JavaScriptPackageInstallerAnnotation(ExecutableResource installerResource) : IResourceAnnotation
+{
+ ///
+ /// The instance of the Installer resource used.
+ ///
+ public ExecutableResource Resource { get; } = installerResource;
+}
\ No newline at end of file
diff --git a/src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs b/src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs
new file mode 100644
index 00000000000..12d2fae0fa6
--- /dev/null
+++ b/src/Aspire.Hosting.NodeJs/JavaScriptPackageManagerAnnotation.cs
@@ -0,0 +1,39 @@
+// 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 JavaScript package manager.
+public sealed class JavaScriptPackageManagerAnnotation(string packageManager) : IResourceAnnotation
+{
+ ///
+ /// Gets the name of the JavaScript package manager.
+ ///
+ public string PackageManager { get; } = packageManager;
+
+ ///
+ /// Gets the command line arguments for the JavaScript package manager's install command.
+ ///
+ public string[] InstallCommandLineArgs { get; init; } = [];
+
+ ///
+ /// Gets the command line arguments for the JavaScript package manager's run command.
+ ///
+ public string[] RunCommandLineArgs { get; init; } = [];
+
+ ///
+ /// Gets a string value that separates the package manager command line args from the tool's command line args.
+ /// By default, this is "--".
+ ///
+ public string? CommandSeparator { get; init; } = "--";
+
+ ///
+ /// Gets the command line arguments for the JavaScript package manager's command that produces assets for distribution.
+ ///
+ public string[] BuildCommandLineArgs { get; init; } = [];
+}
diff --git a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs
index 4bdd94818c8..dc133ade377 100644
--- a/src/Aspire.Hosting.NodeJs/NodeExtensions.cs
+++ b/src/Aspire.Hosting.NodeJs/NodeExtensions.cs
@@ -1,6 +1,10 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.NodeJs;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Hosting;
@@ -69,7 +73,7 @@ public static IResourceBuilder AddNpmApp(this IDistributedAppli
.WithIconName("CodeJsRectangle");
}
- private static IResourceBuilder WithNodeDefaults(this IResourceBuilder builder) =>
+ private static IResourceBuilder WithNodeDefaults(this IResourceBuilder builder) where TResource : NodeAppResource =>
builder.WithOtlpExporter()
.WithEnvironment("NODE_ENV", builder.ApplicationBuilder.Environment.IsDevelopment() ? "development" : "production")
.WithExecutableCertificateTrustCallback((ctx) =>
@@ -86,4 +90,146 @@ private static IResourceBuilder WithNodeDefaults(this IResource
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 working directory of the Vite app.
+ /// When true use HTTPS for the endpoints, otherwise use HTTP.
+ /// 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")
+ /// .WithNpmPackageManager();
+ ///
+ /// builder.Build().Run();
+ ///
+ ///
+ ///
+ public static IResourceBuilder AddViteApp(this IDistributedApplicationBuilder builder, [ResourceName] string name, string workingDirectory, bool useHttps = false)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ ArgumentException.ThrowIfNullOrEmpty(workingDirectory);
+
+ workingDirectory = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, workingDirectory));
+ var resource = new ViteAppResource(name, "node", workingDirectory);
+
+ var resourceBuilder = builder.AddResource(resource)
+ .WithNodeDefaults()
+ .WithIconName("CodeJsRectangle")
+ .WithArgs(c =>
+ {
+ if (resource.TryGetLastAnnotation(out var packageManagerAnnotation))
+ {
+ foreach (var arg in packageManagerAnnotation.RunCommandLineArgs)
+ {
+ c.Args.Add(arg);
+ }
+ }
+ c.Args.Add("dev");
+
+ if (packageManagerAnnotation?.CommandSeparator is string separator)
+ {
+ c.Args.Add(separator);
+ }
+
+ var targetEndpoint = resource.GetEndpoint("https");
+ if (!targetEndpoint.Exists)
+ {
+ targetEndpoint = resource.GetEndpoint("http");
+ }
+
+ c.Args.Add("--port");
+ c.Args.Add(targetEndpoint.Property(EndpointProperty.TargetPort));
+ });
+
+ _ = useHttps
+ ? resourceBuilder.WithHttpsEndpoint(env: "PORT")
+ : resourceBuilder.WithHttpEndpoint(env: "PORT");
+
+ return resourceBuilder
+ .AddNpmPackageManagerAnnotation(useCI: false)
+ .PublishAsDockerFile(c =>
+ {
+ // Only generate a Dockerfile if one doesn't already exist in the app directory
+ if (File.Exists(Path.Combine(resource.WorkingDirectory, "Dockerfile")))
+ {
+ return;
+ }
+
+ c.WithDockerfileBuilder(resource.WorkingDirectory, dockerfileContext =>
+ {
+ if (c.Resource.TryGetLastAnnotation(out var packageManagerAnnotation)
+ && packageManagerAnnotation.BuildCommandLineArgs is { Length: > 0 })
+ {
+ var dockerBuilder = dockerfileContext.Builder
+ .From("node:22-slim")
+ .WorkDir("/app")
+ .Copy(".", ".");
+
+ if (packageManagerAnnotation.InstallCommandLineArgs is { Length: > 0 })
+ {
+ dockerBuilder
+ .Run($"{resourceBuilder.Resource.Command} {string.Join(' ', packageManagerAnnotation.InstallCommandLineArgs)}");
+ }
+ dockerBuilder
+ .Run($"{resourceBuilder.Resource.Command} {string.Join(' ', packageManagerAnnotation.BuildCommandLineArgs)}");
+ }
+ });
+ });
+ }
+
+ ///
+ /// Ensures the Node.js packages are installed before the application starts using npm as the package manager.
+ ///
+ /// The NodeAppResource.
+ /// When true, use npm ci, otherwise use npm install when installing packages.
+ /// Configure the npm installer resource.
+ /// A reference to the .
+ public static IResourceBuilder WithNpmPackageManager(this IResourceBuilder resource, bool useCI = false, Action>? configureInstaller = null) where TResource : NodeAppResource
+ {
+ AddNpmPackageManagerAnnotation(resource, useCI);
+
+ // Only install packages during development, not in publish mode
+ if (!resource.ApplicationBuilder.ExecutionContext.IsPublishMode)
+ {
+ var installerName = $"{resource.Resource.Name}-npm-install";
+ var installer = new NodeInstallerResource(installerName, resource.Resource.WorkingDirectory);
+
+ var installerBuilder = resource.ApplicationBuilder.AddResource(installer)
+ .WithCommand("npm")
+ .WithArgs([useCI ? "ci" : "install"])
+ .WithParentRelationship(resource.Resource)
+ .ExcludeFromManifest();
+
+ // Make the parent resource wait for the installer to complete
+ resource.WaitForCompletion(installerBuilder);
+
+ configureInstaller?.Invoke(installerBuilder);
+
+ resource.WithAnnotation(new JavaScriptPackageInstallerAnnotation(installer));
+ }
+
+ return resource;
+ }
+
+ private static IResourceBuilder AddNpmPackageManagerAnnotation(this IResourceBuilder resource, bool useCI) where TResource : NodeAppResource
+ {
+ resource.WithCommand("npm");
+ resource.WithAnnotation(new JavaScriptPackageManagerAnnotation("npm")
+ {
+ InstallCommandLineArgs = [useCI ? "ci" : "install"],
+ RunCommandLineArgs = ["run"],
+ BuildCommandLineArgs = ["run", "build"]
+ });
+
+ return resource;
+ }
}
diff --git a/src/Aspire.Hosting.NodeJs/NodeInstallerResource.cs b/src/Aspire.Hosting.NodeJs/NodeInstallerResource.cs
new file mode 100644
index 00000000000..c55bb5a6432
--- /dev/null
+++ b/src/Aspire.Hosting.NodeJs/NodeInstallerResource.cs
@@ -0,0 +1,14 @@
+// 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;
+
+///
+/// A resource that represents a package installer for a node app.
+///
+/// The name of the resource.
+/// The working directory to use for the command.
+public class NodeInstallerResource(string name, string workingDirectory)
+ : ExecutableResource(name, "node", workingDirectory);
diff --git a/src/Aspire.Hosting.NodeJs/ViteAppResource.cs b/src/Aspire.Hosting.NodeJs/ViteAppResource.cs
new file mode 100644
index 00000000000..6ca2b1690dc
--- /dev/null
+++ b/src/Aspire.Hosting.NodeJs/ViteAppResource.cs
@@ -0,0 +1,13 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.NodeJs;
+
+///
+/// Represents a Vite application resource that can be managed and executed within a Node.js environment.
+///
+/// The unique name used to identify the Vite application resource.
+/// 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);
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 3d92e979c79..4824a97aa32 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
@@ -4,7 +4,6 @@
#if UseRedisCache
#:package Aspire.Hosting.Redis@!!REPLACE_WITH_LATEST_VERSION!!
#endif
-#:package CommunityToolkit.Aspire.Hosting.NodeJS.Extensions@9.8.0
#pragma warning disable ASPIREHOSTINGPYTHON001
@@ -29,7 +28,7 @@
});
builder.AddViteApp("frontend", "./frontend")
- .WithNpmPackageInstallation()
+ .WithNpmPackageManager()
.WithReference(apiService)
.WaitFor(apiService);
diff --git a/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs
new file mode 100644
index 00000000000..155f723b657
--- /dev/null
+++ b/tests/Aspire.Hosting.NodeJs.Tests/AddViteAppTests.cs
@@ -0,0 +1,55 @@
+// 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.Utils;
+
+namespace Aspire.Hosting.NodeJs.Tests;
+
+public class AddViteAppTests
+{
+ [Fact]
+ public async Task VerifyDefaultDockerfile()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithResourceCleanUp(true);
+
+ var workingDirectory = AppContext.BaseDirectory;
+ var nodeApp = builder.AddViteApp("vite", "vite")
+ .WithNpmPackageManager();
+
+ var manifest = await ManifestUtils.GetManifest(nodeApp.Resource);
+
+ var expectedManifest = $$"""
+ {
+ "type": "container.v1",
+ "build": {
+ "context": "../../../../../tests/Aspire.Hosting.Tests/vite",
+ "dockerfile": "vite.Dockerfile"
+ },
+ "env": {
+ "NODE_ENV": "production",
+ "PORT": "{vite.bindings.http.targetPort}"
+ },
+ "bindings": {
+ "http": {
+ "scheme": "http",
+ "protocol": "tcp",
+ "transport": "http",
+ "targetPort": 8000
+ }
+ }
+ }
+ """;
+ Assert.Equal(expectedManifest, manifest.ToString());
+
+ var dockerfileContents = File.ReadAllText("vite.Dockerfile");
+ var expectedDockerfile = $$"""
+ FROM node:22-slim
+ WORKDIR /app
+ COPY . .
+ RUN npm install
+ RUN npm run build
+
+ """.Replace("\r\n", "\n");
+ Assert.Equal(expectedDockerfile, dockerfileContents);
+ }
+}
diff --git a/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs
new file mode 100644
index 00000000000..4beae9b1200
--- /dev/null
+++ b/tests/Aspire.Hosting.NodeJs.Tests/IntegrationTests.cs
@@ -0,0 +1,81 @@
+// 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 Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Hosting.NodeJs.Tests;
+
+///
+/// Integration test that demonstrates the new resource-based package installer architecture.
+/// This shows how installer resources appear as separate resources in the application model.
+///
+public class IntegrationTests
+{
+ [Fact]
+ public void ResourceBasedPackageInstallersAppearInApplicationModel()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ // Add a vite app with the npm package manager
+ builder.AddViteApp("vite-app", "./frontend")
+ .WithNpmPackageManager(useCI: true);
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ // Verify all Node.js app resources are present
+ var nodeResources = appModel.Resources.OfType().ToList();
+ Assert.Single(nodeResources);
+
+ // Verify all installer resources are present as separate resources
+ var npmInstallers = appModel.Resources.OfType().ToList();
+
+ Assert.Single(npmInstallers);
+
+ // Verify installer resources have expected names (would appear on dashboard)
+ Assert.Equal("vite-app-npm-install", npmInstallers[0].Name);
+
+ // Verify parent-child relationships
+ foreach (var installer in npmInstallers.Cast())
+ {
+ Assert.True(installer.TryGetAnnotationsOfType(out var relationships));
+ Assert.Single(relationships);
+ Assert.Equal("Parent", relationships.First().Type);
+ }
+
+ // Verify all Node.js apps wait for their installers
+ foreach (var nodeApp in nodeResources)
+ {
+ Assert.True(nodeApp.TryGetAnnotationsOfType(out var waitAnnotations));
+ Assert.Single(waitAnnotations);
+
+ var waitedResource = waitAnnotations.First().Resource;
+ Assert.True(waitedResource is NodeInstallerResource);
+ }
+ }
+
+ [Fact]
+ public void InstallerResourcesHaveCorrectExecutableConfiguration()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddNpmApp("test-app", "./test")
+ .WithNpmPackageManager(useCI: true);
+
+ using var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ 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());
+ Assert.Equal(parentApp.WorkingDirectory, installer.WorkingDirectory);
+
+ // Verify command arguments are configured
+ Assert.True(installer.TryGetAnnotationsOfType(out var argsAnnotations));
+ }
+}
diff --git a/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs
new file mode 100644
index 00000000000..5bf8abd794d
--- /dev/null
+++ b/tests/Aspire.Hosting.NodeJs.Tests/PackageInstallationTests.cs
@@ -0,0 +1,121 @@
+// 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 Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Hosting.NodeJs.Tests;
+
+public class PackageInstallationTests
+{
+ ///
+ /// This test validates that the WithNpmPackageManager method creates
+ /// installer resources with proper arguments and relationships.
+ ///
+ [Fact]
+ public async Task WithNpmPackageManager_CanBeConfiguredWithInstallAndCIOptions()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var nodeApp = builder.AddNpmApp("test-app", "./test-app");
+ var nodeApp2 = builder.AddNpmApp("test-app-ci", "./test-app-ci");
+
+ // Test that both configurations can be set up without errors
+ nodeApp.WithNpmPackageManager(useCI: false); // Uses npm install
+ nodeApp2.WithNpmPackageManager(useCI: true); // Uses npm ci
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var nodeResources = appModel.Resources.OfType().ToList();
+ var installerResources = appModel.Resources.OfType().ToList();
+
+ Assert.Equal(2, nodeResources.Count);
+ Assert.Equal(2, installerResources.Count);
+ Assert.All(nodeResources, resource => Assert.Equal("npm", resource.Command));
+
+ // Verify install vs ci commands
+ var installResource = installerResources.Single(r => r.Name == "test-app-npm-install");
+ var ciResource = installerResources.Single(r => r.Name == "test-app-ci-npm-install");
+
+ Assert.Equal("npm", installResource.Command);
+ var args = await installResource.GetArgumentValuesAsync();
+ Assert.Single(args);
+ Assert.Equal("install", args[0]);
+
+ Assert.Equal("npm", ciResource.Command);
+ args = await ciResource.GetArgumentValuesAsync();
+ Assert.Single(args);
+ Assert.Equal("ci", args[0]);
+ }
+
+ [Fact]
+ public void WithNpmPackageManager_ExcludedFromPublishMode()
+ {
+ var builder = DistributedApplication.CreateBuilder(["Publishing:Publisher=manifest", "Publishing:OutputPath=./publish"]);
+
+ var nodeApp = builder.AddNpmApp("test-app", "./test-app");
+ nodeApp.WithNpmPackageManager(useCI: false);
+
+ 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 NO installer resource was created in publish mode
+ var installerResources = appModel.Resources.OfType().ToList();
+ Assert.Empty(installerResources);
+
+ // Verify no wait annotations were added
+ Assert.False(nodeResource.TryGetAnnotationsOfType(out _));
+ }
+
+ [Fact]
+ public async Task WithNpmPackageManager_CanAcceptAdditionalArgs()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var nodeApp = builder.AddNpmApp("test-app", "./test-app");
+ var nodeAppWithArgs = builder.AddNpmApp("test-app-args", "./test-app-args");
+
+ // Test npm install with additional args
+ nodeApp.WithNpmPackageManager(useCI: false, configureInstaller: installerBuilder =>
+ {
+ installerBuilder.WithArgs("--legacy-peer-deps");
+ });
+ nodeAppWithArgs.WithNpmPackageManager(useCI: true, configureInstaller: installerBuilder =>
+ {
+ installerBuilder.WithArgs("--verbose", "--no-optional");
+ });
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+ var installerResources = appModel.Resources.OfType().ToList();
+
+ Assert.Equal(2, installerResources.Count);
+
+ var installResource = installerResources.Single(r => r.Name == "test-app-npm-install");
+ var ciResource = installerResources.Single(r => r.Name == "test-app-args-npm-install");
+
+ // Verify install command with additional args
+ var installArgs = await installResource.GetArgumentValuesAsync();
+ Assert.Collection(
+ installArgs,
+ arg => Assert.Equal("install", arg),
+ arg => Assert.Equal("--legacy-peer-deps", arg)
+ );
+
+ // Verify ci command with additional args
+ var ciArgs = await ciResource.GetArgumentValuesAsync();
+ Assert.Collection(
+ ciArgs,
+ arg => Assert.Equal("ci", arg),
+ arg => Assert.Equal("--verbose", arg),
+ arg => Assert.Equal("--no-optional", arg)
+ );
+ }
+}
diff --git a/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs b/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs
new file mode 100644
index 00000000000..2d0da25f65d
--- /dev/null
+++ b/tests/Aspire.Hosting.NodeJs.Tests/ResourceCreationTests.cs
@@ -0,0 +1,214 @@
+// 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 Microsoft.Extensions.DependencyInjection;
+
+namespace Aspire.Hosting.NodeJs.Tests;
+
+public class ResourceCreationTests
+{
+ [Fact]
+ public void DefaultViteAppUsesNpm()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddViteApp("vite", "vite");
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = appModel.Resources.OfType().SingleOrDefault();
+
+ Assert.NotNull(resource);
+
+ Assert.Equal("npm", resource.Command);
+ }
+
+ [Fact]
+ public void ViteAppUsesSpecifiedWorkingDirectory()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddViteApp("vite", "test");
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = appModel.Resources.OfType().SingleOrDefault();
+
+ Assert.NotNull(resource);
+
+ Assert.Equal(Path.Combine(builder.AppHostDirectory, "test"), resource.WorkingDirectory);
+ }
+
+ [Fact]
+ public void ViteAppHasExposedHttpEndpoints()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddViteApp("vite", "vite");
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = appModel.Resources.OfType().SingleOrDefault();
+
+ Assert.NotNull(resource);
+
+ Assert.True(resource.TryGetAnnotationsOfType(out var endpoints));
+
+ Assert.Contains(endpoints, e => e.UriScheme == "http");
+ }
+
+ [Fact]
+ public void ViteAppHasExposedHttpsEndpoints()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddViteApp("vite", "vite", useHttps: true);
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = appModel.Resources.OfType().SingleOrDefault();
+
+ Assert.NotNull(resource);
+
+ Assert.True(resource.TryGetAnnotationsOfType(out var endpoints));
+
+ Assert.Contains(endpoints, e => e.UriScheme == "https");
+ }
+
+ [Fact]
+ public void ViteAppDoesNotExposeExternalHttpEndpointsByDefault()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddViteApp("vite", "vite");
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = appModel.Resources.OfType().SingleOrDefault();
+
+ Assert.NotNull(resource);
+
+ Assert.True(resource.TryGetAnnotationsOfType(out var endpoints));
+
+ Assert.DoesNotContain(endpoints, e => e.IsExternal);
+ }
+
+ [Fact]
+ public async Task WithNpmPackageManagerDefaultsToInstallCommand()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var nodeApp = builder.AddNpmApp("test-app", "./test-app");
+
+ // Add package installation with default settings (should use npm install, not ci)
+ nodeApp.WithNpmPackageManager(useCI: false);
+
+ 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 installer resource was created
+ var installerResource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal("test-app-npm-install", installerResource.Name);
+ Assert.Equal("npm", installerResource.Command);
+ var args = await installerResource.GetArgumentValuesAsync();
+ Assert.Single(args);
+ Assert.Equal("install", args[0]);
+
+ // Verify the parent-child relationship
+ Assert.True(installerResource.TryGetAnnotationsOfType(out var relationships));
+ var relationship = Assert.Single(relationships);
+ Assert.Same(nodeResource, relationship.Resource);
+ Assert.Equal("Parent", relationship.Type);
+
+ // Verify the wait annotation on the parent
+ Assert.True(nodeResource.TryGetAnnotationsOfType(out var waitAnnotations));
+ var waitAnnotation = Assert.Single(waitAnnotations);
+ Assert.Same(installerResource, waitAnnotation.Resource);
+ }
+
+ [Fact]
+ public async Task WithNpmPackageManagerCanUseCICommand()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ var nodeApp = builder.AddNpmApp("test-app", "./test-app");
+
+ // Add package installation with CI enabled
+ nodeApp.WithNpmPackageManager(useCI: 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 installer resource was created with CI enabled
+ var installerResource = Assert.Single(appModel.Resources.OfType());
+ Assert.Equal("test-app-npm-install", installerResource.Name);
+ Assert.Equal("npm", installerResource.Command);
+ var args = await installerResource.GetArgumentValuesAsync();
+ Assert.Single(args);
+ Assert.Equal("ci", args[0]);
+
+ // Verify the parent-child relationship
+ Assert.True(installerResource.TryGetAnnotationsOfType(out var relationships));
+ var relationship = Assert.Single(relationships);
+ Assert.Same(nodeResource, relationship.Resource);
+ Assert.Equal("Parent", relationship.Type);
+
+ // Verify the wait annotation on the parent
+ Assert.True(nodeResource.TryGetAnnotationsOfType(out var waitAnnotations));
+ var waitAnnotation = Assert.Single(waitAnnotations);
+ Assert.Same(installerResource, waitAnnotation.Resource);
+ }
+
+ [Fact]
+ public void ViteAppConfiguresPortFromEnvironment()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ builder.AddViteApp("vite", "vite");
+
+ using var app = builder.Build();
+
+ var appModel = app.Services.GetRequiredService();
+
+ var resource = Assert.Single(appModel.Resources.OfType());
+
+ // Verify that command line arguments callback is configured
+ Assert.True(resource.TryGetAnnotationsOfType(out var argsCallbackAnnotations));
+ List