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 args = []; + var ctx = new CommandLineArgsCallbackContext(args); + + foreach (var annotation in argsCallbackAnnotations) + { + annotation.Callback(ctx); + } + + Assert.Collection(args, + arg => Assert.Equal("run", arg), + arg => Assert.Equal("dev", arg), + arg => Assert.Equal("--", arg), + arg => Assert.Equal("--port", arg), + arg => Assert.IsType(arg) + ); + } +}