From cd4540c4169e6253648c87adf6cdf44b07a4e31f Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Wed, 23 Sep 2026 09:10:06 -0700 Subject: [PATCH 1/9] Change StabilizePackageVersion condition to true (#20368) ## Description Please include a summary of the changes and the related issue. Please also include relevant motivation and context. List any dependencies that are required for this change. Fixes # (issue) ## Checklist - Is this feature complete? - [ ] Yes. Ready to ship. - [ ] No. Follow-up changes expected. - Are you including unit tests for the changes and scenario tests if relevant? - [ ] Yes - [ ] No - Did you add public API? - [ ] Yes - If yes, did you have an API Review for it? - [ ] Yes - [ ] No - Did you add `` and `` elements on your triple slash comments? - [ ] Yes - [ ] No - [ ] No - Does the change make any security assumptions or guarantees? - [ ] Yes - If yes, have you done a threat model and had a security review? - [ ] Yes - [ ] No - [ ] No --- eng/Versions.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/Versions.props b/eng/Versions.props index 8ecf73d4007..012356154f7 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -21,7 +21,7 @@ 2.4.0 18.10.0 - false + true release From 6aa31cdd0172735e0b214f948a41b172b821210e Mon Sep 17 00:00:00 2001 From: Eric Erhardt Date: Wed, 23 Sep 2026 13:51:34 -0500 Subject: [PATCH 2/9] [release/13.6] Move bundled NuGet operations into Aspire CLI (#20391) Backport of #19847 to release/13.6 /cc @eerhardt ## Customer Impact Bundled (Native AOT) Aspire CLI installs run NuGet search, restore, and manifest generation through the `aspire-managed` helper, which pollutes the dependencies in `aspire-managed`. As a result, user's integrations dependencies can get mangled with NuGet's dependencies. This change moves those operations into the CLI process, which calls NuGet.Client directly with credential providers enabled. It also removes a helper-process launch from every NuGet operation. Everything else behaves as before. ## Testing - Unit tests for the in-process client and its callers: search/restore/manifest parity, source mapping, `NUGET_PACKAGES` and `globalPackagesFolder`, signature-verification scoping, and restore-cache reuse. On release/13.6, all 221 tests in the affected `Aspire.Cli.Tests` classes pass (2 skipped), and `Aspire.Managed.Tests` and `Aspire.Hosting.RemoteHost.Tests` pass. - Native AOT publish on release/13.6 produces no IL diagnostics. - Full CI passed on main. - Manual end-to-end validation of the PR build against the main daily build ([report](https://github.com/microsoft/aspire/pull/19847#issuecomment-5797751726)) covered: - .NET and TypeScript create, search, add, update, and start - cold and warm restore - an authenticated Azure Artifacts feed - package source mapping, and search with one feed unreachable - RID-specific, native, and satellite assets - `aspire doctor` right after a fresh install Search results, manifests, and generated TypeScript were identical to main. ## Risk Medium. The change is large (42 files) and replaces the NuGet search, restore, and manifest code that every bundled CLI uses, and it moves the CLI to NuGet.Client 7.12.0-rc.25. It was checked line by line against the helper and compared end to end with main. The only intended behavior change is credential-provider support. One side effect: a feed that can't be authenticated non-interactively now waits on the credential provider, as `dotnet restore` does, instead of failing immediately with 401. ## Regression? No. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: baa86aab-4a9f-44c7-a92e-34f6498c9e4e --- NuGet.config | 37 +- docs/specs/bundle.md | 15 +- eng/Versions.props | 5 + extension/schemas/aspire-config.schema.json | 2 +- .../aspire-global-settings.schema.json | 2 +- extension/schemas/aspire-settings.schema.json | 2 +- src/Aspire.Cli/Aspire.Cli.csproj | 56 + src/Aspire.Cli/DotNet/DotNetCliRunner.cs | 4 +- src/Aspire.Cli/KnownFeatures.cs | 2 +- .../NuGet/BundleNuGetPackageCache.cs | 302 ++-- src/Aspire.Cli/NuGet/BundleNuGetService.cs | 296 ++-- src/Aspire.Cli/NuGet/NuGetClient.cs | 771 +++++++++++ .../NuGet}/NuGetPackageAssetResolver.cs | 2 +- src/Aspire.Cli/NuGet/NuGetPackageCache.cs | 11 +- .../NuGetSignatureVerificationEnabler.cs | 58 +- src/Aspire.Cli/Program.cs | 1 + .../DcpConnectionHealthCheck.cs | 20 + src/Aspire.Managed/Aspire.Managed.csproj | 21 - .../NuGet/Commands/ManifestCommand.cs | 159 --- .../NuGet/Commands/RestoreCommand.cs | 347 ----- .../NuGet/Commands/SearchCommand.cs | 349 ----- src/Aspire.Managed/NuGet/NuGetLogger.cs | 43 - .../NuGet/TrustedRootsHelper.cs | 230 ---- src/Aspire.Managed/Program.cs | 29 +- .../EmptyAppHostTemplateTests.cs | 8 +- .../Aspire.Cli.Tests/Aspire.Cli.Tests.csproj | 5 + ...uiltAppHostServerChannelResolutionTests.cs | 8 +- .../NuGet/BundleNuGetPackageCacheTests.cs | 450 ++++-- .../NuGet/BundleNuGetServiceTests.cs | 503 +++---- .../NuGet/NuGetClientTests.cs | 1215 +++++++++++++++++ .../NuGetSignatureVerificationEnablerTests.cs | 121 ++ .../Projects/AppHostServerSessionTests.cs | 7 +- .../Projects/PrebuiltAppHostServerTests.cs | 310 ++--- .../TestServices/FakeNuGetClient.cs | 106 ++ tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs | 39 +- .../Utils/DcpConnectionHealthCheckTests.cs | 73 +- .../Aspire.Hosting.RemoteHost.Tests.csproj | 1 - .../ManifestCommandTests.cs | 301 ---- .../RestoreCommandTests.cs | 123 -- .../NuGet/RestoreCommandTests.cs | 121 -- .../TerminalHostSignalTests.cs | 3 +- tools/CreateLayout/README.md | 6 +- 42 files changed, 3334 insertions(+), 2830 deletions(-) create mode 100644 src/Aspire.Cli/NuGet/NuGetClient.cs rename src/{Aspire.Managed/NuGet/Commands => Aspire.Cli/NuGet}/NuGetPackageAssetResolver.cs (99%) delete mode 100644 src/Aspire.Managed/NuGet/Commands/ManifestCommand.cs delete mode 100644 src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs delete mode 100644 src/Aspire.Managed/NuGet/Commands/SearchCommand.cs delete mode 100644 src/Aspire.Managed/NuGet/NuGetLogger.cs delete mode 100644 src/Aspire.Managed/NuGet/TrustedRootsHelper.cs create mode 100644 tests/Aspire.Cli.Tests/NuGet/NuGetClientTests.cs create mode 100644 tests/Aspire.Cli.Tests/TestServices/FakeNuGetClient.cs delete mode 100644 tests/Aspire.Hosting.RemoteHost.Tests/ManifestCommandTests.cs delete mode 100644 tests/Aspire.Hosting.RemoteHost.Tests/RestoreCommandTests.cs delete mode 100644 tests/Aspire.Managed.Tests/NuGet/RestoreCommandTests.cs diff --git a/NuGet.config b/NuGet.config index a0671a748fb..b6a9c24227a 100644 --- a/NuGet.config +++ b/NuGet.config @@ -32,11 +32,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -51,9 +85,6 @@ - - - diff --git a/docs/specs/bundle.md b/docs/specs/bundle.md index 6868ebe16e6..4b553dd89e1 100644 --- a/docs/specs/bundle.md +++ b/docs/specs/bundle.md @@ -27,13 +27,13 @@ The Aspire Bundle distributes the CLI with its runtime components: | Component | Deployment | Purpose | |-----------|------------|---------| -| `aspire[.exe]` | Native AOT | CLI, including native development-certificate management | -| `managed/aspire-managed[.exe]` | Self-contained single-file executable | AppHost Server, NuGet operations, terminal hosting, and a Dashboard compatibility forwarder | +| `aspire[.exe]` | Native AOT | CLI, including native development-certificate management and in-process NuGet operations | +| `managed/aspire-managed[.exe]` | Self-contained single-file executable | AppHost Server, terminal hosting, and a Dashboard compatibility forwarder | | `dashboard/Aspire.Dashboard[.exe]` | Native AOT | Dashboard web application | | `dashboard/wwwroot/` and native dependencies | Publish assets | Dashboard scripts, styles, fonts, images, and SQLite native library | | `dcp/` | Platform-specific native binaries | Developer Control Plane | -The bundle removes the need to acquire DCP and Dashboard separately when using the bundled components. Its pre-built AppHost Server and NuGet helper do not require a globally installed .NET SDK. +The bundle removes the need to acquire DCP and Dashboard separately when using the bundled components. Its pre-built AppHost Server and in-process NuGet client do not require a globally installed .NET SDK. This does **not** mean every application can run without other prerequisites. .NET application development still requires the appropriate SDK, guest languages require their own toolchains, and container resources require a container runtime. Integration packages and application dependencies must be available locally or restored from their configured sources. Offline operation requires those dependencies to have been acquired already. @@ -43,10 +43,11 @@ The managed helper contains its own runtime. The CLI and Dashboard are Native AO ```text aspire (Native AOT CLI) + | + +-- in-process NuGet.Client: package search, restore, and probe manifests | +-- managed/aspire-managed | +-- server: AppHost Server and integration loading - | +-- nuget: package search, restore, and probe manifests | +-- terminalhost: terminal hosting | +-- dashboard: compatibility forwarder to native Dashboard | @@ -231,7 +232,7 @@ The native-version gate and executable-selection behavior are defined in [Layout ## NuGet and AppHost Server -`aspire-managed nuget` provides package search, restore, and package probe-manifest generation without requiring a globally installed SDK. Its command definitions live in [Aspire.Managed/NuGet](../../src/Aspire.Managed/NuGet); use the subcommands' `--help` output for their current arguments. +The CLI calls NuGet.Client APIs in-process for package search, restore, asset selection, and package probe-manifest generation, without requiring a globally installed SDK or an `aspire-managed` subprocess. The implementation lives in [Aspire.Cli/NuGet](../../src/Aspire.Cli/NuGet). The pre-built AppHost Server loads the integrations required by the project. Package restore artifacts and probe manifests are cached beneath `/.aspire/integrations/`: @@ -345,7 +346,7 @@ For the complete tool contract, see [CreateLayout](../../tools/CreateLayout/READ - Extraction validates archive paths and links to prevent traversal outside the extraction root. Validate the staged layout before switching the active link. - Version-rooted paths and leases protect running consumers from cleanup and mixed-version component selection during updates. - Native Dashboard startup does not require a globally selected .NET runtime. The managed helper carries its runtime; SDK-based development remains a separate workflow. -- Package source configuration, authentication, and package trust remain the responsibility of the NuGet acquisition path. Bundling the NuGet helper does not remove those requirements. +- Package source configuration, authentication, and package trust remain the responsibility of the NuGet acquisition path. Running NuGet in-process does not remove those requirements. ## Validation @@ -361,4 +362,4 @@ Tests cover different parts of the distribution contract: | Native browser interactivity | `NativeAotDashboardTests`, including a working directory outside the publish directory and lease cleanup | | CI selection and archives | Infrastructure workflow-selection and archive-download tests | -The dedicated native-archive workflow explicitly opts into the native browser test's outerloop category. Native executables must be exercised on a compatible runner; a successful cross-publish is not evidence that the target binary ran. See [build-cli-native-archives.yml](../../.github/workflows/build-cli-native-archives.yml) for the current matrix and execution exclusions. \ No newline at end of file +The dedicated native-archive workflow explicitly opts into the native browser test's outerloop category. Native executables must be exercised on a compatible runner; a successful cross-publish is not evidence that the target binary ran. See [build-cli-native-archives.yml](../../.github/workflows/build-cli-native-archives.yml) for the current matrix and execution exclusions. diff --git a/eng/Versions.props b/eng/Versions.props index 012356154f7..4c258c5db0a 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -58,6 +58,11 @@ 1.8.3 2.23.32-alpha + + 7.12.0-rc.25 1.0.0 diff --git a/extension/schemas/aspire-config.schema.json b/extension/schemas/aspire-config.schema.json index 9b9ab230e36..bfea28738e9 100644 --- a/extension/schemas/aspire-config.schema.json +++ b/extension/schemas/aspire-config.schema.json @@ -154,7 +154,7 @@ ] } ], - "description": "Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for spawned processes", + "description": "Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for NuGet operations", "default": true }, "polyglotIntegrationFilterEnabled": { diff --git a/extension/schemas/aspire-global-settings.schema.json b/extension/schemas/aspire-global-settings.schema.json index 640eaa9cd62..4a43bee251a 100644 --- a/extension/schemas/aspire-global-settings.schema.json +++ b/extension/schemas/aspire-global-settings.schema.json @@ -106,7 +106,7 @@ ] } ], - "description": "Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for spawned processes", + "description": "Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for NuGet operations", "default": true }, "polyglotIntegrationFilterEnabled": { diff --git a/extension/schemas/aspire-settings.schema.json b/extension/schemas/aspire-settings.schema.json index 09fbc30c1cd..c8246c4cfd8 100644 --- a/extension/schemas/aspire-settings.schema.json +++ b/extension/schemas/aspire-settings.schema.json @@ -106,7 +106,7 @@ ] } ], - "description": "Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for spawned processes", + "description": "Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for NuGet operations", "default": true }, "polyglotIntegrationFilterEnabled": { diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 7257ea20e11..47b1fa4919f 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -22,6 +22,8 @@ not a library referenced by strong-named assemblies, so this is safe. --> false false + + $(NoWarn);IL2104;IL3053 Size $(DefineConstants);CLI true @@ -94,6 +96,50 @@ + + + + + + + + + + + + + + + + + + true + + + true + + + + + + + <_SdkTrustedRootsDir>$([System.IO.Path]::Combine($([System.IO.Path]::GetDirectoryName($(BundledRuntimeIdentifierGraphFile))), 'trustedroots')) + + + + + + @@ -110,9 +156,19 @@ + + + + + + + + diff --git a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs index b4958be762a..027304d64b4 100644 --- a/src/Aspire.Cli/DotNet/DotNetCliRunner.cs +++ b/src/Aspire.Cli/DotNet/DotNetCliRunner.cs @@ -88,8 +88,8 @@ internal sealed class ProcessInvocationOptions /// When true, the child is bound to the CLI's Windows kill-on-close job so the OS terminates /// it when the CLI exits unexpectedly (crash / SIGKILL), even if the child does not react /// to cancellation request. This is an OS-level, Windows-only crash-time safety net - /// for background helpers that must never outlive their parent — the aspire-managed NuGet - /// helper, the standalone dashboard, and the profiling collector — and, unlike + /// for background helpers that must never outlive their parent — such as the standalone dashboard + /// and profiling collector — and, unlike /// , it does not give the child a new console group. /// /// diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs index 3b99b915120..f6bb404000a 100644 --- a/src/Aspire.Cli/KnownFeatures.cs +++ b/src/Aspire.Cli/KnownFeatures.cs @@ -87,7 +87,7 @@ internal static class KnownFeatures [NuGetSignatureVerificationEnabled] = new( NuGetSignatureVerificationEnabled, - "Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for spawned processes", + "Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for NuGet operations", DefaultValue: true), [AspireSkillsRemoteFetchEnabled] = new( diff --git a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs index f13e2096ea9..1dccf410978 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs @@ -2,40 +2,24 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; -using System.Text.Json; -using System.Text.Json.Serialization; -using Aspire.Cli.Bundles; using Aspire.Cli.Configuration; -using Aspire.Cli.Layout; using Aspire.Cli.Resources; -using Aspire.Shared; using Microsoft.Extensions.Logging; using NuGetPackage = Aspire.Shared.NuGetPackageCli; namespace Aspire.Cli.NuGet; /// -/// NuGet package cache implementation that uses the bundle's NuGetHelper tool -/// instead of the .NET SDK's `dotnet package search` command. +/// NuGet package cache implementation for bundled CLIs, which cannot rely on the .NET SDK's +/// dotnet package search command. /// -internal sealed class BundleNuGetPackageCache : INuGetPackageCache +internal sealed class BundleNuGetPackageCache( + INuGetClient nuGetClient, + ILogger logger, + IFeatures features) : INuGetPackageCache { - private readonly IBundleService _bundleService; - private readonly LayoutProcessRunner _layoutProcessRunner; - private readonly ILogger _logger; - private readonly IFeatures _features; - - public BundleNuGetPackageCache( - IBundleService bundleService, - LayoutProcessRunner layoutProcessRunner, - ILogger logger, - IFeatures features) - { - _bundleService = bundleService; - _layoutProcessRunner = layoutProcessRunner; - _logger = logger; - _features = features; - } + // The aspire-managed helper was always invoked with --take 1000. + private const int SearchTake = 1000; public async Task> GetTemplatePackagesAsync( DirectoryInfo workingDirectory, @@ -43,15 +27,14 @@ public async Task> GetTemplatePackagesAsync( FileInfo? nugetConfigFile, CancellationToken cancellationToken) { - var packages = await SearchPackagesInternalAsync( + var packages = await SearchAsync( workingDirectory, - query: "Aspire.ProjectTemplates", - exactMatch: false, + "Aspire.ProjectTemplates", prerelease, nugetConfigFile, cancellationToken).ConfigureAwait(false); - return packages.Where(p => p.Id.Equals("Aspire.ProjectTemplates", StringComparison.OrdinalIgnoreCase)); + return packages.Where(package => package.Id.Equals("Aspire.ProjectTemplates", StringComparison.OrdinalIgnoreCase)); } public async Task> GetIntegrationPackagesAsync( @@ -60,10 +43,9 @@ public async Task> GetIntegrationPackagesAsync( FileInfo? nugetConfigFile, CancellationToken cancellationToken) { - var packages = await SearchPackagesInternalAsync( + var packages = await SearchAsync( workingDirectory, - query: "Aspire.Hosting", - exactMatch: false, + "Aspire.Hosting", prerelease, nugetConfigFile, cancellationToken).ConfigureAwait(false); @@ -77,15 +59,14 @@ public async Task> GetCliPackagesAsync( FileInfo? nugetConfigFile, CancellationToken cancellationToken) { - var packages = await SearchPackagesInternalAsync( + var packages = await SearchAsync( workingDirectory, - query: "Aspire.Cli", - exactMatch: false, + "Aspire.Cli", prerelease, nugetConfigFile, cancellationToken).ConfigureAwait(false); - return packages.Where(p => p.Id.Equals("Aspire.Cli", StringComparison.OrdinalIgnoreCase)); + return packages.Where(package => package.Id.Equals("Aspire.Cli", StringComparison.OrdinalIgnoreCase)); } public async Task> GetPackagesAsync( @@ -97,10 +78,9 @@ public async Task> GetPackagesAsync( bool useCache, CancellationToken cancellationToken) { - var packages = await SearchPackagesInternalAsync( + var packages = await SearchAsync( workingDirectory, - query: packageId, - exactMatch: false, + packageId, prerelease, nugetConfigFile, cancellationToken).ConfigureAwait(false); @@ -116,216 +96,94 @@ public async Task> GetPackageVersionsAsync( bool useCache, CancellationToken cancellationToken) { - var packages = await SearchPackagesInternalAsync( + var results = await SearchClientAsync( workingDirectory, - query: exactPackageId, - exactMatch: true, + exactPackageId, prerelease, nugetConfigFile, cancellationToken).ConfigureAwait(false); - bool FilterExactIdMatch(string? id) => string.Equals(id, exactPackageId, StringComparison.Ordinal); - return FilterPackages(packages, FilterExactIdMatch); + // The helper had no exact-match mode. The CLI ran an ordinary search for the package ID and expanded the + // versions of the one result whose ID matched it exactly, including its casing. + var exactMatch = results.FirstOrDefault(package => package.Id.Equals(exactPackageId, StringComparison.Ordinal)); + if (exactMatch is null) + { + return []; + } + + return exactMatch.AllVersions.Select(version => new NuGetPackage + { + Id = exactMatch.Id, + Version = version, + Source = exactMatch.Source + }).ToList(); } - private async Task> SearchPackagesInternalAsync( + private async Task> SearchAsync( DirectoryInfo workingDirectory, string query, - bool exactMatch, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken) { - // Ensure the bundle is extracted and lease the version before launching aspire-managed. - using var layoutLease = await _bundleService.EnsureExtractedAndAcquireLayoutAsync("cli", "nuget-search", cancellationToken).ConfigureAwait(false); - var layout = layoutLease?.Layout; - if (layout is null) - { - throw new InvalidOperationException("Bundle layout not found. Cannot perform NuGet search in bundle mode."); - } - - var managedPath = layout.GetManagedPath(); - if (managedPath is null || !File.Exists(managedPath)) - { - throw new InvalidOperationException("aspire-managed not found in layout."); - } - - // Build arguments for NuGet search command (via aspire-managed nuget subcommand) - var args = new List - { - "nuget", - "search", - "--query", query, - "--take", "1000", - "--format", "json" - }; - - if (prerelease) - { - args.Add("--prerelease"); - } - - // Pass working directory for nuget.config discovery - args.Add("--working-dir"); - args.Add(workingDirectory.FullName); - - // If explicit nuget.config is provided, use it - if (nugetConfigFile is not null) - { - args.Add("--nuget-config"); - args.Add(nugetConfigFile.FullName); - } - - // Enable verbose output for debugging - goes to stderr so won't mix with JSON on stdout - if (_logger.IsEnabled(LogLevel.Debug)) - { - args.Add("--verbose"); - } - - _logger.LogDebug("Running NuGet search via aspire-managed: {Query}", query); - _logger.LogDebug("aspire-managed path: {ManagedPath}", managedPath); - _logger.LogDebug("NuGet search args: {Args}", string.Join(" ", args)); - _logger.LogDebug("Working directory: {WorkingDir}", workingDirectory.FullName); - - var environmentVariables = new Dictionary(); - layoutLease?.AddEnvironment(environmentVariables); - - var (exitCode, output, error) = await _layoutProcessRunner.RunAsync( - managedPath, - args, - workingDirectory: workingDirectory.FullName, - environmentVariables: environmentVariables, - // A package search against a slow/unresponsive NuGet source can hang. LayoutProcessRunner uses - // this to bind the helper to the CLI's Windows kill-on-close job (and, on non-Windows, to - // instead arm the cooperative parent-liveness watchdog) so a hard-killed CLI cannot leak it. - killOnParentExit: true, - ct: cancellationToken).ConfigureAwait(false); - - // Log stderr output (verbose info from NuGetHelper) - if (!string.IsNullOrWhiteSpace(error)) - { - _logger.LogDebug("NuGetHelper stderr: {Error}", error); - } + var results = await SearchClientAsync( + workingDirectory, + query, + prerelease, + nugetConfigFile, + cancellationToken).ConfigureAwait(false); - if (exitCode != 0) + return results.Select(package => new NuGetPackage { - _logger.LogError("NuGet search failed with exit code {ExitCode}", exitCode); - _logger.LogError("NuGet search stderr: {Error}", error); - _logger.LogError("NuGet search stdout: {Output}", output); - throw new NuGetPackageCacheException(string.Format(CultureInfo.CurrentCulture, ErrorStrings.FailedToSearchForPackages, exitCode)); - } - - _logger.LogDebug("NuGet search returned {Length} bytes", output?.Length ?? 0); + Id = package.Id, + Version = package.Version, + Source = package.Source + }).ToList(); + } + private async Task> SearchClientAsync( + DirectoryInfo workingDirectory, + string query, + bool prerelease, + FileInfo? nugetConfigFile, + CancellationToken cancellationToken) + { try { - if (string.IsNullOrEmpty(output)) - { - _logger.LogWarning("NuGet search returned empty output"); - return []; - } - - // The aspire-managed helper writes the search result as JSON to stdout, but a NuGet credential - // provider can write diagnostics before or after that payload. Extract exactly one complete root object - // with the expected bundle-search shape so provider diagnostics cannot be deserialized as the result. - // See https://github.com/microsoft/aspire/issues/19339. - var result = JsonSerializer.Deserialize(PackageUpdateHelpers.ExtractJsonPayload(output, IsBundleSearchPayload), BundleSearchJsonContext.Default.BundleSearchResult); - if (result?.Packages is null) - { - return []; - } - - // Convert to NuGetPackage format - if (!exactMatch) - { - return result.Packages.Select(p => new NuGetPackage - { - Id = p.Id, - Version = p.Version, - Source = p.Source ?? string.Empty - }).ToList(); - } - else - { - var exactMatchResultPackage = result.Packages - .FirstOrDefault(p => p.Id.Equals(query, StringComparison.Ordinal)); - if (exactMatchResultPackage is null || exactMatchResultPackage.AllVersions is null) - { - return []; - } - return exactMatchResultPackage.AllVersions.Select(packageVersion => new NuGetPackage - { - Id = exactMatchResultPackage.Id, - Version = packageVersion, - Source = exactMatchResultPackage.Source ?? string.Empty - }).ToList(); - } + return await nuGetClient.SearchAsync( + query, + prerelease, + SearchTake, + explicitSources: [], + nugetConfigFile?.FullName, + workingDirectory.FullName, + cancellationToken).ConfigureAwait(false); } - catch (JsonException ex) + catch (NuGetOperationException ex) { - _logger.LogError(ex, "Failed to parse search results"); - throw new NuGetPackageCacheException(ErrorStrings.FailedToParsePackageSearchResults); + logger.LogError("NuGet search failed"); + logger.LogError("NuGet search stderr: {Error}", ex.Output); + + // The helper exited with code 1 for every search failure, and this is the message the CLI reported for it. + throw new NuGetPackageCacheException( + string.Format(CultureInfo.CurrentCulture, ErrorStrings.FailedToSearchForPackages, 1), + ex); } } - private static bool IsBundleSearchPayload(JsonElement root) + private IEnumerable FilterPackages( + IEnumerable packages, + Func? filter) { - return root.ValueKind == JsonValueKind.Object && - root.TryGetProperty("packages", out var packages) && - packages.ValueKind == JsonValueKind.Array && - root.TryGetProperty("totalHits", out var totalHits) && - totalHits.ValueKind == JsonValueKind.Number; + return filter is not null + ? packages.Where(package => filter(package.Id)) + : FilterDeprecatedPackages(packages.Where(package => PackageIdFilters.IsOfficialOrCommunityToolkitPackage(package.Id))); } - private IEnumerable FilterPackages(IEnumerable packages, Func? filter) + private IEnumerable FilterDeprecatedPackages(IEnumerable packages) { - var showDeprecatedPackages = _features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false); - var effectiveFilter = (NuGetPackage p) => - { - if (filter is not null) - { - return filter(p.Id); - } - - var isOfficialPackage = PackageIdFilters.IsOfficialOrCommunityToolkitPackage(p.Id); - - // Apply deprecated package filter unless the user wants to show deprecated packages - if (isOfficialPackage && !showDeprecatedPackages) - { - return !DeprecatedPackages.IsDeprecated(p.Id); - } - - return isOfficialPackage; - }; - - return packages.Where(effectiveFilter); + return features.IsFeatureEnabled(KnownFeatures.ShowDeprecatedPackages, defaultValue: false) + ? packages + : packages.Where(package => !DeprecatedPackages.IsDeprecated(package.Id)); } } - -#region JSON Models for NuGetHelper output - -internal sealed class BundleSearchResult -{ - public List? Packages { get; set; } - public int TotalHits { get; set; } -} - -internal sealed class BundlePackageInfo -{ - public string Id { get; set; } = ""; - public string Version { get; set; } = ""; - public string? Description { get; set; } - public string? Authors { get; set; } - public List? AllVersions { get; set; } - public string? Source { get; set; } - public bool Deprecated { get; set; } -} - -[JsonSerializable(typeof(BundleSearchResult))] -[JsonSerializable(typeof(BundlePackageInfo))] -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -internal sealed partial class BundleSearchJsonContext : JsonSerializerContext -{ -} - -#endregion diff --git a/src/Aspire.Cli/NuGet/BundleNuGetService.cs b/src/Aspire.Cli/NuGet/BundleNuGetService.cs index 675f4695802..6c8f4df0318 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetService.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetService.cs @@ -1,20 +1,20 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO.Hashing; +using System.Runtime.CompilerServices; +using System.Text; using System.Text.Json; -using Aspire.Cli.Bundles; -using Aspire.Cli.Configuration; -using Aspire.Cli.Layout; using Aspire.Cli.Utils; using Aspire.Hosting; using Aspire.Shared; using Microsoft.Extensions.Logging; +using NuGet.ProjectModel; namespace Aspire.Cli.NuGet; /// -/// Service for NuGet operations that works in bundle mode. -/// Uses the NuGetHelper tool via the layout runtime. +/// Restores integration packages and creates package probe manifests. /// internal interface INuGetService { @@ -40,31 +40,19 @@ Task RestorePackagesAsync( } /// -/// NuGet service implementation that uses the bundle's NuGetHelper tool. +/// Restores integration packages in-process through the NuGet client libraries. /// internal sealed class BundleNuGetService : INuGetService { - private readonly ILayoutDiscovery _layoutDiscovery; - private readonly LayoutProcessRunner _layoutProcessRunner; - private readonly IFeatures _features; - private readonly IEnvironment _environment; private readonly ILogger _logger; - private readonly IBundleService? _bundleService; + private readonly INuGetClient _nuGetClient; public BundleNuGetService( - ILayoutDiscovery layoutDiscovery, - LayoutProcessRunner layoutProcessRunner, - IFeatures features, - IEnvironment environment, ILogger logger, - IBundleService? bundleService = null) + INuGetClient nuGetClient) { - _layoutDiscovery = layoutDiscovery; - _layoutProcessRunner = layoutProcessRunner; - _features = features; - _environment = environment; _logger = logger; - _bundleService = bundleService; + _nuGetClient = nuGetClient; } public async Task RestorePackagesAsync( @@ -78,184 +66,78 @@ public async Task RestorePackagesAsync( { ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); - using var layoutLease = _bundleService is null - ? null - : await _bundleService.EnsureExtractedAndAcquireLayoutAsync("cli", "nuget-restore", ct).ConfigureAwait(false); - var layout = layoutLease?.Layout ?? _layoutDiscovery.DiscoverLayout(); - if (layout is null) - { - throw new InvalidOperationException("Bundle layout not found. Cannot perform NuGet restore in bundle mode."); - } - - var managedPath = layout.GetManagedPath(); - if (managedPath is null || !File.Exists(managedPath)) - { - throw new InvalidOperationException("aspire-managed not found in layout."); - } - var packageList = packages.ToList(); if (packageList.Count == 0) { throw new ArgumentException("At least one package is required", nameof(packages)); } - // Compute a hash for the package set to create a unique restore location. - var packageHash = ComputePackageHash(packageList, targetFramework, runtimeIdentifier, managedPath, sources); + var sourceList = sources?.ToArray(); + + // The restore is now performed by this process, so the CLI's implementation is what must invalidate cached + // manifests when it changes, just as the aspire-managed binary's size and timestamp did before. + var packageHash = ComputePackageHash( + packageList, + targetFramework, + runtimeIdentifier, + GetRestoreToolPath(), + sourceList); var restoreCacheDirectory = GetPackageRestoreCacheDirectory(workingDirectory); - var restoreDir = Path.Combine(restoreCacheDirectory, packageHash); - var objDir = Path.Combine(restoreDir, "obj"); - var manifestPath = Path.Combine(restoreDir, IntegrationPackageProbeManifest.FileName); - var assetsPath = Path.Combine(objDir, "project.assets.json"); - var lockPath = Path.Combine(restoreDir, "restore.lock"); + var restoreDirectory = Path.Combine(restoreCacheDirectory, packageHash); + var objectDirectory = Path.Combine(restoreDirectory, "obj"); + var manifestPath = Path.Combine(restoreDirectory, IntegrationPackageProbeManifest.FileName); + var lockPath = Path.Combine(restoreDirectory, "restore.lock"); // The package cache is shared by every AppHost in the workspace. Serialize the - // restore and manifest write so one process cannot start RemoteHost while another - // process is rewriting the same manifest or project.assets.json file. + // restore and manifest write so consumers never observe partially written files. using var fileLock = await FileLock.AcquireAsync(lockPath, ct).ConfigureAwait(false); - // Check if already restored after acquiring the lock because another process may - // have populated the shared cache while this process was waiting. if (File.Exists(manifestPath) && TryValidatePackageManifest(manifestPath, _logger)) { _logger.LogDebug("Using cached package manifest at {Path}", manifestPath); return manifestPath; } - Directory.CreateDirectory(objDir); - - // Step 1: Restore packages - // Prepend "nuget" subcommand for aspire-managed dispatch - var restoreArgs = new List - { - "nuget", - "restore", - "--output", objDir, - "--framework", targetFramework - }; - - if (!string.IsNullOrEmpty(runtimeIdentifier)) - { - restoreArgs.Add("--runtime-identifier"); - restoreArgs.Add(runtimeIdentifier); - } - - foreach (var (id, version) in packageList) - { - restoreArgs.Add("--package"); - restoreArgs.Add($"{id},{version}"); - } - - if (sources is not null) - { - foreach (var source in sources) - { - restoreArgs.Add("--source"); - restoreArgs.Add(source); - } - } - - // Pass working directory for nuget.config discovery. - restoreArgs.Add("--working-dir"); - restoreArgs.Add(workingDirectory); - - if (!string.IsNullOrEmpty(nugetConfigPath)) - { - restoreArgs.Add("--nuget-config"); - restoreArgs.Add(nugetConfigPath); - } - - // Enable verbose output for debugging - if (_logger.IsEnabled(LogLevel.Debug)) - { - restoreArgs.Add("--verbose"); - } - - _logger.LogDebug("Restoring {Count} packages", packageList.Count); - _logger.LogDebug("aspire-managed path: {ManagedPath}", managedPath); - if (_logger.IsEnabled(LogLevel.Debug)) - { - // Build a redacted copy of the args specifically for the log line so user-supplied - // credentialed feeds (e.g., `https://user:pat@host/v3/index.json`, SAS-token URLs) do - // not flow to the debug log alongside the rest of the restore invocation. The - // original `restoreArgs` list is still passed verbatim to the process below. - _logger.LogDebug("NuGet restore args: {Args}", string.Join(" ", BuildRedactedArgsForLog(restoreArgs))); - } - - var environmentVariables = new Dictionary(); - NuGetSignatureVerificationEnabler.Apply(environmentVariables, _features, _environment); - layoutLease?.AddEnvironment(environmentVariables); - - var (exitCode, output, error) = await _layoutProcessRunner.RunAsync( - managedPath, - restoreArgs, - environmentVariables: environmentVariables, - // A restore against a slow/unresponsive NuGet source can hang. LayoutProcessRunner uses this - // to bind the helper to the CLI's Windows kill-on-close job (and, on non-Windows, to instead - // arm the cooperative parent-liveness watchdog) so a hard-killed CLI cannot leak it. - killOnParentExit: true, - ct: ct); - - // Log stderr at debug level for diagnostics - if (!string.IsNullOrWhiteSpace(error)) - { - _logger.LogDebug("NuGetHelper restore stderr: {Error}", error); - } + Directory.CreateDirectory(objectDirectory); + _logger.LogDebug("Restoring {Count} integration packages in-process", packageList.Count); - if (exitCode != 0) - { - _logger.LogError("Package restore failed with exit code {ExitCode}", exitCode); - _logger.LogError("Package restore stderr: {Error}", error); - _logger.LogError("Package restore stdout: {Output}", output); - throw new InvalidOperationException($"Package restore failed: {error}"); - } - - // Step 2: Create package probe manifest - // Prepend "nuget" subcommand for aspire-managed dispatch - var manifestArgs = new List - { - "nuget", - "manifest", - "--assets", assetsPath, - "--output", manifestPath, - "--framework", targetFramework - }; - - if (!string.IsNullOrEmpty(runtimeIdentifier)) + // Failures keep the helper-era messages, which embed what the helper wrote to stderr, because + // PrebuiltAppHostServer shows exception messages to users. + try { - manifestArgs.Add("--runtime-identifier"); - manifestArgs.Add(runtimeIdentifier); + await _nuGetClient.RestoreAsync( + packageList, + targetFramework, + runtimeIdentifier, + objectDirectory, + sourceList ?? [], + nugetConfigPath, + workingDirectory, + ct).ConfigureAwait(false); } - - // Enable verbose output for debugging - if (_logger.IsEnabled(LogLevel.Debug)) + catch (NuGetOperationException ex) { - manifestArgs.Add("--verbose"); + _logger.LogError("Package restore failed"); + _logger.LogError("Package restore stderr: {Error}", ex.Output); + throw new InvalidOperationException($"Package restore failed: {ex.Output}", ex); } - _logger.LogDebug("Creating package manifest from {AssetsPath}", assetsPath); - _logger.LogDebug("NuGet manifest args: {Args}", string.Join(" ", manifestArgs)); - - (exitCode, output, error) = await _layoutProcessRunner.RunAsync( - managedPath, - manifestArgs, - environmentVariables: environmentVariables, - // Same rationale as the restore step above: keep this aspire-managed helper from outliving a - // hard-killed CLI (Windows kill-on-close job, or the cooperative watchdog on other hosts). - killOnParentExit: true, - ct: ct); - - // Log stderr at debug level for diagnostics - if (!string.IsNullOrWhiteSpace(error)) + // The manifest is built from the assets file the restore just wrote, so asset selection + // comes from NuGet rather than from a second walk over the package folders. + try { - _logger.LogDebug("NuGetHelper manifest stderr: {Error}", error); + await _nuGetClient.WriteManifestAsync( + Path.Combine(objectDirectory, LockFileFormat.AssetsFileName), + manifestPath, + targetFramework, + runtimeIdentifier, + ct).ConfigureAwait(false); } - - if (exitCode != 0) + catch (NuGetOperationException ex) { - _logger.LogError("Manifest creation failed with exit code {ExitCode}", exitCode); - _logger.LogError("Manifest creation stderr: {Error}", error); - _logger.LogError("Manifest creation stdout: {Output}", output); - throw new InvalidOperationException($"Manifest creation failed: {error}"); + _logger.LogError("Manifest creation failed"); + _logger.LogError("Manifest creation stderr: {Error}", ex.Output); + throw new InvalidOperationException($"Manifest creation failed: {ex.Output}", ex); } _logger.LogDebug("Package manifest created at {Path}", manifestPath); @@ -276,72 +158,64 @@ private static bool TryValidatePackageManifest(string manifestPath, ILogger logg } } - // Returns a redacted copy of the restore args suitable for debug logging. Replaces the value - // immediately following each `--source` token with the credential-safe form from - // PackageSourceRedactor. Built defensively to handle repeated `--source` flags and a missing - // trailing value at the end of the args list. - private static IReadOnlyList BuildRedactedArgsForLog(IReadOnlyList args) + /// + /// Gets the file containing the NuGet implementation that performs restores, for the restore cache key. + /// + /// + /// Native AOT compiles the implementation into the executable. A managed launch such as dotnet aspire.dll + /// runs it from the CLI assembly instead, and is then the dotnet host, + /// which does not change when the CLI is updated. + /// + internal static string? GetRestoreToolPath() { - var redacted = new List(args.Count); - for (var i = 0; i < args.Count; i++) + if (!RuntimeFeature.IsDynamicCodeSupported) { - redacted.Add(args[i]); - if (string.Equals(args[i], "--source", StringComparison.Ordinal) && i + 1 < args.Count) - { - redacted.Add(PackageSourceRedactor.RedactForDisplay(args[++i])); - } + return Environment.ProcessPath; } - return redacted; + // Assembly.Location is unavailable to single-file and Native AOT builds, so derive the path from the base + // directory the managed host loaded the CLI from. + var assemblyPath = Path.Combine(AppContext.BaseDirectory, $"{typeof(BundleNuGetService).Assembly.GetName().Name}.dll"); + return File.Exists(assemblyPath) ? assemblyPath : Environment.ProcessPath; } internal static string ComputePackageHash( List<(string Id, string Version)> packages, string tfm, string? runtimeIdentifier, - string? managedPath = null, + string? toolPath = null, IEnumerable? sources = null) { - var content = string.Join(";", packages.OrderBy(p => p.Id).Select(p => $"{p.Id}:{p.Version}")); + // Same inputs and ordering as the helper-era key, so the same restores share a cache entry. In particular, + // sources are sorted: their order does not change what NuGet restores. + var content = string.Join(";", packages.OrderBy(package => package.Id).Select(package => $"{package.Id}:{package.Version}")); content += $";tfm:{tfm}"; content += $";rid:{runtimeIdentifier ?? ""}"; - content += $";managed:{GetManagedToolFingerprint(managedPath)}"; + content += $";tool:{GetToolFingerprint(toolPath)}"; if (sources is not null) { - content += $";sources:{string.Join("|", sources.OrderBy(s => s, StringComparer.OrdinalIgnoreCase))}"; + content += $";sources:{string.Join("|", sources.OrderBy(source => source, StringComparer.OrdinalIgnoreCase))}"; } - // Use SHA256 for stable hash across processes/runtimes - var hashBytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(content)); - return Convert.ToHexString(hashBytes)[..16]; // Use first 16 chars (64 bits) for reasonable uniqueness + var hash = XxHash3.HashToUInt64(Encoding.UTF8.GetBytes(content)); + return hash.ToString("X16", System.Globalization.CultureInfo.InvariantCulture); } - private static string GetManagedToolFingerprint(string? managedPath) + private static string GetToolFingerprint(string? toolPath) { - if (string.IsNullOrEmpty(managedPath)) + if (string.IsNullOrEmpty(toolPath)) { return ""; } try { - var fileInfo = new FileInfo(managedPath); - if (!fileInfo.Exists) - { - return ""; - } - - return $"{fileInfo.Length}|{fileInfo.LastWriteTimeUtc.Ticks}"; - } - catch (IOException) - { - return ""; - } - catch (UnauthorizedAccessException) - { - return ""; + var fileInfo = new FileInfo(toolPath); + return fileInfo.Exists + ? $"{fileInfo.Length}|{fileInfo.LastWriteTimeUtc.Ticks}" + : ""; } - catch (NotSupportedException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) { return ""; } diff --git a/src/Aspire.Cli/NuGet/NuGetClient.cs b/src/Aspire.Cli/NuGet/NuGetClient.cs new file mode 100644 index 00000000000..36e85d0e996 --- /dev/null +++ b/src/Aspire.Cli/NuGet/NuGetClient.cs @@ -0,0 +1,771 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Text; +using Aspire.Cli.Configuration; +using Aspire.Cli.Utils; +using Aspire.Hosting; +using Microsoft.Extensions.Logging; +using NuGet.Commands; +using NuGet.Configuration; +using NuGet.Credentials; +using NuGet.Frameworks; +using NuGet.LibraryModel; +using NuGet.Packaging.Signing; +using NuGet.ProjectModel; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; +using NuGet.RuntimeModel; +using NuGet.Versioning; +using INuGetLogger = NuGet.Common.ILogger; +using NuGetLogLevel = NuGet.Common.LogLevel; +using NuGetLogMessage = NuGet.Common.ILogMessage; + +namespace Aspire.Cli.NuGet; + +/// +/// Runs the NuGet operations that the aspire-managed nuget helper used to run out of process. +/// +/// +/// Each operation mirrors one helper subcommand (restore, manifest, and search) so bundled CLIs +/// keep the same restore results, search results, and failure text. Failures are reported as +/// , whose is the text the helper +/// wrote to stderr. +/// +internal interface INuGetClient +{ + Task RestoreAsync( + IReadOnlyList<(string Id, string Version)> packages, + string framework, + string? runtimeIdentifier, + string outputPath, + IReadOnlyList sources, + string? nugetConfigPath, + string workingDirectory, + CancellationToken cancellationToken); + + Task WriteManifestAsync( + string assetsFilePath, + string outputPath, + string framework, + string? runtimeIdentifier, + CancellationToken cancellationToken); + + Task> SearchAsync( + string query, + bool prerelease, + int take, + IReadOnlyList explicitSources, + string? nugetConfigPath, + string workingDirectory, + CancellationToken cancellationToken); +} + +internal sealed record NuGetSearchResult( + string Id, + string Version, + string Source, + IReadOnlyList AllVersions); + +/// +/// Reports a failed in-process NuGet operation. +/// +/// The diagnostic text the aspire-managed nuget helper would have written to stderr. +/// The exception that caused the failure, if any. +internal sealed class NuGetOperationException(string output, Exception? innerException = null) + : Exception("NuGet operation failed.", innerException) +{ + /// + /// Gets the text the helper would have written to stderr. Callers surface it exactly as they surfaced the + /// helper's stderr, so user-visible failure messages are unchanged. + /// + public string Output { get; } = output; +} + +internal sealed class NuGetClient( + IFeatures features, + IEnvironment environment, + ILogger logger) : INuGetClient +{ + private const string NuGetOrgUrl = "https://api.nuget.org/v3/index.json"; + private const string RuntimeIdentifierGraphResourceName = "Aspire.Cli.RuntimeIdentifierGraph.json"; + private static readonly Lock s_operationLock = new(); + private static int s_activeOperationCount; + + // Output the helper never produced -- credential provider and trust store diagnostics -- only goes to the debug + // log. Keeping it out of each operation's captured output keeps failure messages identical to the helper's. + private readonly DiagnosticNuGetLogger _diagnosticLogger = new(logger); + + public async Task RestoreAsync( + IReadOnlyList<(string Id, string Version)> packages, + string framework, + string? runtimeIdentifier, + string outputPath, + IReadOnlyList sources, + string? nugetConfigPath, + string workingDirectory, + CancellationToken cancellationToken) + { + using var operation = BeginOperation(); + var output = new NuGetOperationOutput(logger); + + // The helper received DOTNET_NUGET_SIGNATURE_VERIFICATION only in its own environment. NuGet reads it from the + // process environment, so it has to be set here, but only for the duration of the restore. + using var signatureVerification = NuGetSignatureVerificationEnabler.ApplyToCurrentProcess(features, environment); + try + { + Directory.CreateDirectory(outputPath); + + // Restore is delegated to NuGet's RestoreRunner so the CLI resolves packages exactly the way + // the aspire-managed helper did. Reimplementing the graph walk here previously diverged from + // NuGet on RID-specific dependencies, placeholder assets, and version selection. + var machineWideSettings = new XPlatMachineWideSetting(); + var settings = Settings.LoadDefaultSettings(workingDirectory, nugetConfigPath, machineWideSettings); + + var packageSources = ResolvePackageSources(settings, sources); + var targetFramework = NuGetFramework.Parse(framework); + var packageSpec = BuildPackageSpec( + packages, + targetFramework, + runtimeIdentifier, + outputPath, + packageSources, + settings); + + var dgSpec = new DependencyGraphSpec(); + dgSpec.AddProject(packageSpec); + dgSpec.AddRestore(packageSpec.RestoreMetadata.ProjectUniqueName); + + var providerCache = new RestoreCommandProvidersCache(); + var dgProvider = new DependencyGraphSpecRequestProvider(providerCache, dgSpec, settings); + + using var cacheContext = new SourceCacheContext(); + var restoreArgs = new RestoreArgs + { + CacheContext = cacheContext, + Log = output, + PreLoadedRequestProviders = [dgProvider], + DisableParallel = Environment.ProcessorCount == 1, + AllowNoOp = false, + MachineWideSettings = machineWideSettings, + }; + + NativeAotNuGetTrustStore.Initialize(output, _diagnosticLogger, environment); + + var results = await RestoreRunner.RunAsync(restoreArgs, cancellationToken).ConfigureAwait(false); + var summary = results.Count > 0 ? results[0] : null; + + if (summary is null) + { + output.WriteLine("Error: Restore returned no results"); + throw new NuGetOperationException(output.Text); + } + + if (!summary.Success) + { + var errors = string.Join( + Environment.NewLine, + summary.Errors?.Select(error => error.Message) ?? ["Unknown error"]); + output.WriteLine($"Error: Restore failed: {errors}"); + throw new NuGetOperationException(output.Text); + } + } + catch (Exception ex) when (ex is not OperationCanceledException and not NuGetOperationException) + { + output.WriteLine($"Error: {ex.Message}"); + if (output.Verbose) + { + output.WriteLine(ex.ToString()); + } + + throw new NuGetOperationException(output.Text, ex); + } + } + + private static PackageSpec BuildPackageSpec( + IReadOnlyList<(string Id, string Version)> packages, + NuGetFramework framework, + string? runtimeIdentifier, + string outputPath, + List sources, + ISettings settings) + { + var projectName = "AspireRestore"; + var projectPath = Path.Combine(outputPath, "project.json"); + var tfmShort = framework.GetShortFolderName(); + var runtimeIdentifierGraphPath = !string.IsNullOrWhiteSpace(runtimeIdentifier) + ? EnsureRuntimeIdentifierGraphPath(outputPath) + : null; + + var dependencies = packages.Select(package => new LibraryDependency + { + LibraryRange = new LibraryRange( + package.Id, + VersionRange.Parse(package.Version), + LibraryDependencyTarget.Package) + }).ToImmutableArray(); + + var tfInfo = new TargetFrameworkInformation + { + FrameworkName = framework, + TargetAlias = tfmShort, + Dependencies = dependencies, + RuntimeIdentifierGraphPath = runtimeIdentifierGraphPath + }; + + var restoreMetadata = new ProjectRestoreMetadata + { + ProjectUniqueName = projectName, + ProjectName = projectName, + ProjectPath = projectPath, + ProjectStyle = ProjectStyle.PackageReference, + OutputPath = outputPath, + PackagesPath = SettingsUtility.GetGlobalPackagesFolder(settings), + OriginalTargetFrameworks = [tfmShort], + ConfigFilePaths = settings.GetConfigFilePaths().ToList(), + }; + + foreach (var source in sources) + { + restoreMetadata.Sources.Add(source); + } + + restoreMetadata.TargetFrameworks.Add(new ProjectRestoreMetadataFrameworkInfo(framework) + { + TargetAlias = tfmShort + }); + + var packageSpec = new PackageSpec([tfInfo]) + { + Name = projectName, + FilePath = projectPath, + RestoreMetadata = restoreMetadata, + }; + + if (!string.IsNullOrWhiteSpace(runtimeIdentifier)) + { + packageSpec.RuntimeGraph = new RuntimeGraph([new RuntimeDescription(runtimeIdentifier)]); + } + + return packageSpec; + } + + /// + /// Writes the SDK runtime identifier graph next to the restore output. NuGet reads it from disk + /// to expand RID-specific dependencies, and the Native AOT CLI has no SDK layout to point at. + /// + private static string EnsureRuntimeIdentifierGraphPath(string outputPath) + { + var graphPath = Path.Combine(outputPath, "RuntimeIdentifierGraph.json"); + if (File.Exists(graphPath)) + { + return graphPath; + } + + using var resourceStream = typeof(NuGetClient).Assembly.GetManifestResourceStream(RuntimeIdentifierGraphResourceName) + ?? throw new InvalidOperationException( + $"Embedded runtime identifier graph '{RuntimeIdentifierGraphResourceName}' was not found."); + using var fileStream = File.Create(graphPath); + resourceStream.CopyTo(fileStream); + + return graphPath; + } + + public async Task WriteManifestAsync( + string assetsFilePath, + string outputPath, + string framework, + string? runtimeIdentifier, + CancellationToken cancellationToken) + { + using var operation = BeginOperation(); + var output = new NuGetOperationOutput(logger); + try + { + // Asset selection is delegated to NuGet's own restore output. The assets file already + // records which assemblies, resources, and native libraries apply to this target, so the + // manifest reflects NuGet's RID fallback, `_._` placeholder, and locale semantics instead + // of a reimplementation of them. + var resolution = NuGetPackageAssetResolver.Resolve( + assetsFilePath, + framework, + runtimeIdentifier, + output.Verbose ? output.WriteDiagnostic : null); + + var managedAssemblies = new List(); + var nativeLibraries = new List(); + + foreach (var asset in resolution.Assets) + { + if (asset.IsManagedAssembly) + { + managedAssemblies.Add(new IntegrationPackageManagedAssembly + { + PackageId = asset.PackageId, + PackageVersion = asset.PackageVersion, + Name = Path.GetFileNameWithoutExtension(asset.RelativePath), + Culture = asset.Culture, + Path = asset.SourcePath + }); + } + + if (asset.IsNativeLibrary) + { + nativeLibraries.Add(new IntegrationPackageNativeLibrary + { + FileName = Path.GetFileName(asset.RelativePath), + Path = asset.SourcePath + }); + } + } + + var outputDirectory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + var manifest = IntegrationPackageProbeManifest.Create(managedAssemblies, nativeLibraries); + await IntegrationPackageProbeManifest.WriteAsync(outputPath, manifest, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + output.WriteLine($"Error: {ex.Message}"); + if (output.Verbose) + { + output.WriteLine(ex.StackTrace ?? string.Empty); + } + + throw new NuGetOperationException(output.Text, ex); + } + } + + public async Task> SearchAsync( + string query, + bool prerelease, + int take, + IReadOnlyList explicitSources, + string? nugetConfigPath, + string workingDirectory, + CancellationToken cancellationToken) + { + using var operation = BeginOperation(); + var output = new NuGetOperationOutput(logger); + try + { + var settings = LoadSettings(nugetConfigPath, workingDirectory); + var packageSources = LoadPackageSources(settings, explicitSources, output); + var searchFilter = new global::NuGet.Protocol.Core.Types.SearchFilter(prerelease); + + var searchResults = await Task.WhenAll(packageSources.Select(source => SearchSourceSafelyAsync( + source, + query, + searchFilter, + take, + output, + cancellationToken))).ConfigureAwait(false); + + // Shape the results exactly as the helper did, including its comparers. Versions are compared as strings + // rather than as NuGet versions because that is what decided which source's entry survived deduplication. + return searchResults + .SelectMany(packages => packages) + .GroupBy(package => package.Id, StringComparer.OrdinalIgnoreCase) + .Select(group => group.OrderByDescending(package => package.Version).First()) + .OrderBy(package => package.Id) + .Take(take) + .ToArray(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + output.WriteLine($"Error: {ex.Message}"); + if (output.Verbose) + { + output.WriteLine(ex.StackTrace ?? string.Empty); + } + + throw new NuGetOperationException(output.Text, ex); + } + } + + private static async Task> SearchSourceSafelyAsync( + PackageSource source, + string query, + global::NuGet.Protocol.Core.Types.SearchFilter filter, + int take, + NuGetOperationOutput output, + CancellationToken cancellationToken) + { + try + { + return await SearchSourceAsync(source, query, filter, take, output, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Like the helper, report the failed source and keep the results from the others. The helper wrote the + // exception message, but NuGet protocol failures format the feed URL into it -- often a derived resource + // URL rather than the configured source -- which would leak UserInfo/SAS credentials into ~/.aspire/logs. + output.WriteLine($"Warning: Failed to search {PackageSourceRedactor.RedactForDisplay(source.Name)}: {ex.GetType().Name}"); + return []; + } + } + + /// + /// Starts a NuGet operation and returns the scope that ends it. + /// + /// + /// NuGet keeps process-wide state between operations: the credential service with its cached credentials, + /// credential provider plugin processes, the HTTP throttle, and other caches. The helper discarded all of it by + /// exiting after every operation. The CLI can live much longer -- for example for an entire aspire run -- + /// so when the last overlapping operation ends, NuGet's own end-of-build reset is raised to discard that state the + /// same way. Operations are counted so one ending cannot reset state another is still using. + /// + internal IDisposable BeginOperation() + { + lock (s_operationLock) + { + s_activeOperationCount++; + + // Credential providers are a deliberate addition over the aspire-managed helper, which never set up NuGet's + // credential service and so could only authenticate with credentials stored in nuget.config. The service + // is set up per operation because the reset at the end of the previous one discards it; this is a no-op + // while an overlapping operation still has it set up. + DefaultCredentialServiceUtility.SetupDefaultCredentialService(_diagnosticLogger, nonInteractive: true); + } + + return new OperationScope(_diagnosticLogger); + } + + private sealed class OperationScope(INuGetLogger diagnosticLogger) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + lock (s_operationLock) + { + if (--s_activeOperationCount != 0) + { + return; + } + + // Raised under the lock so an operation starting concurrently cannot set up state that this reset + // then discards. + try + { + global::NuGet.Common.StaticState.RaiseBuildEnded(); + } + catch (Exception ex) + { + // Reset handlers tear down plugin processes. A failure there must not turn a completed operation + // into a failed one. + diagnosticLogger.LogDebug($"Failed to reset NuGet process state: {ex}"); + } + } + } + } + + private static async Task> SearchSourceAsync( + PackageSource source, + string query, + global::NuGet.Protocol.Core.Types.SearchFilter filter, + int take, + INuGetLogger nuGetLogger, + CancellationToken cancellationToken) + { + var repository = Repository.Factory.GetCoreV3(source); + var searchResource = await repository.GetResourceAsync(cancellationToken).ConfigureAwait(false); + if (searchResource is null) + { + return []; + } + + // The helper requested a single page starting at the first result; it never paged further. + var results = await searchResource.SearchAsync( + query, + filter, + skip: 0, + take, + nuGetLogger, + cancellationToken).ConfigureAwait(false); + + var packages = new List(); + foreach (var result in results) + { + var versions = await result.GetVersionsAsync().ConfigureAwait(false); + packages.Add(new NuGetSearchResult( + result.Identity.Id, + result.Identity.Version.ToString(), + source.Source, + versions?.Select(version => version.Version.ToString()).ToArray() ?? [])); + } + + return packages; + } + + private static ISettings LoadSettings(string? nugetConfigPath, string workingDirectory) + { + // A config path that does not exist falls back to normal discovery, as it did in the helper. + if (!string.IsNullOrEmpty(nugetConfigPath) && File.Exists(nugetConfigPath)) + { + return Settings.LoadSpecificSettings( + Path.GetDirectoryName(nugetConfigPath)!, + Path.GetFileName(nugetConfigPath)); + } + + return Settings.LoadDefaultSettings(workingDirectory); + } + + private static List LoadPackageSources( + ISettings settings, + IReadOnlyList explicitSources, + NuGetOperationOutput output) + { + var sources = explicitSources.Select(source => new PackageSource(source)).ToList(); + + if (sources.Count == 0) + { + sources.AddRange(new PackageSourceProvider(settings) + .LoadPackageSources() + .Where(source => source.IsEnabled)); + } + + if (sources.Count == 0) + { + sources.Add(new PackageSource(NuGetOrgUrl, "nuget.org")); + output.WriteLine("Note: No package sources configured, using nuget.org as fallback."); + } + + return sources; + } + + private static List ResolvePackageSources( + ISettings settings, + IReadOnlyList cliSources) + { + var sources = new PackageSourceProvider(settings) + .LoadPackageSources() + .Where(source => source.IsEnabled) + .ToList(); + + foreach (var cliSource in cliSources) + { + if (!sources.Any(source => source.Source.Equals(cliSource, StringComparison.OrdinalIgnoreCase))) + { + sources.Add(new PackageSource(cliSource)); + } + } + + if (!sources.Any(source => source.Source.Equals(NuGetOrgUrl, StringComparison.OrdinalIgnoreCase))) + { + sources.Add(new PackageSource(NuGetOrgUrl, "nuget.org")); + } + + return sources; + } + + /// + /// Records one operation's output the way the aspire-managed helper wrote it to stderr. + /// + /// + /// The helper's stderr was the only diagnostic channel back to the CLI: the CLI logged it and put it into the + /// exception message when the operation failed. This buffers the same text for + /// and also streams each entry to the debug log, where the CLI logged successful operations' stderr. + /// + private sealed class NuGetOperationOutput(ILogger logger) : INuGetLogger + { + private readonly Lock _lock = new(); + private readonly StringBuilder _text = new(); + + /// + /// Gets whether the helper would have run with --verbose. The CLI passed that flag exactly when its + /// logger had debug logging enabled. + /// + public bool Verbose { get; } = logger.IsEnabled(LogLevel.Debug); + + public string Text + { + get + { + lock (_lock) + { + return _text.ToString(); + } + } + } + + public void WriteLine(string text) + { + // The CLI read the helper's stderr line by line and re-joined it with AppendLine, which normalized any + // line breaks embedded in a message to Environment.NewLine. Split the same way so the text matches. + lock (_lock) + { + foreach (var line in text.ReplaceLineEndings("\n").Split('\n')) + { + _text.AppendLine(line); + } + } + + logger.LogDebug("{Message}", text); + } + + /// + /// Logs text the helper wrote to stdout. The CLI only surfaced stdout when an operation failed, so it goes to + /// the debug log without becoming part of . + /// + public void WriteDiagnostic(string text) => logger.LogDebug("{Message}", text); + + public void Log(NuGetLogLevel level, string data) + { + // Same filtering and prefixes as the helper's NuGet logger. + if (!Verbose && level < NuGetLogLevel.Warning) + { + return; + } + + var prefix = level switch + { + NuGetLogLevel.Error => "ERROR: ", + NuGetLogLevel.Warning => "WARNING: ", + _ => "" + }; + + WriteLine($"{prefix}{data}"); + } + + public void Log(NuGetLogMessage message) => Log(message.Level, message.Message); + + public Task LogAsync(NuGetLogLevel level, string data) + { + Log(level, data); + return Task.CompletedTask; + } + + public Task LogAsync(NuGetLogMessage message) + { + Log(message); + return Task.CompletedTask; + } + + public void LogDebug(string data) => Log(NuGetLogLevel.Debug, data); + public void LogError(string data) => Log(NuGetLogLevel.Error, data); + public void LogInformation(string data) => Log(NuGetLogLevel.Information, data); + public void LogInformationSummary(string data) => Log(NuGetLogLevel.Information, data); + public void LogMinimal(string data) => Log(NuGetLogLevel.Minimal, data); + public void LogVerbose(string data) => Log(NuGetLogLevel.Verbose, data); + public void LogWarning(string data) => Log(NuGetLogLevel.Warning, data); + } + + /// + /// Sends NuGet output that has no helper equivalent to the debug log only. + /// + private sealed class DiagnosticNuGetLogger(ILogger logger) : INuGetLogger + { + public void Log(NuGetLogLevel level, string data) => logger.LogDebug("{Message}", data); + public void Log(NuGetLogMessage message) => Log(message.Level, message.Message); + + public Task LogAsync(NuGetLogLevel level, string data) + { + Log(level, data); + return Task.CompletedTask; + } + + public Task LogAsync(NuGetLogMessage message) + { + Log(message); + return Task.CompletedTask; + } + + public void LogDebug(string data) => Log(NuGetLogLevel.Debug, data); + public void LogError(string data) => Log(NuGetLogLevel.Error, data); + public void LogInformation(string data) => Log(NuGetLogLevel.Information, data); + public void LogInformationSummary(string data) => Log(NuGetLogLevel.Information, data); + public void LogMinimal(string data) => Log(NuGetLogLevel.Minimal, data); + public void LogVerbose(string data) => Log(NuGetLogLevel.Verbose, data); + public void LogWarning(string data) => Log(NuGetLogLevel.Warning, data); + } + + private static class NativeAotNuGetTrustStore + { + private static readonly object s_lock = new(); + private static bool s_initialized; + + public static void Initialize(NuGetOperationOutput output, INuGetLogger diagnosticLogger, IEnvironment environment) + { + if (s_initialized || + !environment.IsLinux() || + !bool.TryParse(environment.GetEnvironmentVariable( + NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification), out var enabled) || + !enabled) + { + return; + } + + lock (s_lock) + { + if (s_initialized) + { + return; + } + + try + { + InitializeFromEmbeddedResources(diagnosticLogger); + s_initialized = true; + } + catch (Exception ex) + { + // Like the helper, report the failure but let the restore continue. NuGet can still succeed when + // these packages do not need verification or the system provides its own certificate bundles. + output.WriteLine($"WARNING: Failed to initialize NuGet trust store from embedded certificates: {ex}"); + } + } + } + + private static void InitializeFromEmbeddedResources(INuGetLogger diagnosticLogger) + { + var previousSdkRoot = AppContext.GetData("Microsoft.DotNet.Sdk.Root"); + var rootDirectory = Directory.CreateTempSubdirectory("aspire-nuget-trust-"); + try + { + var trustedRootsDirectory = Directory.CreateDirectory( + Path.Combine(rootDirectory.FullName, "trustedroots")); + WriteResource("codesignctl.pem", trustedRootsDirectory.FullName); + WriteResource("timestampctl.pem", trustedRootsDirectory.FullName); + + // NuGet resolves its fallback trust bundles under Microsoft.DotNet.Sdk.Root. + // Point it at the securely extracted embedded SDK bundles only while the factories initialize. + AppContext.SetData("Microsoft.DotNet.Sdk.Root", rootDirectory.FullName); + X509TrustStore.InitializeForDotNetSdk(diagnosticLogger); + } + finally + { + AppContext.SetData("Microsoft.DotNet.Sdk.Root", previousSdkRoot); + + // The factories hold the certificates they loaded, so a directory that cannot be removed is only a + // leftover temp file and must not undo a successful initialization. + try + { + rootDirectory.Delete(recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + diagnosticLogger.LogDebug($"Failed to delete temporary trust store directory '{rootDirectory.FullName}': {ex.Message}"); + } + } + } + + private static void WriteResource(string resourceName, string destinationDirectory) + { + using var resourceStream = typeof(NuGetClient).Assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Embedded NuGet trust root resource '{resourceName}' was not found."); + using var fileStream = File.Create(Path.Combine(destinationDirectory, resourceName)); + resourceStream.CopyTo(fileStream); + } + } +} diff --git a/src/Aspire.Managed/NuGet/Commands/NuGetPackageAssetResolver.cs b/src/Aspire.Cli/NuGet/NuGetPackageAssetResolver.cs similarity index 99% rename from src/Aspire.Managed/NuGet/Commands/NuGetPackageAssetResolver.cs rename to src/Aspire.Cli/NuGet/NuGetPackageAssetResolver.cs index 1da8ceb188a..cde718919b6 100644 --- a/src/Aspire.Managed/NuGet/Commands/NuGetPackageAssetResolver.cs +++ b/src/Aspire.Cli/NuGet/NuGetPackageAssetResolver.cs @@ -7,7 +7,7 @@ using NuGet.ProjectModel; using NuGet.RuntimeModel; -namespace Aspire.Managed.NuGet.Commands; +namespace Aspire.Cli.NuGet; internal sealed class NuGetPackageAssetResolution { diff --git a/src/Aspire.Cli/NuGet/NuGetPackageCache.cs b/src/Aspire.Cli/NuGet/NuGetPackageCache.cs index 8fb828080f8..4d7b865757a 100644 --- a/src/Aspire.Cli/NuGet/NuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/NuGetPackageCache.cs @@ -231,6 +231,15 @@ public async Task> GetPackageVersionsAsync(DirectoryIn } } -internal sealed class NuGetPackageCacheException(string message) : Exception(message) +internal sealed class NuGetPackageCacheException : Exception { + public NuGetPackageCacheException(string message) + : base(message) + { + } + + public NuGetPackageCacheException(string message, Exception innerException) + : base(message, innerException) + { + } } diff --git a/src/Aspire.Cli/NuGet/NuGetSignatureVerificationEnabler.cs b/src/Aspire.Cli/NuGet/NuGetSignatureVerificationEnabler.cs index f50edb8a897..d4f593b6895 100644 --- a/src/Aspire.Cli/NuGet/NuGetSignatureVerificationEnabler.cs +++ b/src/Aspire.Cli/NuGet/NuGetSignatureVerificationEnabler.cs @@ -6,13 +6,17 @@ namespace Aspire.Cli.NuGet; /// -/// Enables NuGet signature verification when spawning aspire-managed processes. +/// Enables NuGet signature verification for NuGet operations. /// Mirrors the .NET SDK's NuGetSignatureVerificationEnabler behavior. /// internal static class NuGetSignatureVerificationEnabler { internal const string DotNetNuGetSignatureVerification = "DOTNET_NUGET_SIGNATURE_VERIFICATION"; + private static readonly Lock s_currentProcessLock = new(); + private static int s_currentProcessScopeCount; + private static string? s_previousCurrentProcessValue; + /// /// Applies NuGet signature verification environment variables to the given dictionary. /// On Linux, sets DOTNET_NUGET_SIGNATURE_VERIFICATION to "true" unless the user @@ -38,4 +42,56 @@ public static void Apply(Dictionary environmentVariables, IFeatu environmentVariables[DotNetNuGetSignatureVerification] = effectiveValue; } + + /// + /// Applies NuGet signature verification to the current process until the returned scope is disposed. + /// + /// + /// The aspire-managed helper received this variable only in its own environment. The in-process client needs it + /// in the CLI's environment because NuGet reads it from there, so it is set for the duration of each operation and + /// the previous value is restored when the last overlapping scope ends. Otherwise it would leak into every process + /// the CLI starts afterwards. + /// + public static IDisposable ApplyToCurrentProcess(IFeatures features, IEnvironment environment) + { + lock (s_currentProcessLock) + { + if (s_currentProcessScopeCount++ == 0) + { + s_previousCurrentProcessValue = Environment.GetEnvironmentVariable(DotNetNuGetSignatureVerification); + + var environmentVariables = new Dictionary(); + Apply(environmentVariables, features, environment); + + if (environmentVariables.TryGetValue(DotNetNuGetSignatureVerification, out var value)) + { + Environment.SetEnvironmentVariable(DotNetNuGetSignatureVerification, value); + } + } + } + + return new CurrentProcessScope(); + } + + private sealed class CurrentProcessScope : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + lock (s_currentProcessLock) + { + if (--s_currentProcessScopeCount == 0) + { + Environment.SetEnvironmentVariable(DotNetNuGetSignatureVerification, s_previousCurrentProcessValue); + s_previousCurrentProcessValue = null; + } + } + } + } } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 7f19947110c..2a1b6411ea2 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -539,6 +539,7 @@ internal static async Task BuildApplicationAsync(string[] args, CliStartu // Bundle layout services (for polyglot apphost without .NET SDK). // Registered before NuGetPackageCache so the factory can choose implementation. builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Git repository operations. diff --git a/src/Aspire.Cli/Utils/EnvironmentChecker/DcpConnectionHealthCheck.cs b/src/Aspire.Cli/Utils/EnvironmentChecker/DcpConnectionHealthCheck.cs index 60ffd505bbe..296367d86ab 100644 --- a/src/Aspire.Cli/Utils/EnvironmentChecker/DcpConnectionHealthCheck.cs +++ b/src/Aspire.Cli/Utils/EnvironmentChecker/DcpConnectionHealthCheck.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using Aspire.Cli.Bundles; using Aspire.Cli.Layout; using Aspire.Cli.Resources; using Aspire.Shared; @@ -14,6 +15,7 @@ namespace Aspire.Cli.Utils.EnvironmentChecker; /// internal sealed class DcpConnectionHealthCheck( ILayoutDiscovery layoutDiscovery, + IBundleService bundleService, IDcpConnectionChecker connectionTester, CliExecutionContext executionContext, ILogger logger) : IEnvironmentCheck @@ -27,9 +29,23 @@ internal sealed class DcpConnectionHealthCheck( public async Task> CheckAsync(CancellationToken cancellationToken = default) { + BundleLayoutLease? layoutLease = null; try { + // An existing layout wins, which keeps an ASPIRE_DCP_PATH override and an already + // extracted bundle working exactly as before. var dcpDirectory = layoutDiscovery.GetComponentPath(LayoutComponent.Dcp, executionContext.WorkingDirectory.FullName); + if (string.IsNullOrWhiteSpace(dcpDirectory)) + { + // Layout discovery only finds a bundle that is already on disk. It used to be extracted + // by the time doctor ran because bundled NuGet search, which the background CLI update + // check runs on startup, extracted it before launching aspire-managed. NuGet now runs + // in-process and no longer does, so on a fresh install extract it here. The lease is + // held for the whole check because the connection probes below launch DCP from it. + layoutLease = await bundleService.EnsureExtractedAndAcquireLayoutAsync("cli", "doctor", cancellationToken).ConfigureAwait(false); + dcpDirectory = layoutLease?.Layout.GetDcpPath(); + } + if (string.IsNullOrWhiteSpace(dcpDirectory)) { logger.LogDebug("Skipping DCP connection health checks because no Aspire bundle layout was discovered."); @@ -96,6 +112,10 @@ public async Task> CheckAsync(Cancellation Details = ex.Message }]; } + finally + { + layoutLease?.Dispose(); + } } } diff --git a/src/Aspire.Managed/Aspire.Managed.csproj b/src/Aspire.Managed/Aspire.Managed.csproj index c62b60251f7..02a6493d24f 100644 --- a/src/Aspire.Managed/Aspire.Managed.csproj +++ b/src/Aspire.Managed/Aspire.Managed.csproj @@ -23,38 +23,17 @@ true true - - <_SdkTrustedRootsDir>$([System.IO.Path]::Combine($([System.IO.Path]::GetDirectoryName($(BundledRuntimeIdentifierGraphFile))), 'trustedroots')) - - - - - - - - - - - - - - - - - - - diff --git a/src/Aspire.Managed/NuGet/Commands/ManifestCommand.cs b/src/Aspire.Managed/NuGet/Commands/ManifestCommand.cs deleted file mode 100644 index b3d6bf84f17..00000000000 --- a/src/Aspire.Managed/NuGet/Commands/ManifestCommand.cs +++ /dev/null @@ -1,159 +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 System.CommandLine; -using System.Globalization; -using Aspire.Hosting; - -namespace Aspire.Managed.NuGet.Commands; - -/// -/// Manifest command - creates a package asset probe manifest from a project.assets.json file. -/// -public static class ManifestCommand -{ - /// - /// Creates the manifest command. - /// - public static Command Create() - { - var command = new Command("manifest", "Create package asset probe manifest from project.assets.json"); - - var assetsOption = new Option("--assets", "-a") - { - Description = "Path to project.assets.json file", - Required = true - }; - command.Options.Add(assetsOption); - - var outputOption = new Option("--output", "-o") - { - Description = "Output path for the package probe manifest", - Required = true - }; - command.Options.Add(outputOption); - - var frameworkOption = new Option("--framework", "-f") - { - Description = "Target framework (default: net10.0)", - DefaultValueFactory = _ => "net10.0" - }; - command.Options.Add(frameworkOption); - - var runtimeIdentifierOption = new Option("--runtime-identifier", "--rid") - { - Description = "Runtime identifier used to prefer runtime-specific assets (defaults to the current runtime)" - }; - command.Options.Add(runtimeIdentifierOption); - - var verboseOption = new Option("--verbose", "-v") - { - Description = "Enable verbose output" - }; - command.Options.Add(verboseOption); - - command.SetAction((parseResult, ct) => - { - var assetsPath = parseResult.GetValue(assetsOption)!; - var outputPath = parseResult.GetValue(outputOption)!; - var framework = parseResult.GetValue(frameworkOption)!; - var runtimeIdentifier = parseResult.GetValue(runtimeIdentifierOption); - var verbose = parseResult.GetValue(verboseOption); - - return ExecuteManifestAsync(assetsPath, outputPath, framework, runtimeIdentifier, verbose, ct); - }); - - return command; - } - - private static async Task ExecuteManifestAsync( - string assetsPath, - string outputPath, - string framework, - string? runtimeIdentifier, - bool verbose, - CancellationToken cancellationToken) - { - try - { - var resolution = NuGetPackageAssetResolver.Resolve( - assetsPath, - framework, - runtimeIdentifier, - verbose ? Console.WriteLine : null); - - if (verbose) - { - Console.WriteLine($"Using packages path: {resolution.PackagesPath}"); - Console.WriteLine($"Target framework: {resolution.TargetFramework}"); - Console.WriteLine($"Runtime identifier: {resolution.RuntimeIdentifier}"); - Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "Libraries: {0}", resolution.LibraryCount)); - } - - var manifest = CreateManifest(resolution.Assets); - var outputDirectory = Path.GetDirectoryName(outputPath); - if (!string.IsNullOrEmpty(outputDirectory)) - { - Directory.CreateDirectory(outputDirectory); - } - - await IntegrationPackageProbeManifest.WriteAsync(outputPath, manifest, cancellationToken).ConfigureAwait(false); - - Console.WriteLine(string.Format( - CultureInfo.InvariantCulture, - "Manifest created: {0} managed assemblies and {1} native libraries written to {2}", - manifest.ManagedAssemblies.Count, - manifest.NativeLibraries.Count, - outputPath)); - - if (resolution.SkippedPackageCount > 0 && verbose) - { - Console.WriteLine(string.Format(CultureInfo.InvariantCulture, " ({0} packages skipped - not found in cache)", resolution.SkippedPackageCount)); - } - - return 0; - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error: {ex.Message}"); - if (verbose) - { - Console.Error.WriteLine(ex.StackTrace); - } - - return 1; - } - } - - internal static IntegrationPackageProbeManifest CreateManifest(IEnumerable assets) - { - var managedAssemblies = new List(); - var nativeLibraries = new List(); - - foreach (var asset in assets) - { - if (asset.IsManagedAssembly) - { - managedAssemblies.Add(new IntegrationPackageManagedAssembly - { - PackageId = asset.PackageId, - PackageVersion = asset.PackageVersion, - Name = Path.GetFileNameWithoutExtension(asset.RelativePath), - Culture = asset.Culture, - Path = asset.SourcePath - }); - } - - if (asset.IsNativeLibrary) - { - nativeLibraries.Add(new IntegrationPackageNativeLibrary - { - FileName = Path.GetFileName(asset.RelativePath), - Path = asset.SourcePath - }); - } - } - - return IntegrationPackageProbeManifest.Create(managedAssemblies, nativeLibraries); - } -} diff --git a/src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs b/src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs deleted file mode 100644 index cc90a4202ed..00000000000 --- a/src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs +++ /dev/null @@ -1,347 +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 System.Collections.Immutable; -using System.CommandLine; -using NuGet.Commands; -using NuGet.Configuration; -using NuGet.Frameworks; -using NuGet.LibraryModel; -using NuGet.ProjectModel; -using NuGet.Protocol.Core.Types; -using NuGet.RuntimeModel; -using NuGet.Versioning; - -namespace Aspire.Managed.NuGet.Commands; - -/// -/// Restore command - restores NuGet packages without requiring a .csproj file. -/// Uses NuGet's RestoreRunner to produce a project.assets.json file. -/// -public static class RestoreCommand -{ - private const string RuntimeIdentifierGraphFileName = "RuntimeIdentifierGraph.json"; - private const string RuntimeIdentifierGraphResourceName = "Aspire.Managed.RuntimeIdentifierGraph.json"; - - /// - /// Creates the restore command. - /// - public static Command Create() - { - var command = new Command("restore", "Restore NuGet packages"); - - var packageOption = new Option("--package", "-p") - { - Description = "Package reference as 'PackageId,Version' (can specify multiple)", - Required = true, - AllowMultipleArgumentsPerToken = true - }; - command.Options.Add(packageOption); - - var frameworkOption = new Option("--framework", "-f") - { - Description = "Target framework (default: net10.0)", - DefaultValueFactory = _ => "net10.0" - }; - command.Options.Add(frameworkOption); - - var runtimeIdentifierOption = new Option("--runtime-identifier", "--rid") - { - Description = "Runtime identifier to restore for" - }; - command.Options.Add(runtimeIdentifierOption); - - var outputOption = new Option("--output", "-o") - { - Description = "Output directory for project.assets.json", - DefaultValueFactory = _ => "./obj" - }; - command.Options.Add(outputOption); - - var sourceOption = new Option("--source") - { - Description = "NuGet feed URL (can specify multiple)", - DefaultValueFactory = _ => Array.Empty(), - AllowMultipleArgumentsPerToken = true - }; - command.Options.Add(sourceOption); - - var configOption = new Option("--nuget-config") - { - Description = "Path to nuget.config file" - }; - command.Options.Add(configOption); - - var workingDirOption = new Option("--working-dir", "-w") - { - Description = "Working directory for nuget.config discovery" - }; - command.Options.Add(workingDirOption); - - var noNugetOrgOption = new Option("--no-nuget-org") - { - Description = "Don't add nuget.org as fallback source" - }; - command.Options.Add(noNugetOrgOption); - - var verboseOption = new Option("--verbose") - { - Description = "Enable verbose output" - }; - command.Options.Add(verboseOption); - - command.SetAction(async (parseResult, ct) => - { - // Note: ?? is used for null-safety even with DefaultValueFactory because GetValue returns T? - var packageArgs = parseResult.GetValue(packageOption) ?? []; - var framework = parseResult.GetValue(frameworkOption)!; - var runtimeIdentifier = parseResult.GetValue(runtimeIdentifierOption); - var output = parseResult.GetValue(outputOption)!; - var sources = parseResult.GetValue(sourceOption) ?? []; - var nugetConfigPath = parseResult.GetValue(configOption); - var workingDir = parseResult.GetValue(workingDirOption); - var noNugetOrg = parseResult.GetValue(noNugetOrgOption); - var verbose = parseResult.GetValue(verboseOption); - - // Parse packages (format: PackageId,Version) - var packages = new List<(string Id, string Version)>(); - foreach (var pkgArg in packageArgs) - { - if (verbose) - { - Console.WriteLine($"Parsing package argument: {pkgArg}"); - } - var parts = pkgArg.Split(',', 2, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length < 2) - { - Console.Error.WriteLine($"Error: Package argument '{pkgArg}' must be in format 'PackageId,Version'"); - return 1; - } - packages.Add((parts[0], parts[1])); - } - - return await ExecuteRestoreAsync(packages, framework, runtimeIdentifier, output, sources, nugetConfigPath, workingDir, noNugetOrg, verbose).ConfigureAwait(false); - }); - - return command; - } - - private static async Task ExecuteRestoreAsync( - List<(string Id, string Version)> packages, - string framework, - string? runtimeIdentifier, - string output, - string[] cliSources, - string? nugetConfigPath, - string? workingDir, - bool noNugetOrg, - bool verbose) - { - var outputPath = Path.GetFullPath(output); - Directory.CreateDirectory(outputPath); - - var logger = new NuGetLogger(verbose); - - try - { - // Load NuGet settings once — handles working dir, config file, and machine-wide settings. - var machineWideSettings = new XPlatMachineWideSetting(); - var settings = Settings.LoadDefaultSettings(workingDir, nugetConfigPath, machineWideSettings); - - if (verbose) - { - Console.WriteLine($"Restoring {packages.Count} packages for {framework}"); - Console.WriteLine($"Output: {outputPath}"); - if (!string.IsNullOrWhiteSpace(runtimeIdentifier)) - { - Console.WriteLine($"Runtime identifier: {runtimeIdentifier}"); - } - if (workingDir is not null) - { - Console.WriteLine($"Working dir: {workingDir}"); - } - if (nugetConfigPath is not null) - { - Console.WriteLine($"NuGet config: {nugetConfigPath}"); - } - } - - // Resolve package sources using NuGet's PackageSourceProvider - var packageSources = ResolvePackageSources(settings, cliSources, noNugetOrg); - - var nugetFramework = NuGetFramework.Parse(framework); - - // Build PackageSpec and DependencyGraphSpec - var packageSpec = BuildPackageSpec(packages, nugetFramework, runtimeIdentifier, outputPath, packageSources, settings); - - var dgSpec = new DependencyGraphSpec(); - dgSpec.AddProject(packageSpec); - dgSpec.AddRestore(packageSpec.RestoreMetadata.ProjectUniqueName); - - // Pass settings to the provider so it reuses our pre-loaded settings - var providerCache = new RestoreCommandProvidersCache(); - var dgProvider = new DependencyGraphSpecRequestProvider(providerCache, dgSpec, settings); - - // Run restore — let NuGet handle source credentials, parallel execution, etc. - using var cacheContext = new SourceCacheContext(); - var restoreArgs = new RestoreArgs - { - CacheContext = cacheContext, - Log = logger, - PreLoadedRequestProviders = [dgProvider], - DisableParallel = Environment.ProcessorCount == 1, - AllowNoOp = false, - MachineWideSettings = machineWideSettings, - }; - - // Initialize NuGet's trust store with embedded trusted root certificates - // so that package signature verification works on Linux. - TrustedRootsHelper.InitializeTrustStore(); - - var results = await RestoreRunner.RunAsync(restoreArgs).ConfigureAwait(false); - var summary = results.Count > 0 ? results[0] : null; - - if (summary is null) - { - Console.Error.WriteLine("Error: Restore returned no results"); - return 1; - } - - if (!summary.Success) - { - var errors = string.Join(Environment.NewLine, - summary.Errors?.Select(e => e.Message) ?? ["Unknown error"]); - Console.Error.WriteLine($"Error: Restore failed: {errors}"); - return 1; - } - - var assetsPath = Path.Combine(outputPath, "project.assets.json"); - Console.WriteLine($"Restore completed successfully"); - Console.WriteLine($"Assets file: {assetsPath}"); - return 0; - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error: {ex.Message}"); - if (verbose) - { - Console.Error.WriteLine(ex.ToString()); - } - - return 1; - } - } - - private static List ResolvePackageSources(ISettings settings, string[] cliSources, bool noNugetOrg) - { - // Load enabled sources from NuGet config - var provider = new PackageSourceProvider(settings); - var sources = provider.LoadPackageSources().Where(s => s.IsEnabled).ToList(); - - // Append CLI --source values (matching NuGet's behavior of merging, not replacing) - foreach (var cliSource in cliSources) - { - if (!sources.Any(s => s.Source.Equals(cliSource, StringComparison.OrdinalIgnoreCase))) - { - sources.Add(new PackageSource(cliSource)); - } - } - - // Add nuget.org as a fallback source unless opted out - if (!noNugetOrg) - { - const string nugetOrgUrl = "https://api.nuget.org/v3/index.json"; - if (!sources.Any(s => s.Source.Equals(nugetOrgUrl, StringComparison.OrdinalIgnoreCase))) - { - Console.WriteLine("Note: Adding nuget.org as fallback package source. Use --no-nuget-org to disable."); - sources.Add(new PackageSource(nugetOrgUrl, "nuget.org")); - } - } - - return sources; - } - - private static PackageSpec BuildPackageSpec( - List<(string Id, string Version)> packages, - NuGetFramework framework, - string? runtimeIdentifier, - string outputPath, - List sources, - ISettings settings) - { - var projectName = "AspireRestore"; - var projectPath = Path.Combine(outputPath, "project.json"); - var tfmShort = framework.GetShortFolderName(); - var runtimeIdentifierGraphPath = !string.IsNullOrWhiteSpace(runtimeIdentifier) - ? EnsureRuntimeIdentifierGraphPath(outputPath) - : null; - - var dependencies = packages.Select(p => new LibraryDependency - { - LibraryRange = new LibraryRange( - p.Id, - VersionRange.Parse(p.Version), - LibraryDependencyTarget.Package) - }).ToImmutableArray(); - - var tfInfo = new TargetFrameworkInformation - { - FrameworkName = framework, - TargetAlias = tfmShort, - Dependencies = dependencies, - RuntimeIdentifierGraphPath = runtimeIdentifierGraphPath - }; - - var restoreMetadata = new ProjectRestoreMetadata - { - ProjectUniqueName = projectName, - ProjectName = projectName, - ProjectPath = projectPath, - ProjectStyle = ProjectStyle.PackageReference, - OutputPath = outputPath, - PackagesPath = SettingsUtility.GetGlobalPackagesFolder(settings), - OriginalTargetFrameworks = [tfmShort], - ConfigFilePaths = settings.GetConfigFilePaths().ToList(), - }; - - foreach (var source in sources) - { - restoreMetadata.Sources.Add(source); - } - - restoreMetadata.TargetFrameworks.Add(new ProjectRestoreMetadataFrameworkInfo(framework) - { - TargetAlias = tfmShort - }); - - var packageSpec = new PackageSpec([tfInfo]) - { - Name = projectName, - FilePath = projectPath, - RestoreMetadata = restoreMetadata, - }; - - if (!string.IsNullOrWhiteSpace(runtimeIdentifier)) - { - packageSpec.RuntimeGraph = new RuntimeGraph([new RuntimeDescription(runtimeIdentifier)]); - } - - return packageSpec; - } - - private static string EnsureRuntimeIdentifierGraphPath(string outputPath) - { - var graphPath = Path.Combine(outputPath, RuntimeIdentifierGraphFileName); - if (File.Exists(graphPath)) - { - return graphPath; - } - - using var resourceStream = typeof(RestoreCommand).Assembly.GetManifestResourceStream(RuntimeIdentifierGraphResourceName) - ?? throw new InvalidOperationException($"Embedded runtime identifier graph resource '{RuntimeIdentifierGraphResourceName}' was not found."); - using var fileStream = File.Create(graphPath); - resourceStream.CopyTo(fileStream); - - return graphPath; - } -} diff --git a/src/Aspire.Managed/NuGet/Commands/SearchCommand.cs b/src/Aspire.Managed/NuGet/Commands/SearchCommand.cs deleted file mode 100644 index d738110ce88..00000000000 --- a/src/Aspire.Managed/NuGet/Commands/SearchCommand.cs +++ /dev/null @@ -1,349 +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 System.CommandLine; -using System.Globalization; -using System.Text.Json; -using System.Text.Json.Serialization; -using NuGet.Configuration; -using NuGet.Protocol; -using NuGet.Protocol.Core.Types; -using INuGetLogger = NuGet.Common.ILogger; - -namespace Aspire.Managed.NuGet.Commands; - -/// -/// Search command - searches NuGet feeds for packages using NuGet.Protocol. -/// -public static class SearchCommand -{ - /// - /// Creates the search command. - /// - public static Command Create() - { - var command = new Command("search", "Search for NuGet packages"); - - var queryOption = new Option("--query", "-q") - { - Description = "Search query string", - Required = true - }; - command.Options.Add(queryOption); - - var prereleaseOption = new Option("--prerelease", "-p") - { - Description = "Include prerelease packages" - }; - command.Options.Add(prereleaseOption); - - var takeOption = new Option("--take", "-t") - { - Description = "Maximum number of results (default: 100)", - DefaultValueFactory = _ => 100 - }; - command.Options.Add(takeOption); - - var skipOption = new Option("--skip", "-s") - { - Description = "Number of results to skip (default: 0)", - DefaultValueFactory = _ => 0 - }; - command.Options.Add(skipOption); - - var sourceOption = new Option("--source") - { - Description = "NuGet feed URL (can specify multiple)", - DefaultValueFactory = _ => Array.Empty(), - AllowMultipleArgumentsPerToken = true - }; - command.Options.Add(sourceOption); - - var configOption = new Option("--nuget-config") - { - Description = "Path to nuget.config file" - }; - command.Options.Add(configOption); - - var workingDirOption = new Option("--working-dir", "-d") - { - Description = "Working directory to search for nuget.config" - }; - command.Options.Add(workingDirOption); - - var formatOption = new Option("--format", "-f") - { - Description = "Output format: json or text (default: json)", - DefaultValueFactory = _ => "json" - }; - command.Options.Add(formatOption); - - var verboseOption = new Option("--verbose", "-v") - { - Description = "Enable verbose output" - }; - command.Options.Add(verboseOption); - - command.SetAction(async (parseResult, ct) => - { - var query = parseResult.GetValue(queryOption)!; - var prerelease = parseResult.GetValue(prereleaseOption); - var take = parseResult.GetValue(takeOption); - var skip = parseResult.GetValue(skipOption); - var sources = parseResult.GetValue(sourceOption) ?? []; - var configPath = parseResult.GetValue(configOption); - var workingDir = parseResult.GetValue(workingDirOption); - var format = parseResult.GetValue(formatOption) ?? "json"; - var verbose = parseResult.GetValue(verboseOption); - - return await ExecuteSearchAsync(query, prerelease, take, skip, sources, configPath, workingDir, format, verbose).ConfigureAwait(false); - }); - - return command; - } - - private static async Task ExecuteSearchAsync( - string query, - bool prerelease, - int take, - int skip, - string[] explicitSources, - string? configPath, - string? workingDir, - string format, - bool verbose) - { - var logger = new NuGetLogger(verbose); - var allPackages = new List(); - - try - { - // Load settings from nuget.config - var settings = LoadSettings(configPath, workingDir); - var packageSources = LoadPackageSources(settings, explicitSources, verbose); - - if (verbose) - { - Console.Error.WriteLine(string.Format(CultureInfo.InvariantCulture, "Searching {0} source(s) for '{1}'", packageSources.Count, query)); - } - - // Search each source in parallel using NuGet.Protocol - var searchFilter = new SearchFilter(prerelease); - var searchTasks = packageSources.Select(async source => - { - try - { - if (verbose) - { - Console.Error.WriteLine($"Searching {source.Name} ({source.Source})..."); - } - - return await SearchSourceAsync(source, query, searchFilter, skip, take, logger).ConfigureAwait(false); - } - catch (Exception ex) - { - Console.Error.WriteLine($"Warning: Failed to search {source.Name}: {ex.Message}"); - return new List(); - } - }).ToList(); - - var searchResults = await Task.WhenAll(searchTasks).ConfigureAwait(false); - foreach (var packages in searchResults) - { - allPackages.AddRange(packages); - } - - // Deduplicate by package ID, keeping the highest version - var dedupedPackages = allPackages - .GroupBy(p => p.Id, StringComparer.OrdinalIgnoreCase) - .Select(g => g.OrderByDescending(p => p.Version).First()) - .OrderBy(p => p.Id) - .Take(take) - .ToList(); - - OutputResults(dedupedPackages, format); - return 0; - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error: {ex.Message}"); - if (verbose) - { - Console.Error.WriteLine(ex.StackTrace); - } - - return 1; - } - } - - private static ISettings LoadSettings(string? configPath, string? workingDir) - { - if (!string.IsNullOrEmpty(configPath) && File.Exists(configPath)) - { - var configDir = Path.GetDirectoryName(configPath)!; - var configFile = Path.GetFileName(configPath); - return Settings.LoadSpecificSettings(configDir, configFile); - } - - var searchDir = workingDir ?? Directory.GetCurrentDirectory(); - return Settings.LoadDefaultSettings(searchDir); - } - - private static List LoadPackageSources(ISettings settings, string[] explicitSources, bool verbose) - { - var packageSources = new List(); - - // Add explicit sources first - foreach (var source in explicitSources) - { - packageSources.Add(new PackageSource(source)); - if (verbose) - { - Console.Error.WriteLine($"Using explicit source: {source}"); - } - } - - // If no explicit sources, load from settings - if (packageSources.Count == 0) - { - var provider = new PackageSourceProvider(settings); - foreach (var source in provider.LoadPackageSources()) - { - if (source.IsEnabled) - { - packageSources.Add(source); - if (verbose) - { - Console.Error.WriteLine($"Using source from config: {source.Name} ({source.Source})"); - } - } - } - } - - // Default to nuget.org if still no sources - if (packageSources.Count == 0) - { - var defaultSource = new PackageSource("https://api.nuget.org/v3/index.json", "nuget.org"); - packageSources.Add(defaultSource); - Console.Error.WriteLine("Note: No package sources configured, using nuget.org as fallback."); - } - - return packageSources; - } - - private static async Task> SearchSourceAsync( - PackageSource source, - string query, - SearchFilter filter, - int skip, - int take, - INuGetLogger logger) - { - var repository = Repository.Factory.GetCoreV3(source); - var searchResource = await repository.GetResourceAsync().ConfigureAwait(false); - - if (searchResource is null) - { - return []; - } - - var results = await searchResource.SearchAsync( - query, - filter, - skip, - take, - logger, - CancellationToken.None).ConfigureAwait(false); - - var packages = new List(); - foreach (var result in results) - { - var versions = await result.GetVersionsAsync().ConfigureAwait(false); - var allVersions = versions?.Select(v => v.Version.ToString()).ToList() ?? []; - - packages.Add(new PackageInfo - { - Id = result.Identity.Id, - Version = result.Identity.Version.ToString(), - Description = result.Description, - Authors = result.Authors, - AllVersions = allVersions, - Source = source.Source, - Deprecated = await result.GetDeprecationMetadataAsync().ConfigureAwait(false) is not null - }); - } - - return packages; - } - - private static void OutputResults(List packages, string format) - { - if (format.Equals("json", StringComparison.OrdinalIgnoreCase)) - { - var result = new SearchResult - { - Packages = packages, - TotalHits = packages.Count - }; - Console.WriteLine(JsonSerializer.Serialize(result, SearchJsonContext.Default.SearchResult)); - } - else - { - foreach (var pkg in packages) - { - Console.WriteLine($"{pkg.Id} {pkg.Version}"); - if (!string.IsNullOrEmpty(pkg.Description)) - { - Console.WriteLine($" {pkg.Description}"); - } - - Console.WriteLine(); - } - } - } -} - -#region JSON Models - -/// -/// Result of a package search. -/// -public sealed class SearchResult -{ - /// Gets or sets the list of packages found. - public List Packages { get; set; } = []; - /// Gets or sets the total number of hits. - public int TotalHits { get; set; } -} - -/// -/// Information about a NuGet package. -/// -public sealed class PackageInfo -{ - /// Gets or sets the package ID. - public string Id { get; set; } = ""; - /// Gets or sets the latest version. - public string Version { get; set; } = ""; - /// Gets or sets the package description. - public string? Description { get; set; } - /// Gets or sets the package authors. - public string? Authors { get; set; } - /// Gets or sets all available versions. - public List AllVersions { get; set; } = []; - /// Gets or sets the source feed. - public string? Source { get; set; } - /// Gets or sets whether the package is deprecated. - public bool Deprecated { get; set; } -} - -[JsonSerializable(typeof(SearchResult))] -[JsonSerializable(typeof(PackageInfo))] -[JsonSourceGenerationOptions( - PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] -internal sealed partial class SearchJsonContext : JsonSerializerContext -{ -} - -#endregion diff --git a/src/Aspire.Managed/NuGet/NuGetLogger.cs b/src/Aspire.Managed/NuGet/NuGetLogger.cs deleted file mode 100644 index 247e9f6907d..00000000000 --- a/src/Aspire.Managed/NuGet/NuGetLogger.cs +++ /dev/null @@ -1,43 +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 NuGetLogLevel = NuGet.Common.LogLevel; -using NuGetLogMessage = NuGet.Common.ILogMessage; -using INuGetLogger = NuGet.Common.ILogger; - -namespace Aspire.Managed.NuGet; - -/// -/// Console logger adapter for NuGet operations. -/// -internal sealed class NuGetLogger(bool verbose) : INuGetLogger -{ - public void Log(NuGetLogLevel level, string data) - { - if (!verbose && level < NuGetLogLevel.Warning) - { - return; - } - - var prefix = level switch - { - NuGetLogLevel.Error => "ERROR: ", - NuGetLogLevel.Warning => "WARNING: ", - _ => "" - }; - - // All log output goes to stderr to avoid mixing with JSON output on stdout - Console.Error.WriteLine($"{prefix}{data}"); - } - - public void Log(NuGetLogMessage message) => Log(message.Level, message.Message); - public Task LogAsync(NuGetLogLevel level, string data) { Log(level, data); return Task.CompletedTask; } - public Task LogAsync(NuGetLogMessage message) { Log(message); return Task.CompletedTask; } - public void LogDebug(string data) => Log(NuGetLogLevel.Debug, data); - public void LogError(string data) => Log(NuGetLogLevel.Error, data); - public void LogInformation(string data) => Log(NuGetLogLevel.Information, data); - public void LogInformationSummary(string data) => Log(NuGetLogLevel.Information, data); - public void LogMinimal(string data) => Log(NuGetLogLevel.Minimal, data); - public void LogVerbose(string data) => Log(NuGetLogLevel.Verbose, data); - public void LogWarning(string data) => Log(NuGetLogLevel.Warning, data); -} diff --git a/src/Aspire.Managed/NuGet/TrustedRootsHelper.cs b/src/Aspire.Managed/NuGet/TrustedRootsHelper.cs deleted file mode 100644 index e82f3629d54..00000000000 --- a/src/Aspire.Managed/NuGet/TrustedRootsHelper.cs +++ /dev/null @@ -1,230 +0,0 @@ -// 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 CA1852 // DispatchProxy classes can't be sealed - -using System.Reflection; -using System.Security.Cryptography.X509Certificates; -using NuGet.Packaging.Signing; - -namespace Aspire.Managed.NuGet; - -/// -/// Initializes NuGet's X509 trust store from embedded trusted root PEM certificates -/// for package signature verification on Linux, without writing to disk. -/// -internal static class TrustedRootsHelper -{ - private static bool s_initialized; - - /// - /// Initializes the NuGet trust store with embedded trusted root certificates. - /// On Linux, when DOTNET_NUGET_SIGNATURE_VERIFICATION is set to "true", NuGet requires - /// certificate bundles for signature verification. The .NET SDK ships these as PEM files - /// in its trustedroots directory, but aspire-managed is a single-file app without access - /// to the SDK's directory structure. This method loads embedded PEM resources in memory - /// and uses DispatchProxy to create IX509ChainFactory implementations that NuGet's trust - /// store can use. - /// - /// - /// TODO: Remove this once NuGet supports a public API for configuring the trust store, - /// or when it supports single-file apps. See https://github.com/dotnet/aspire/issues/15282. - /// - public static void InitializeTrustStore() - { - if (s_initialized) - { - return; - } - - if (!OperatingSystem.IsLinux()) - { - // On Windows, NuGet uses the system certificate store directly. - // On macOS, matching .NET SDK behavior which only enables this on Linux. - return; - } - - var envValue = Environment.GetEnvironmentVariable("DOTNET_NUGET_SIGNATURE_VERIFICATION"); - if (!(bool.TryParse(envValue, out var enabled) && enabled)) - { - // If DOTNET_NUGET_SIGNATURE_VERIFICATION is not set to "true", NuGet won't - // perform signature verification on Linux, so there's no need to initialize - // the trust store. - return; - } - - try - { - InitializeTrustStoreFromEmbeddedResources(); - s_initialized = true; - } - catch (Exception ex) - { - // Log but don't fail the restore. If trust store initialization fails, - // NuGet may still work if signature verification is not required or if - // the system has its own certificate bundles. - Console.Error.WriteLine($"WARNING: Failed to initialize NuGet trust store from embedded certificates: {ex}"); - } - } - - private static void InitializeTrustStoreFromEmbeddedResources() - { - var nugetPackagingAssembly = typeof(X509TrustStore).Assembly; - - // Resolve internal NuGet types needed for DispatchProxy creation - var chainFactoryInterfaceType = nugetPackagingAssembly.GetType("NuGet.Packaging.Signing.IX509ChainFactory"); - var chainInterfaceType = nugetPackagingAssembly.GetType("NuGet.Packaging.Signing.IX509Chain"); - - if (chainFactoryInterfaceType is null || chainInterfaceType is null) - { - Console.Error.WriteLine("WARNING: Could not find IX509ChainFactory or IX509Chain types in NuGet.Packaging."); - return; - } - - // Set up code signing trust store - SetTrustStoreFactory( - "SetCodeSigningX509ChainFactory", - "codesignctl.pem", - chainFactoryInterfaceType, - chainInterfaceType); - - // Set up timestamping trust store - SetTrustStoreFactory( - "SetTimestampingX509ChainFactory", - "timestampctl.pem", - chainFactoryInterfaceType, - chainInterfaceType); - } - - private static void SetTrustStoreFactory( - string setterMethodName, - string resourceName, - Type chainFactoryInterfaceType, - Type chainInterfaceType) - { - var certificates = LoadCertificatesFromResource(resourceName); - if (certificates is null || certificates.Count == 0) - { - Console.Error.WriteLine($"WARNING: No certificates loaded from embedded resource: {resourceName}"); - return; - } - - // Create IX509ChainFactory proxy via DispatchProxy - var factory = ChainFactoryDispatchProxy.CreateFactory( - chainFactoryInterfaceType, chainInterfaceType, certificates); - - // Call the setter on X509TrustStore to register the factory - var setter = typeof(X509TrustStore).GetMethod( - setterMethodName, - BindingFlags.NonPublic | BindingFlags.Static); - - if (setter is null) - { - Console.Error.WriteLine($"WARNING: Could not find {setterMethodName} on X509TrustStore."); - return; - } - - setter.Invoke(null, [factory]); - } - - private static X509Certificate2Collection? LoadCertificatesFromResource(string resourceName) - { - using var stream = typeof(TrustedRootsHelper).Assembly.GetManifestResourceStream(resourceName); - if (stream is null) - { - return null; - } - - using var reader = new StreamReader(stream); - var pemContents = reader.ReadToEnd(); - - var certificates = new X509Certificate2Collection(); - certificates.ImportFromPem(pemContents); - return certificates; - } -} - -/// -/// DispatchProxy that implements NuGet's internal IX509ChainFactory interface. -/// Creates X509Chain instances configured with custom root trust using embedded certificates. -/// -internal class ChainFactoryDispatchProxy : DispatchProxy -{ - private X509Certificate2Collection _certificates = []; - private Type _chainInterfaceType = null!; - - internal static object CreateFactory( - Type chainFactoryInterfaceType, - Type chainInterfaceType, - X509Certificate2Collection certificates) - { - var proxy = (ChainFactoryDispatchProxy)Create(chainFactoryInterfaceType, typeof(ChainFactoryDispatchProxy)); - - proxy._certificates = certificates; - proxy._chainInterfaceType = chainInterfaceType; - return proxy; - } - - protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) - { - // IX509ChainFactory has a single method: IX509Chain Create() - if (targetMethod?.Name == "Create") - { - return CreateChain(); - } - - throw new NotSupportedException($"Method '{targetMethod?.Name}' is not supported on IX509ChainFactory proxy. This may indicate a NuGet.Packaging version mismatch — the library may have added new interface members."); - } - - private object CreateChain() - { - // Create an IX509Chain proxy that wraps an X509Chain with custom root trust - return ChainDispatchProxy.CreateChain(_chainInterfaceType, _certificates); - } -} - -/// -/// DispatchProxy that implements NuGet's internal IX509Chain interface. -/// Wraps an X509Chain configured with CustomRootTrust mode. -/// -internal class ChainDispatchProxy : DispatchProxy -{ - private readonly X509Chain _chain = new(); - - internal static object CreateChain( - Type chainInterfaceType, - X509Certificate2Collection certificates) - { - var proxy = (ChainDispatchProxy)Create(chainInterfaceType, typeof(ChainDispatchProxy)); - - proxy._chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust; - proxy._chain.ChainPolicy.CustomTrustStore.AddRange(certificates); - return proxy; - } - - protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) - { - return targetMethod?.Name switch - { - "Build" => Build((X509Certificate2)args![0]!), - "Dispose" => Dispose(), - "get_ChainElements" => _chain.ChainElements, - "get_ChainPolicy" => _chain.ChainPolicy, - "get_ChainStatus" => _chain.ChainStatus, - "get_PrivateReference" => _chain, - "get_AdditionalContext" => (global::NuGet.Common.ILogMessage?)null, - _ => throw new NotSupportedException($"Method '{targetMethod?.Name}' is not supported on IX509Chain proxy. This may indicate a NuGet.Packaging version mismatch — the library may have added new interface members.") - }; - } - - private bool Build(X509Certificate2 certificate) - { - return _chain.Build(certificate); - } - - private object? Dispose() - { - _chain.Dispose(); - return null; - } -} diff --git a/src/Aspire.Managed/Program.cs b/src/Aspire.Managed/Program.cs index 55ee69d8ed8..0454aebd25d 100644 --- a/src/Aspire.Managed/Program.cs +++ b/src/Aspire.Managed/Program.cs @@ -1,11 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.CommandLine; using System.Diagnostics; using System.Globalization; using Aspire.Hosting; -using Aspire.Managed.NuGet.Commands; using Aspire.Shared; using Aspire.TerminalHost; @@ -26,7 +24,6 @@ { ["dashboard", .. var rest] => await RunDashboard(rest).ConfigureAwait(false), ["server", .. var rest] => await RunServer(rest).ConfigureAwait(false), - ["nuget", .. var rest] => await RunNuGet(rest).ConfigureAwait(false), ["terminalhost", .. var rest] => await RunTerminalHost(rest).ConfigureAwait(false), _ => ShowUsage() }; @@ -104,30 +101,6 @@ static async Task RunServer(string[] args) return 0; } -static async Task RunNuGet(string[] args) -{ - // Tear this helper down if the launching CLI dies so a hung/slow NuGet operation cannot linger as an - // orphaned aspire-managed process. No-op when ASPIRE_CLI_PID is not set — either invoked directly, or - // on Windows where the CLI relies on the kernel kill-on-close job instead (see LayoutProcessRunner). - using var operationCts = new CancellationTokenSource(); - var parentWatchdog = ParentProcessWatchdog.Start(operationCts); - try - { - var rootCommand = new RootCommand("Aspire NuGet Helper - Package operations for Aspire CLI bundle"); - rootCommand.Subcommands.Add(SearchCommand.Create()); - rootCommand.Subcommands.Add(RestoreCommand.Create()); - rootCommand.Subcommands.Add(ManifestCommand.Create()); - return await rootCommand.Parse(args).InvokeAsync(cancellationToken: operationCts.Token).ConfigureAwait(false); - } - finally - { - if (parentWatchdog is not null) - { - await parentWatchdog.DisposeAsync().ConfigureAwait(false); - } - } -} - static async Task RunTerminalHost(string[] args) { return await TerminalHostProcessRunner.RunAsync(args).ConfigureAwait(false); @@ -135,6 +108,6 @@ static async Task RunTerminalHost(string[] args) static int ShowUsage() { - Console.Error.WriteLine($"Usage: {AppDomain.CurrentDomain.FriendlyName} [args...]"); + Console.Error.WriteLine($"Usage: {AppDomain.CurrentDomain.FriendlyName} [args...]"); return 1; } diff --git a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs index fb1a6e2a715..669a3602582 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/EmptyAppHostTemplateTests.cs @@ -104,14 +104,10 @@ await auto.RunCommandAsync( // Running dotnet in /tmp/aspire-nuget-configABC123 with args: new install /workspace/.../source-feed/Aspire.ProjectTemplates.13.0.0-preview.1.nupkg // Match the argument list instead of the randomly-suffixed temp directory. // - // The negative guard has to name the search path this CLI would actually take. Shipped (and - // CI-installed) CLIs are bundles, so a feed-backed template lookup goes through - // BundleNuGetPackageCache -> `aspire-managed nuget search`, which logs: + // The negative guard names the legacy bundled search path to ensure this local-source flow + // never regresses to spawning aspire-managed. That removed path logged: // Running NuGet search via aspire-managed: Aspire.ProjectTemplates // NuGet search args: nuget search --query Aspire.ProjectTemplates --take 1000 --format json ... - // Those two Debug lines are the only trace of it, because the helper process itself is spawned - // with SuppressLogging (as is the SDK-backed `dotnet package search` fallback), so asserting on - // a logged `package search` command line can never fail and would not catch a regression. // Because the positive assertion above matches a Debug line from the same log, an empty or // level-filtered log cannot make these negative assertions pass vacuously. await auto.RunCommandAsync( diff --git a/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj b/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj index 2aba040ef01..b8296de103c 100644 --- a/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj +++ b/tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj @@ -45,6 +45,11 @@ + + + + + diff --git a/tests/Aspire.Cli.Tests/Configuration/PrebuiltAppHostServerChannelResolutionTests.cs b/tests/Aspire.Cli.Tests/Configuration/PrebuiltAppHostServerChannelResolutionTests.cs index 8195afe1d96..f3a7d3336d5 100644 --- a/tests/Aspire.Cli.Tests/Configuration/PrebuiltAppHostServerChannelResolutionTests.cs +++ b/tests/Aspire.Cli.Tests/Configuration/PrebuiltAppHostServerChannelResolutionTests.cs @@ -92,11 +92,8 @@ public void PrebuiltAppHostServer_ResolveRequestedChannel_PrefersAspireConfigJso private static PrebuiltAppHostServer CreateServer(string appPath) { var nugetService = new BundleNuGetService( - new NullLayoutDiscovery(), - new LayoutProcessRunner(new TestProcessExecutionFactory()), - new TestFeatures(), - new TestEnvironment(), - NullLogger.Instance); + NullLogger.Instance, + new FakeNuGetClient()); return new PrebuiltAppHostServer( appPath, @@ -112,4 +109,3 @@ private static PrebuiltAppHostServer CreateServer(string appPath) NullLogger.Instance); } } - diff --git a/tests/Aspire.Cli.Tests/NuGet/BundleNuGetPackageCacheTests.cs b/tests/Aspire.Cli.Tests/NuGet/BundleNuGetPackageCacheTests.cs index 7633d2e00f9..66dd2342493 100644 --- a/tests/Aspire.Cli.Tests/NuGet/BundleNuGetPackageCacheTests.cs +++ b/tests/Aspire.Cli.Tests/NuGet/BundleNuGetPackageCacheTests.cs @@ -1,107 +1,262 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Aspire.Cli.Layout; +using System.Globalization; +using Aspire.Cli.Configuration; using Aspire.Cli.NuGet; +using Aspire.Cli.Resources; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.AspNetCore.InternalTesting; namespace Aspire.Cli.Tests.NuGet; public class BundleNuGetPackageCacheTests(ITestOutputHelper outputHelper) { [Fact] - public async Task GetPackageVersionsAsync_ExpandsAllVersionsFromExactMatchResult() + public async Task NonAspireCliPackagesWillNotBeConsidered() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure => + { + configure.NuGetClientFactory = _ => + { + return new FakeNuGetClient + { + SearchCallback = (_, _, _, _, _, _, _) => Task.FromResult>( + [ + new("CommunityToolkit.Aspire.Hosting.Foo", "9.4.0-xyz", "nuget.org", ["9.4.0-xyz"]), + new("Aspire.Cli", "9.4.0-preview", "nuget.org", ["9.4.0-preview"]) + ]) + }; + }; + }); + + using var provider = services.BuildServiceProvider(); + + var nuGetPackageCache = CreateCache(provider); + var packages = await nuGetPackageCache.GetCliPackagesAsync(workspace.WorkspaceRoot, prerelease: true, nugetConfigFile: null, CancellationToken.None).DefaultTimeout(); + + Assert.Collection( + packages, + package => Assert.Equal("Aspire.Cli", package.Id) + ); + } - var layout = new LayoutConfiguration + [Fact] + public async Task DeprecatedPackagesAreFilteredByDefault() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure => { - LayoutPath = workspace.WorkspaceRoot.FullName, - Components = new LayoutComponents + configure.NuGetClientFactory = _ => { - Managed = "managed" - } - }; + return new FakeNuGetClient + { + SearchCallback = (_, _, _, _, _, _, _) => Task.FromResult>( + [ + new("Aspire.Hosting.Redis", "9.4.0", "nuget.org", ["9.4.0"]), + new("Aspire.Hosting.Dapr", "9.4.0", "nuget.org", ["9.4.0"]), + new("Aspire.Hosting.GitHub.Models", "9.4.0", "nuget.org", ["9.4.0"]), + new("Aspire.Hosting.NodeJs", "9.4.0", "nuget.org", ["9.4.0"]), + new("Aspire.Hosting.PostgreSQL", "9.4.0", "nuget.org", ["9.4.0"]) + ]) + }; + }; + }); + + using var provider = services.BuildServiceProvider(); - var managedDirectory = workspace.WorkspaceRoot.CreateSubdirectory("managed"); - var managedPath = layout.GetManagedPath(); - Assert.NotNull(managedPath); - await File.WriteAllTextAsync(managedPath!, string.Empty); + var nuGetPackageCache = CreateCache(provider); + var packages = await nuGetPackageCache.GetPackagesAsync(workspace.WorkspaceRoot, "Aspire.Hosting", null, prerelease: false, nugetConfigFile: null, useCache: true, CancellationToken.None).DefaultTimeout(); - var bundleService = new TestBundleService(isBundle: true) + // Should include regular packages but exclude deprecated Dapr package + var packageIds = packages.Select(p => p.Id).ToList(); + Assert.Contains("Aspire.Hosting.Redis", packageIds); + Assert.Contains("Aspire.Hosting.PostgreSQL", packageIds); + Assert.DoesNotContain("Aspire.Hosting.Dapr", packageIds); + Assert.DoesNotContain("Aspire.Hosting.GitHub.Models", packageIds); + Assert.DoesNotContain("Aspire.Hosting.NodeJs", packageIds); + } + + [Fact] + public async Task DeprecatedPackagesAreIncludedWhenShowDeprecatedPackagesEnabled() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure => { - Layout = layout - }; + // Enable showing deprecated packages + configure.EnabledFeatures = [Aspire.Cli.KnownFeatures.ShowDeprecatedPackages]; + + configure.NuGetClientFactory = _ => + { + return new FakeNuGetClient + { + SearchCallback = (_, _, _, _, _, _, _) => Task.FromResult>( + [ + new("Aspire.Hosting.Redis", "9.4.0", "nuget.org", ["9.4.0"]), + new("Aspire.Hosting.Dapr", "9.4.0", "nuget.org", ["9.4.0"]), + new("Aspire.Hosting.GitHub.Models", "9.4.0", "nuget.org", ["9.4.0"]), + new("Aspire.Hosting.NodeJs", "9.4.0", "nuget.org", ["9.4.0"]), + new("Aspire.Hosting.PostgreSQL", "9.4.0", "nuget.org", ["9.4.0"]) + ]) + }; + }; + }); + + using var provider = services.BuildServiceProvider(); + + var nuGetPackageCache = CreateCache(provider); + var packages = await nuGetPackageCache.GetPackagesAsync(workspace.WorkspaceRoot, "Aspire.Hosting", null, prerelease: false, nugetConfigFile: null, useCache: true, CancellationToken.None).DefaultTimeout(); - var executionFactory = new TestProcessExecutionFactory + // Should include all packages including deprecated Dapr package when showing deprecated is enabled + var packageIds = packages.Select(p => p.Id).ToList(); + Assert.Contains("Aspire.Hosting.Redis", packageIds); + Assert.Contains("Aspire.Hosting.PostgreSQL", packageIds); + Assert.Contains("Aspire.Hosting.Dapr", packageIds); + Assert.Contains("Aspire.Hosting.GitHub.Models", packageIds); + Assert.Contains("Aspire.Hosting.NodeJs", packageIds); + } + + [Fact] + public async Task CustomFilterBypassesDeprecatedPackageFiltering() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure => { - AttemptCallback = (_, _) => (0, - """ - {"packages":[{"id":"Aspire.Hosting.Redis","version":"13.3.0","allVersions":["13.3.0","13.2.0"],"source":"nuget.org"}],"totalHits":1} - """) - }; - - var cache = new BundleNuGetPackageCache( - bundleService, - new LayoutProcessRunner(executionFactory), - NullLogger.Instance, - new TestFeatures()); + configure.NuGetClientFactory = _ => + { + return new FakeNuGetClient + { + SearchCallback = (_, _, _, _, _, _, _) => Task.FromResult>( + [ + new("Aspire.Hosting.Redis", "9.4.0", "nuget.org", ["9.4.0"]), + new("Aspire.Hosting.Dapr", "9.4.0", "nuget.org", ["9.4.0"]), + new("Other.Package", "9.4.0", "nuget.org", ["9.4.0"]) + ]) + }; + }; + }); + + using var provider = services.BuildServiceProvider(); + + var nuGetPackageCache = CreateCache(provider); - var packages = (await cache.GetPackageVersionsAsync( + // Use a custom filter that includes all packages containing "Dapr" + var packages = await nuGetPackageCache.GetPackagesAsync( workspace.WorkspaceRoot, - "Aspire.Hosting.Redis", + "Aspire.Hosting", + filter: id => id.Contains("Dapr", StringComparison.OrdinalIgnoreCase), prerelease: false, nugetConfigFile: null, useCache: true, - CancellationToken.None)).OrderBy(package => package.Version).ToArray(); + CancellationToken.None).DefaultTimeout(); - Assert.Collection( - packages, - package => Assert.Equal("13.2.0", package.Version), - package => Assert.Equal("13.3.0", package.Version)); + // Custom filter should bypass deprecated package filtering + var packageIds = packages.Select(p => p.Id).ToList(); + Assert.Contains("Aspire.Hosting.Dapr", packageIds); + Assert.DoesNotContain("Aspire.Hosting.Redis", packageIds); + Assert.DoesNotContain("Other.Package", packageIds); } [Fact] - public async Task GetPackageVersionsAsync_RequestsKillOnParentExitForSearchHelper() + public async Task DeprecatedPackageFilteringIsCaseInsensitive() { - // The aspire-managed NuGet search helper can hang against a slow/unresponsive source, so the - // search launch must bind it to the Windows kill-on-close job (KillOnParentExit) as an OS-level - // backstop against leaking the helper when the CLI is hard-killed. using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - - var layout = new LayoutConfiguration + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure => { - LayoutPath = workspace.WorkspaceRoot.FullName, - Components = new LayoutComponents + configure.NuGetClientFactory = _ => { - Managed = "managed" - } - }; + return new FakeNuGetClient + { + SearchCallback = (_, _, _, _, _, _, _) => Task.FromResult>( + [ + new("aspire.hosting.dapr", "9.4.0", "nuget.org", ["9.4.0"]), + new("ASPIRE.HOSTING.DAPR", "9.4.0", "nuget.org", ["9.4.0"]), + new("Aspire.Hosting.Redis", "9.4.0", "nuget.org", ["9.4.0"]) + ]) + }; + }; + }); + + using var provider = services.BuildServiceProvider(); + + var nuGetPackageCache = CreateCache(provider); + var packages = await nuGetPackageCache.GetPackagesAsync(workspace.WorkspaceRoot, "Aspire.Hosting", null, prerelease: false, nugetConfigFile: null, useCache: true, CancellationToken.None).DefaultTimeout(); - var managedDirectory = workspace.WorkspaceRoot.CreateSubdirectory("managed"); - var managedPath = layout.GetManagedPath(); - Assert.NotNull(managedPath); - await File.WriteAllTextAsync(managedPath!, string.Empty); + // Should filter out all case variations of deprecated package + var packageIds = packages.Select(p => p.Id).ToList(); + Assert.Contains("Aspire.Hosting.Redis", packageIds); + Assert.DoesNotContain("aspire.hosting.dapr", packageIds); + Assert.DoesNotContain("ASPIRE.HOSTING.DAPR", packageIds); + } - var bundleService = new TestBundleService(isBundle: true) + [Fact] + public async Task AnalyzerPackageIsFilteredFromDefaultPackageSearch() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure => { - Layout = layout - }; + configure.NuGetClientFactory = _ => + { + return new FakeNuGetClient + { + SearchCallback = (_, _, _, _, _, _, _) => Task.FromResult>( + [ + new("Aspire.Hosting.Redis", "13.4.0", "nuget.org", ["13.4.0"]), + new("Aspire.Hosting.Integration.Analyzers", "13.4.0", "nuget.org", ["13.4.0"]), + new("Aspire.Hosting.PostgreSQL", "13.4.0", "nuget.org", ["13.4.0"]) + ]) + }; + }; + }); + + using var provider = services.BuildServiceProvider(); - var executionFactory = new TestProcessExecutionFactory + var nuGetPackageCache = CreateCache(provider); + var packages = await nuGetPackageCache.GetPackagesAsync(workspace.WorkspaceRoot, "Aspire.Hosting", filter: null, prerelease: false, nugetConfigFile: null, useCache: true, CancellationToken.None).DefaultTimeout(); + + Assert.Collection( + packages.Select(p => p.Id), + id => Assert.Equal("Aspire.Hosting.Redis", id), + id => Assert.Equal("Aspire.Hosting.PostgreSQL", id)); + } + + [Fact] + public async Task GetPackageVersionsAsync_ExpandsVersionsOfExactIdMatch() + { + string? observedQuery = null; + var observedTake = -1; + + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure => { - AttemptCallback = (_, _) => (0, """{"packages":[],"totalHits":0}""") - }; + configure.NuGetClientFactory = _ => + { + return new FakeNuGetClient + { + SearchCallback = (query, _, take, _, _, _, _) => + { + observedQuery = query; + observedTake = take; - var cache = new BundleNuGetPackageCache( - bundleService, - new LayoutProcessRunner(executionFactory), - NullLogger.Instance, - new TestFeatures()); + // An ordinary search also returns packages whose IDs only start with the query. + return Task.FromResult>( + [ + new("Aspire.Hosting.Redis", "13.3.0", "nuget.org", ["13.3.0", "13.2.0"]), + new("Aspire.Hosting.Redis.Extras", "14.0.0", "nuget.org", ["14.0.0"]) + ]); + } + }; + }; + }); + + using var provider = services.BuildServiceProvider(); - await cache.GetPackageVersionsAsync( + var nuGetPackageCache = CreateCache(provider); + var packages = await nuGetPackageCache.GetPackageVersionsAsync( workspace.WorkspaceRoot, "Aspire.Hosting.Redis", prerelease: false, @@ -109,64 +264,157 @@ await cache.GetPackageVersionsAsync( useCache: true, CancellationToken.None); - Assert.True(executionFactory.LastProcessInvocationOptions?.KillOnParentExit); + Assert.Equal("Aspire.Hosting.Redis", observedQuery); + Assert.Equal(1000, observedTake); + Assert.Collection( + packages, + package => + { + Assert.Equal("Aspire.Hosting.Redis", package.Id); + Assert.Equal("13.3.0", package.Version); + Assert.Equal("nuget.org", package.Source); + }, + package => + { + Assert.Equal("Aspire.Hosting.Redis", package.Id); + Assert.Equal("13.2.0", package.Version); + Assert.Equal("nuget.org", package.Source); + }); } [Fact] - public async Task GetPackageVersionsAsync_ToleratesCredentialProviderOutputAroundPayload() + public async Task GetPackageVersionsAsync_MatchesPackageIdCaseSensitively() { - // Credential-provider diagnostics use an inherited stdout handle, so braced text or JSON can arrive before - // the package-search payload and additional output can arrive after it. The diagnostic JSON deliberately - // has the expected root array but omits other required bundle-search fields. using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - - var layout = new LayoutConfiguration + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure => { - LayoutPath = workspace.WorkspaceRoot.FullName, - Components = new LayoutComponents + configure.NuGetClientFactory = _ => { - Managed = "managed" - } - }; + return new FakeNuGetClient + { + SearchCallback = (_, _, _, _, _, _, _) => Task.FromResult>( + [ + new("aspire.hosting.redis", "13.3.0", "nuget.org", ["13.3.0"]) + ]) + }; + }; + }); - var managedDirectory = workspace.WorkspaceRoot.CreateSubdirectory("managed"); - var managedPath = layout.GetManagedPath(); - Assert.NotNull(managedPath); - await File.WriteAllTextAsync(managedPath!, string.Empty); + using var provider = services.BuildServiceProvider(); - var bundleService = new TestBundleService(isBundle: true) - { - Layout = layout - }; + var nuGetPackageCache = CreateCache(provider); + var packages = await nuGetPackageCache.GetPackageVersionsAsync( + workspace.WorkspaceRoot, + "Aspire.Hosting.Redis", + prerelease: false, + nugetConfigFile: null, + useCache: true, + CancellationToken.None); - var pollutedStdout = - " [CredentialProvider]Acquiring token for request {request-42}\n" + - """{"packages":[]}""" + "\n" + - """{"packages":[{"id":"Aspire.Hosting.Redis","version":"13.3.0","allVersions":["13.3.0","13.2.0"],"source":"nuget.org"}],"totalHits":1}""" + - "\n [CredentialProvider]VstsCredentialProvider - Acquired bearer token using 'MSAL Silent'"; + Assert.Empty(packages); + } - var executionFactory = new TestProcessExecutionFactory + [Fact] + public async Task GetPackageVersionsAsync_IncludesDeprecatedPackage() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure => { - AttemptCallback = (_, _) => (0, pollutedStdout) - }; + configure.NuGetClientFactory = _ => + { + return new FakeNuGetClient + { + SearchCallback = (_, _, _, _, _, _, _) => + Task.FromResult>( + [ + new("Aspire.Hosting.Dapr", "13.4.0", "nuget.org", ["13.4.0"]) + ]) + }; + }; + }); - var cache = new BundleNuGetPackageCache( - bundleService, - new LayoutProcessRunner(executionFactory), - NullLogger.Instance, - new TestFeatures()); + using var provider = services.BuildServiceProvider(); - var packages = (await cache.GetPackageVersionsAsync( + var nuGetPackageCache = CreateCache(provider); + var package = Assert.Single(await nuGetPackageCache.GetPackageVersionsAsync( workspace.WorkspaceRoot, - "Aspire.Hosting.Redis", + "Aspire.Hosting.Dapr", prerelease: false, nugetConfigFile: null, useCache: true, - CancellationToken.None)).OrderBy(package => package.Version).ToArray(); + CancellationToken.None)); - Assert.Collection( - packages, - package => Assert.Equal("13.2.0", package.Version), - package => Assert.Equal("13.3.0", package.Version)); + Assert.Equal("Aspire.Hosting.Dapr", package.Id); + } + + [Fact] + public async Task SearchFailureReportsHelperExitCodeMessage() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var expectedException = new NuGetOperationException("Error: Search failed." + Environment.NewLine); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure => + { + configure.NuGetClientFactory = _ => + { + return new FakeNuGetClient + { + SearchCallback = (_, _, _, _, _, _, _) => throw expectedException + }; + }; + }); + + using var provider = services.BuildServiceProvider(); + + var nuGetPackageCache = CreateCache(provider); + var exception = await Assert.ThrowsAsync(() => + nuGetPackageCache.GetPackageVersionsAsync( + workspace.WorkspaceRoot, + "Aspire.Hosting.Redis", + prerelease: false, + nugetConfigFile: null, + useCache: true, + CancellationToken.None)); + + Assert.Equal( + string.Format(CultureInfo.CurrentCulture, ErrorStrings.FailedToSearchForPackages, 1), + exception.Message); + Assert.Same(expectedException, exception.InnerException); } + + [Fact] + public async Task SearchCancellationIsNotWrapped() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.Cancel(); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure => + { + configure.NuGetClientFactory = _ => + { + return new FakeNuGetClient + { + SearchCallback = (_, _, _, _, _, _, cancellationToken) => + Task.FromCanceled>(cancellationToken) + }; + }; + }); + + using var provider = services.BuildServiceProvider(); + + var nuGetPackageCache = CreateCache(provider); + await Assert.ThrowsAnyAsync(() => + nuGetPackageCache.GetPackageVersionsAsync( + workspace.WorkspaceRoot, + "Aspire.Hosting.Redis", + prerelease: false, + nugetConfigFile: null, + useCache: true, + cancellationSource.Token)); + } + + private static INuGetPackageCache CreateCache(IServiceProvider provider) => + new BundleNuGetPackageCache( + provider.GetRequiredService(), + NullLogger.Instance, + provider.GetRequiredService()); } diff --git a/tests/Aspire.Cli.Tests/NuGet/BundleNuGetServiceTests.cs b/tests/Aspire.Cli.Tests/NuGet/BundleNuGetServiceTests.cs index 5bb6e0be812..2cc6c919ea6 100644 --- a/tests/Aspire.Cli.Tests/NuGet/BundleNuGetServiceTests.cs +++ b/tests/Aspire.Cli.Tests/NuGet/BundleNuGetServiceTests.cs @@ -1,12 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Concurrent; -using Aspire.Cli.Layout; using Aspire.Cli.NuGet; using Aspire.Cli.Tests.TestServices; -using Aspire.Cli.Tests.Utils; -using Aspire.Shared; using Microsoft.Extensions.Logging.Abstractions; namespace Aspire.Cli.Tests.NuGet; @@ -14,72 +10,58 @@ namespace Aspire.Cli.Tests.NuGet; public class BundleNuGetServiceTests(ITestOutputHelper outputHelper) { [Fact] - public async Task RestorePackagesAsync_UsesWorkspaceAspireDirectoryForRestoreArtifacts() + public async Task RestorePackagesAsync_UsesWorkspaceAspireDirectoryAndForwardsInputs() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - var appHostDirectory = workspace.CreateDirectory("apphost"); - var layoutRoot = workspace.CreateDirectory("layout"); - var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName); - var managedPath = Path.Combine( - managedDirectory.FullName, - BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); - File.WriteAllText(managedPath, string.Empty); - - List invocations = []; - var executionFactory = new TestProcessExecutionFactory + var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"); + File.WriteAllText(nugetConfigPath, ""); + + string? capturedOutputPath = null; + string? capturedConfigPath = null; + IReadOnlyList? capturedSources = null; + var nuGetClient = new FakeNuGetClient { - AssertionCallback = (args, _, _, _) => invocations.Add(args.ToArray()) + RestoreCallback = (_, _, _, outputPath, sources, configPath, _, _) => + { + capturedOutputPath = outputPath; + capturedConfigPath = configPath; + capturedSources = sources; + return Task.CompletedTask; + } }; - - var service = new BundleNuGetService( - new FixedLayoutDiscovery(new LayoutConfiguration { LayoutPath = layoutRoot.FullName }), - new LayoutProcessRunner(executionFactory), - new TestFeatures(), - new TestEnvironment(), - NullLogger.Instance); + var service = CreateService(nuGetClient); var manifestPath = await service.RestorePackagesAsync( [("Aspire.Hosting.JavaScript", "9.4.0")], - workingDirectory: appHostDirectory.FullName); - - var restoreRoot = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "integrations", "package-restore"); - var restoreDirectory = Directory.GetParent(manifestPath)!.FullName; + workingDirectory: appHostDirectory.FullName, + sources: ["https://example.com/v3/index.json"], + nugetConfigPath: nugetConfigPath); + var restoreRoot = Path.Combine( + workspace.WorkspaceRoot.FullName, + ".aspire", + "integrations", + "package-restore"); Assert.StartsWith(restoreRoot, manifestPath, StringComparison.OrdinalIgnoreCase); - Assert.Equal(2, invocations.Count); - Assert.Equal(Path.Combine(restoreDirectory, "obj"), GetArgumentValue(invocations[0], "--output")); - Assert.Equal("manifest", invocations[1][1]); - Assert.Equal(manifestPath, GetArgumentValue(invocations[1], "--output")); - Assert.Equal(Path.Combine(restoreDirectory, "obj", "project.assets.json"), GetArgumentValue(invocations[1], "--assets")); + Assert.Equal(Path.Combine(Path.GetDirectoryName(manifestPath)!, "obj"), capturedOutputPath); + Assert.Equal(nugetConfigPath, capturedConfigPath); + Assert.Equal(["https://example.com/v3/index.json"], capturedSources); + Assert.Equal(1, nuGetClient.RestoreCallCount); + Assert.Equal(1, nuGetClient.WriteManifestCallCount); } [Fact] public async Task RestorePackagesAsync_UsesDistinctCachePathsForDifferentSources() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - var appHostDirectory = workspace.CreateDirectory("apphost"); - var layoutRoot = workspace.CreateDirectory("layout"); - var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName); - var managedPath = Path.Combine( - managedDirectory.FullName, - BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); - File.WriteAllText(managedPath, string.Empty); - - var executionFactory = new TestProcessExecutionFactory(); - var service = new BundleNuGetService( - new FixedLayoutDiscovery(new LayoutConfiguration { LayoutPath = layoutRoot.FullName }), - new LayoutProcessRunner(executionFactory), - new TestFeatures(), - new TestEnvironment(), - NullLogger.Instance); + var service = CreateService(new FakeNuGetClient()); var resultA = await service.RestorePackagesAsync( [("Aspire.Hosting.JavaScript", "9.4.0")], sources: ["https://example.com/feed-a/index.json"], workingDirectory: appHostDirectory.FullName); - var resultB = await service.RestorePackagesAsync( [("Aspire.Hosting.JavaScript", "9.4.0")], sources: ["https://example.com/feed-b/index.json"], @@ -89,346 +71,273 @@ public async Task RestorePackagesAsync_UsesDistinctCachePathsForDifferentSources } [Fact] - public async Task RestorePackagesAsync_PassesNuGetConfigToRestore() + public void ComputePackageHash_IgnoresSourceOrder() { - using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - - var appHostDirectory = workspace.CreateDirectory("apphost"); - var layoutRoot = workspace.CreateDirectory("layout"); - var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName); - var managedPath = Path.Combine( - managedDirectory.FullName, - BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); - File.WriteAllText(managedPath, string.Empty); + var packageList = new List<(string Id, string Version)> + { + ("Aspire.Hosting.JavaScript", "9.4.0") + }; - var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"); - File.WriteAllText(nugetConfigPath, ""); + var resultA = BundleNuGetService.ComputePackageHash( + packageList, + "net10.0", + runtimeIdentifier: null, + sources: ["https://example.com/feed-a/index.json", "https://example.com/feed-b/index.json"]); + var resultB = BundleNuGetService.ComputePackageHash( + packageList, + "net10.0", + runtimeIdentifier: null, + sources: ["https://example.com/feed-b/index.json", "https://example.com/feed-a/index.json"]); + + Assert.Equal(resultA, resultB); + } - List invocations = []; - var executionFactory = new TestProcessExecutionFactory + [Fact] + public void ComputePackageHash_ChangesWhenRestoreToolChanges() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var toolPath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.dll"); + File.WriteAllText(toolPath, "original implementation"); + var packageList = new List<(string Id, string Version)> { - AssertionCallback = (args, _, _, _) => invocations.Add(args.ToArray()) + ("Aspire.Hosting.JavaScript", "9.4.0") }; - var service = new BundleNuGetService( - new FixedLayoutDiscovery(new LayoutConfiguration { LayoutPath = layoutRoot.FullName }), - new LayoutProcessRunner(executionFactory), - new TestFeatures(), - new TestEnvironment(), - NullLogger.Instance); + var originalHash = BundleNuGetService.ComputePackageHash(packageList, "net10.0", runtimeIdentifier: null, toolPath); + File.WriteAllText(toolPath, "updated implementation with a different size"); + var updatedHash = BundleNuGetService.ComputePackageHash(packageList, "net10.0", runtimeIdentifier: null, toolPath); - await service.RestorePackagesAsync( - [("Aspire.Hosting.JavaScript", "9.4.0")], - workingDirectory: appHostDirectory.FullName, - nugetConfigPath: nugetConfigPath); + // An updated CLI must not reuse manifests produced by the previous implementation. + Assert.NotEqual(originalHash, updatedHash); + } - Assert.Equal(nugetConfigPath, GetArgumentValue(invocations[0], "--nuget-config")); + [Fact] + public void GetRestoreToolPath_UsesCliAssemblyForManagedLaunch() + { + // Tests run the CLI assembly under a managed host, like `dotnet aspire.dll`, where Environment.ProcessPath is + // the host rather than the code performing the restore. + var toolPath = BundleNuGetService.GetRestoreToolPath(); + + Assert.Equal( + Path.Combine(AppContext.BaseDirectory, $"{typeof(BundleNuGetService).Assembly.GetName().Name}.dll"), + toolPath); + Assert.NotEqual(Environment.ProcessPath, toolPath); } [Fact] - public async Task RestorePackagesAsync_UsesCachedManifestWithoutRunningHelper() + public async Task RestorePackagesAsync_RestoreFailureReportsHelperOutput() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - var appHostDirectory = workspace.CreateDirectory("apphost"); - var layoutRoot = workspace.CreateDirectory("layout"); - var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName); - var managedPath = Path.Combine( - managedDirectory.FullName, - BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); - File.WriteAllText(managedPath, string.Empty); - - var packageList = new List<(string Id, string Version)> { ("Aspire.Hosting.JavaScript", "9.4.0") }; - var packageHash = BundleNuGetService.ComputePackageHash(packageList, "net10.0", null, managedPath); - var manifestPath = Path.Combine( - workspace.WorkspaceRoot.FullName, - ".aspire", - "integrations", - "package-restore", - packageHash, - "integration-package-probe-manifest.json"); - Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); - File.WriteAllText(manifestPath, "{}"); - - List invocations = []; - var executionFactory = new TestProcessExecutionFactory + var output = "ERROR: NU1101: Unable to find package Missing.Package." + Environment.NewLine + + "Error: Restore failed: NU1101: Unable to find package Missing.Package." + Environment.NewLine; + var nuGetClient = new FakeNuGetClient { - AssertionCallback = (args, _, _, _) => invocations.Add(args.ToArray()) + RestoreCallback = (_, _, _, _, _, _, _, _) => throw new NuGetOperationException(output) }; + var service = CreateService(nuGetClient); - var service = new BundleNuGetService( - new FixedLayoutDiscovery(new LayoutConfiguration { LayoutPath = layoutRoot.FullName }), - new LayoutProcessRunner(executionFactory), - new TestFeatures(), - new TestEnvironment(), - NullLogger.Instance); - - var result = await service.RestorePackagesAsync(packageList, workingDirectory: appHostDirectory.FullName); + var exception = await Assert.ThrowsAsync(() => service.RestorePackagesAsync( + [("Missing.Package", "1.0.0")], + workingDirectory: appHostDirectory.FullName)); - Assert.Equal(manifestPath, result); - Assert.Empty(invocations); + Assert.Equal($"Package restore failed: {output}", exception.Message); + Assert.Equal(0, nuGetClient.WriteManifestCallCount); } [Fact] - public async Task RestorePackagesAsync_RegeneratesCachedManifestWhenManifestIsInvalid() + public async Task RestorePackagesAsync_ManifestFailureReportsHelperOutput() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - var appHostDirectory = workspace.CreateDirectory("apphost"); - var layoutRoot = workspace.CreateDirectory("layout"); - var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName); - var managedPath = Path.Combine( - managedDirectory.FullName, - BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); - File.WriteAllText(managedPath, string.Empty); - - var packageList = new List<(string Id, string Version)> { ("Aspire.Hosting.JavaScript", "9.4.0") }; - var packageHash = BundleNuGetService.ComputePackageHash(packageList, "net10.0", null, managedPath); - var manifestPath = Path.Combine( - workspace.WorkspaceRoot.FullName, - ".aspire", - "integrations", - "package-restore", - packageHash, - "integration-package-probe-manifest.json"); - Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); - File.WriteAllText(manifestPath, "{ invalid json"); - - List invocations = []; - var executionFactory = new TestProcessExecutionFactory + var output = "Error: Assets file not found." + Environment.NewLine; + var nuGetClient = new FakeNuGetClient { - AssertionCallback = (args, _, _, _) => - { - invocations.Add(args.ToArray()); - if (args.Contains("manifest")) - { - File.WriteAllText(manifestPath, """{"managedAssemblies":[],"nativeLibraries":[]}"""); - } - } + WriteManifestCallback = (_, _, _, _, _) => throw new NuGetOperationException(output) }; + var service = CreateService(nuGetClient); - var service = new BundleNuGetService( - new FixedLayoutDiscovery(new LayoutConfiguration { LayoutPath = layoutRoot.FullName }), - new LayoutProcessRunner(executionFactory), - new TestFeatures(), - new TestEnvironment(), - NullLogger.Instance); - - var result = await service.RestorePackagesAsync(packageList, workingDirectory: appHostDirectory.FullName); + var exception = await Assert.ThrowsAsync(() => service.RestorePackagesAsync( + [("Aspire.Hosting.JavaScript", "9.4.0")], + workingDirectory: appHostDirectory.FullName)); - Assert.Equal(manifestPath, result); - Assert.Equal(2, invocations.Count); - Assert.Equal("restore", invocations[0][1]); - Assert.Equal("manifest", invocations[1][1]); + Assert.Equal($"Manifest creation failed: {output}", exception.Message); } [Fact] - public async Task RestorePackagesAsync_UsesDistinctCachePathsWhenManagedHelperChanges() + public async Task RestorePackagesAsync_UsesCachedValidManifest() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - var appHostDirectory = workspace.CreateDirectory("apphost"); - var layoutRoot = workspace.CreateDirectory("layout"); - var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName); - var managedPath = Path.Combine( - managedDirectory.FullName, - BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); - File.WriteAllText(managedPath, "v1"); - - var executionFactory = new TestProcessExecutionFactory(); - var service = new BundleNuGetService( - new FixedLayoutDiscovery(new LayoutConfiguration { LayoutPath = layoutRoot.FullName }), - new LayoutProcessRunner(executionFactory), - new TestFeatures(), - new TestEnvironment(), - NullLogger.Instance); + var packageList = new List<(string Id, string Version)> + { + ("Aspire.Hosting.JavaScript", "9.4.0") + }; + var manifestPath = Path.Combine(GetRestoreDirectory(workspace, packageList), ManifestFileName); + Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); + File.WriteAllText(manifestPath, """{"managedAssemblies":[],"nativeLibraries":[]}"""); + var nuGetClient = new FakeNuGetClient(); + var service = CreateService(nuGetClient); - var resultA = await service.RestorePackagesAsync( - [("Aspire.Hosting.JavaScript", "9.4.0")], + var result = await service.RestorePackagesAsync( + packageList, workingDirectory: appHostDirectory.FullName); - File.WriteAllText(managedPath, "v2-changed"); + Assert.Equal(manifestPath, result); + Assert.Equal(0, nuGetClient.RestoreCallCount); + Assert.Equal(0, nuGetClient.WriteManifestCallCount); + } - var resultB = await service.RestorePackagesAsync( - [("Aspire.Hosting.JavaScript", "9.4.0")], + [Fact] + public async Task RestorePackagesAsync_RegeneratesInvalidCachedManifest() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var appHostDirectory = workspace.CreateDirectory("apphost"); + var packageList = new List<(string Id, string Version)> + { + ("Aspire.Hosting.JavaScript", "9.4.0") + }; + var manifestPath = Path.Combine(GetRestoreDirectory(workspace, packageList), ManifestFileName); + Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); + File.WriteAllText(manifestPath, "{ invalid json"); + var nuGetClient = new FakeNuGetClient(); + var service = CreateService(nuGetClient); + + var result = await service.RestorePackagesAsync( + packageList, workingDirectory: appHostDirectory.FullName); - Assert.NotEqual(resultA, resultB); + Assert.Equal(manifestPath, result); + Assert.Equal(1, nuGetClient.RestoreCallCount); + Assert.Equal(1, nuGetClient.WriteManifestCallCount); + Assert.Equal("""{"managedAssemblies":[],"nativeLibraries":[]}""", File.ReadAllText(manifestPath)); } [Fact] public async Task RestorePackagesAsync_SharesRestoreCacheAcrossAppHostsInSameWorkspace() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - var firstAppHost = workspace.CreateDirectory(Path.Combine("apps", "api")); var secondAppHost = workspace.CreateDirectory(Path.Combine("apps", "web")); - var layoutRoot = workspace.CreateDirectory("layout"); - var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName); - var managedPath = Path.Combine( - managedDirectory.FullName, - BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); - File.WriteAllText(managedPath, string.Empty); - - var executionFactory = new TestProcessExecutionFactory(); - var service = new BundleNuGetService( - new FixedLayoutDiscovery(new LayoutConfiguration { LayoutPath = layoutRoot.FullName }), - new LayoutProcessRunner(executionFactory), - new TestFeatures(), - new TestEnvironment(), - NullLogger.Instance); - + var nuGetClient = new FakeNuGetClient(); + var service = CreateService(nuGetClient); var restoreRoot = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "integrations", "package-restore"); - // Same packages + sources across two apphosts in one workspace should share the cache. - var sharedManifestFirst = await service.RestorePackagesAsync( + var firstManifest = await service.RestorePackagesAsync( [("Aspire.Hosting.JavaScript", "9.4.0")], workingDirectory: firstAppHost.FullName); - var sharedManifestSecond = await service.RestorePackagesAsync( + var secondManifest = await service.RestorePackagesAsync( [("Aspire.Hosting.JavaScript", "9.4.0")], workingDirectory: secondAppHost.FullName); - Assert.StartsWith(restoreRoot, sharedManifestFirst, StringComparison.OrdinalIgnoreCase); - Assert.StartsWith(restoreRoot, sharedManifestSecond, StringComparison.OrdinalIgnoreCase); - Assert.Equal(sharedManifestFirst, sharedManifestSecond); + Assert.StartsWith(restoreRoot, firstManifest, StringComparison.OrdinalIgnoreCase); + Assert.Equal(firstManifest, secondManifest); + Assert.Equal(1, nuGetClient.RestoreCallCount); - // Different package sets must NOT collide even when workspace is shared. + // A different package set must not collide with the shared entry even though the workspace is shared. var divergedManifest = await service.RestorePackagesAsync( [("Aspire.Hosting.Python", "9.4.0")], workingDirectory: secondAppHost.FullName); Assert.StartsWith(restoreRoot, divergedManifest, StringComparison.OrdinalIgnoreCase); - Assert.NotEqual(sharedManifestSecond, divergedManifest); + Assert.NotEqual(secondManifest, divergedManifest); + Assert.Equal(2, nuGetClient.RestoreCallCount); } [Fact] - public async Task RestorePackagesAsync_SerializesConcurrentRestoreForSameCachePath() + public async Task RestorePackagesAsync_IgnoresLockedLegacyLibsDirectory() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - var appHostDirectory = workspace.CreateDirectory("apphost"); - var layoutRoot = workspace.CreateDirectory("layout"); - var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName); - var managedPath = Path.Combine( - managedDirectory.FullName, - BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); - File.WriteAllText(managedPath, string.Empty); - - var invocations = new ConcurrentQueue(); - var firstRestoreStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var allowFirstRestoreToComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var restoreAttemptCount = 0; - var manifestAttemptCount = 0; - - var executionFactory = new TestProcessExecutionFactory + var packageList = new List<(string Id, string Version)> { - AssertionCallback = (args, _, _, _) => invocations.Enqueue(args.ToArray()), - AsyncAttemptCallback = async (attempt, _, cancellationToken) => - { - var args = invocations.ElementAt(attempt - 1); - if (args.Contains("restore")) - { - if (Interlocked.Increment(ref restoreAttemptCount) == 1) - { - firstRestoreStarted.SetResult(); - await allowFirstRestoreToComplete.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - } - - return (0, null); - } - - if (args.Contains("manifest")) - { - Interlocked.Increment(ref manifestAttemptCount); - await File.WriteAllTextAsync( - GetArgumentValue(args, "--output"), - """{"managedAssemblies":[],"nativeLibraries":[]}""", - cancellationToken).ConfigureAwait(false); - } - - return (0, null); - } + ("Aspire.Hosting.JavaScript", "9.4.0") }; + var restoreDirectory = GetRestoreDirectory(workspace, packageList); - var service = new BundleNuGetService( - new FixedLayoutDiscovery(new LayoutConfiguration { LayoutPath = layoutRoot.FullName }), - new LayoutProcessRunner(executionFactory), - new TestFeatures(), - new TestEnvironment(), - NullLogger.Instance); - - var packageList = new List<(string Id, string Version)> { ("Aspire.Hosting.JavaScript", "9.4.0") }; - var firstRestoreTask = service.RestorePackagesAsync(packageList, workingDirectory: appHostDirectory.FullName); - await firstRestoreStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); + // Older CLIs copied package assets into a libs directory next to the manifest, and an AppHost that is + // still running can hold those files open. Restore must neither clean up nor rebuild that directory. + var legacyLibsDirectory = Directory.CreateDirectory(Path.Combine(restoreDirectory, "libs")); + var lockedFilePath = Path.Combine(legacyLibsDirectory.FullName, "Microsoft.Extensions.DependencyInjection.xml"); + File.WriteAllText(lockedFilePath, "legacy"); + var nuGetClient = new FakeNuGetClient(); + var service = CreateService(nuGetClient); - var secondRestoreTask = service.RestorePackagesAsync(packageList, workingDirectory: appHostDirectory.FullName); - allowFirstRestoreToComplete.SetResult(); + using var lockedFile = new FileStream(lockedFilePath, FileMode.Open, FileAccess.Read, FileShare.None); - var manifests = await Task.WhenAll(firstRestoreTask, secondRestoreTask); + var result = await service.RestorePackagesAsync( + packageList, + workingDirectory: appHostDirectory.FullName); - Assert.Equal(manifests[0], manifests[1]); - Assert.Equal(1, restoreAttemptCount); - Assert.Equal(1, manifestAttemptCount); - Assert.Equal(2, invocations.Count); + Assert.Equal(Path.Combine(restoreDirectory, ManifestFileName), result); + Assert.Equal(1, nuGetClient.RestoreCallCount); + Assert.Equal(1, nuGetClient.WriteManifestCallCount); } [Fact] - public async Task RestorePackagesAsync_IgnoresLockedLegacyLibsDirectory() + public async Task RestorePackagesAsync_SerializesConcurrentRestoreForSameCachePath() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - var appHostDirectory = workspace.CreateDirectory("apphost"); - var layoutRoot = workspace.CreateDirectory("layout"); - var managedDirectory = layoutRoot.CreateSubdirectory(BundleDiscovery.ManagedDirectoryName); - var managedPath = Path.Combine( - managedDirectory.FullName, - BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); - File.WriteAllText(managedPath, string.Empty); - - var packageList = new List<(string Id, string Version)> { ("Aspire.Hosting.JavaScript", "9.4.0") }; - var packageHash = BundleNuGetService.ComputePackageHash(packageList, "net10.0", null, managedPath); - var restoreDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "integrations", "package-restore", packageHash); - var legacyLibsDirectory = Path.Combine(restoreDirectory, "libs"); - Directory.CreateDirectory(legacyLibsDirectory); - var lockedFilePath = Path.Combine(legacyLibsDirectory, "Microsoft.Extensions.DependencyInjection.xml"); - File.WriteAllText(lockedFilePath, "legacy"); - - List invocations = []; - var executionFactory = new TestProcessExecutionFactory + var firstRestoreStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowFirstRestoreToComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var nuGetClient = new FakeNuGetClient { - AssertionCallback = (args, _, _, _) => invocations.Add(args.ToArray()) + RestoreCallback = async (_, _, _, _, _, _, _, cancellationToken) => + { + firstRestoreStarted.TrySetResult(); + await allowFirstRestoreToComplete.Task.WaitAsync(cancellationToken); + } + }; + var service = CreateService(nuGetClient); + var packageList = new List<(string Id, string Version)> + { + ("Aspire.Hosting.JavaScript", "9.4.0") }; - var service = new BundleNuGetService( - new FixedLayoutDiscovery(new LayoutConfiguration { LayoutPath = layoutRoot.FullName }), - new LayoutProcessRunner(executionFactory), - new TestFeatures(), - new TestEnvironment(), - NullLogger.Instance); + var firstRestoreTask = service.RestorePackagesAsync( + packageList, + workingDirectory: appHostDirectory.FullName); + await firstRestoreStarted.Task.WaitAsync(TimeSpan.FromSeconds(10)); - using var lockedFile = new FileStream(lockedFilePath, FileMode.Open, FileAccess.Read, FileShare.None); + var secondRestoreTask = service.RestorePackagesAsync( + packageList, + workingDirectory: appHostDirectory.FullName); + allowFirstRestoreToComplete.SetResult(); - var result = await service.RestorePackagesAsync(packageList, workingDirectory: appHostDirectory.FullName); + var manifests = await Task.WhenAll(firstRestoreTask, secondRestoreTask); - Assert.Equal(Path.Combine(restoreDirectory, "integration-package-probe-manifest.json"), result); - Assert.Equal(2, invocations.Count); - Assert.DoesNotContain(invocations, args => args.Contains("layout")); - Assert.Equal("manifest", invocations[1][1]); + Assert.Equal(manifests[0], manifests[1]); + Assert.Equal(1, nuGetClient.RestoreCallCount); + Assert.Equal(1, nuGetClient.WriteManifestCallCount); } - private static string GetArgumentValue(string[] arguments, string optionName) + private const string ManifestFileName = "integration-package-probe-manifest.json"; + + private static BundleNuGetService CreateService(INuGetClient nuGetClient) { - var optionIndex = Array.IndexOf(arguments, optionName); - Assert.True(optionIndex >= 0 && optionIndex < arguments.Length - 1, $"Option '{optionName}' was not found."); - return arguments[optionIndex + 1]; + return new BundleNuGetService( + NullLogger.Instance, + nuGetClient); } - private sealed class FixedLayoutDiscovery(LayoutConfiguration layout) : ILayoutDiscovery + /// + /// Returns the cache directory uses for the packages + /// with the default framework, no runtime identifier, and no explicit sources. + /// + private static string GetRestoreDirectory(TemporaryWorkspace workspace, List<(string Id, string Version)> packages) { - public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null) => layout; + var packageHash = BundleNuGetService.ComputePackageHash( + packages, + "net10.0", + runtimeIdentifier: null, + BundleNuGetService.GetRestoreToolPath()); - public string? GetComponentPath(LayoutComponent component, string? projectDirectory = null) => layout.GetComponentPath(component); - - public bool IsBundleModeAvailable(string? projectDirectory = null) => true; + return Path.Combine( + workspace.WorkspaceRoot.FullName, + ".aspire", + "integrations", + "package-restore", + packageHash); } } diff --git a/tests/Aspire.Cli.Tests/NuGet/NuGetClientTests.cs b/tests/Aspire.Cli.Tests/NuGet/NuGetClientTests.cs new file mode 100644 index 00000000000..044f3170020 --- /dev/null +++ b/tests/Aspire.Cli.Tests/NuGet/NuGetClientTests.cs @@ -0,0 +1,1215 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Compression; +using Aspire.Cli.NuGet; +using Aspire.Cli.Tests.TestServices; +using Aspire.Cli.Tests.Utils; +using Aspire.Hosting; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Extensions.Logging.Abstractions; +using NuGet.Configuration; +using NuGet.ProjectModel; +using NuGet.Packaging; +using NuGet.Protocol; + +namespace Aspire.Cli.Tests.NuGet; + +public class NuGetClientTests(ITestOutputHelper outputHelper) +{ + [Fact] + public async Task SearchAsync_ReturnsOnlyTheFirstPage() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var feedDirectory = workspace.CreateDirectory("feed"); + CreatePackage(feedDirectory.FullName, "Aspire.Test.Package.One"); + CreatePackage(feedDirectory.FullName, "Aspire.Test.Package.Two"); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + // The aspire-managed helper requested one page per source and never paged further. + var results = await client.SearchAsync( + "Aspire.Test.Package", + prerelease: false, + take: 1, + [feedDirectory.FullName], + nugetConfigPath: null, + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken); + + Assert.Single(results); + } + + [Fact] + public async Task SearchAsync_KeepsOneEntryPerPackageAcrossSources() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var firstFeedDirectory = workspace.CreateDirectory("first-feed"); + var secondFeedDirectory = workspace.CreateDirectory("second-feed"); + CreatePackage(firstFeedDirectory.FullName, "Aspire.Test.Package"); + CreatePackage(secondFeedDirectory.FullName, "Aspire.Test.Package", version: "2.0.0"); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + var results = await client.SearchAsync( + "Aspire.Test.Package", + prerelease: false, + take: 1000, + [firstFeedDirectory.FullName, secondFeedDirectory.FullName], + nugetConfigPath: null, + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken); + + // Only the winning source's entry survives, so its versions are not merged with the other source's. + var package = Assert.Single(results); + Assert.Equal("Aspire.Test.Package", package.Id); + Assert.Equal("2.0.0", package.Version); + Assert.Equal(secondFeedDirectory.FullName, package.Source); + Assert.Equal(["2.0.0"], package.AllVersions); + } + + [Fact] + public async Task SearchAsync_ComparesVersionsAsStringsAcrossSources() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var firstFeedDirectory = workspace.CreateDirectory("first-feed"); + var secondFeedDirectory = workspace.CreateDirectory("second-feed"); + CreatePackage(firstFeedDirectory.FullName, "Aspire.Test.Package", version: "9.0.0"); + CreatePackage(secondFeedDirectory.FullName, "Aspire.Test.Package", version: "10.0.0"); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + var results = await client.SearchAsync( + "Aspire.Test.Package", + prerelease: false, + take: 1000, + [firstFeedDirectory.FullName, secondFeedDirectory.FullName], + nugetConfigPath: null, + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken); + + // The aspire-managed helper picked the entry with the highest version *string*, so "9.0.0" wins over + // "10.0.0". This pins that behavior so a change to it is a deliberate decision rather than a side effect. + var package = Assert.Single(results); + Assert.Equal("9.0.0", package.Version); + Assert.Equal(firstFeedDirectory.FullName, package.Source); + } + + [Fact] + public async Task SearchAsync_FallsBackToDiscoveryWhenConfigFileIsMissing() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var feedDirectory = workspace.CreateDirectory("feed"); + CreatePackage(feedDirectory.FullName, "Aspire.Test.Package"); + File.WriteAllText( + Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"), + $""" + + + + + + + """); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + var results = await client.SearchAsync( + "Aspire.Test.Package", + prerelease: false, + take: 1000, + [], + nugetConfigPath: Path.Combine(workspace.WorkspaceRoot.FullName, "missing", "nuget.config"), + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken); + + var package = Assert.Single(results); + Assert.Equal("Aspire.Test.Package", package.Id); + Assert.Equal(feedDirectory.FullName, package.Source); + } + + [Fact] + public async Task SearchAsync_ReturnsResultsWhenOneSourceFails() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var feedDirectory = workspace.CreateDirectory("feed"); + CreatePackage(feedDirectory.FullName, "Aspire.Test.Package"); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + var results = await client.SearchAsync( + "Aspire.Test.Package", + prerelease: false, + take: 100, + ["https://127.0.0.1:1/v3/index.json", feedDirectory.FullName], + nugetConfigPath: null, + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken); + + var package = Assert.Single(results); + Assert.Equal("Aspire.Test.Package", package.Id); + } + + [Fact] + public async Task RestoreAsync_FailureReportsHelperOutput() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var feedDirectory = workspace.CreateDirectory("feed"); + var packagesDirectory = workspace.CreateDirectory("packages"); + var restoreDirectory = workspace.CreateDirectory("restore"); + var packageId = $"Aspire.Test.Missing.{Guid.NewGuid():N}"; + var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"); + File.WriteAllText( + nugetConfigPath, + $""" + + + + + + + + + + """); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + var exception = await Assert.ThrowsAsync(() => client.RestoreAsync( + [(packageId, "[1.0.0]")], + "net10.0", + runtimeIdentifier: null, + restoreDirectory.FullName, + [], + nugetConfigPath, + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken)); + + // Without debug logging the helper wrote only NuGet's warnings and errors, prefixed the way its logger + // prefixed them, followed by its own summary of the failed restore. + var lines = exception.Output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.Contains(lines, line => line.StartsWith("ERROR: ", StringComparison.Ordinal) && line.Contains(packageId, StringComparison.OrdinalIgnoreCase)); + Assert.Contains(lines, line => line.StartsWith("Error: Restore failed: ", StringComparison.Ordinal)); + } + + // The tests below observe process-wide state -- the real environment and NuGet's static credential service -- so + // each runs in its own process, where no other test's operation can overlap it. + + [Fact] + public void RestoreAsync_RestoresSignatureVerificationVariableAfterSuccess() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var feedDirectory = workspace.CreateDirectory("feed"); + var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}"; + CreatePackage(feedDirectory.FullName, packageId); + var nugetConfigPath = CreateLocalFeedConfig(workspace, feedDirectory, workspace.CreateDirectory("packages")); + + RemoteExecutor.Invoke( + static async (packageId, configPath, restorePath, workingDirectory) => + { + Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, null); + var client = new NuGetClient(new TestFeatures(), TestEnvironment.CreateLinux(), NullLogger.Instance); + + await client.RestoreAsync( + [(packageId, "[1.0.0]")], + "net10.0", + runtimeIdentifier: null, + restorePath, + [], + configPath, + workingDirectory, + CancellationToken.None); + + Assert.Null(Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + }, + packageId, + nugetConfigPath, + workspace.CreateDirectory("restore").FullName, + workspace.WorkspaceRoot.FullName).Dispose(); + } + + [Fact] + public void RestoreAsync_RestoresSignatureVerificationVariableAfterFailure() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var nugetConfigPath = CreateLocalFeedConfig(workspace, workspace.CreateDirectory("feed"), workspace.CreateDirectory("packages")); + + RemoteExecutor.Invoke( + static async (configPath, restorePath, workingDirectory) => + { + Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, null); + var client = new NuGetClient(new TestFeatures(), TestEnvironment.CreateLinux(), NullLogger.Instance); + + await Assert.ThrowsAsync(() => client.RestoreAsync( + [($"Aspire.Test.Missing.{Guid.NewGuid():N}", "[1.0.0]")], + "net10.0", + runtimeIdentifier: null, + restorePath, + [], + configPath, + workingDirectory, + CancellationToken.None)); + + Assert.Null(Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + }, + nugetConfigPath, + workspace.CreateDirectory("restore").FullName, + workspace.WorkspaceRoot.FullName).Dispose(); + } + + [Fact] + public void RestoreAsync_RestoresSignatureVerificationVariableAfterCancellation() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var feedDirectory = workspace.CreateDirectory("feed"); + var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}"; + CreatePackage(feedDirectory.FullName, packageId); + var nugetConfigPath = CreateLocalFeedConfig(workspace, feedDirectory, workspace.CreateDirectory("packages")); + + RemoteExecutor.Invoke( + static async (packageId, configPath, restorePath, workingDirectory) => + { + Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, null); + var client = new NuGetClient(new TestFeatures(), TestEnvironment.CreateLinux(), NullLogger.Instance); + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.Cancel(); + + await Assert.ThrowsAnyAsync(() => client.RestoreAsync( + [(packageId, "[1.0.0]")], + "net10.0", + runtimeIdentifier: null, + restorePath, + [], + configPath, + workingDirectory, + cancellationSource.Token)); + + Assert.Null(Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + }, + packageId, + nugetConfigPath, + workspace.CreateDirectory("restore").FullName, + workspace.WorkspaceRoot.FullName).Dispose(); + } + + [Fact] + public void BeginOperation_ResetsNuGetStateWhenLastOverlappingOperationEnds() + { + RemoteExecutor.Invoke(static () => + { + var client = new NuGetClient(new TestFeatures(), new TestEnvironment(), NullLogger.Instance); + + var first = client.BeginOperation(); + var second = client.BeginOperation(); + Assert.NotNull(HttpHandlerResourceV3.CredentialService); + + // Ending one of two overlapping operations, even twice, must not reset state the other is still using. + first.Dispose(); + first.Dispose(); + Assert.NotNull(HttpHandlerResourceV3.CredentialService); + + second.Dispose(); + Assert.Null(HttpHandlerResourceV3.CredentialService); + + // The next operation sets the credential service up again after the reset. + using (client.BeginOperation()) + { + Assert.NotNull(HttpHandlerResourceV3.CredentialService); + } + + Assert.Null(HttpHandlerResourceV3.CredentialService); + }).Dispose(); + } + + [Fact] + public void SearchAsync_ResetsNuGetStateWhenComplete() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var feedDirectory = workspace.CreateDirectory("feed"); + CreatePackage(feedDirectory.FullName, "Aspire.Test.Package"); + + RemoteExecutor.Invoke( + static async (feedPath, workingDirectory) => + { + var client = new NuGetClient(new TestFeatures(), new TestEnvironment(), NullLogger.Instance); + + var results = await client.SearchAsync( + "Aspire.Test.Package", + prerelease: false, + take: 1000, + [feedPath], + nugetConfigPath: null, + workingDirectory, + CancellationToken.None); + + Assert.Single(results); + Assert.Null(HttpHandlerResourceV3.CredentialService); + }, + feedDirectory.FullName, + workspace.WorkspaceRoot.FullName).Dispose(); + } + + [Fact] + public async Task RestoreAndWriteManifestAsync_UsesLocalPackageRuntimeAssets() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var feedDirectory = workspace.CreateDirectory("feed"); + var packagesDirectory = workspace.CreateDirectory("packages"); + var restoreDirectory = workspace.CreateDirectory("restore"); + var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}"; + CreatePackage(feedDirectory.FullName, packageId); + + var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"); + File.WriteAllText( + nugetConfigPath, + $""" + + + + + + + + + + """); + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + string? packageRoot = null; + try + { + await client.RestoreAsync( + [(packageId, "[1.0.0]")], + "net10.0", + "win-x64", + restoreDirectory.FullName, + [], + nugetConfigPath, + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken); + var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName, + nugetConfigPath, workspace.WorkspaceRoot.FullName); + packageRoot = Path.GetDirectoryName(restoredPackages[0].InstallPath); + var manifestPath = Path.Combine(restoreDirectory.FullName, IntegrationPackageProbeManifest.FileName); + await client.WriteManifestAsync( + Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName), + manifestPath, + "net10.0", + "win-x64", + TestContext.Current.CancellationToken); + + // The manifest points at assets in the global packages folder; older CLIs copied them into a libs + // directory beside it instead. + Assert.False(Directory.Exists(Path.Combine(restoreDirectory.FullName, "libs"))); + var manifest = IntegrationPackageProbeManifest.Load(manifestPath); + Assert.Equal( + Path.Combine( + restoredPackages[0].InstallPath, + "runtimes", + "win-x64", + "lib", + "net10.0", + "Aspire.Test.Package.dll"), + manifest.TryGetManagedAssemblyPath(new("Aspire.Test.Package"))); + Assert.EndsWith( + Path.Combine("runtimes", "win-x64", "lib", "net10.0", "RuntimeOnly.dll"), + manifest.TryGetManagedAssemblyPath(new("RuntimeOnly")), + StringComparison.OrdinalIgnoreCase); + Assert.EndsWith( + Path.Combine("runtimes", "win-x64", "lib", "net10.0", "Neutral.resources.dll"), + manifest.TryGetManagedAssemblyPath(new("Neutral.resources")), + StringComparison.OrdinalIgnoreCase); + Assert.EndsWith( + Path.Combine("runtimes", "win-x64", "lib", "net10.0", "fr", "RuntimeOnly.resources.dll"), + manifest.TryGetManagedAssemblyPath(new("RuntimeOnly.resources, Culture=fr")), + StringComparison.OrdinalIgnoreCase); + Assert.EndsWith( + Path.Combine("runtimes", "win-x64", "lib", "net10.0", "de", "RuntimeOnly.resources.dll"), + manifest.TryGetManagedAssemblyPath(new("RuntimeOnly.resources, Culture=de")), + StringComparison.OrdinalIgnoreCase); + Assert.Single(manifest.GetNativeLibraryPaths("native-test")); + } + finally + { + if (packageRoot is not null) + { + Directory.Delete(packageRoot, recursive: true); + } + } + } + + [Fact] + public void RestoreAsync_RespectsNuGetPackagesEnvironmentVariable() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var feedDirectory = workspace.CreateDirectory("feed"); + var environmentPackagesDirectory = workspace.CreateDirectory("env-packages"); + var restoreDirectory = workspace.CreateDirectory("restore"); + var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}"; + CreatePackage(feedDirectory.FullName, packageId); + + // No globalPackagesFolder entry: this covers the environment override specifically, which is + // resolved by NuGet's settings rather than by anything this client passes explicitly. + var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"); + File.WriteAllText( + nugetConfigPath, + $""" + + + + + + + """); + + // The restore runs in a child process because NUGET_PACKAGES is read once when NuGet's + // settings are first loaded, and the test host has its own value pointing at a shared cache. + // The variable is set both on the child's start info and again inside the child: passing it + // through start info alone did not reach the child reliably when other classes ran alongside + // this one. + var options = new RemoteInvokeOptions(); + options.StartInfo.Environment["NUGET_PACKAGES"] = environmentPackagesDirectory.FullName; + + RemoteExecutor.Invoke( + static async (packageId, configPath, restorePath, workingDirectory, packagesDirectory) => + { + Environment.SetEnvironmentVariable("NUGET_PACKAGES", packagesDirectory); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + await client.RestoreAsync( + [(packageId, "[1.0.0]")], + "net10.0", + runtimeIdentifier: null, + restorePath, + [], + configPath, + workingDirectory, + CancellationToken.None); + }, + packageId, + nugetConfigPath, + restoreDirectory.FullName, + workspace.WorkspaceRoot.FullName, + environmentPackagesDirectory.FullName, + options).Dispose(); + + // NuGet records the resolved packages folder in the assets file, which is also what feeds + // the restore cache key, so assert on it rather than only on the extracted files. + var assets = new LockFileFormat().Read(Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName)); + Assert.Contains( + assets.PackageFolders, + folder => string.Equals( + Path.TrimEndingDirectorySeparator(folder.Path), + Path.TrimEndingDirectorySeparator(environmentPackagesDirectory.FullName), + StringComparison.OrdinalIgnoreCase)); + + // The package must actually land under the override, not merely be referenced from it. + Assert.True( + Directory.Exists(Path.Combine(environmentPackagesDirectory.FullName, packageId.ToLowerInvariant(), "1.0.0")), + $"Expected '{packageId}' to be installed under the NUGET_PACKAGES directory."); + } + + [Fact] + public void RestoreAsync_RespectsNuGetConfigGlobalPackagesFolder() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var feedDirectory = workspace.CreateDirectory("feed"); + var configPackagesDirectory = workspace.CreateDirectory("config-packages"); + var restoreDirectory = workspace.CreateDirectory("restore"); + var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}"; + CreatePackage(feedDirectory.FullName, packageId); + var nugetConfigPath = CreateLocalFeedConfig(workspace, feedDirectory, configPackagesDirectory); + + // NUGET_PACKAGES takes precedence over globalPackagesFolder, and the test host often sets it to a shared + // cache, so the restore runs in a child process without it. The variable is cleared inside the child + // as well, for the same reason the NUGET_PACKAGES test sets it there. + var options = new RemoteInvokeOptions(); + options.StartInfo.Environment.Remove("NUGET_PACKAGES"); + + RemoteExecutor.Invoke( + static async (packageId, configPath, restorePath, workingDirectory) => + { + Environment.SetEnvironmentVariable("NUGET_PACKAGES", null); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + await client.RestoreAsync( + [(packageId, "[1.0.0]")], + "net10.0", + runtimeIdentifier: null, + restorePath, + [], + configPath, + workingDirectory, + CancellationToken.None); + }, + packageId, + nugetConfigPath, + restoreDirectory.FullName, + workspace.WorkspaceRoot.FullName, + options).Dispose(); + + var assets = new LockFileFormat().Read(Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName)); + Assert.Contains( + assets.PackageFolders, + folder => string.Equals( + Path.TrimEndingDirectorySeparator(folder.Path), + Path.TrimEndingDirectorySeparator(configPackagesDirectory.FullName), + StringComparison.OrdinalIgnoreCase)); + Assert.True( + Directory.Exists(Path.Combine(configPackagesDirectory.FullName, packageId.ToLowerInvariant(), "1.0.0")), + $"Expected '{packageId}' to be installed under the configured globalPackagesFolder."); + } + + [Fact] + public async Task RestoreAndWriteManifestAsync_UsesRuntimeGraphFallbackAssets() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var feedDirectory = workspace.CreateDirectory("feed"); + var restoreDirectory = workspace.CreateDirectory("restore"); + var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}"; + CreatePackage( + feedDirectory.FullName, + packageId, + additionalEntries: new Dictionary + { + ["runtimes/unix-x64/lib/net10.0/UnixFallback.dll"] = "unix-fallback" + }); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + using var restoredPackageScope = new RestoredPackageScope( + GetEffectiveGlobalPackagesFolder(nugetConfigPath: null, workspace.WorkspaceRoot.FullName), + packageId); + + await client.RestoreAsync( + [(packageId, "1.0.0")], + "net10.0", + "linux-x64", + restoreDirectory.FullName, + [feedDirectory.FullName], + nugetConfigPath: null, + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken); + var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName, + nugetConfigPath: null, workspace.WorkspaceRoot.FullName); + Assert.NotEmpty(restoredPackages); + var manifestPath = Path.Combine(restoreDirectory.FullName, IntegrationPackageProbeManifest.FileName); + await client.WriteManifestAsync( + Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName), + manifestPath, + "net10.0", + "linux-x64", + TestContext.Current.CancellationToken); + + var manifest = IntegrationPackageProbeManifest.Load(manifestPath); + Assert.EndsWith( + Path.Combine("runtimes", "unix-x64", "lib", "net10.0", "UnixFallback.dll"), + manifest.TryGetManagedAssemblyPath(new("UnixFallback")), + StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task RestoreAndWriteManifestAsync_WritesCanonicalPackageIdForLowercaseRequest() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var feedDirectory = workspace.CreateDirectory("feed"); + var packagesDirectory = workspace.CreateDirectory("packages"); + var restoreDirectory = workspace.CreateDirectory("restore"); + var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}"; + CreatePackage( + feedDirectory.FullName, + packageId, + additionalEntries: new Dictionary + { + ["lib/net10.0/Aspire.Test.Package.xml"] = "" + }); + var nugetConfigPath = CreateLocalFeedConfig(workspace, feedDirectory, packagesDirectory); + using var restoredPackageScope = new RestoredPackageScope( + GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName), + packageId); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + // Package IDs are case-insensitive, so a request may use any casing. The manifest's package ID must + // come from the restored package, not from the request. + await client.RestoreAsync( + [(packageId.ToLowerInvariant(), "1.0.0")], + "net10.0", + runtimeIdentifier: null, + restoreDirectory.FullName, + [], + nugetConfigPath, + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken); + var restoredPackage = Assert.Single(ReadRestoredPackages(restoreDirectory.FullName, + nugetConfigPath, workspace.WorkspaceRoot.FullName)); + var manifestPath = Path.Combine(restoreDirectory.FullName, IntegrationPackageProbeManifest.FileName); + await client.WriteManifestAsync( + Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName), + manifestPath, + "net10.0", + runtimeIdentifier: null, + TestContext.Current.CancellationToken); + + // Without a runtime identifier, the assets selected depend on the RID of the machine running the test, + // so check every entry rather than a fixed list. + var manifest = IntegrationPackageProbeManifest.Load(manifestPath); + Assert.NotEmpty(manifest.ManagedAssemblies); + Assert.All(manifest.ManagedAssemblies, assembly => + { + Assert.Equal(packageId, assembly.PackageId); + Assert.StartsWith(restoredPackage.InstallPath, assembly.Path, StringComparison.OrdinalIgnoreCase); + Assert.EndsWith(".dll", assembly.Path, StringComparison.OrdinalIgnoreCase); + }); + } + + [Fact] + public async Task RestoreAsync_AppendsExplicitSourcesToConfiguredSources() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var configuredFeed = workspace.CreateDirectory("configured-feed"); + var explicitFeed = workspace.CreateDirectory("explicit-feed"); + var packagesDirectory = workspace.CreateDirectory("packages"); + var restoreDirectory = workspace.CreateDirectory("restore"); + var configuredPackageId = $"Aspire.Test.Configured.{Guid.NewGuid():N}"; + var explicitPackageId = $"Aspire.Test.Explicit.{Guid.NewGuid():N}"; + CreatePackage(configuredFeed.FullName, configuredPackageId); + CreatePackage(explicitFeed.FullName, explicitPackageId); + var nugetConfigPath = CreateLocalFeedConfig(workspace, configuredFeed, packagesDirectory); + using var restoredPackageScope = new RestoredPackageScope( + GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName), + configuredPackageId, + explicitPackageId); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + // Each package exists on only one feed, so the restore succeeds only if the explicit source is used in + // addition to the configured one rather than in place of it. + await client.RestoreAsync( + [(configuredPackageId, "1.0.0"), (explicitPackageId, "1.0.0")], + "net10.0", + runtimeIdentifier: null, + restoreDirectory.FullName, + [explicitFeed.FullName], + nugetConfigPath, + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken); + var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName, + nugetConfigPath, workspace.WorkspaceRoot.FullName); + + Assert.Equal( + [configuredPackageId, explicitPackageId], + restoredPackages.Select(package => package.Id).Order(StringComparer.Ordinal).ToArray()); + } + + [Fact] + public async Task RestoreAsync_HonorsPackageSourceMapping() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var firstFeed = workspace.CreateDirectory("first-feed"); + var mappedFeed = workspace.CreateDirectory("mapped-feed"); + var packagesDirectory = workspace.CreateDirectory("packages"); + var restoreDirectory = workspace.CreateDirectory("restore"); + var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}"; + CreatePackage(firstFeed.FullName, packageId, "wrong-source"); + CreatePackage(mappedFeed.FullName, packageId, "mapped-source"); + + var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"); + File.WriteAllText( + nugetConfigPath, + $""" + + + + + + + + + + + + + + + + + + + """); + using var restoredPackageScope = new RestoredPackageScope( + GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName), + packageId); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + await client.RestoreAsync( + [(packageId, "1.0.0")], + "net10.0", + runtimeIdentifier: null, + restoreDirectory.FullName, + [], + nugetConfigPath, + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken); + var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName, + nugetConfigPath, workspace.WorkspaceRoot.FullName); + + var restoredPackage = Assert.Single(restoredPackages); + Assert.Equal( + "mapped-source", + await File.ReadAllTextAsync( + Path.Combine(restoredPackage.InstallPath, "lib", "net10.0", "Aspire.Test.Package.dll"), + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task RestoreAsync_PrefersAvailableMappedSourceOverExplicitSource() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var mappedFeed = workspace.CreateDirectory("mapped-feed"); + var explicitFeed = workspace.CreateDirectory("explicit-feed"); + var packagesDirectory = workspace.CreateDirectory("packages"); + var restoreDirectory = workspace.CreateDirectory("restore"); + var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}"; + CreatePackage(mappedFeed.FullName, packageId, "mapped-source"); + CreatePackage(explicitFeed.FullName, packageId, "explicit-source"); + + var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"); + File.WriteAllText( + nugetConfigPath, + $""" + + + + + + + + + + + + + + + + + + """); + using var restoredPackageScope = new RestoredPackageScope( + GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName), + packageId); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + await client.RestoreAsync( + [(packageId, "1.0.0")], + "net10.0", + runtimeIdentifier: null, + restoreDirectory.FullName, + [explicitFeed.FullName], + nugetConfigPath, + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken); + var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName, + nugetConfigPath, workspace.WorkspaceRoot.FullName); + + var restoredPackage = Assert.Single(restoredPackages); + Assert.Equal( + "mapped-source", + await File.ReadAllTextAsync( + Path.Combine(restoredPackage.InstallPath, "lib", "net10.0", "Aspire.Test.Package.dll"), + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task RestoreAsync_RejectsExplicitSourceWhenPackageHasNoMapping() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var explicitFeed = workspace.CreateDirectory("explicit-feed"); + var packagesDirectory = workspace.CreateDirectory("packages"); + var restoreDirectory = workspace.CreateDirectory("restore"); + var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}"; + CreatePackage(explicitFeed.FullName, packageId); + + var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"); + File.WriteAllText( + nugetConfigPath, + $""" + + + + + + + + + + + + + + """); + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + var exception = await Assert.ThrowsAsync(() => client.RestoreAsync( + [(packageId, "1.0.0")], + "net10.0", + runtimeIdentifier: null, + restoreDirectory.FullName, + [explicitFeed.FullName], + nugetConfigPath, + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken)); + + Assert.Contains(packageId, exception.Output, StringComparison.Ordinal); + Assert.Contains("PackageSourceMapping", exception.Output, StringComparison.Ordinal); + } + + [Fact] + public async Task RestoreAsync_SelectsLowestSatisfyingDependencyVersion() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var feedDirectory = workspace.CreateDirectory("feed"); + var packagesDirectory = workspace.CreateDirectory("packages"); + var restoreDirectory = workspace.CreateDirectory("restore"); + var rootPackage = $"Aspire.Test.Package.Root.{Guid.NewGuid():N}"; + var dependencyPackage = $"Aspire.Test.Package.Dep.{Guid.NewGuid():N}"; + var missingPackage = $"Aspire.Test.Package.Missing.{Guid.NewGuid():N}"; + + CreatePackage( + feedDirectory.FullName, + rootPackage, + dependencies: [(dependencyPackage, "1.0.0")]); + CreatePackage(feedDirectory.FullName, dependencyPackage, version: "1.0.0"); + + // Newer versions of the dependency are not selectable under DependencyBehavior.Lowest. + // Expanding them anyway is what made the pre-walk fan out across the whole version history + // of every transitive package and stall restore (#19847), so they declare a dependency that + // does not exist to keep them clearly off the selected path. + CreatePackage( + feedDirectory.FullName, + dependencyPackage, + version: "2.0.0", + dependencies: [(missingPackage, "1.0.0")]); + CreatePackage( + feedDirectory.FullName, + dependencyPackage, + version: "3.0.0", + dependencies: [(missingPackage, "1.0.0")]); + var nugetConfigPath = CreateWorkspaceGlobalPackagesConfig(workspace, packagesDirectory); + using var restoredPackageScope = new RestoredPackageScope( + GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName), + rootPackage, + dependencyPackage); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + await client.RestoreAsync( + [(rootPackage, "1.0.0")], + "net10.0", + runtimeIdentifier: null, + restoreDirectory.FullName, + [feedDirectory.FullName], + nugetConfigPath, + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken); + var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName, + nugetConfigPath, workspace.WorkspaceRoot.FullName); + + Assert.Collection( + restoredPackages.OrderBy(package => package.Id, StringComparer.OrdinalIgnoreCase), + package => + { + Assert.Equal(dependencyPackage, package.Id); + Assert.Equal("1.0.0", package.Version); + }, + package => + { + Assert.Equal(rootPackage, package.Id); + Assert.Equal("1.0.0", package.Version); + }); + } + + [Fact] + public async Task RestoreAsync_ReplacesIncompleteGlobalPackage() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var feedDirectory = workspace.CreateDirectory("feed"); + var packagesDirectory = workspace.CreateDirectory("packages"); + var restoreDirectory = workspace.CreateDirectory("restore"); + var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}"; + CreatePackage(feedDirectory.FullName, packageId); + + var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"); + File.WriteAllText( + nugetConfigPath, + $""" + + + + + + + + + + """); + var settings = Settings.LoadSpecificSettings( + Path.GetDirectoryName(nugetConfigPath)!, + Path.GetFileName(nugetConfigPath)); + var globalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(settings); + var incompleteInstallPath = Path.Combine(globalPackagesFolder, packageId.ToLowerInvariant(), "1.0.0"); + Directory.CreateDirectory(incompleteInstallPath); + File.WriteAllText(Path.Combine(incompleteInstallPath, "partial.txt"), "incomplete"); + + var client = new NuGetClient( + new TestFeatures(), + new TestEnvironment(), + NullLogger.Instance); + + try + { + await client.RestoreAsync( + [(packageId, "1.0.0")], + "net10.0", + runtimeIdentifier: null, + restoreDirectory.FullName, + [], + nugetConfigPath, + workspace.WorkspaceRoot.FullName, + TestContext.Current.CancellationToken); + var package = Assert.Single(ReadRestoredPackages(restoreDirectory.FullName, nugetConfigPath, workspace.WorkspaceRoot.FullName)); + + Assert.Equal(incompleteInstallPath, package.InstallPath, ignoreCase: true); + Assert.True(File.Exists(Path.Combine(package.InstallPath, ".nupkg.metadata"))); + Assert.True(File.Exists(Path.Combine(package.InstallPath, "lib", "net10.0", "Aspire.Test.Package.dll"))); + } + finally + { + Directory.Delete(Path.Combine(globalPackagesFolder, packageId.ToLowerInvariant()), recursive: true); + } + } + + /// + /// Writes a NuGet config that redirects the global packages folder into the temporary workspace + /// so restored packages are removed with the workspace instead of accumulating in the machine's + /// real global packages folder. + /// + private static string CreateWorkspaceGlobalPackagesConfig(TemporaryWorkspace workspace, DirectoryInfo packagesDirectory) + { + var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"); + File.WriteAllText( + nugetConfigPath, + $""" + + + + + + """); + + return nugetConfigPath; + } + + /// + /// Writes a nuget.config with a single local feed and a workspace-scoped global packages folder, so restores + /// neither read the machine's configured feeds nor add packages to its real global packages folder. + /// + private static string CreateLocalFeedConfig(TemporaryWorkspace workspace, DirectoryInfo feedDirectory, DirectoryInfo packagesDirectory) + { + var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"); + File.WriteAllText( + nugetConfigPath, + $""" + + + + + + + + + + """); + + return nugetConfigPath; + } + + /// + /// Resolves the global packages folder the client will actually restore into, using the same + /// settings lookup. NUGET_PACKAGES takes precedence over the globalPackagesFolder config + /// value, so a workspace-scoped config alone does not keep restores out of the machine-wide + /// folder on developer machines or CI agents that set the variable. + /// + private static string GetEffectiveGlobalPackagesFolder(string? nugetConfigPath, string workingDirectory) + { + var settings = nugetConfigPath is not null + ? Settings.LoadSpecificSettings(Path.GetDirectoryName(nugetConfigPath)!, Path.GetFileName(nugetConfigPath)) + : Settings.LoadDefaultSettings(workingDirectory); + + return SettingsUtility.GetGlobalPackagesFolder(settings); + } + + /// + /// Reads the packages NuGet restored from the assets file it wrote, mirroring how the manifest + /// step consumes restore output. + /// + private static IReadOnlyList<(string Id, string Version, string InstallPath)> ReadRestoredPackages( + string restoreDirectory, + string? nugetConfigPath, + string workingDirectory) + { + var lockFile = new LockFileFormat().Read(Path.Combine(restoreDirectory, LockFileFormat.AssetsFileName)); + var packagesFolder = lockFile.PackageFolders.FirstOrDefault()?.Path + ?? GetEffectiveGlobalPackagesFolder(nugetConfigPath, workingDirectory); + var pathResolver = new VersionFolderPathResolver(packagesFolder); + + return lockFile.Libraries + .Where(library => string.Equals(library.Type, "package", StringComparison.OrdinalIgnoreCase)) + .Select(library => ( + library.Name, + library.Version.ToNormalizedString(), + pathResolver.GetInstallPath(library.Name, library.Version))) + .ToArray(); + } + + private static void CreatePackage( + string feedDirectory, + string packageId, + string baseAssemblyContents = "base", + string version = "1.0.0", + IReadOnlyDictionary? additionalEntries = null, + IReadOnlyList<(string Id, string Version)>? dependencies = null) + { + var packagePath = Path.Combine(feedDirectory, $"{packageId}.{version}.nupkg"); + using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create); + var dependencyElements = dependencies is null + ? string.Empty + : $""" + + + {string.Join( + Environment.NewLine, + dependencies.Select(dependency => $" "))} + + + """; + WriteEntry( + archive, + $"{packageId}.nuspec", + $""" + + + + {packageId} + {version} + Aspire + Package used to validate in-process NuGet restore. + {dependencyElements} + + + """); + WriteEntry(archive, "lib/net10.0/Aspire.Test.Package.dll", baseAssemblyContents); + WriteEntry(archive, "runtimes/win-x64/lib/net10.0/Aspire.Test.Package.dll", "runtime"); + WriteEntry(archive, "runtimes/win-x64/lib/net10.0/RuntimeOnly.dll", "runtime-only"); + WriteEntry(archive, "runtimes/win-x64/lib/net10.0/Neutral.resources.dll", "neutral"); + WriteEntry(archive, "runtimes/win-x64/lib/net10.0/fr/RuntimeOnly.resources.dll", "french"); + WriteEntry(archive, "runtimes/win-x64/lib/net10.0/de/RuntimeOnly.resources.dll", "german"); + WriteEntry(archive, "runtimes/win-x64/native/native-test.dll", "native"); + + if (additionalEntries is not null) + { + foreach (var (path, contents) in additionalEntries) + { + WriteEntry(archive, path, contents); + } + } + } + + private static void WriteEntry(ZipArchive archive, string path, string contents) + { + using var writer = new StreamWriter(archive.CreateEntry(path).Open()); + writer.Write(contents); + } + + /// + /// Removes the packages a restore test installed from the global packages folder. Disposal runs + /// even when an assertion fails, so a failing test cannot leave packages behind in a folder that + /// outlives the temporary workspace. + /// + private sealed class RestoredPackageScope(string globalPackagesFolder, params string[] packageIds) : IDisposable + { + public void Dispose() + { + foreach (var packageId in packageIds) + { + var packageDirectory = Path.Combine(globalPackagesFolder, packageId.ToLowerInvariant()); + if (!Directory.Exists(packageDirectory)) + { + continue; + } + + try + { + Directory.Delete(packageDirectory, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Another test process may hold the extracted files open. Leaving a stray + // package behind must not fail an otherwise passing test. + } + } + } + } +} diff --git a/tests/Aspire.Cli.Tests/NuGet/NuGetSignatureVerificationEnablerTests.cs b/tests/Aspire.Cli.Tests/NuGet/NuGetSignatureVerificationEnablerTests.cs index d35519fa9f5..6c905be394a 100644 --- a/tests/Aspire.Cli.Tests/NuGet/NuGetSignatureVerificationEnablerTests.cs +++ b/tests/Aspire.Cli.Tests/NuGet/NuGetSignatureVerificationEnablerTests.cs @@ -4,6 +4,7 @@ using Aspire.Cli.NuGet; using Aspire.Cli.Tests.TestServices; using Aspire.Cli.Tests.Utils; +using Microsoft.DotNet.RemoteExecutor; namespace Aspire.Cli.Tests.NuGet; @@ -102,4 +103,124 @@ public void Apply_NonLinux_DoesNotSetEnvVar() Assert.Empty(envVars); } + + // The ApplyToCurrentProcess tests change the real process environment and a process-wide scope count, so each + // one runs in its own process. The variable is also set inside the child, because passing it through the child's + // start info alone was not reliable when other test classes ran alongside. + + [Fact] + public void ApplyToCurrentProcess_RestoresUnsetValueWhenScopeEnds() + { + RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, null); + + using (NuGetSignatureVerificationEnabler.ApplyToCurrentProcess(new TestFeatures(), TestEnvironment.CreateLinux())) + { + Assert.Equal("True", Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + } + + Assert.Null(Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + }).Dispose(); + } + + [Fact] + public void ApplyToCurrentProcess_PreservesExplicitFalse() + { + RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, "false"); + var environment = TestEnvironment.CreateLinux(new Dictionary + { + [NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification] = "false" + }); + + using (NuGetSignatureVerificationEnabler.ApplyToCurrentProcess(new TestFeatures(), environment)) + { + Assert.Equal("False", Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + } + + // The user's original spelling comes back, not the normalized value applied during the scope. + Assert.Equal("false", Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + }).Dispose(); + } + + [Fact] + public void ApplyToCurrentProcess_RestoresPreviousValueWhenScopeEnds() + { + RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, "not-a-bool"); + var environment = TestEnvironment.CreateLinux(new Dictionary + { + [NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification] = "not-a-bool" + }); + + using (NuGetSignatureVerificationEnabler.ApplyToCurrentProcess(new TestFeatures(), environment)) + { + Assert.Equal("True", Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + } + + Assert.Equal("not-a-bool", Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + }).Dispose(); + } + + [Fact] + public void ApplyToCurrentProcess_KeepsValueUntilLastOverlappingScopeEnds() + { + RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, null); + var features = new TestFeatures(); + var environment = TestEnvironment.CreateLinux(); + + var first = NuGetSignatureVerificationEnabler.ApplyToCurrentProcess(features, environment); + var second = NuGetSignatureVerificationEnabler.ApplyToCurrentProcess(features, environment); + + first.Dispose(); + Assert.Equal("True", Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + + second.Dispose(); + Assert.Null(Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + }).Dispose(); + } + + [Fact] + public void ApplyToCurrentProcess_IgnoresRepeatedDisposal() + { + RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, null); + var features = new TestFeatures(); + var environment = TestEnvironment.CreateLinux(); + + var first = NuGetSignatureVerificationEnabler.ApplyToCurrentProcess(features, environment); + var second = NuGetSignatureVerificationEnabler.ApplyToCurrentProcess(features, environment); + + // Disposing one scope twice must not end the other scope early. + first.Dispose(); + first.Dispose(); + Assert.Equal("True", Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + + second.Dispose(); + Assert.Null(Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + }).Dispose(); + } + + [Fact] + public void ApplyToCurrentProcess_FeatureDisabled_LeavesEnvironmentUnchanged() + { + RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, null); + var features = new TestFeatures().SetFeature(KnownFeatures.NuGetSignatureVerificationEnabled, false); + + using (NuGetSignatureVerificationEnabler.ApplyToCurrentProcess(features, TestEnvironment.CreateLinux())) + { + Assert.Null(Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + } + + Assert.Null(Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification)); + }).Dispose(); + } } diff --git a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs index 23986d78b2d..44c8009b3ef 100644 --- a/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/AppHostServerSessionTests.cs @@ -565,11 +565,8 @@ private static AppHostServerProjectFactory CreateAppHostServerProjectFactory() { var executionContext = TestExecutionContextFactory.CreateTestContext(); var nugetService = new BundleNuGetService( - new NullLayoutDiscovery(), - new LayoutProcessRunner(new TestProcessExecutionFactory()), - new TestFeatures(), - new TestEnvironment(), - NullLogger.Instance); + NullLogger.Instance, + new FakeNuGetClient()); return new AppHostServerProjectFactory( new TestDotNetCliRunner(), diff --git a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs index 55dcca8b272..6e2b6fa4463 100644 --- a/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/PrebuiltAppHostServerTests.cs @@ -1278,11 +1278,8 @@ private static PrebuiltAppHostServer CreatePrebuiltAppHostServer( executionContext ??= TestExecutionContextFactory.CreateTestContext(); nugetService ??= new BundleNuGetService( - new NullLayoutDiscovery(), - new LayoutProcessRunner(new TestProcessExecutionFactory()), - new TestFeatures(), - new TestEnvironment(), - NullLogger.Instance); + NullLogger.Instance, + new FakeNuGetClient()); return new PrebuiltAppHostServer( appPath ?? workspace.WorkspaceRoot.FullName, @@ -1416,7 +1413,7 @@ public async Task PrepareAsync_WithPackageReferences_SetsOnlyPackageProbeManifes { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); - var (server, executionFactory) = CreatePackageReferenceServer(workspace); + var (server, nuGetClient) = CreatePackageReferenceServer(workspace); var workingDirectory = GetWorkingDirectory(server); try @@ -1427,7 +1424,8 @@ public async Task PrepareAsync_WithPackageReferences_SetsOnlyPackageProbeManifes Assert.True(result.Success); Assert.Null(server.SelectedProjectLayoutPath); - Assert.Equal(2, executionFactory.AttemptCount); + Assert.Equal(1, nuGetClient.RestoreCallCount); + Assert.Equal(1, nuGetClient.WriteManifestCallCount); var manifestPath = Assert.IsType(server.IntegrationProbeManifestPath); Assert.StartsWith( @@ -1450,11 +1448,11 @@ public async Task PrepareAsync_WhenPackageRestoreIsCanceled_PropagatesCancellati { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); using var cancellation = new CancellationTokenSource(); - var (server, executionFactory) = CreatePackageReferenceServer(workspace); - executionFactory.AsyncAttemptCallback = (_, _, cancellationToken) => + var (server, nuGetClient) = CreatePackageReferenceServer(workspace); + nuGetClient.RestoreCallback = (_, _, _, _, _, _, _, cancellationToken) => { cancellation.Cancel(); - return Task.FromCanceled<(int ExitCode, string? Stdout)>(cancellationToken); + return Task.FromCanceled(cancellationToken); }; var workingDirectory = GetWorkingDirectory(server); @@ -1476,16 +1474,7 @@ public async Task PrepareAsync_WithPackageReferences_UsesPackageSourceOverride() { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); const string packageSourceOverride = "/tmp/aspire-pr-hive/packages"; - List? restoreArgs = null; - - var (server, executionFactory) = CreatePackageReferenceServer(workspace); - executionFactory.AssertionCallback = (args, _, _, _) => - { - if (args is ["nuget", "restore", ..]) - { - restoreArgs = [.. args]; - } - }; + var (server, nuGetClient) = CreatePackageReferenceServer(workspace); var workingDirectory = GetWorkingDirectory(server); @@ -1500,11 +1489,10 @@ public async Task PrepareAsync_WithPackageReferences_UsesPackageSourceOverride() packageSourceOverride: packageSourceOverride); Assert.True(result.Success); - Assert.NotNull(restoreArgs); - Assert.Equal([packageSourceOverride, NuGetOrgSource], GetSourceArguments(restoreArgs!)); - Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); - Assert.Contains("CommunityToolkit.Aspire.Hosting.Redis,1.0.0", restoreArgs!); - Assert.Contains("--nuget-config", restoreArgs!); + Assert.Equal([packageSourceOverride, NuGetOrgSource], nuGetClient.LastRestoreSources); + Assert.Contains(("Aspire.Hosting.CodeGeneration.TypeScript", "[13.4.0-pr.17141.gf142085f]"), nuGetClient.LastRestorePackages!); + Assert.Contains(("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0"), nuGetClient.LastRestorePackages!); + Assert.NotNull(nuGetClient.LastNuGetConfigPath); } finally { @@ -1517,16 +1505,7 @@ public async Task PrepareAsync_WithPackageSourceOverride_AddsNuGetOrgFallbackSou { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); const string packageSourceOverride = "/tmp/aspire-pr-hive/packages"; - List? restoreArgs = null; - - var (server, executionFactory) = CreatePackageReferenceServer(workspace); - executionFactory.AssertionCallback = (args, _, _, _) => - { - if (args is ["nuget", "restore", ..]) - { - restoreArgs = [.. args]; - } - }; + var (server, nuGetClient) = CreatePackageReferenceServer(workspace); var workingDirectory = GetWorkingDirectory(server); @@ -1541,8 +1520,7 @@ public async Task PrepareAsync_WithPackageSourceOverride_AddsNuGetOrgFallbackSou packageSourceOverride: packageSourceOverride); Assert.True(result.Success); - Assert.NotNull(restoreArgs); - Assert.Equal([packageSourceOverride, NuGetOrgSource], GetSourceArguments(restoreArgs!)); + Assert.Equal([packageSourceOverride, NuGetOrgSource], nuGetClient.LastRestoreSources); } finally { @@ -1558,8 +1536,6 @@ public async Task PrepareAsync_WithHiveBackedChannel_UsesLocalAspireSourceAsOver { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); var packageSource = workspace.CreateDirectory("hive-packages"); - List? restoreArgs = null; - var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); await File.WriteAllTextAsync(aspireConfigPath, $$""" { @@ -1578,14 +1554,7 @@ await File.WriteAllTextAsync(aspireConfigPath, $$""" GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) }; - var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); - executionFactory.AssertionCallback = (args, _, _, _) => - { - if (args is ["nuget", "restore", ..]) - { - restoreArgs = [.. args]; - } - }; + var (server, nuGetClient) = CreatePackageReferenceServer(workspace, packagingService); var workingDirectory = GetWorkingDirectory(server); @@ -1599,11 +1568,10 @@ await File.WriteAllTextAsync(aspireConfigPath, $$""" ]); Assert.True(result.Success); - Assert.NotNull(restoreArgs); - Assert.Equal([packageSource.FullName, NuGetOrgSource], GetSourceArguments(restoreArgs!)); - Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); - Assert.Contains("CommunityToolkit.Aspire.Hosting.Redis,1.0.0", restoreArgs!); - Assert.Contains("--nuget-config", restoreArgs!); + Assert.Equal([packageSource.FullName, NuGetOrgSource], nuGetClient.LastRestoreSources); + Assert.Contains(("Aspire.Hosting.CodeGeneration.TypeScript", "[13.4.0-pr.17141.gf142085f]"), nuGetClient.LastRestorePackages!); + Assert.Contains(("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0"), nuGetClient.LastRestorePackages!); + Assert.NotNull(nuGetClient.LastNuGetConfigPath); } finally { @@ -1617,8 +1585,6 @@ public async Task PrepareAsync_WithHiveBackedChannelUsingFileUri_UsesLocalAspire using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); var packageSource = workspace.CreateDirectory("hive-packages"); var packageSourceUri = new Uri(packageSource.FullName).AbsoluteUri; - List? restoreArgs = null; - var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); await File.WriteAllTextAsync(aspireConfigPath, """ { @@ -1637,14 +1603,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """ GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) }; - var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); - executionFactory.AssertionCallback = (args, _, _, _) => - { - if (args is ["nuget", "restore", ..]) - { - restoreArgs = [.. args]; - } - }; + var (server, nuGetClient) = CreatePackageReferenceServer(workspace, packagingService); var workingDirectory = GetWorkingDirectory(server); @@ -1658,11 +1617,10 @@ await File.WriteAllTextAsync(aspireConfigPath, """ ]); Assert.True(result.Success); - Assert.NotNull(restoreArgs); - Assert.Contains(packageSourceUri, GetSourceArguments(restoreArgs!)); - Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); - Assert.Contains("CommunityToolkit.Aspire.Hosting.Redis,1.0.0", restoreArgs!); - Assert.Contains("--nuget-config", restoreArgs!); + Assert.Contains(packageSourceUri, nuGetClient.LastRestoreSources!); + Assert.Contains(("Aspire.Hosting.CodeGeneration.TypeScript", "[13.4.0-pr.17141.gf142085f]"), nuGetClient.LastRestorePackages!); + Assert.Contains(("CommunityToolkit.Aspire.Hosting.Redis", "1.0.0"), nuGetClient.LastRestorePackages!); + Assert.NotNull(nuGetClient.LastNuGetConfigPath); } finally { @@ -1677,8 +1635,6 @@ public async Task PrepareAsync_WithExplicitPackageSourceOverride_IgnoresHiveBack var explicitPackageSource = workspace.CreateDirectory("explicit-packages"); var hivePackageSource = workspace.CreateDirectory("hive-packages"); const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; - List? restoreArgs = null; - var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); await File.WriteAllTextAsync(aspireConfigPath, """ { @@ -1701,14 +1657,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """ GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) }; - var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); - executionFactory.AssertionCallback = (args, _, _, _) => - { - if (args is ["nuget", "restore", ..]) - { - restoreArgs = [.. args]; - } - }; + var (server, nuGetClient) = CreatePackageReferenceServer(workspace, packagingService); var workingDirectory = GetWorkingDirectory(server); @@ -1720,10 +1669,9 @@ await File.WriteAllTextAsync(aspireConfigPath, """ packageSourceOverride: explicitPackageSource.FullName); Assert.True(result.Success); - Assert.NotNull(restoreArgs); - Assert.Equal([explicitPackageSource.FullName, channelSource], GetSourceArguments(restoreArgs!)); - Assert.DoesNotContain(hivePackageSource.FullName, restoreArgs!); - Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); + Assert.Equal([explicitPackageSource.FullName, channelSource], nuGetClient.LastRestoreSources); + Assert.DoesNotContain(hivePackageSource.FullName, nuGetClient.LastRestoreSources!); + Assert.Contains(("Aspire.Hosting.CodeGeneration.TypeScript", "[13.4.0-pr.17141.gf142085f]"), nuGetClient.LastRestorePackages!); } finally { @@ -1736,8 +1684,6 @@ public async Task PrepareAsync_WithHttpBackedChannel_DoesNotUseExactPackageVersi { using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); const string channelSource = "https://pkgs.dev.azure.com/fake/v3/index.json"; - List? restoreArgs = null; - var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); await File.WriteAllTextAsync(aspireConfigPath, """ { @@ -1756,14 +1702,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """ GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) }; - var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); - executionFactory.AssertionCallback = (args, _, _, _) => - { - if (args is ["nuget", "restore", ..]) - { - restoreArgs = [.. args]; - } - }; + var (server, nuGetClient) = CreatePackageReferenceServer(workspace, packagingService); var workingDirectory = GetWorkingDirectory(server); @@ -1774,10 +1713,9 @@ await File.WriteAllTextAsync(aspireConfigPath, """ [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")]); Assert.True(result.Success); - Assert.NotNull(restoreArgs); - Assert.Equal([channelSource], GetSourceArguments(restoreArgs!)); - Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,13.4.0-pr.17141.gf142085f", restoreArgs!); - Assert.DoesNotContain("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); + Assert.Equal([channelSource], nuGetClient.LastRestoreSources); + Assert.Contains(("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f"), nuGetClient.LastRestorePackages!); + Assert.DoesNotContain(("Aspire.Hosting.CodeGeneration.TypeScript", "[13.4.0-pr.17141.gf142085f]"), nuGetClient.LastRestorePackages!); } finally { @@ -1796,8 +1734,6 @@ public async Task PrepareAsync_WhenPackagingServiceThrowsDuringAutoDiscovery_Deg // temp config", matching the defensive catch in GetNuGetSourcesAsync. using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); const string channelName = "pr-12345"; - List? restoreArgs = null; - var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); await File.WriteAllTextAsync(aspireConfigPath, $$""" { @@ -1810,14 +1746,7 @@ await File.WriteAllTextAsync(aspireConfigPath, $$""" GetChannelsAsyncCallback = _ => Task.FromException>( new InvalidOperationException("simulated packaging service failure")) }; - var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); - executionFactory.AssertionCallback = (args, _, _, _) => - { - if (args is ["nuget", "restore", ..]) - { - restoreArgs = [.. args]; - } - }; + var (server, nuGetClient) = CreatePackageReferenceServer(workspace, packagingService); var workingDirectory = GetWorkingDirectory(server); @@ -1828,10 +1757,10 @@ await File.WriteAllTextAsync(aspireConfigPath, $$""" [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")]); Assert.True(result.Success); - Assert.NotNull(restoreArgs); + Assert.NotNull(nuGetClient.LastRestorePackages); // No override resolved → no exact version pinning, no synthesized [override, nuget.org] source set. - Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,13.4.0-pr.17141.gf142085f", restoreArgs!); - Assert.DoesNotContain("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); + Assert.Contains(("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f"), nuGetClient.LastRestorePackages); + Assert.DoesNotContain(("Aspire.Hosting.CodeGeneration.TypeScript", "[13.4.0-pr.17141.gf142085f]"), nuGetClient.LastRestorePackages); } finally { @@ -1851,8 +1780,6 @@ public async Task PrepareAsync_WithHiveBackedChannelPointingAtMissingLocalDirect using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); var missingPackageSource = Path.Combine(workspace.WorkspaceRoot.FullName, "this-hive-was-deleted"); Assert.False(Directory.Exists(missingPackageSource)); - List? restoreArgs = null; - var aspireConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, AspireConfigFile.FileName); await File.WriteAllTextAsync(aspireConfigPath, """ { @@ -1871,14 +1798,7 @@ await File.WriteAllTextAsync(aspireConfigPath, """ GetChannelsAsyncCallback = _ => Task.FromResult>([channel]) }; - var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); - executionFactory.AssertionCallback = (args, _, _, _) => - { - if (args is ["nuget", "restore", ..]) - { - restoreArgs = [.. args]; - } - }; + var (server, nuGetClient) = CreatePackageReferenceServer(workspace, packagingService); var workingDirectory = GetWorkingDirectory(server); @@ -1889,17 +1809,17 @@ await File.WriteAllTextAsync(aspireConfigPath, """ [IntegrationReference.FromPackage("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f")]); Assert.True(result.Success); - Assert.NotNull(restoreArgs); + Assert.NotNull(nuGetClient.LastRestorePackages); // The override was not applied (Directory.Exists check failed), so the source list // is just the channel's raw Aspire mapping with no NuGet.org fallback appended (the // fallback only fires on the override path), and no exact-pin is emitted. Contrast // with PrepareAsync_WithHiveBackedChannel_UsesLocalAspireSourceAsOverride where the // existing local directory promotes the channel source to an override and adds the // NuGet.org fallback + exact-pinning. - Assert.Equal([missingPackageSource], GetSourceArguments(restoreArgs!)); - Assert.DoesNotContain(NuGetOrgSource, GetSourceArguments(restoreArgs!)); - Assert.Contains("Aspire.Hosting.CodeGeneration.TypeScript,13.4.0-pr.17141.gf142085f", restoreArgs!); - Assert.DoesNotContain("Aspire.Hosting.CodeGeneration.TypeScript,[13.4.0-pr.17141.gf142085f]", restoreArgs!); + Assert.Equal([missingPackageSource], nuGetClient.LastRestoreSources); + Assert.DoesNotContain(NuGetOrgSource, nuGetClient.LastRestoreSources!); + Assert.Contains(("Aspire.Hosting.CodeGeneration.TypeScript", "13.4.0-pr.17141.gf142085f"), nuGetClient.LastRestorePackages); + Assert.DoesNotContain(("Aspire.Hosting.CodeGeneration.TypeScript", "[13.4.0-pr.17141.gf142085f]"), nuGetClient.LastRestorePackages); } finally { @@ -1940,30 +1860,14 @@ await File.WriteAllTextAsync(aspireConfigPath, """ GetChannelsAsyncCallback = _ => Task.FromResult>([dailyChannel]) }; - var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); XDocument? tempConfigDoc = null; - executionFactory.AssertionCallback = (args, _, _, _) => + var (server, nuGetClient) = CreatePackageReferenceServer(workspace, packagingService); + nuGetClient.RestoreCallback = (_, _, _, _, _, configPath, _, _) => { - if (args is ["nuget", "restore", ..]) - { - // Read the temp NuGet.config while it still exists; it is disposed when - // PrepareAsync's inner `using var` exits, which races with our assertions. - var argsList = (IReadOnlyList)args; - var nugetConfigIndex = -1; - for (var i = 0; i < argsList.Count - 1; i++) - { - if (argsList[i] == "--nuget-config") - { - nugetConfigIndex = i; - break; - } - } - - if (nugetConfigIndex >= 0) - { - tempConfigDoc = XDocument.Load(argsList[nugetConfigIndex + 1]); - } - } + // Read the temp NuGet.config while it still exists; PrepareAsync deletes it + // after the in-process restore call returns. + tempConfigDoc = XDocument.Load(configPath!); + return Task.CompletedTask; }; var workingDirectory = GetWorkingDirectory(server); @@ -2008,10 +1912,9 @@ await File.WriteAllTextAsync(aspireConfigPath, """ } """); - var (server, executionFactory) = CreatePackageReferenceServer(workspace); - // Fail the restore step itself; BundleNuGetService throws on non-zero exit which - // propagates through PrepareAsync's outer catch. - executionFactory.DefaultExitCode = 1; + var (server, nuGetClient) = CreatePackageReferenceServer(workspace); + nuGetClient.RestoreCallback = (_, _, _, _, _, _, _, _) => + Task.FromException(new InvalidOperationException("simulated restore failure")); var workingDirectory = GetWorkingDirectory(server); @@ -2045,8 +1948,9 @@ public async Task PrepareAsync_RestoreFailure_WithManyPackages_TruncatesPackageL using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); const string packageSourceOverride = "/tmp/aspire-pr-hive/packages"; - var (server, executionFactory) = CreatePackageReferenceServer(workspace); - executionFactory.DefaultExitCode = 1; + var (server, nuGetClient) = CreatePackageReferenceServer(workspace); + nuGetClient.RestoreCallback = (_, _, _, _, _, _, _, _) => + Task.FromException(new InvalidOperationException("simulated restore failure")); var packages = Enumerable.Range(0, 8) .Select(i => IntegrationReference.FromPackage($"Aspire.Hosting.Pkg{i}", "1.0.0")) @@ -2190,8 +2094,9 @@ await File.WriteAllTextAsync(aspireConfigPath, """ GetChannelsAsyncCallback = _ => Task.FromResult>([prChannel]) }; - var (server, executionFactory) = CreatePackageReferenceServer(workspace, packagingService); - executionFactory.DefaultExitCode = 1; + var (server, nuGetClient) = CreatePackageReferenceServer(workspace, packagingService); + nuGetClient.RestoreCallback = (_, _, _, _, _, _, _, _) => + Task.FromException(new InvalidOperationException("simulated restore failure")); var workingDirectory = GetWorkingDirectory(server); @@ -2303,26 +2208,23 @@ public async Task PrepareAsync_WithStagingPinnedProjectOutsideLaunchDirectory_Us workspace.WorkspaceRoot, identityChannel: PackageChannelNames.Stable); - string[]? restoreInvocation = null; + IReadOnlyList? restoreSources = null; + string? restoreWorkingDirectory = null; string? temporaryNuGetConfigContent = null; - var executionFactory = CreatePackageRestoreExecutionFactory(); - executionFactory.AssertionCallback = (args, _, _, _) => + var nuGetClient = new FakeNuGetClient { - if (args.Length > 1 && - args[0] == "nuget" && - args[1] == "restore") + RestoreCallback = (_, _, _, _, sources, configPath, workingDirectory, _) => { - restoreInvocation = args.ToArray(); - temporaryNuGetConfigContent = File.ReadAllText(GetArgumentValue(args, "--nuget-config")); + restoreSources = sources; + restoreWorkingDirectory = workingDirectory; + temporaryNuGetConfigContent = File.ReadAllText(configPath!); + return Task.CompletedTask; } }; var nugetService = new BundleNuGetService( - new FixedLayoutDiscovery(layout), - new LayoutProcessRunner(executionFactory), - new TestFeatures(), - new TestEnvironment(), - NullLogger.Instance); + NullLogger.Instance, + nuGetClient); var stagingChannel = PackageChannel.CreateExplicitChannel( PackageChannelNames.Staging, @@ -2357,9 +2259,9 @@ public async Task PrepareAsync_WithStagingPinnedProjectOutsideLaunchDirectory_Us Assert.True(result.Success); Assert.Equal(PackageChannelNames.Staging, result.ChannelName); - Assert.NotNull(restoreInvocation); - Assert.Contains(stagingFeed, restoreInvocation!); - Assert.Contains(projectDirectory.FullName, restoreInvocation!); + Assert.NotNull(restoreSources); + Assert.Contains(stagingFeed, restoreSources); + Assert.Equal(projectDirectory.FullName, restoreWorkingDirectory); Assert.NotNull(temporaryNuGetConfigContent); Assert.Contains(stagingFeed, temporaryNuGetConfigContent!); Assert.Contains("Aspire*", temporaryNuGetConfigContent!); @@ -2822,23 +2724,20 @@ private static PrebuiltAppHostServer CreateProjectReferenceServer( return CreatePrebuiltAppHostServer(workspace, layout: layout, dotNetCliRunner: dotNetCliRunner); } - private static (PrebuiltAppHostServer Server, TestProcessExecutionFactory ExecutionFactory) CreatePackageReferenceServer(TemporaryWorkspace workspace) + private static (PrebuiltAppHostServer Server, FakeNuGetClient NuGetClient) CreatePackageReferenceServer(TemporaryWorkspace workspace) { return CreatePackageReferenceServer(workspace, MockPackagingServiceFactory.Create()); } - private static (PrebuiltAppHostServer Server, TestProcessExecutionFactory ExecutionFactory) CreatePackageReferenceServer( + private static (PrebuiltAppHostServer Server, FakeNuGetClient NuGetClient) CreatePackageReferenceServer( TemporaryWorkspace workspace, IPackagingService packagingService) { var layout = CreateBundleLayout(workspace); - var executionFactory = CreatePackageRestoreExecutionFactory(); + var nuGetClient = new FakeNuGetClient(); var nugetService = new BundleNuGetService( - new FixedLayoutDiscovery(layout), - new LayoutProcessRunner(executionFactory), - new TestFeatures(), - new TestEnvironment(), - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, + nuGetClient); var server = CreatePrebuiltAppHostServer( workspace, @@ -2846,25 +2745,7 @@ private static (PrebuiltAppHostServer Server, TestProcessExecutionFactory Execut packagingService: packagingService, nugetService: nugetService); - return (server, executionFactory); - } - - private static TestProcessExecutionFactory CreatePackageRestoreExecutionFactory() - { - var executionFactory = new TestProcessExecutionFactory(); - executionFactory.FileNameAssertionCallback = (_, args, _, _, _) => - { - if (args is ["nuget", "manifest", ..]) - { - File.WriteAllText(GetArgumentValue(args, "--output"), JsonSerializer.Serialize(new - { - managedAssemblies = Array.Empty(), - nativeLibraries = Array.Empty() - })); - } - }; - - return executionFactory; + return (server, nuGetClient); } private static LayoutConfiguration CreateBundleLayout(TemporaryWorkspace workspace) @@ -2971,22 +2852,6 @@ private static string GetWorkingDirectory(PrebuiltAppHostServer server) .GetValue(server)); } - private static string GetArgumentValue(IReadOnlyList arguments, string optionName) - { - var optionIndex = -1; - for (var i = 0; i < arguments.Count; i++) - { - if (string.Equals(arguments[i], optionName, StringComparison.Ordinal)) - { - optionIndex = i; - break; - } - } - - Assert.True(optionIndex >= 0 && optionIndex < arguments.Count - 1, $"Option '{optionName}' was not found."); - return arguments[optionIndex + 1]; - } - [Theory] [InlineData("13.6.0", "13.5.0", false)] [InlineData("13.6.0", "13.2.0", false)] @@ -3146,11 +3011,8 @@ public void CreateStartInfo_SetsCliLogFilePathEnvironmentVariable() var layout = CreateBundleLayout(workspace); var executionContext = TestExecutionContextFactory.CreateTestContext(); var nugetService = new BundleNuGetService( - new FixedLayoutDiscovery(layout), - new LayoutProcessRunner(new TestProcessExecutionFactory()), - new TestFeatures(), - new TestEnvironment(), - NullLogger.Instance); + NullLogger.Instance, + new FakeNuGetClient()); var server = CreatePrebuiltAppHostServer( workspace, @@ -3163,20 +3025,6 @@ public void CreateStartInfo_SetsCliLogFilePathEnvironmentVariable() Assert.Equal(executionContext.LogFilePath, startInfo.Environment[KnownConfigNames.CliLogFilePath]); } - private static string[] GetSourceArguments(IReadOnlyList args) - { - var sources = new List(); - for (var i = 0; i < args.Count - 1; i++) - { - if (args[i] == "--source") - { - sources.Add(args[i + 1]); - } - } - - return [.. sources]; - } - private static string[] GetPackagePatternsForSource(XDocument doc, string source) { return [.. doc.Descendants("packageSource") diff --git a/tests/Aspire.Cli.Tests/TestServices/FakeNuGetClient.cs b/tests/Aspire.Cli.Tests/TestServices/FakeNuGetClient.cs new file mode 100644 index 00000000000..e19222dc03a --- /dev/null +++ b/tests/Aspire.Cli.Tests/TestServices/FakeNuGetClient.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.NuGet; + +namespace Aspire.Cli.Tests.TestServices; + +internal sealed class FakeNuGetClient : INuGetClient +{ + public int RestoreCallCount { get; private set; } + public int WriteManifestCallCount { get; private set; } + public int SearchCallCount { get; private set; } + public IReadOnlyList<(string Id, string Version)>? LastRestorePackages { get; private set; } + public IReadOnlyList? LastRestoreSources { get; private set; } + public string? LastNuGetConfigPath { get; private set; } + public string? LastWorkingDirectory { get; private set; } + + public Func< + IReadOnlyList<(string Id, string Version)>, + string, + string?, + string, + IReadOnlyList, + string?, + string, + CancellationToken, + Task>? RestoreCallback { get; set; } + + public Func? WriteManifestCallback { get; init; } + + public Func< + string, + bool, + int, + IReadOnlyList, + string?, + string, + CancellationToken, + Task>>? SearchCallback { get; init; } + + public Task RestoreAsync( + IReadOnlyList<(string Id, string Version)> packages, + string framework, + string? runtimeIdentifier, + string outputPath, + IReadOnlyList sources, + string? nugetConfigPath, + string workingDirectory, + CancellationToken cancellationToken) + { + RestoreCallCount++; + LastRestorePackages = packages; + LastRestoreSources = sources; + LastNuGetConfigPath = nugetConfigPath; + LastWorkingDirectory = workingDirectory; + return RestoreCallback?.Invoke( + packages, + framework, + runtimeIdentifier, + outputPath, + sources, + nugetConfigPath, + workingDirectory, + cancellationToken) ?? Task.CompletedTask; + } + + public Task WriteManifestAsync( + string assetsFilePath, + string outputPath, + string framework, + string? runtimeIdentifier, + CancellationToken cancellationToken) + { + WriteManifestCallCount++; + if (WriteManifestCallback is not null) + { + return WriteManifestCallback(assetsFilePath, outputPath, framework, runtimeIdentifier, cancellationToken); + } + + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); + return File.WriteAllTextAsync( + outputPath, + """{"managedAssemblies":[],"nativeLibraries":[]}""", + cancellationToken); + } + + public Task> SearchAsync( + string query, + bool prerelease, + int take, + IReadOnlyList explicitSources, + string? nugetConfigPath, + string workingDirectory, + CancellationToken cancellationToken) + { + SearchCallCount++; + return SearchCallback?.Invoke( + query, + prerelease, + take, + explicitSources, + nugetConfigPath, + workingDirectory, + cancellationToken) ?? Task.FromResult>([]); + } +} diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index d01a8ddfe0b..0b6697da395 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -148,6 +148,7 @@ public static IServiceCollection CreateServiceCollection(TemporaryWorkspace work services.AddSingleton(options.PublishCommandPrompterFactory); services.AddTransient(options.DotNetCliExecutionFactoryFactory); services.AddTransient(options.DotNetCliRunnerFactory); + services.AddSingleton(options.NuGetClientFactory); services.AddTransient(options.NuGetPackageCacheFactory); services.AddSingleton(); services.AddSingleton(options.TemplateProviderFactory); @@ -582,13 +583,15 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser public Func NuGetPackageCacheFactory { get; set; } = (IServiceProvider serviceProvider) => { - var runner = serviceProvider.GetRequiredService(); + var cliRunner = serviceProvider.GetRequiredService(); var cache = serviceProvider.GetRequiredService(); var telemetry = serviceProvider.GetRequiredService(); var features = serviceProvider.GetRequiredService(); - return new NuGetPackageCache(runner, cache, telemetry, features); + return new NuGetPackageCache(cliRunner, cache, telemetry, features); }; + public Func NuGetClientFactory { get; set; } = _ => new FakeNuGetClient(); + public Func AppHostBackchannelFactory { get; set; } = (IServiceProvider serviceProvider) => { var logger = serviceProvider.GetRequiredService>(); @@ -802,6 +805,38 @@ public Task ExtractAsync(string destinationPath, bool force public string? GetDefaultExtractDir(string processPath) => null; } +/// +/// A bundle service that reports an already-extracted layout and records extraction requests. +/// +internal sealed class RecordingBundleService(string dcpDirectory) : IBundleService +{ + public int EnsureExtractedAndAcquireLayoutCallCount { get; private set; } + + public bool IsBundle => true; + + public Task EnsureExtractedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task ExtractAsync(string destinationPath, bool force = false, CancellationToken cancellationToken = default) + => Task.FromResult(BundleExtractResult.NoPayload); + + public Task EnsureExtractedAndAcquireLayoutAsync(string holderKind, string? commandName = null, CancellationToken cancellationToken = default) + { + EnsureExtractedAndAcquireLayoutCallCount++; + + // GetComponentPath combines LayoutPath with the component's relative path, so root the + // layout at the DCP directory's parent and reference the directory by name. + var layout = new LayoutConfiguration + { + LayoutPath = Path.GetDirectoryName(dcpDirectory), + Components = new LayoutComponents { Dcp = Path.GetFileName(dcpDirectory) } + }; + + return Task.FromResult(new BundleLayoutLease(layout, lease: null)); + } + + public string? GetDefaultExtractDir(string processPath) => null; +} + /// /// A no-op payload provider that reports no payload available. /// diff --git a/tests/Aspire.Cli.Tests/Utils/DcpConnectionHealthCheckTests.cs b/tests/Aspire.Cli.Tests/Utils/DcpConnectionHealthCheckTests.cs index 4517b3f5590..6fc70a52630 100644 --- a/tests/Aspire.Cli.Tests/Utils/DcpConnectionHealthCheckTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/DcpConnectionHealthCheckTests.cs @@ -28,6 +28,7 @@ public async Task CheckAsync_WhenNoDcpBundleIsDiscovered_ReturnsWarningAndSkipsC }; var check = new DcpConnectionHealthCheck( new NullLayoutDiscovery(), + new NullBundleService(), tester, CreateExecutionContext(workspace), NullLogger.Instance); @@ -45,6 +46,7 @@ public async Task CheckAsync_WhenDcpExecutableIsMissing_ReturnsFailure() var dcpDirectory = workspace.WorkspaceRoot.CreateSubdirectory("dcp"); var check = new DcpConnectionHealthCheck( new FixedLayoutDiscovery(LayoutComponent.Dcp, dcpDirectory.FullName), + new NullBundleService(), new TestDcpConnectionChecker(), CreateExecutionContext(workspace), NullLogger.Instance); @@ -56,6 +58,71 @@ public async Task CheckAsync_WhenDcpExecutableIsMissing_ReturnsFailure() Assert.Contains("Developer Control Plane (DCP) executable not found", result.Message, StringComparison.Ordinal); } + [Fact] + public async Task CheckAsync_WhenNoLayoutIsDiscovered_ExtractsBundleBeforeProbingForDcp() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var dcpDirectory = CreateDcpDirectoryWithExecutable(workspace); + var probedPaths = new List(); + var tester = new TestDcpConnectionChecker + { + TestConnectionAsyncCallback = (path, useDeveloperCertificate, _) => + { + probedPaths.Add(path); + return Task.FromResult(TestDcpConnectionChecker.CreateResult(useDeveloperCertificate, EnvironmentCheckStatus.Pass, "passed")); + } + }; + + // On a fresh install nothing has extracted the bundle yet, so discovery finds no layout. The check has + // to extract it rather than report DCP as missing. + var bundleService = new RecordingBundleService(dcpDirectory.FullName); + var check = new DcpConnectionHealthCheck( + new NullLayoutDiscovery(), + bundleService, + tester, + CreateExecutionContext(workspace), + NullLogger.Instance); + + var result = Assert.Single(await check.CheckAsync()); + + Assert.Equal(1, bundleService.EnsureExtractedAndAcquireLayoutCallCount); + Assert.Equal(EnvironmentCheckStatus.Pass, result.Status); + Assert.All(probedPaths, path => Assert.Equal(dcpDirectory.FullName, path)); + Assert.NotEmpty(probedPaths); + } + + [Fact] + public async Task CheckAsync_PrefersDiscoveredLayoutOverExtraction() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var discoveredDcpDirectory = CreateDcpDirectoryWithExecutable(workspace, "discovered-dcp"); + var extractedDcpDirectory = CreateDcpDirectoryWithExecutable(workspace, "extracted-dcp"); + var probedPaths = new List(); + var tester = new TestDcpConnectionChecker + { + TestConnectionAsyncCallback = (path, useDeveloperCertificate, _) => + { + probedPaths.Add(path); + return Task.FromResult(TestDcpConnectionChecker.CreateResult(useDeveloperCertificate, EnvironmentCheckStatus.Pass, "passed")); + } + }; + + // Discovery honors ASPIRE_DCP_PATH and already extracted bundles, so whatever it returns must win. + var bundleService = new RecordingBundleService(extractedDcpDirectory.FullName); + var check = new DcpConnectionHealthCheck( + new FixedLayoutDiscovery(LayoutComponent.Dcp, discoveredDcpDirectory.FullName), + bundleService, + tester, + CreateExecutionContext(workspace), + NullLogger.Instance); + + await check.CheckAsync(); + + Assert.Equal(0, bundleService.EnsureExtractedAndAcquireLayoutCallCount); + Assert.All(probedPaths, path => Assert.Equal(discoveredDcpDirectory.FullName, path)); + Assert.NotEmpty(probedPaths); + } + [Fact] public async Task TestConnectionAsync_WhenStartIsCanceledBeforeProcessStarts_PropagatesCancellationAndDisposesExecution() { @@ -116,6 +183,7 @@ public async Task CheckAsync_WhenDcpConnectionsSucceed_ReturnsSingleSuccessResul }; var check = new DcpConnectionHealthCheck( new FixedLayoutDiscovery(LayoutComponent.Dcp, dcpDirectory.FullName), + new NullBundleService(), tester, CreateExecutionContext(workspace), NullLogger.Instance); @@ -145,6 +213,7 @@ public async Task CheckAsync_WhenDeveloperCertificateConnectionFails_ReturnsFail }; var check = new DcpConnectionHealthCheck( new FixedLayoutDiscovery(LayoutComponent.Dcp, dcpDirectory.FullName), + new NullBundleService(), tester, CreateExecutionContext(workspace), NullLogger.Instance); @@ -243,9 +312,9 @@ public void DcpDeveloperCertificateCache_WhenCachedKeyExists_WritesPublicCertifi }, workspace.Path, options).Dispose(); } - private static DirectoryInfo CreateDcpDirectoryWithExecutable(TemporaryWorkspace workspace) + private static DirectoryInfo CreateDcpDirectoryWithExecutable(TemporaryWorkspace workspace, string directoryName = "dcp") { - var dcpDirectory = workspace.WorkspaceRoot.CreateSubdirectory("dcp"); + var dcpDirectory = workspace.WorkspaceRoot.CreateSubdirectory(directoryName); File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(dcpDirectory.FullName), string.Empty); return dcpDirectory; diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.csproj b/tests/Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.csproj index 498c0712880..a6d8a86e0b5 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.csproj +++ b/tests/Aspire.Hosting.RemoteHost.Tests/Aspire.Hosting.RemoteHost.Tests.csproj @@ -10,7 +10,6 @@ - diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/ManifestCommandTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/ManifestCommandTests.cs deleted file mode 100644 index 18fd1e6b4de..00000000000 --- a/tests/Aspire.Hosting.RemoteHost.Tests/ManifestCommandTests.cs +++ /dev/null @@ -1,301 +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 System.IO.Compression; -using System.Runtime.InteropServices; -using System.Text.Json; -using System.Xml.Linq; -using Aspire.Managed.NuGet.Commands; -using Xunit; - -namespace Aspire.Hosting.RemoteHost.Tests; - -public class ManifestCommandTests -{ - [Fact] - public async Task ManifestCommand_WritesPackageProbeManifestWithoutCreatingLibsLayout() - { - var workspaceRoot = Directory.CreateTempSubdirectory("aspire-manifest-tests").FullName; - - try - { - var packageRoot = Path.Combine(workspaceRoot, "packages", "test.package", "1.0.0"); - Directory.CreateDirectory(Path.Combine(packageRoot, "lib", "net10.0", "fr")); - Directory.CreateDirectory(Path.Combine(packageRoot, "runtimes", "unix", "lib", "net10.0")); - Directory.CreateDirectory(Path.Combine(packageRoot, "runtimes", "win", "lib", "net10.0")); - Directory.CreateDirectory(Path.Combine(packageRoot, "runtimes", GetCurrentRuntimeIdentifier(), "native")); - - await File.WriteAllTextAsync(Path.Combine(packageRoot, "lib", "net10.0", "Test.Package.dll"), "base"); - await File.WriteAllTextAsync(Path.Combine(packageRoot, "runtimes", "unix", "lib", "net10.0", "Test.Package.dll"), "unix"); - await File.WriteAllTextAsync(Path.Combine(packageRoot, "runtimes", "win", "lib", "net10.0", "Test.Package.dll"), "win"); - await File.WriteAllTextAsync(Path.Combine(packageRoot, "lib", "net10.0", "fr", "Test.Package.resources.dll"), "fr"); - await File.WriteAllTextAsync(Path.Combine(packageRoot, "runtimes", GetCurrentRuntimeIdentifier(), "native", GetNativeFileName()), "runtime-native"); - - var assetsPath = Path.Combine(workspaceRoot, "project.assets.json"); - await File.WriteAllTextAsync(assetsPath, CreateAssetsJsonWithResolvedRidTarget(workspaceRoot, GetCurrentRuntimeIdentifier(), GetNativeFileName())); - - var outputPath = Path.Combine(workspaceRoot, "integration-package-probe-manifest.json"); - var command = ManifestCommand.Create(); - var parseResult = command.Parse([ - "--assets", assetsPath, - "--output", outputPath, - "--framework", "net10.0", - "--runtime-identifier", GetCurrentRuntimeIdentifier() - ]); - - var exitCode = await parseResult.InvokeAsync(); - - Assert.Equal(0, exitCode); - Assert.False(Directory.Exists(Path.Combine(workspaceRoot, "libs"))); - - await using var manifestStream = File.OpenRead(outputPath); - using var manifest = await JsonDocument.ParseAsync(manifestStream); - - var managedAssemblies = manifest.RootElement.GetProperty("managedAssemblies").EnumerateArray().ToList(); - Assert.Contains( - managedAssemblies, - assembly => assembly.GetProperty("name").GetString() == "Test.Package" && - assembly.GetProperty("path").GetString() == Path.Combine(packageRoot, GetExpectedRuntimeAssemblyPath().Replace('/', Path.DirectorySeparatorChar))); - Assert.Contains( - managedAssemblies, - assembly => assembly.GetProperty("name").GetString() == "Test.Package.resources" && - assembly.GetProperty("culture").GetString() == "fr" && - assembly.GetProperty("path").GetString() == Path.Combine(packageRoot, "lib", "net10.0", "fr", "Test.Package.resources.dll")); - - var nativeLibraries = manifest.RootElement.GetProperty("nativeLibraries").EnumerateArray().ToList(); - Assert.Contains( - nativeLibraries, - nativeLibrary => nativeLibrary.GetProperty("fileName").GetString() == GetNativeFileName() && - nativeLibrary.GetProperty("path").GetString() == Path.Combine(packageRoot, "runtimes", GetCurrentRuntimeIdentifier(), "native", GetNativeFileName())); - } - finally - { - if (Directory.Exists(workspaceRoot)) - { - Directory.Delete(workspaceRoot, recursive: true); - } - } - } - - [Fact] - public async Task RestoreAndManifestCommands_WriteCanonicalPackageIdFromLowercaseRequest() - { - var workspaceRoot = Directory.CreateTempSubdirectory("aspire-restore-manifest-tests").FullName; - - try - { - var sourcePath = Path.Combine(workspaceRoot, "source"); - Directory.CreateDirectory(sourcePath); - await CreateTestPackageAsync(sourcePath); - - var globalPackagesPath = Path.Combine(workspaceRoot, "global-packages"); - var nugetConfigPath = Path.Combine(workspaceRoot, "nuget.config"); - WriteNuGetConfig(nugetConfigPath, sourcePath, globalPackagesPath); - - var objPath = Path.Combine(workspaceRoot, "restore", "obj"); - var restoreCommand = RestoreCommand.Create(); - var restoreParseResult = restoreCommand.Parse([ - "--package", "test.package,1.0.0", - "--framework", "net10.0", - "--output", objPath, - "--source", sourcePath, - "--nuget-config", nugetConfigPath, - "--working-dir", workspaceRoot, - "--no-nuget-org" - ]); - - var restoreExitCode = await restoreParseResult.InvokeAsync(); - Assert.Equal(0, restoreExitCode); - - var assetsPath = Path.Combine(objPath, "project.assets.json"); - var manifestPath = Path.Combine(workspaceRoot, "restore", "integration-package-probe-manifest.json"); - var manifestCommand = ManifestCommand.Create(); - var manifestParseResult = manifestCommand.Parse([ - "--assets", assetsPath, - "--output", manifestPath, - "--framework", "net10.0" - ]); - - var manifestExitCode = await manifestParseResult.InvokeAsync(); - Assert.Equal(0, manifestExitCode); - Assert.False(Directory.Exists(Path.Combine(workspaceRoot, "restore", "libs"))); - - await using var manifestStream = File.OpenRead(manifestPath); - using var manifest = await JsonDocument.ParseAsync(manifestStream); - var managedAssemblies = manifest.RootElement.GetProperty("managedAssemblies").EnumerateArray().ToList(); - var expectedAssemblyPath = Path.Combine(GetPackageFolderFromAssets(assetsPath), "test.package", "1.0.0", "lib", "net10.0", "Test.Package.dll"); - - Assert.Contains( - managedAssemblies, - assembly => assembly.GetProperty("name").GetString() == "Test.Package" && - assembly.GetProperty("packageId").GetString() == "Test.Package" && - assembly.GetProperty("path").GetString() == expectedAssemblyPath); - Assert.DoesNotContain( - managedAssemblies, - assembly => assembly.GetProperty("path").GetString()?.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) == true); - } - finally - { - if (Directory.Exists(workspaceRoot)) - { - Directory.Delete(workspaceRoot, recursive: true); - } - } - } - - private static string CreateAssetsJsonWithResolvedRidTarget(string rootPath, string runtimeIdentifier, string nativeFileName) - { - var packagesPath = Path.Combine(rootPath, "packages") + Path.DirectorySeparatorChar; - var escapedPackagesPath = packagesPath.Replace("\\", "\\\\"); - var outputPath = Path.Combine(rootPath, "obj") + Path.DirectorySeparatorChar; - var escapedOutputPath = outputPath.Replace("\\", "\\\\"); - var runtimeAssemblyPath = GetExpectedRuntimeAssemblyPath(); - var runtimeTargetRid = GetExpectedRuntimeAssetRid(); - - return $$""" - { - "version": 3, - "targets": { - "net10.0": { - "Test.Package/1.0.0": { - "type": "package", - "runtime": { - "lib/net10.0/Test.Package.dll": {} - }, - "resource": { - "lib/net10.0/fr/Test.Package.resources.dll": { "locale": "fr" } - }, - "runtimeTargets": { - "{{runtimeAssemblyPath}}": { "rid": "{{runtimeTargetRid}}", "assetType": "runtime" }, - "runtimes/{{runtimeIdentifier}}/native/{{nativeFileName}}": { "rid": "{{runtimeIdentifier}}", "assetType": "native" } - } - } - }, - "net10.0/{{runtimeIdentifier}}": { - "Test.Package/1.0.0": { - "type": "package", - "runtime": { - "{{runtimeAssemblyPath}}": {} - }, - "native": { - "runtimes/{{runtimeIdentifier}}/native/{{nativeFileName}}": {} - }, - "resource": { - "lib/net10.0/fr/Test.Package.resources.dll": { "locale": "fr" } - } - } - } - }, - "libraries": { - "Test.Package/1.0.0": { - "type": "package", - "path": "test.package/1.0.0", - "files": [ - "lib/net10.0/Test.Package.dll", - "{{runtimeAssemblyPath}}", - "runtimes/{{runtimeIdentifier}}/native/{{nativeFileName}}", - "lib/net10.0/fr/Test.Package.resources.dll" - ] - } - }, - "packageFolders": { - "{{escapedPackagesPath}}": {} - }, - "project": { - "version": "1.0.0", - "restore": { - "projectUniqueName": "test", - "projectName": "test", - "projectPath": "test", - "packagesPath": "{{escapedPackagesPath}}", - "outputPath": "{{escapedOutputPath}}", - "projectStyle": "PackageReference", - "originalTargetFrameworks": [ "net10.0" ], - "sources": {}, - "frameworks": { - "net10.0": { - "targetAlias": "net10.0", - "projectReferences": {} - } - } - }, - "frameworks": { - "net10.0": { - "targetAlias": "net10.0", - "dependencies": {} - } - } - } - } - """; - } - - private static async Task CreateTestPackageAsync(string sourcePath) - { - var packagePath = Path.Combine(sourcePath, "Test.Package.1.0.0.nupkg"); - using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create); - - await WriteZipEntryAsync( - archive, - "Test.Package.nuspec", - """ - - - - Test.Package - 1.0.0 - Test - Test package - - - """); - await WriteZipEntryAsync(archive, "lib/net10.0/Test.Package.dll", "assembly"); - await WriteZipEntryAsync(archive, "lib/net10.0/Test.Package.xml", ""); - } - - private static async Task WriteZipEntryAsync(ZipArchive archive, string entryName, string content) - { - var entry = archive.CreateEntry(entryName); - await using var stream = entry.Open(); - await using var writer = new StreamWriter(stream); - await writer.WriteAsync(content); - } - - private static void WriteNuGetConfig(string nugetConfigPath, string sourcePath, string globalPackagesPath) - { - var document = new XDocument( - new XElement("configuration", - new XElement("config", - new XElement("add", - new XAttribute("key", "globalPackagesFolder"), - new XAttribute("value", globalPackagesPath))), - new XElement("packageSources", - new XElement("clear"), - new XElement("add", - new XAttribute("key", "local"), - new XAttribute("value", sourcePath))))); - - document.Save(nugetConfigPath); - } - - private static string GetPackageFolderFromAssets(string assetsPath) - { - using var stream = File.OpenRead(assetsPath); - using var document = JsonDocument.Parse(stream); - var packageFolders = document.RootElement.GetProperty("packageFolders").EnumerateObject(); - - return packageFolders.MoveNext() - ? packageFolders.Current.Name - : throw new InvalidOperationException("project.assets.json did not contain a packageFolders entry."); - } - - private static string GetCurrentRuntimeIdentifier() => RuntimeInformation.RuntimeIdentifier; - - private static string GetExpectedRuntimeAssemblyPath() => OperatingSystem.IsWindows() - ? "runtimes/win/lib/net10.0/Test.Package.dll" - : "runtimes/unix/lib/net10.0/Test.Package.dll"; - - private static string GetExpectedRuntimeAssetRid() => OperatingSystem.IsWindows() ? "win" : "unix"; - - private static string GetNativeFileName() => OperatingSystem.IsWindows() ? "TestNative.dll" : OperatingSystem.IsMacOS() ? "libTestNative.dylib" : "libTestNative.so"; -} diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/RestoreCommandTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/RestoreCommandTests.cs deleted file mode 100644 index ee6a849fe13..00000000000 --- a/tests/Aspire.Hosting.RemoteHost.Tests/RestoreCommandTests.cs +++ /dev/null @@ -1,123 +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 System.IO.Compression; -using System.Runtime.InteropServices; -using System.Text.Json; -using Aspire.Managed.NuGet.Commands; -using Xunit; - -namespace Aspire.Hosting.RemoteHost.Tests; - -public class RestoreCommandTests -{ - [Fact] - public async Task RestoreAndManifestCommands_WritePortableRuntimeAssetsToPackageProbeManifest() - { - var workspaceRoot = Directory.CreateTempSubdirectory("aspire-restore-tests").FullName; - - try - { - var sourcePath = Path.Combine(workspaceRoot, "source"); - var outputPath = Path.Combine(workspaceRoot, "obj"); - - Directory.CreateDirectory(sourcePath); - Directory.CreateDirectory(outputPath); - - var packageId = $"Test.Package.RuntimeAssets.{Guid.NewGuid():N}"; - CreatePackage(sourcePath, packageId); - - var command = RestoreCommand.Create(); - var parseResult = command.Parse([ - "--package", $"{packageId},1.0.0", - "--framework", "net10.0", - "--runtime-identifier", RuntimeInformation.RuntimeIdentifier, - "--output", outputPath, - "--source", sourcePath, - "--no-nuget-org" - ]); - - var exitCode = await parseResult.InvokeAsync(); - - Assert.Equal(0, exitCode); - - var assetsPath = Path.Combine(outputPath, "project.assets.json"); - var manifestPath = Path.Combine(outputPath, "integration-package-probe-manifest.json"); - var manifestCommand = ManifestCommand.Create(); - var manifestParseResult = manifestCommand.Parse([ - "--assets", assetsPath, - "--output", manifestPath, - "--framework", "net10.0", - "--runtime-identifier", RuntimeInformation.RuntimeIdentifier - ]); - - var manifestExitCode = await manifestParseResult.InvokeAsync(); - - Assert.Equal(0, manifestExitCode); - - await using var stream = File.OpenRead(manifestPath); - using var manifest = await JsonDocument.ParseAsync(stream); - var managedAssemblyPaths = manifest.RootElement - .GetProperty("managedAssemblies") - .EnumerateArray() - .Select(p => p.GetProperty("path").GetString()) - .ToArray(); - - Assert.Contains( - managedAssemblyPaths, - path => path?.EndsWith( - Path.Combine(packageId.ToLowerInvariant(), "1.0.0", GetExpectedRuntimeAssemblyPath().Replace('/', Path.DirectorySeparatorChar)), - StringComparison.OrdinalIgnoreCase) == true); - Assert.DoesNotContain( - managedAssemblyPaths, - path => path?.EndsWith( - Path.Combine(packageId.ToLowerInvariant(), "1.0.0", "lib", "net10.0", "Test.Package.dll"), - StringComparison.OrdinalIgnoreCase) == true); - } - finally - { - if (Directory.Exists(workspaceRoot)) - { - Directory.Delete(workspaceRoot, recursive: true); - } - } - } - - private static void CreatePackage(string sourcePath, string packageId) - { - var packagePath = Path.Combine(sourcePath, $"{packageId}.1.0.0.nupkg"); - - using var fileStream = File.Create(packagePath); - using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create); - - AddEntry( - archive, - "Test.Package.nuspec", - $""" - - - - {packageId} - 1.0.0 - Aspire - Test package - - - """); - - AddEntry(archive, "lib/net10.0/Test.Package.dll", "base"); - AddEntry(archive, "runtimes/unix/lib/net10.0/Test.Package.dll", "unix"); - AddEntry(archive, "runtimes/win/lib/net10.0/Test.Package.dll", "win"); - } - - private static void AddEntry(ZipArchive archive, string path, string content) - { - var entry = archive.CreateEntry(path); - using var writer = new StreamWriter(entry.Open()); - writer.Write(content); - } - - private static string GetExpectedRuntimeAssemblyPath() => OperatingSystem.IsWindows() - ? "runtimes/win/lib/net10.0/Test.Package.dll" - : "runtimes/unix/lib/net10.0/Test.Package.dll"; -} diff --git a/tests/Aspire.Managed.Tests/NuGet/RestoreCommandTests.cs b/tests/Aspire.Managed.Tests/NuGet/RestoreCommandTests.cs deleted file mode 100644 index 6f5267ce694..00000000000 --- a/tests/Aspire.Managed.Tests/NuGet/RestoreCommandTests.cs +++ /dev/null @@ -1,121 +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.Managed.NuGet.Commands; -using Microsoft.DotNet.RemoteExecutor; -using Xunit; - -namespace Aspire.Managed.Tests.NuGet; - -public class RestoreCommandTests(ITestOutputHelper outputHelper) : IDisposable -{ - private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(outputHelper); - - public void Dispose() => _workspace.Dispose(); - - [Fact] - public void RestoreCommand_RespectsNuGetConfigGlobalPackagesFolder() - { - var customPackagesDir = Path.GetFullPath(Path.Combine(_workspace.Path, "custom-packages")); - var nugetConfigPath = Path.Combine(_workspace.Path, "NuGet.config"); - - File.WriteAllText(nugetConfigPath, $""" - - - - - - - """); - - // Run in a separate process so NUGET_PACKAGES env var from the parent - // doesn't interfere. The env var takes precedence over config files - // in NuGet's resolution order. - var options = new RemoteInvokeOptions(); - options.StartInfo.Environment.Remove("NUGET_PACKAGES"); - - RemoteExecutor.Invoke(static async (tempDirPath) => - { - var command = RestoreCommand.Create(); - var outputDir = Path.Combine(tempDirPath, "obj"); - - await command.Parse(["--package", "Fake.Package,1.0.0", "--no-nuget-org", "--output", outputDir, "--working-dir", tempDirPath]).InvokeAsync(); - }, _workspace.Path, options).Dispose(); - - // NuGet writes packageFolders into project.assets.json with the resolved packages directory. - var assetsContent = File.ReadAllText(Path.Combine(_workspace.Path, "obj", "project.assets.json")); - Assert.Contains(JsonEncodedPath(customPackagesDir), assetsContent); - } - - [Fact] - public void RestoreCommand_RespectsNuGetPackagesEnvironmentVariable() - { - var customPackagesDir = Path.GetFullPath(Path.Combine(_workspace.Path, "env-packages")); - - // Run in a separate process with NUGET_PACKAGES set to the custom directory. - // The env var takes priority over all config file settings. - var options = new RemoteInvokeOptions(); - options.StartInfo.Environment["NUGET_PACKAGES"] = customPackagesDir; - - RemoteExecutor.Invoke(static async (tempDirPath) => - { - var command = RestoreCommand.Create(); - var outputDir = Path.Combine(tempDirPath, "obj"); - - await command.Parse(["--package", "Fake.Package,1.0.0", "--no-nuget-org", "--output", outputDir, "--working-dir", tempDirPath]).InvokeAsync(); - }, _workspace.Path, options).Dispose(); - - // NuGet writes packageFolders into project.assets.json with the resolved packages directory. - var assetsContent = File.ReadAllText(Path.Combine(_workspace.Path, "obj", "project.assets.json")); - Assert.Contains(JsonEncodedPath(customPackagesDir), assetsContent); - } - - [Fact] - public void RestoreCommand_CliSourcesAreAppendedToConfigSources() - { - var nugetConfigPath = Path.Combine(_workspace.Path, "NuGet.config"); - var configSourcePath = Path.Combine(_workspace.Path, "config-source"); - var cliSourcePath = Path.Combine(_workspace.Path, "cli-source"); - - File.WriteAllText(nugetConfigPath, $""" - - - - - - - - """); - - // Run in a separate process so the parent's NuGet config doesn't interfere. - var options = new RemoteInvokeOptions(); - options.StartInfo.Environment.Remove("NUGET_PACKAGES"); - - RemoteExecutor.Invoke(static async (nugetConfig, cliSourcePath, tempDirPath) => - { - var command = RestoreCommand.Create(); - var outputDir = Path.Combine(tempDirPath, "obj"); - - // Pass --source in addition to the config source. Both should be used. - await command.Parse([ - "--package", "Fake.Package,1.0.0", - "--no-nuget-org", - "--nuget-config", nugetConfig, - "--source", cliSourcePath, - "--output", outputDir, - "--working-dir", tempDirPath]).InvokeAsync(); - }, nugetConfigPath, cliSourcePath, _workspace.Path, options).Dispose(); - - // NuGet writes the resolved sources into project.assets.json regardless of - // whether the restore succeeds. Verify both sources are present. - var assetsContent = File.ReadAllText(Path.Combine(_workspace.Path, "obj", "project.assets.json")); - Assert.Contains(JsonEncodedPath(configSourcePath), assetsContent); - Assert.Contains(JsonEncodedPath(cliSourcePath), assetsContent); - } - - /// - /// Converts a file path to its JSON-escaped representation (e.g. backslashes doubled). - /// - private static string JsonEncodedPath(string path) => - path.Replace(@"\", @"\\"); -} \ No newline at end of file diff --git a/tests/Aspire.Managed.Tests/TerminalHostSignalTests.cs b/tests/Aspire.Managed.Tests/TerminalHostSignalTests.cs index 53e4dbecdb1..77d0c9e88d7 100644 --- a/tests/Aspire.Managed.Tests/TerminalHostSignalTests.cs +++ b/tests/Aspire.Managed.Tests/TerminalHostSignalTests.cs @@ -5,7 +5,6 @@ using System.Globalization; using System.Runtime.InteropServices; using Aspire.Hosting; -using Aspire.Managed.NuGet.Commands; using Aspire.TerminalHost; using Xunit; @@ -207,7 +206,7 @@ private static ProcessStartInfo CreateTerminalHostStartInfo( }; if (hostAssemblyPath is null) { - startInfo.ArgumentList.Add(typeof(ManifestCommand).Assembly.Location); + startInfo.ArgumentList.Add(typeof(global::Program).Assembly.Location); startInfo.ArgumentList.Add("terminalhost"); } else diff --git a/tools/CreateLayout/README.md b/tools/CreateLayout/README.md index 3325383bc61..761649d0555 100644 --- a/tools/CreateLayout/README.md +++ b/tools/CreateLayout/README.md @@ -6,7 +6,7 @@ This tool creates the Aspire bundle layout for distribution. It assembles Aspire The bundle layout enables polyglot app hosts (TypeScript, Python, Go, etc.) to use Aspire without needing a .NET SDK installed. The bundle includes: -- **Aspire.Managed** - Self-contained executable containing the AppHost server, NuGet helper, and terminal host +- **Aspire.Managed** - Self-contained executable containing the AppHost server and terminal host - **Dashboard** - Native AOT compiled Blazor-based monitoring UI, native dependencies, and static assets - **DCP** - Developer Control Plane (orchestrator) @@ -79,7 +79,7 @@ The tool creates the following layout: ```text {output}/ ├── managed/ -│ └── aspire-managed[.exe] # AppHost server, NuGet helper, and terminal host +│ └── aspire-managed[.exe] # AppHost server and terminal host ├── dashboard/ │ ├── Aspire.Dashboard[.exe] │ ├── @@ -90,7 +90,7 @@ The tool creates the following layout: ## How It Works -1. **Copies aspire-managed** - Copies the self-contained AppHost server, NuGet helper, and terminal host executable +1. **Copies aspire-managed** - Copies the self-contained AppHost server and terminal host executable 2. **Copies Dashboard** - Copies the complete Native AOT Dashboard publish payload, including native libraries and `wwwroot` static assets, excluding `.pdb`, `.dbg`, and `.dSYM` debug symbols 3. **Copies DCP** - Finds DCP binaries from NuGet package restore output 4. **Creates Archive** - Optionally creates `aspire-{version}-{rid}.tar.gz` beside the output directory on all platforms, including Windows From e034ff518bc0a36f30d12e91e1600840dccb58ae Mon Sep 17 00:00:00 2001 From: Jose Perez Rodriguez Date: Wed, 23 Sep 2026 16:27:22 -0700 Subject: [PATCH 3/9] Change StabilizePackageVersion to false condition (#20403) --- eng/Versions.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eng/Versions.props b/eng/Versions.props index 4c258c5db0a..2d18ece0393 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -21,7 +21,7 @@ 2.4.0 18.10.0 - true + false release From a2b368ee452a376175d002aa539001be1c26bb4a Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:49:11 -0700 Subject: [PATCH 4/9] [release/13.6] Scope dashboard cookies and resource state by application name (#20407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of #20299 to release/13.6 /cc @JamesNK ## Customer Impact Dashboards sharing a hostname could interfere with one another’s authentication and antiforgery cookies. Resource-collapse preferences could also collide for application names with the same sanitized form. Existing users will need to sign in again and will initially lose their saved resource expansion state. ## Testing Dashboard and component test CI passed on Windows and Linux for this backport; Ubuntu x64 native-AOT Dashboard validation passed. The source PR reports focused cookie/storage tests and manual browser checks of same-name and different-name dashboard isolation. Live OpenID Connect and HTTPS browser flows were not manually exercised. ## Risk Medium. Cookie and storage names change across dashboard authentication modes; existing cookies and collapsed-resource preferences are not migrated, requiring reauthentication and resetting those preferences. ## Regression? Unknown — please confirm. --------- Co-authored-by: James Newton-King Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DashboardAuthenticationCookieNames.cs | 19 +++++ .../Configuration/DashboardOptions.cs | 7 ++ .../DashboardWebApplication.cs | 17 ++-- src/Aspire.Dashboard/README.md | 2 +- .../ServiceClient/DashboardClient.cs | 6 +- .../ServiceClient/DashboardRunStore.cs | 7 +- .../ServiceClient/SelectedDashboardClient.cs | 6 +- .../Utils/BrowserStorageKeys.cs | 15 +--- .../Utils/DashboardApplicationNameKey.cs | 44 +++++++++++ .../Pages/ResourcesTests.cs | 5 +- .../BrowserStorageKeysTests.cs | 21 +++++ .../DashboardOptionsTests.cs | 59 +++++++++++++- .../FrontendBrowserTokenAuthTests.cs | 15 ++-- .../BrowserTokenAuthenticationTests.cs | 47 +++++++++++ .../Infrastructure/DashboardServerFixture.cs | 32 +++++--- .../Model/DashboardClientTests.cs | 32 +++++--- .../Model/DashboardDataSourceTests.cs | 78 ++++++++++++++++++- 17 files changed, 345 insertions(+), 67 deletions(-) create mode 100644 src/Aspire.Dashboard/Authentication/DashboardAuthenticationCookieNames.cs create mode 100644 src/Aspire.Dashboard/Utils/DashboardApplicationNameKey.cs create mode 100644 tests/Aspire.Dashboard.Tests/BrowserStorageKeysTests.cs diff --git a/src/Aspire.Dashboard/Authentication/DashboardAuthenticationCookieNames.cs b/src/Aspire.Dashboard/Authentication/DashboardAuthenticationCookieNames.cs new file mode 100644 index 00000000000..eea77341d69 --- /dev/null +++ b/src/Aspire.Dashboard/Authentication/DashboardAuthenticationCookieNames.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Utils; + +namespace Aspire.Dashboard.Authentication; + +internal static class DashboardAuthenticationCookieNames +{ + private const string AuthCookieNamePrefix = ".Aspire.Dashboard.Auth"; + private const string HttpAuthCookieNamePrefix = ".Aspire.Dashboard.Auth.Http"; + + public static (string AuthCookieName, string HttpAuthCookieName) Create(string applicationName) + { + var suffix = DashboardApplicationNameKey.Create(applicationName); + + return ($"{AuthCookieNamePrefix}.{suffix}", $"{HttpAuthCookieNamePrefix}.{suffix}"); + } +} \ No newline at end of file diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index 1d8a245f825..bd2fb329709 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -11,6 +11,8 @@ namespace Aspire.Dashboard.Configuration; public sealed class DashboardOptions { + internal const string DefaultApplicationName = "Aspire"; + public string? ApplicationName { get; set; } public OtlpOptions Otlp { get; set; } = new(); public ApiOptions Api { get; set; } = new(); @@ -20,6 +22,11 @@ public sealed class DashboardOptions public DebugSessionOptions DebugSession { get; set; } = new(); public UIOptions UI { get; set; } = new(); public DashboardDataOptions Data { get; set; } = new(); + + internal string GetApplicationNameOrDefault() => GetApplicationNameOrDefault(ApplicationName); + + internal static string GetApplicationNameOrDefault(string? applicationName) => + string.IsNullOrWhiteSpace(applicationName) ? DefaultApplicationName : applicationName; } public sealed class DashboardDataOptions diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index bf97825c925..01d9879ce93 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -64,9 +64,7 @@ public sealed class DashboardWebApplication : IAsyncDisposable /// public const int ExitCodeAddressInUse = DashboardExitCodes.AddressInUse; - private const string DashboardAuthCookieName = ".Aspire.Dashboard.Auth"; - private const string DashboardHttpAuthCookieName = ".Aspire.Dashboard.Auth.Http"; - private const string DashboardAntiForgeryCookieName = ".Aspire.Dashboard.Antiforgery"; + private const string DashboardAntiForgeryCookieNamePrefix = ".Aspire.Dashboard.Antiforgery"; private const string OtlpExporterEndpointConfigurationKey = "OTEL_EXPORTER_OTLP_ENDPOINT"; // Blazor discovers routed pages and layouts as Type values, then activates them and assigns // component parameters and [Inject] properties through reflection. @@ -425,7 +423,8 @@ public DashboardWebApplication( builder.Services.AddAntiforgery(options => { - options.Cookie.Name = DashboardAntiForgeryCookieName; + var applicationNameKey = DashboardApplicationNameKey.Create(dashboardOptions.GetApplicationNameOrDefault()); + options.Cookie.Name = $"{DashboardAntiForgeryCookieNamePrefix}.{applicationNameKey}"; }); _app = builder.Build(); @@ -889,6 +888,8 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb }; }); + var (authCookieName, httpAuthCookieName) = DashboardAuthenticationCookieNames.Create(dashboardOptions.GetApplicationNameOrDefault()); + switch (dashboardOptions.Frontend.AuthMode) { case FrontendAuthMode.OpenIdConnect: @@ -901,8 +902,8 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb authentication.AddCookie(options => { - options.Cookie.Name = DashboardAuthCookieName; - options.CookieManager = new AspireDashboardCookieManager(DashboardHttpAuthCookieName); + options.Cookie.Name = authCookieName; + options.CookieManager = new AspireDashboardCookieManager(httpAuthCookieName); }); authentication.AddOpenIdConnect(options => @@ -961,8 +962,8 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb claimsIdentity.AddClaim(new Claim(FrontendAuthorizationDefaults.BrowserTokenClaimName, bool.TrueString)); return Task.CompletedTask; }; - options.Cookie.Name = DashboardAuthCookieName; - options.CookieManager = new AspireDashboardCookieManager(DashboardHttpAuthCookieName); + options.Cookie.Name = authCookieName; + options.CookieManager = new AspireDashboardCookieManager(httpAuthCookieName); }); break; case FrontendAuthMode.Unsecured: diff --git a/src/Aspire.Dashboard/README.md b/src/Aspire.Dashboard/README.md index cb02ee2fc4f..da769c8c72f 100644 --- a/src/Aspire.Dashboard/README.md +++ b/src/Aspire.Dashboard/README.md @@ -188,7 +188,7 @@ Telemetry limits have different scopes depending on the telemetry type: | Option | Description | |--------|-------------| -| `Dashboard:ApplicationName`
Default: `Aspire` | The application name displayed in the UI. This applies only when no resource service URL is specified. When a resource service exists, the service specifies the application name. | +| `Dashboard:ApplicationName`
Default: `Aspire` | The configured application name scopes authentication and antiforgery cookie names and dashboard data persisted on disk. It also supplies the UI display name when no resource service URL is specified; otherwise, the resource service supplies the display name. Collapsed-resource state in browser storage is scoped by that display name. Other browser preferences, such as time format, are not application-scoped and can be shared when applications use the same browser origin. Application-name scoping is not a security boundary. | | `Dashboard:UI:DisableResourceGraph`
Default: `false` | Disables the resource graph UI. | | `Dashboard:UI:DisableImport`
Default: `false` | Disables the telemetry import UI. | | `Dashboard:UI:DisableAgentHelp`
Default: `false` | Disables the **AI Agents** button in the dashboard header. When `false`, the button opens a dialog with instructions for using AI coding agents with the dashboard. | diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs index 4a0f6864806..394cde95245 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardClient.cs @@ -845,9 +845,9 @@ public Task WhenConnected public string ApplicationName { - get => _applicationName - ?? _dashboardOptions.ApplicationName - ?? "Aspire"; + get => string.IsNullOrWhiteSpace(_applicationName) + ? _dashboardOptions.GetApplicationNameOrDefault() + : _applicationName; } public string? MinRequiredVersion => _minRequiredVersion; diff --git a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs index fc5791aa68d..db20e6d08fb 100644 --- a/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs +++ b/src/Aspire.Dashboard/ServiceClient/DashboardRunStore.cs @@ -109,7 +109,7 @@ internal DashboardRunStore( _logger = logger; _timeProvider = timeProvider; _deleteRunDirectory = deleteRunDirectory; - var applicationName = string.IsNullOrWhiteSpace(options.Value.ApplicationName) ? "Aspire" : options.Value.ApplicationName; + var applicationName = options.Value.GetApplicationNameOrDefault(); _applicationMarkerFileName = GetApplicationDirectoryName(applicationName); var startedAt = timeProvider.GetUtcNow(); // A millisecond timestamp collision is very unlikely. The exclusive run lock below also ensures that if two @@ -201,7 +201,7 @@ internal DashboardRunStore( SchemaVersion = SchemaVersion, RunId = runId, StartedAtUtc = startedAt, - ApplicationName = options.Value.ApplicationName, + ApplicationName = applicationName, DatabaseFileName = Path.GetFileName(DatabasePath) }; _runs = new(LoadRuns); @@ -602,7 +602,8 @@ private static DashboardRunDescriptor CreateDescriptor(DashboardRunMetadata meta metadata.StartedAtUtc, metadata.EndedAtUtc, metadata.CleanShutdown, - metadata.ApplicationName, + // Older runs can contain an unset or blank name despite using the default application's disk key. + DashboardOptions.GetApplicationNameOrDefault(metadata.ApplicationName), Path.Combine(runDirectory, metadata.DatabaseFileName), isCurrent) { diff --git a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs index cd21744b5b3..d5b6085990d 100644 --- a/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs +++ b/src/Aspire.Dashboard/ServiceClient/SelectedDashboardClient.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Dashboard.Configuration; using Aspire.Dashboard.Model; using Aspire.DashboardService.Proto.V1; @@ -12,7 +13,10 @@ internal sealed class SelectedDashboardClient(DashboardClient currentClient, Das public bool IsEnabled => IsReadOnly || currentClient.IsEnabled; public bool IsReadOnly => dataSource.IsReadOnly; public DashboardConnectionState ConnectionState => IsReadOnly ? DashboardConnectionState.Connected : currentClient.ConnectionState; - public string ApplicationName => dataSource.SelectedRun.ApplicationName ?? currentClient.ApplicationName; + // Current runs must retain the resource service's name rather than the startup configuration stored in metadata. + public string ApplicationName => IsReadOnly + ? DashboardOptions.GetApplicationNameOrDefault(dataSource.SelectedRun.ApplicationName) + : currentClient.ApplicationName; public string? MinRequiredVersion => IsReadOnly ? null : currentClient.MinRequiredVersion; public event Action? ConnectionStateChanged diff --git a/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs b/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs index 5b7aaa9e01a..438858cb972 100644 --- a/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs +++ b/src/Aspire.Dashboard/Utils/BrowserStorageKeys.cs @@ -1,7 +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 System.Text; using Microsoft.FluentUI.AspNetCore.Components; namespace Aspire.Dashboard.Utils; @@ -34,19 +33,7 @@ internal static class BrowserStorageKeys public static string CollapsedResourceNamesKey(string applicationName) { - ArgumentNullException.ThrowIfNull(applicationName); - - var builder = new StringBuilder(applicationName.Length); - - foreach (var c in applicationName) - { - if (char.IsLetterOrDigit(c)) - { - builder.Append(c); - } - } - - return $"{CollapsedResourceNamesKeyPrefix}{builder.ToString()}"; + return $"{CollapsedResourceNamesKeyPrefix}{DashboardApplicationNameKey.Create(applicationName)}"; } public static string SplitterOrientationKey(string viewKey) diff --git a/src/Aspire.Dashboard/Utils/DashboardApplicationNameKey.cs b/src/Aspire.Dashboard/Utils/DashboardApplicationNameKey.cs new file mode 100644 index 00000000000..ed9132df94d --- /dev/null +++ b/src/Aspire.Dashboard/Utils/DashboardApplicationNameKey.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO.Hashing; +using System.Text; +using Aspire.Dashboard.Configuration; + +namespace Aspire.Dashboard.Utils; + +/// +/// Creates a stable, collision-resistant key segment from a dashboard application name. +/// +internal static class DashboardApplicationNameKey +{ + public static string Create(string applicationName) + { + ArgumentNullException.ThrowIfNull(applicationName); + + const int maxApplicationNameLength = 32; + + var nameBuilder = new StringBuilder(); + + foreach (var character in applicationName) + { + if (nameBuilder.Length == maxApplicationNameLength) + { + break; + } + + nameBuilder.Append(character is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or (>= '0' and <= '9') or '-' or '_' + ? character + : '-'); + } + + var sanitizedApplicationName = nameBuilder.ToString().Trim('-', '_'); + if (sanitizedApplicationName.Length == 0) + { + sanitizedApplicationName = DashboardOptions.DefaultApplicationName; + } + + var hash = Convert.ToHexString(XxHash3.Hash(Encoding.UTF8.GetBytes(applicationName))).ToLowerInvariant(); + return $"{sanitizedApplicationName.ToLowerInvariant()}-{hash}"; + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs index 5f4ba45592f..7ca0579ef56 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs @@ -1126,7 +1126,7 @@ public void ParametersView_IncludesErrorParameters() } [Fact] - public void CollapsedResourceNames_FetchedAfterDashboardClientConnected_KeyIncludesApplicationName() + public void CollapsedResourceNames_FetchedAfterDashboardClientConnected_KeyIncludesApplicationNameAndHash() { // Arrange var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); @@ -1174,8 +1174,7 @@ public void CollapsedResourceNames_FetchedAfterDashboardClientConnected_KeyInclu }); // Assert 1 - The key should include the application name - var expectedKey = BrowserStorageKeys.CollapsedResourceNamesKey(applicationName); - Assert.Equal(expectedKey, collapsedResourceNamesKeyUsed); + Assert.Matches("^Aspire_Resources_CollapsedResourceNames_mytestapplication-[a-f0-9]{16}$", collapsedResourceNamesKeyUsed); // Assert 2 - CollapsedResourceNames was only fetched after connection was completed var collapsedResourceNamesCall = getAsyncCallOrder.FirstOrDefault(c => c.Key.Contains(BrowserStorageKeys.CollapsedResourceNamesKeyPrefix)); diff --git a/tests/Aspire.Dashboard.Tests/BrowserStorageKeysTests.cs b/tests/Aspire.Dashboard.Tests/BrowserStorageKeysTests.cs new file mode 100644 index 00000000000..12623a85909 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/BrowserStorageKeysTests.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Utils; +using Xunit; + +namespace Aspire.Dashboard.Tests; + +public class BrowserStorageKeysTests +{ + [Theory] + [InlineData("my-app", "my app")] + [InlineData("my_app", "my app")] + [InlineData("日本語", "한국어")] + public void CollapsedResourceNamesKey_SanitizationDoesNotCauseCollisions(string firstApplicationName, string secondApplicationName) + { + Assert.NotEqual( + BrowserStorageKeys.CollapsedResourceNamesKey(firstApplicationName), + BrowserStorageKeys.CollapsedResourceNamesKey(secondApplicationName)); + } +} diff --git a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs index 76deba6cf00..84c81a9c09f 100644 --- a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs +++ b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs @@ -5,8 +5,10 @@ using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; +using Aspire.Dashboard.Authentication; using Aspire.Dashboard.Configuration; using Aspire.Hosting; +using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Http; @@ -149,6 +151,18 @@ public void PostConfigure_InvalidDashboardPersistenceMode_IsInvalid() result.FailureMessage); } + [Theory] + [InlineData(null, "Aspire")] + [InlineData("", "Aspire")] + [InlineData(" ", "Aspire")] + [InlineData("My application", "My application")] + public void ApplicationName_GetApplicationNameOrDefault(string? applicationName, string expected) + { + var options = new DashboardOptions { ApplicationName = applicationName }; + + Assert.Equal(expected, options.GetApplicationNameOrDefault()); + } + #region Frontend options [Fact] @@ -375,6 +389,42 @@ public async Task OtlpOptions_SuppressUnsecuredMessage_LegacyName() #region OpenIDConnect options + [Theory] + [InlineData("My application", "my-application")] + [InlineData("<> /", "aspire")] + [InlineData("abcdefghijklmnopqrstuvwxyz1234567890", "abcdefghijklmnopqrstuvwxyz123456")] + public void AuthCookieNames_IncludeSanitizedApplicationNameAndHash(string applicationName, string expectedApplicationName) + { + var (authCookieName, httpAuthCookieName) = DashboardAuthenticationCookieNames.Create(applicationName); + + Assert.Matches($"^\\.Aspire\\.Dashboard\\.Auth\\.{expectedApplicationName}-[a-f0-9]{{16}}$", authCookieName); + Assert.Matches($"^\\.Aspire\\.Dashboard\\.Auth\\.Http\\.{expectedApplicationName}-[a-f0-9]{{16}}$", httpAuthCookieName); + } + + [Fact] + public void AuthCookieNames_HashFullApplicationName() + { + var first = DashboardAuthenticationCookieNames.Create($"{new string('a', 32)}-first"); + var second = DashboardAuthenticationCookieNames.Create($"{new string('a', 32)}-second"); + + Assert.NotEqual(first, second); + } + + [Fact] + public async Task AntiforgeryCookieName_IncludesApplicationNameAndHash() + { + await using var app = new DashboardWebApplication(builder => builder.Configuration.AddInMemoryCollection( + [ + new("ASPNETCORE_URLS", "http://localhost:8000/"), + new("ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL", "http://localhost:4319/"), + new(DashboardConfigNames.DashboardApplicationName.ConfigKey, "My application"), + ])); + + var options = app.Services.GetRequiredService>().Value; + + Assert.Matches("^\\.Aspire\\.Dashboard\\.Antiforgery\\.my-application-[a-f0-9]{16}$", options.Cookie.Name); + } + [Fact] public void OpenIdConnectOptions_NoNameClaimType() { @@ -461,21 +511,22 @@ public async Task OpenIdConnectOptions_UsesDashboardCookieManager() new("ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL", "http://localhost:4319/"), new("Authentication:Schemes:OpenIdConnect:Authority", "https://id.aspire.dev/"), new("Authentication:Schemes:OpenIdConnect:ClientId", "aspire-dashboard"), - new("Dashboard:Frontend:AuthMode", "OpenIdConnect") + new("Dashboard:Frontend:AuthMode", "OpenIdConnect"), + new("Dashboard:ApplicationName", "Test application") ])); var cookieOptions = app.Services.GetRequiredService>().Get(CookieAuthenticationDefaults.AuthenticationScheme); - Assert.Equal(".Aspire.Dashboard.Auth", cookieOptions.Cookie.Name); + Assert.StartsWith(".Aspire.Dashboard.Auth.test-application-", cookieOptions.Cookie.Name, StringComparison.Ordinal); var httpContext = new DefaultHttpContext(); cookieOptions.CookieManager.AppendResponseCookie(httpContext, cookieOptions.Cookie.Name!, "value", new CookieOptions()); var httpCookie = Assert.Single(httpContext.Response.Headers.SetCookie); - Assert.StartsWith(".Aspire.Dashboard.Auth.Http=", httpCookie, StringComparison.Ordinal); + Assert.StartsWith(".Aspire.Dashboard.Auth.Http.test-application-", httpCookie, StringComparison.Ordinal); var httpsContext = new DefaultHttpContext(); httpsContext.Request.Scheme = "https"; cookieOptions.CookieManager.AppendResponseCookie(httpsContext, cookieOptions.Cookie.Name!, "value", new CookieOptions()); var httpsCookie = Assert.Single(httpsContext.Response.Headers.SetCookie); - Assert.StartsWith(".Aspire.Dashboard.Auth=", httpsCookie, StringComparison.Ordinal); + Assert.StartsWith(".Aspire.Dashboard.Auth.test-application-", httpsCookie, StringComparison.Ordinal); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs index 9c5ad650d25..7f147060e78 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs @@ -16,6 +16,8 @@ namespace Aspire.Dashboard.Tests.Integration; public class FrontendBrowserTokenAuthTests { + private const string ApplicationName = "Test application"; + private static readonly (string AuthCookieName, string HttpAuthCookieName) s_cookieNames = DashboardAuthenticationCookieNames.Create(ApplicationName); private readonly ITestOutputHelper _testOutputHelper; public FrontendBrowserTokenAuthTests(ITestOutputHelper testOutputHelper) @@ -131,6 +133,7 @@ public async Task Get_LoginPage_ValidToken_HttpEndpointWithHttpsEndpoint_UsesHtt config[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = "https://127.0.0.1:0;http://127.0.0.1:0"; config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString(); config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey; + config[DashboardConfigNames.DashboardApplicationName.ConfigKey] = ApplicationName; }); await app.StartAsync().DefaultTimeout(); @@ -154,8 +157,8 @@ public async Task Get_LoginPage_ValidToken_HttpEndpointWithHttpsEndpoint_UsesHtt Assert.Equal(HttpStatusCode.Redirect, httpsResponse.StatusCode); Assert.Equal(HttpStatusCode.Redirect, httpResponse.StatusCode); - var httpsCookie = Assert.Single(httpsResponse.Headers.GetValues("Set-Cookie"), c => c.StartsWith(".Aspire.Dashboard.Auth=", StringComparison.Ordinal)); - var httpCookie = Assert.Single(httpResponse.Headers.GetValues("Set-Cookie"), c => c.StartsWith(".Aspire.Dashboard.Auth.Http=", StringComparison.Ordinal)); + var httpsCookie = Assert.Single(httpsResponse.Headers.GetValues("Set-Cookie"), c => c.StartsWith($"{s_cookieNames.AuthCookieName}=", StringComparison.Ordinal)); + var httpCookie = Assert.Single(httpResponse.Headers.GetValues("Set-Cookie"), c => c.StartsWith($"{s_cookieNames.HttpAuthCookieName}=", StringComparison.Ordinal)); Assert.Contains("; secure", httpsCookie, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("; secure", httpCookie, StringComparison.OrdinalIgnoreCase); } @@ -170,6 +173,7 @@ public async Task Get_Signout_HttpsEndpointWithHttpAndHttpsCookies_DeletesBothCo config[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = "https://127.0.0.1:0;http://127.0.0.1:0"; config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString(); config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey; + config[DashboardConfigNames.DashboardApplicationName.ConfigKey] = ApplicationName; }); await app.StartAsync().DefaultTimeout(); @@ -201,12 +205,12 @@ public async Task Get_Signout_HttpsEndpointWithHttpAndHttpsCookies_DeletesBothCo deletedCookies, c => { - Assert.StartsWith(".Aspire.Dashboard.Auth=", c, StringComparison.Ordinal); + Assert.StartsWith($"{s_cookieNames.AuthCookieName}=", c, StringComparison.Ordinal); Assert.Contains("expires=Thu, 01 Jan 1970", c, StringComparison.OrdinalIgnoreCase); }, c => { - Assert.StartsWith(".Aspire.Dashboard.Auth.Http=", c, StringComparison.Ordinal); + Assert.StartsWith($"{s_cookieNames.HttpAuthCookieName}=", c, StringComparison.Ordinal); Assert.Contains("expires=Thu, 01 Jan 1970", c, StringComparison.OrdinalIgnoreCase); }); } @@ -253,6 +257,7 @@ public async Task Get_LoginPage_ValidToken_ForwardedHttps_UsesHttpsCookie() config[DashboardConfigNames.ForwardedHeaders.ConfigKey] = bool.TrueString; config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString(); config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey; + config[DashboardConfigNames.DashboardApplicationName.ConfigKey] = ApplicationName; }); await app.StartAsync().DefaultTimeout(); @@ -272,7 +277,7 @@ public async Task Get_LoginPage_ValidToken_ForwardedHttps_UsesHttpsCookie() Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); - var cookie = Assert.Single(response.Headers.GetValues("Set-Cookie"), c => c.StartsWith(".Aspire.Dashboard.Auth=", StringComparison.Ordinal)); + var cookie = Assert.Single(response.Headers.GetValues("Set-Cookie"), c => c.StartsWith($"{s_cookieNames.AuthCookieName}=", StringComparison.Ordinal)); Assert.Contains("; secure", cookie, StringComparison.OrdinalIgnoreCase); } diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/BrowserTokenAuthenticationTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/BrowserTokenAuthenticationTests.cs index 9fa4b6ea115..d6945e7e297 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/BrowserTokenAuthenticationTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/BrowserTokenAuthenticationTests.cs @@ -167,6 +167,53 @@ await RunTestAsync(async page => } } +[RequiresFeature(TestFeature.Playwright)] +public sealed class BrowserTokenAuthenticationApplicationNameTests(PlaywrightFixture playwrightFixture) : IClassFixture +{ + private const string BrowserToken = "VALID_TOKEN"; + + [Theory] + [InlineData("Same application", "Same application", true)] + [InlineData("First application", "Second application", false)] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task BrowserToken_ApplicationName_ScopesAuthentication(string firstApplicationName, string secondApplicationName, bool canAccessBoth) + { + await using var firstDashboard = CreateDashboard(firstApplicationName); + await using var secondDashboard = CreateDashboard(secondApplicationName); + await firstDashboard.StartAsync(); + await secondDashboard.StartAsync(); + + var firstBaseUrl = firstDashboard.FrontendSingleEndPointAccessor().GetResolvedAddress(); + var secondBaseUrl = secondDashboard.FrontendSingleEndPointAccessor().GetResolvedAddress(); + + // Browser cookies are scoped to the loopback host rather than the port, so both dashboards + // must be accessed from the same browser context to reproduce localhost cookie sharing. + await using var context = await playwrightFixture.Browser.NewContextAsync(); + var page = await context.NewPageAsync(); + + var firstResponse = await page.GotoAsync($"{firstBaseUrl}/login?t={BrowserToken}").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + Assert.Equal("/", new Uri(firstResponse!.Url).AbsolutePath); + + var secondResponse = await page.GotoAsync(secondBaseUrl).DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + Assert.Equal(canAccessBoth ? "/" : "/login", new Uri(secondResponse!.Url).AbsolutePath); + } + + private static DashboardWebApplication CreateDashboard(string applicationName) + { + var configuration = new Dictionary + { + [DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = "http://127.0.0.1:0", + [DashboardConfigNames.DashboardOtlpHttpUrlName.ConfigKey] = "http://127.0.0.1:0", + [DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey] = nameof(OtlpAuthMode.Unsecured), + [DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = nameof(FrontendAuthMode.BrowserToken), + [DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = BrowserToken, + [DashboardConfigNames.DashboardApplicationName.ConfigKey] = applicationName + }; + + return DashboardServerFixture.CreateDashboardApp(configuration); + } +} + [RequiresFeature(TestFeature.Playwright)] public class BrowserTokenAuthenticationHttpAndHttpsTests : PlaywrightTestsBase { diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs index d442f6889c6..f6492408ded 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs @@ -46,15 +46,31 @@ public async ValueTask InitializeAsync() { await PlaywrightFixture.InitializeAsync(); + DashboardApp = CreateDashboardApp(Configuration, Resources, ConfigureServices); + + await DashboardApp.StartAsync(); + + if (Resources is not null) + { + var writer = DashboardApp.Services.GetRequiredService(); + await writer.ReplaceResourcesAsync(Resources.Select(CreateResource).ToList()); + } + } + + internal static DashboardWebApplication CreateDashboardApp( + IReadOnlyDictionary configuration, + IReadOnlyList? resources = null, + Action? configureServices = null) + { const string aspireDashboardAssemblyName = "Aspire.Dashboard"; var currentAssemblyName = Assembly.GetExecutingAssembly().GetName().Name!; var currentAssemblyDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; var aspireAssemblyDirectory = currentAssemblyDirectory.Replace(currentAssemblyName, aspireDashboardAssemblyName); - var config = new ConfigurationManager().AddInMemoryCollection(Configuration).Build(); + var config = new ConfigurationManager().AddInMemoryCollection(configuration).Build(); // Add services to the container. - DashboardApp = new DashboardWebApplication( + return new DashboardWebApplication( options: new WebApplicationOptions { EnvironmentName = "Development", @@ -65,20 +81,12 @@ public async ValueTask InitializeAsync() preConfigureBuilder: builder => { builder.Configuration.AddConfiguration(config); - var dashboardClient = new MockDashboardClient(Resources); + var dashboardClient = new MockDashboardClient(resources); builder.Services.AddSingleton(dashboardClient); builder.Services.AddSingleton( services => new MockRepositoryFactory(services, dashboardClient)); - ConfigureServices(builder.Services); + configureServices?.Invoke(builder.Services); }); - - await DashboardApp.StartAsync(); - - if (Resources is not null) - { - var writer = DashboardApp.Services.GetRequiredService(); - await writer.ReplaceResourcesAsync(Resources.Select(CreateResource).ToList()); - } } private static Resource CreateResource(ResourceViewModel resource) diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index 5065fff1454..075af8d585c 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -569,6 +569,21 @@ public async Task WhenConnected_InteractionMethodUnimplemented_InteractionWatchC await instance.InteractionWatchCompleteTask.DefaultTimeout(); } + [Theory] + [InlineData("", null, "Aspire")] + [InlineData(" \t", "Configured", "Configured")] + [InlineData("", " ", "Aspire")] + [InlineData("Service", "Configured", "Service")] + public async Task ApplicationName_ServiceNameFallsBackToConfiguredName(string serviceName, string? configuredName, string expected) + { + await using var instance = CreateResourceServiceClient(applicationName: configuredName); + instance.SetDashboardServiceClient(new MockDashboardServiceClient { ApplicationName = serviceName }); + + await instance.WhenConnected.DefaultTimeout(); + + Assert.Equal(expected, instance.ApplicationName); + } + [Fact] public async Task ConnectionState_InitialState_IsConnecting() { @@ -1031,6 +1046,7 @@ private sealed class MockDashboardServiceClient : Aspire.DashboardService.Proto. public bool FailOnGetApplicationInformation { get; init; } public bool FailOnExecuteResourceCommand { get; init; } public bool CancelExecuteResourceCommandOnCallCancellation { get; init; } + public string ApplicationName { get; init; } = "TestApplication"; public string MinDashboardVersion { get; init; } = ""; public IReadOnlyList ConsoleLogUpdates { get; init; } = []; public IReadOnlyList ResourceUpdates { get; init; } = []; @@ -1087,7 +1103,7 @@ public override AsyncUnaryCall GetApplicationInf return new AsyncUnaryCall( Task.FromResult(new ApplicationInformationResponse { - ApplicationName = "TestApplication", + ApplicationName = ApplicationName, MinDashboardVersion = MinDashboardVersion }), Task.FromResult(new Metadata()), @@ -1262,21 +1278,13 @@ public Task WriteAsync(T message, CancellationToken cancellationToken) private DashboardClient CreateResourceServiceClient( DashboardActivitySource? activitySource = null, IResourceRepositoryWriter? resourceRepositoryWriter = null, - ResourceServiceClientCertificateOptions? clientCertificate = null, - Action? configureHttpHandler = null) - { - return CreateResourceServiceClient(_loggerFactory, activitySource, resourceRepositoryWriter, clientCertificate, configureHttpHandler); - } - - private static DashboardClient CreateResourceServiceClient( - ILoggerFactory loggerFactory, - DashboardActivitySource? activitySource, - IResourceRepositoryWriter? resourceRepositoryWriter, + string? applicationName = null, ResourceServiceClientCertificateOptions? clientCertificate = null, Action? configureHttpHandler = null) { var options = new DashboardOptions { + ApplicationName = applicationName, ResourceServiceClient = { AuthMode = ResourceClientAuthMode.Unsecured, @@ -1293,7 +1301,7 @@ private static DashboardClient CreateResourceServiceClient( return new DashboardClient( activitySource ?? new DashboardActivitySource(), - loggerFactory, + _loggerFactory, new ConfigurationManager(), Options.Create(options), new MockKnownPropertyLookup(), diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs index 57e218b7dc9..a4cdf0726cb 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardDataSourceTests.cs @@ -9,6 +9,7 @@ using Aspire.Dashboard.Otlp.Storage; using Aspire.DashboardService.Proto.V1; using Aspire.Dashboard.Tests.Shared; +using Aspire.Dashboard.Utils; using Aspire.Shared; using Google.Protobuf.Collections; using Google.Protobuf.WellKnownTypes; @@ -160,6 +161,81 @@ public async Task RunMetadata_IsPublishedAfterApplicationStarted() Assert.Equal(DashboardRunStore.SchemaVersion, Assert.Single(runStore.GetRuns()).SchemaVersion); } + [Theory] + [InlineData(null, "Aspire")] + [InlineData("", "Aspire")] + [InlineData(" ", "Aspire")] + [InlineData("Aspire", "Aspire")] + [InlineData("My Dashboard", "My Dashboard")] + public async Task RunMetadata_UsesEffectiveApplicationName(string? applicationName, string expectedName) + { + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + using var runStore = CreateRunStore(CreateOptions(workspace, applicationName)); + await InitializeAndPublishRunAsync(runStore); + + using var metadata = JsonDocument.Parse(File.ReadAllText(Path.Combine(runStore.CurrentWorkingDirectory, "run.json"))); + + Assert.Equal(expectedName, metadata.RootElement.GetProperty("ApplicationName").GetString()); + Assert.Equal(expectedName, runStore.GetCurrentRun().ApplicationName); + } + + [Theory] + [InlineData(null, "Aspire")] + [InlineData("", "Aspire")] + [InlineData(" ", "Aspire")] + [InlineData("Aspire", "Aspire")] + [InlineData("My Dashboard", "My Dashboard")] + public async Task SelectedRun_ApplicationName_NormalizesLegacyMetadataAndPreservesCurrentClientName(string? applicationName, string expectedName) + { + using var workspace = TemporaryWorkspace.Create(testOutputHelper); + var options = CreateOptions(workspace, expectedName); + var startedAt = new DateTimeOffset(2026, 7, 20, 12, 34, 56, TimeSpan.Zero); + string historicalRunId; + string metadataPath; + + using (var historicalRunStore = CreateRunStore(options, new FixedTimeProvider(startedAt))) + { + historicalRunId = historicalRunStore.RunId; + metadataPath = Path.Combine(historicalRunStore.CurrentWorkingDirectory, "run.json"); + await InitializeAndPublishRunAsync(historicalRunStore); + } + + // Older run.json files store the raw configured name, e.g. {"ApplicationName": ""}, rather than "Aspire". + var metadata = JsonNode.Parse(File.ReadAllText(metadataPath))!.AsObject(); + metadata["ApplicationName"] = applicationName; + File.WriteAllText(metadataPath, metadata.ToJsonString()); + + using var currentRunStore = CreateRunStore(options, new FixedTimeProvider(startedAt.AddSeconds(1))); + using var dataSourcePool = new DashboardDataSourcePool(currentRunStore, CreateRepositoryFactory(options)); + using var dataSource = CreateDataSource(currentRunStore, dataSourcePool); + using var activitySource = new DashboardActivitySource(); + await using var currentClient = new DashboardClient( + activitySource, + NullLoggerFactory.Instance, + new ConfigurationManager(), + Options.Create(new DashboardOptions { ApplicationName = "Live application" }), + new MockKnownPropertyLookup(), + new TestStringLocalizer(), + (IResourceRepositoryWriter)dataSource.ResourceRepository); + IDashboardClient selectedClient = new SelectedDashboardClient(currentClient, dataSource); + + Assert.Equal("Live application", selectedClient.ApplicationName); + + dataSource.SelectRun(historicalRunId); + + Assert.True(dataSource.IsReadOnly); + Assert.Equal(expectedName, dataSource.SelectedRun.ApplicationName); + Assert.Equal(expectedName, selectedClient.ApplicationName); + Assert.Equal( + BrowserStorageKeys.CollapsedResourceNamesKey(expectedName), + BrowserStorageKeys.CollapsedResourceNamesKey(selectedClient.ApplicationName)); + + dataSource.SelectRun(runId: null); + + Assert.False(dataSource.IsReadOnly); + Assert.Equal("Live application", selectedClient.ApplicationName); + } + [Fact] public async Task CurrentRun_PinPersistsWhenRunBecomesHistorical() { @@ -1526,7 +1602,7 @@ public void UnavailableHistoricalRun_SelectsCurrentRun() private static IOptions CreateOptions( TemporaryWorkspace workspace, - string applicationName = "TestApp", + string? applicationName = "TestApp", DashboardPersistenceMode persistenceMode = DashboardPersistenceMode.Run) { return Options.Create(new DashboardOptions From 68d891618bd22a67a451ee439f729f64ad60fcfd Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:50:54 -0700 Subject: [PATCH 5/9] [release/13.6] Reject empty OTLP API keys (#20408) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of #20363 to release/13.6 /cc @JamesNK ## Customer Impact An explicitly empty secondary OTLP API key could allow a request with an empty `x-otlp-api-key` header to authenticate. This prevents that unintended access while still allowing an unset optional secondary key. ## Testing Dashboard test CI passed on Windows and Linux for this backport; Ubuntu x64 native-AOT Dashboard validation passed. The source PR adds configuration, authentication-handler, and OTLP HTTP endpoint coverage for empty keys and a null optional secondary key. ## Risk Medium. This changes OTLP authentication and startup validation: configurations with explicitly empty keys that were previously accepted will now fail validation; no public API changes. ## Regression? Unknown — please confirm. --------- Co-authored-by: James Newton-King Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OtlpApiKeyAuthenticationHandler.cs | 10 ++- .../Configuration/ValidateDashboardOptions.cs | 4 + .../DashboardOptionsTests.cs | 41 +++++++++++ .../Integration/OtlpHttpServiceTests.cs | 73 +++++++++++++++++++ .../OtlpApiKeyAuthenticationHandlerTests.cs | 14 ++++ 5 files changed, 140 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Dashboard/Authentication/OtlpApiKey/OtlpApiKeyAuthenticationHandler.cs b/src/Aspire.Dashboard/Authentication/OtlpApiKey/OtlpApiKeyAuthenticationHandler.cs index 5725a150fad..31d6d66c56a 100644 --- a/src/Aspire.Dashboard/Authentication/OtlpApiKey/OtlpApiKeyAuthenticationHandler.cs +++ b/src/Aspire.Dashboard/Authentication/OtlpApiKey/OtlpApiKeyAuthenticationHandler.cs @@ -31,9 +31,15 @@ protected override Task HandleAuthenticateAsync() return Task.FromResult(AuthenticateResult.Fail($"Multiple '{ApiKeyHeaderName}' headers in request.")); } - if (!CompareHelpers.CompareKey(options.GetPrimaryApiKeyBytes(), apiKey.ToString())) + var providedApiKey = apiKey.ToString(); + if (string.IsNullOrEmpty(providedApiKey)) { - if (options.GetSecondaryApiKeyBytes() is not { } secondaryBytes || !CompareHelpers.CompareKey(secondaryBytes, apiKey.ToString())) + return Task.FromResult(AuthenticateResult.Fail($"API key from '{ApiKeyHeaderName}' header is empty.")); + } + + if (!CompareHelpers.CompareKey(options.GetPrimaryApiKeyBytes(), providedApiKey)) + { + if (options.GetSecondaryApiKeyBytes() is not { } secondaryBytes || !CompareHelpers.CompareKey(secondaryBytes, providedApiKey)) { return Task.FromResult(AuthenticateResult.Fail($"Incoming API key from '{ApiKeyHeaderName}' header doesn't match configured API key.")); } diff --git a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs index bfdc4cdbef1..567a380ae57 100644 --- a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs @@ -87,6 +87,10 @@ public ValidateOptionsResult Validate(string? name, DashboardOptions options) { errorMessages.Add($"PrimaryApiKey is required when OTLP authentication mode is API key. Specify a {DashboardConfigNames.DashboardOtlpPrimaryApiKeyName.ConfigKey} value."); } + if (options.Otlp.SecondaryApiKey is { Length: 0 }) + { + errorMessages.Add($"SecondaryApiKey must not be empty when OTLP authentication mode is API key. Remove {DashboardConfigNames.DashboardOtlpSecondaryApiKeyName.ConfigKey} or specify a non-empty value."); + } break; case OtlpAuthMode.ClientCertificate: for (var i = 0; i < options.Otlp.AllowedCertificates.Count; i++) diff --git a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs index 84c81a9c09f..fc7260a950b 100644 --- a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs +++ b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs @@ -371,6 +371,47 @@ public void OtlpOptions_HTTP_InvalidUrl() Assert.Equal("Failed to parse OTLP HTTP endpoint URL 'invalid'.", result.FailureMessage); } + [Fact] + public void OtlpOptions_ApiKeyMode_NullSecondaryApiKey_Succeeds() + { + var options = GetValidOptions(); + options.Otlp.AuthMode = OtlpAuthMode.ApiKey; + options.Otlp.PrimaryApiKey = "primary"; + options.Otlp.SecondaryApiKey = null; + + var result = new ValidateDashboardOptions().Validate(null, options); + + Assert.True(result.Succeeded); + Assert.Null(result.FailureMessage); + } + + [Fact] + public void OtlpOptions_ApiKeyMode_EmptyPrimaryApiKey_Fails() + { + var options = GetValidOptions(); + options.Otlp.AuthMode = OtlpAuthMode.ApiKey; + options.Otlp.PrimaryApiKey = ""; + + var result = new ValidateDashboardOptions().Validate(null, options); + + Assert.False(result.Succeeded); + Assert.Equal($"PrimaryApiKey is required when OTLP authentication mode is API key. Specify a {DashboardConfigNames.DashboardOtlpPrimaryApiKeyName.ConfigKey} value.", result.FailureMessage); + } + + [Fact] + public void OtlpOptions_ApiKeyMode_EmptySecondaryApiKey_Fails() + { + var options = GetValidOptions(); + options.Otlp.AuthMode = OtlpAuthMode.ApiKey; + options.Otlp.PrimaryApiKey = "primary"; + options.Otlp.SecondaryApiKey = ""; + + var result = new ValidateDashboardOptions().Validate(null, options); + + Assert.False(result.Succeeded); + Assert.Equal($"SecondaryApiKey must not be empty when OTLP authentication mode is API key. Remove {DashboardConfigNames.DashboardOtlpSecondaryApiKeyName.ConfigKey} or specify a non-empty value.", result.FailureMessage); + } + [Fact] public async Task OtlpOptions_SuppressUnsecuredMessage_LegacyName() { diff --git a/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpServiceTests.cs b/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpServiceTests.cs index f9a81299f88..1830cd0b7f8 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/OtlpHttpServiceTests.cs @@ -13,6 +13,7 @@ using Google.Protobuf; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Options; using OpenTelemetry.Proto.Collector.Logs.V1; using OpenTelemetry.Proto.Collector.Metrics.V1; using OpenTelemetry.Proto.Collector.Trace.V1; @@ -134,6 +135,78 @@ public async Task CallService_OtlpHttpEndPoint_RequiredApiKeyMissing_Failure(str Assert.Equal(string.Empty, await responseMessage.Content.ReadAsStringAsync().DefaultTimeout()); } + [Theory] + [InlineData("null", false)] + [InlineData("\"\"", true)] + public async Task Configuration_OtlpHttpEndPoint_SecondaryApiKeyInJson_Validation(string secondaryApiKeyJson, bool isEmpty) + { + var tempDirectory = Directory.CreateTempSubdirectory(); + var configFilePath = Path.Combine(tempDirectory.FullName, "appsettings.json"); + var configJson = $$""" + { + "Dashboard": { + "Otlp": { + "AuthMode": "ApiKey", + "PrimaryApiKey": "TestKey123!", + "SecondaryApiKey": {{secondaryApiKeyJson}} + } + } + } + """; + await File.WriteAllTextAsync(configFilePath, configJson).DefaultTimeout(); + + try + { + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[KnownConfigNames.DashboardConfigFilePath] = configFilePath; + }); + + if (isEmpty) + { + var exception = Assert.Throws(() => _ = app.DashboardOptionsMonitor.CurrentValue); + Assert.Contains($"SecondaryApiKey must not be empty when OTLP authentication mode is API key. Remove {DashboardConfigNames.DashboardOtlpSecondaryApiKeyName.ConfigKey} or specify a non-empty value.", exception.Failures); + } + else + { + var options = app.DashboardOptionsMonitor.CurrentValue.Otlp; + Assert.Equal(OtlpAuthMode.ApiKey, options.AuthMode); + Assert.Equal("TestKey123!", options.PrimaryApiKey); + Assert.Null(options.SecondaryApiKey); + } + } + finally + { + Directory.Delete(tempDirectory.FullName, recursive: true); + } + } + + [Fact] + public async Task CallService_OtlpHttpEndPoint_EmptyApiKey_Failure() + { + // Arrange + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey] = OtlpAuthMode.ApiKey.ToString(); + config[DashboardConfigNames.DashboardOtlpPrimaryApiKeyName.ConfigKey] = "TestKey123!"; + }); + await app.StartAsync().DefaultTimeout(); + + using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.OtlpServiceHttpEndPointAccessor().EndPoint}"); + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/v1/logs") + { + Content = new ByteArrayContent(new ExportLogsServiceRequest().ToByteArray()) + }; + requestMessage.Content.Headers.TryAddWithoutValidation("content-type", OtlpHttpEndpointsBuilder.ProtobufContentType); + requestMessage.Headers.TryAddWithoutValidation(OtlpApiKeyAuthenticationHandler.ApiKeyHeaderName, string.Empty); + + // Act + var responseMessage = await httpClient.SendAsync(requestMessage).DefaultTimeout(); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, responseMessage.StatusCode); + } + [Fact] public async Task CallService_OtlpHttpEndPoint_RequiredApiKeyWrong_Failure() { diff --git a/tests/Aspire.Dashboard.Tests/OtlpApiKeyAuthenticationHandlerTests.cs b/tests/Aspire.Dashboard.Tests/OtlpApiKeyAuthenticationHandlerTests.cs index 7827da851ac..fcbf73d534e 100644 --- a/tests/Aspire.Dashboard.Tests/OtlpApiKeyAuthenticationHandlerTests.cs +++ b/tests/Aspire.Dashboard.Tests/OtlpApiKeyAuthenticationHandlerTests.cs @@ -73,6 +73,20 @@ public async Task AuthenticateAsync_MatchHeader_Success(string primaryApiKey, st Assert.Equal(success, result.Failure == null); } + [Fact] + public async Task AuthenticateAsync_EmptyHeader_Failure() + { + // Arrange + var handler = await CreateAuthHandlerAsync(primaryApiKey: "abc", secondaryApiKey: "", otlpApiKeyHeader: "").DefaultTimeout(); + + // Act + var result = await handler.AuthenticateAsync().DefaultTimeout(); + + // Assert + Assert.NotNull(result.Failure); + Assert.Equal($"API key from '{OtlpApiKeyAuthenticationHandler.ApiKeyHeaderName}' header is empty.", result.Failure.Message); + } + private static async Task CreateAuthHandlerAsync(string primaryApiKey, string? secondaryApiKey, string? otlpApiKeyHeader) { var options = new DashboardOptions From e47348d4ef26c3b1ee8982a6cda9d8ccaec51faf Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Thu, 24 Sep 2026 10:52:04 +0800 Subject: [PATCH 6/9] [release/13.6] Fix Dashboard tests and popup behavior (#20410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backports #20360, #20341, and #20362 to release/13.6. ## Customer Impact Dashboard users can encounter popups that are misaligned, overflow the viewport, or disappear while metric dimensions update. Resource graph context menus may not reopen immediately after dismissal, and terminal auto-fit can move focus away from the active control. ## Testing Validated on the release/13.6 backport branch: 570 Aspire.Dashboard.Components.Tests and 1,892 Aspire.Dashboard.Tests passed, excluding quarantined and outerloop tests. The source changes also include targeted Playwright coverage for resources, terminal, navigation, authentication, accessibility, and popup behavior. `git diff --check` passes. ## Risk Medium. The production changes are localized to Dashboard popup, focus, and component-state behavior with no public API changes, but this combines three related changes and a broad component-test migration. ## Regression? Yes — fixes Dashboard behavior and test regressions introduced by the Fluent UI v5 migration. --- eng/Versions.props | 2 + .../Components/Controls/AspireMenu.razor.cs | 24 ++++- .../Controls/AspireMenuButton.razor | 2 +- .../Controls/Chart/ChartContainer.razor | 4 +- .../Controls/Chart/ChartFilterPopover.razor | 67 ++++++-------- .../Chart/ChartFilterPopover.razor.cs | 22 ++++- .../Chart/ChartFilterPopover.razor.css | 33 ++----- .../Controls/Chart/ChartFilterTags.razor | 2 +- .../Controls/Chart/ChartFilterTags.razor.cs | 9 +- .../Controls/Chart/ChartFilters.razor | 27 +++++- .../Controls/Chart/ChartFilters.razor.cs | 25 +++++ .../Controls/Chart/ChartFilters.razor.css | 23 +++++ .../Components/Controls/ResourceActions.razor | 2 +- .../Components/Controls/TerminalView.razor | 4 +- .../Components/Controls/TerminalView.razor.cs | 3 - .../Components/Controls/TerminalView.razor.js | 2 - .../Components/Controls/UserProfile.razor | 6 +- .../Components/Controls/UserProfile.razor.css | 8 +- .../Components/Dialogs/SettingsDialog.razor | 14 +-- .../Layout/DesktopToolbarDivider.razor | 2 +- .../Components/Pages/Resources.razor | 16 ++-- .../Components/Pages/Resources.razor.cs | 42 ++++----- .../Components/Pages/Resources.razor.css | 8 +- .../UrlsColumnDisplay.razor.css | 12 ++- .../Model/DimensionFilterViewModel.cs | 7 +- src/Aspire.Dashboard/wwwroot/css/controls.css | 13 +++ .../Aspire.Dashboard.Components.Tests.csproj | 5 + .../Controls/ApplicationNameTests.cs | 6 +- .../Controls/AspireFluentDataGridTests.cs | 10 +- .../Controls/AspireLogoTests.cs | 4 +- .../Controls/AspireMenuButtonTests.cs | 18 ++-- .../Controls/AspireMenuTests.cs | 70 +++++++++++--- .../Controls/AspireProgressRingTests.cs | 6 +- ...AspirePropertyDetailsAccordionItemTests.cs | 4 +- .../Controls/ChartFiltersTests.cs | 72 ++++++++++++++- .../Controls/GenAIVisualizerDialogTests.cs | 6 +- .../Controls/LogViewerTests.cs | 38 ++++---- .../Controls/PlotlyChartTests.cs | 4 +- .../Controls/ResourceDetailsTests.cs | 44 ++++----- .../Controls/ResourceSelectTests.cs | 2 +- .../Controls/StructuredLogDetailsTests.cs | 2 +- .../Controls/TerminalViewTests.cs | 80 ++++++++-------- .../Controls/TerminalWindowButtonTests.cs | 54 +++++------ .../Controls/TextVisualizerDialogTests.cs | 10 +- .../Controls/TotalItemsFooterTests.cs | 2 +- .../Controls/UrlsColumnDisplayTests.cs | 4 +- .../Controls/UserProfileTests.cs | 5 +- .../Dialogs/FilterDialogTests.cs | 36 ++++---- .../Dialogs/HelpDialogTests.cs | 2 +- .../InteractionMessageBoxDialogTests.cs | 6 +- .../Dialogs/InteractionsInputDialogTests.cs | 2 +- .../InteractionsTerminalDialogTests.cs | 2 +- .../Dialogs/ManageDataDialogTests.cs | 8 +- .../NotificationEntryComponentTests.cs | 2 +- .../Dialogs/SettingsDialogTests.cs | 2 +- .../GridColumnManagerTests.cs | 6 +- .../InteractionsProviderTests.Terminal.cs | 4 +- .../Interactions/InteractionsProviderTests.cs | 28 +++--- .../JavaScript/TerminalView.test.mjs | 12 ++- .../Layout/DashboardDialogProviderTests.cs | 14 +-- .../Layout/MainLayoutTerminalTests.cs | 6 +- .../Layout/MainLayoutTests.cs | 42 ++++----- .../Layout/MobileNavMenuTests.cs | 6 +- .../ResourceServiceConnectionProviderTests.cs | 2 +- .../Layout/TerminalDockInitializationTests.cs | 10 +- .../Layout/TerminalDockTelemetryTests.cs | 2 +- .../Layout/TerminalDockTests.cs | 44 ++++----- .../Layout/TerminalDockWindowTrackingTests.cs | 10 +- .../Pages/ConsoleLogsTerminalTests.cs | 32 +++---- .../Pages/ConsoleLogsTests.cs | 32 +++---- .../Pages/MetricsTests.cs | 47 ++++++---- .../Pages/ResourcesTests.cs | 56 ++++++------ .../Pages/StructuredLogsTests.cs | 16 ++-- .../Pages/TerminalWindowTests.cs | 38 ++++---- .../Pages/TraceDetailsTests.cs | 28 +++--- .../Pages/TracesTests.cs | 4 +- .../Shared/DashboardPageTestContext.cs | 2 +- .../Shared/FluentUISetupHelpers.cs | 50 +++++----- .../Shared/InteractionsSetupHelpers.cs | 8 +- .../Shared/LayoutSetupHelpers.cs | 2 +- .../Shared/MetricsSetupHelpers.cs | 6 +- .../Shared/ResourceSetupHelpers.cs | 4 +- .../Shared/StructuredLogsSetupHelpers.cs | 2 +- .../Shared/TerminalSetupHelpers.cs | 15 +-- .../Shared/TestJSObjectReference.cs | 2 +- .../Playwright/AccessibilityTests.cs | 91 ++++++++++--------- .../Integration/Playwright/AppBarTests.cs | 6 +- .../Playwright/AspireMenuLayoutTests.cs | 2 +- .../BrowserTokenAuthenticationTests.cs | 59 ++++++------ .../Playwright/DashboardInteractionsTests.cs | 12 ++- .../Infrastructure/DashboardServerFixture.cs | 6 +- .../Infrastructure/PlaywrightFixture.cs | 17 +++- .../Infrastructure/PlaywrightTestsBase.cs | 35 +++++-- .../Playwright/MobileNavMenuTests.cs | 4 +- .../Playwright/NativeAotDashboardTests.cs | 2 + .../Integration/Playwright/ResourcesTests.cs | 59 ++++++++++-- .../Playwright/TerminalDockTests.cs | 77 +--------------- .../Integration/Playwright/TerminalTests.cs | 26 ++++-- .../Model/DashboardClientTests.cs | 45 ++++----- tests/Directory.Packages.props | 2 +- 100 files changed, 1055 insertions(+), 803 deletions(-) diff --git a/eng/Versions.props b/eng/Versions.props index 2d18ece0393..3c2fe3c78e8 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -74,6 +74,8 @@ 11.0.0-rc.1.26425.128 11.0.0-rc.1.26425.128 11.0.0-rc.1.26425.128 + 11.0.0-rc.1.26425.128 + 11.0.0-rc.1.26425.128 11.0.0-rc.1.26425.128 11.0.0-rc.1.26425.128
diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs index 34282ec96aa..7394c2d1b48 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs @@ -19,6 +19,7 @@ public AspireMenu(LibraryConfiguration configuration) private FluentMenu? _menu; private IReadOnlyList? _renderedItems; private bool _refreshMenuAfterRender; + private bool _reopenInProgress; private bool? _appliedOpen; private int _cursorLeft; private int _cursorTop; @@ -98,10 +99,15 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { // Trigger identifies either the button anchor or the cursor anchor. The parameterless // path leaves placement to Fluent's CSS anchor positioning and viewport fallbacks. + if (_reopenInProgress) + { + await _menu.CloseMenuAsync(); + } await _menu.OpenMenuAsync(); } else { + _reopenInProgress = false; await _menu.CloseMenuAsync(); } @@ -127,8 +133,10 @@ public async Task OpenAsync(int clientX, int clientY) .AddStyle("min-width", "64px") .Build(); - // Escape and light-dismiss can close the browser popover without raising OpenedChanged. - // Treat every cursor request as a new open/position request even when Open is still true. + // Escape and light-dismiss update the browser popover before their asynchronous + // OpenedChanged notification reaches this component. Treat every cursor request as a + // new open/position request even when Open is still true. + _reopenInProgress = Open; _refreshMenuAfterRender = true; await SetOpenAsync(true); @@ -186,6 +194,18 @@ private async Task HandleSecondaryActionClicked(MenuButtonItem item) private async Task OnOpenChanged(bool open) { + // Fluent reports native popover toggles through asynchronous JS callbacks. During a deliberate + // close-and-reopen, ignore the reset's close notification until the following open is confirmed. + if (_reopenInProgress) + { + if (!open) + { + return; + } + + _reopenInProgress = false; + } + _appliedOpen = open; await SetOpenAsync(open); } diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor index d61597a49bb..449efc311b6 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor @@ -13,7 +13,7 @@ }; } -
+
@if (IconStart is not null) { diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor index 73cd3db4f19..b79a7c4e146 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartContainer.razor @@ -39,7 +39,7 @@ else IconStart="@(new Icons.Regular.Size24.DataArea())">
- +
- +
diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterPopover.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterPopover.razor index 9d66a1c467e..cd3ce2e0513 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterPopover.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterPopover.razor @@ -4,42 +4,35 @@ @using Aspire.Dashboard.Resources @inject IStringLocalizer Loc -@{ - var id = $"typeFilterButton-{Filter.SanitizedHtmlId}-{_idSuffix}"; -} - - - - - @* Each item is approximately 36px tall, plus 48px for the header and container padding. Include the always-rendered "All" checkbox row. The popover body has a CSS max-height of 300px. *@ - - @Filter.Name -
- - - @foreach (var tag in ChartFilterTags.GetOrderedValues(Filter.Values)) - { - var isChecked = Filter.SelectedValues.Contains(tag); - + + @Filter.Name +
+ + - } - -
-
+ ThreeState="true" + ShowIndeterminate="false" + ThreeStateOrderUncheckToIntermediate="true" + Value="@(Filter.AreAllValuesSelected is true)" + @bind-CheckState:get="Filter.AreAllValuesSelected" + @bind-CheckState:set="@(v => OnAllValuesSelectionChangedAsync(v))"/> + @foreach (var tag in ChartFilterTags.GetOrderedValues(Filter.Values)) + { + var isChecked = Filter.SelectedValues.Contains(tag); + + } +
+
+
+ diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterPopover.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterPopover.razor.cs index f14980b9c88..2a6d70a9858 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterPopover.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterPopover.razor.cs @@ -8,17 +8,33 @@ namespace Aspire.Dashboard.Components; public partial class ChartFilterPopover : IDisposable { - private readonly Guid _idSuffix = Guid.NewGuid(); + private DimensionFilterViewModel? _subscribedFilter; + + [Parameter, EditorRequired] + public required string AnchorId { get; set; } [Parameter, EditorRequired] public required DimensionFilterViewModel Filter { get; set; } + [Parameter, EditorRequired] + public required bool Opened { get; set; } + + [Parameter, EditorRequired] + public required EventCallback OpenedChanged { get; set; } + [Parameter, EditorRequired] public required EventCallback OnSelectionChanged { get; set; } - protected override void OnInitialized() + protected override void OnParametersSet() { + if (ReferenceEquals(_subscribedFilter, Filter)) + { + return; + } + + _subscribedFilter?.NotifyStateChanged -= OnFilterStateChanged; Filter.NotifyStateChanged += OnFilterStateChanged; + _subscribedFilter = Filter; } private void OnFilterStateChanged() @@ -42,6 +58,6 @@ private async Task OnAllValuesSelectionChangedAsync(bool? isChecked) public void Dispose() { - Filter.NotifyStateChanged -= OnFilterStateChanged; + _subscribedFilter?.NotifyStateChanged -= OnFilterStateChanged; } } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterPopover.razor.css b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterPopover.razor.css index d8cdc47b4ae..28fcd26463b 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterPopover.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterPopover.razor.css @@ -1,28 +1,11 @@ -.chart-filter-button-container { - display: inline-flex; - width: 32px; - height: 32px; -} - -.chart-filter-button-container ::deep .chart-filter-button { - width: 32px; - min-width: 32px; - max-width: 32px; - height: 32px; - flex: 0 0 32px; - font-weight: var(--fontWeightRegular); -} - -.chart-filter-button-container ::deep .chart-filter-button::part(control) { - width: 32px; - min-width: 32px; - height: 32px; - padding: 0; - font-weight: var(--fontWeightRegular); -} - -.chart-filter-button-container + ::deep .chart-filter-popover::part(dialog) { - transform: translateX(calc(-100% + 32px)); +.chart-filter-popover-container ::deep .chart-filter-popover::part(dialog) { + position-anchor: var(--chart-filter-anchor); + /* Fluent sets the dialog insets inline; the anchor position must override them. */ + top: anchor(bottom) !important; + right: anchor(right) !important; + bottom: auto !important; + left: auto !important; + position-try-fallbacks: flip-block; box-sizing: border-box; max-width: 400px; width: max-content; diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterTags.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterTags.razor index 4c2643c3fe5..6c89d43cd6d 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterTags.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterTags.razor @@ -29,7 +29,7 @@ diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterTags.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterTags.razor.cs index 1652fc145ef..19dfc6c5e53 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterTags.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilterTags.razor.cs @@ -21,6 +21,9 @@ public partial class ChartFilterTags : IDisposable [Parameter, EditorRequired] public required EventCallback OnSelectionChanged { get; set; } + [Parameter, EditorRequired] + public required EventCallback OnShowPopover { get; set; } + private const int MaxRenderedOverflowItems = 20; protected override void OnInitialized() @@ -54,11 +57,7 @@ private async Task OnTagActivatedAsync(DimensionValueViewModel tag, bool toggleS await OnSelectionChanged.InvokeAsync(Filter); } - private void ShowPopover() - { - Filter.PopupVisible = true; - Filter.NotifyStateChanged?.Invoke(); - } + private Task ShowPopoverAsync() => OnShowPopover.InvokeAsync(Filter); /// /// Orders dimension values numerically if all values are parsable as doubles; diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor index c6d289fe210..b67e9af8aa7 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor @@ -10,19 +10,42 @@
@Loc[nameof(ControlsStrings.ChartContainerFiltersHeader)]
- + - + + + + + + @foreach (var filter in DimensionFilters) + { + + }
} @if (InstrumentType == OtlpInstrumentType.Histogram) diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs index fa743e3e9d1..b1a689e24d5 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.cs @@ -10,6 +10,9 @@ namespace Aspire.Dashboard.Components; public partial class ChartFilters { + private readonly Guid _idSuffix = Guid.NewGuid(); + private readonly HashSet _openFilterNames = []; + [Parameter, EditorRequired] public required OtlpInstrumentType InstrumentType { get; set; } @@ -25,5 +28,27 @@ public partial class ChartFilters [Parameter] public EventCallback OnDimensionValuesChanged { get; set; } + private string GetFilterButtonId(DimensionFilterViewModel filter) => $"typeFilterButton-{filter.SanitizedHtmlId}-{filter.NameHash}-{_idSuffix}"; + + private bool IsPopupOpen(DimensionFilterViewModel filter) => _openFilterNames.Contains(filter.Name); + + private void TogglePopup(DimensionFilterViewModel filter) => SetPopupOpen(filter, !IsPopupOpen(filter)); + + private void ShowPopup(DimensionFilterViewModel filter) => SetPopupOpen(filter, opened: true); + + private void SetPopupOpen(DimensionFilterViewModel filter, bool opened) + { + if (opened) + { + _openFilterNames.Add(filter.Name); + } + else + { + _openFilterNames.Remove(filter.Name); + } + } + + private Task OnFilterSelectionChangedAsync(DimensionFilterViewModel filter) => OnDimensionValuesChanged.InvokeAsync(filter); + private Task OnShowCountChangedAsync(bool value) => ShowCountChanged.InvokeAsync(value); } diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.css b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.css index 60acf930690..48b71d89975 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor.css @@ -1 +1,24 @@ /* Tag-related styles are in ChartFilterTags.razor.css */ + +.chart-filter-button-container { + display: inline-flex; + width: 32px; + height: 32px; +} + +.chart-filter-button-container ::deep .chart-filter-button { + width: 32px; + min-width: 32px; + max-width: 32px; + height: 32px; + flex: 0 0 32px; + font-weight: var(--fontWeightRegular); +} + +.chart-filter-button-container ::deep .chart-filter-button::part(control) { + width: 32px; + min-width: 32px; + height: 32px; + padding: 0; + font-weight: var(--fontWeightRegular); +} diff --git a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor index 374eea580b2..683a4967121 100644 --- a/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor +++ b/src/Aspire.Dashboard/Components/Controls/ResourceActions.razor @@ -30,7 +30,7 @@
- + }
diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor index c1bc46bb994..10adcc5d33f 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor @@ -63,11 +63,11 @@ aria-label="@(FitLabel ?? Loc[nameof(Resources.TerminalStrings.TerminalToolbarGridSizeAuto)])" OnClick="FitToContainerAsync">@(FitLabel ?? Loc[nameof(Resources.TerminalStrings.TerminalToolbarGridSizeAuto)]) + Disabled="@(!_state.SizeSelectEnabled || ReadOnly)" /> }
diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs index 23a9e09abd4..499bbd0e09a 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs @@ -245,7 +245,6 @@ private async Task InitializeTerminalCoreAsync(string endpoint) Label = Loc[nameof(Resources.TerminalStrings.TerminalInputLabel)], DecreaseFontSize = DecreaseFontSizeLabel ?? Loc[nameof(Resources.TerminalStrings.TerminalToolbarDecreaseFontSize)], IncreaseFontSize = IncreaseFontSizeLabel ?? Loc[nameof(Resources.TerminalStrings.TerminalToolbarIncreaseFontSize)], - TerminalDimensions = TerminalDimensionsLabel ?? Loc[nameof(Resources.TerminalStrings.TerminalToolbarGridSize)], Fit = FitLabel ?? Loc[nameof(Resources.TerminalStrings.TerminalToolbarGridSizeAuto)], FocusControlsHint = FocusControlsHintLabel ?? Loc[nameof(Resources.TerminalStrings.TerminalFocusControlsHint)], }, _selectionTemplateElement, _footerElement); @@ -527,8 +526,6 @@ public sealed record TerminalViewOptions public required string DecreaseFontSize { get; init; } /// The accessible increase-font-size label. public required string IncreaseFontSize { get; init; } - /// The accessible grid-size label. - public required string TerminalDimensions { get; init; } /// The localized automatic-sizing option. public required string Fit { get; init; } /// The localized focus-navigation hint. diff --git a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js index 80a9f0a9155..fbdbbae1768 100644 --- a/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js +++ b/src/Aspire.Dashboard/Components/Controls/TerminalView.razor.js @@ -636,8 +636,6 @@ export function setAutoFit(id, autoFit) { if (!autoFit) { state.pendingSizing = null; state.focusPending = false; - } else { - requestFocus(state); } applyAutoFit(state); applyPendingFocus(state); diff --git a/src/Aspire.Dashboard/Components/Controls/UserProfile.razor b/src/Aspire.Dashboard/Components/Controls/UserProfile.razor index 9668da68325..ef1f18deb9d 100644 --- a/src/Aspire.Dashboard/Components/Controls/UserProfile.razor +++ b/src/Aspire.Dashboard/Components/Controls/UserProfile.razor @@ -5,6 +5,7 @@
@_initials - +
@Loc[nameof(Resources.Login.LoggedInAs)] diff --git a/src/Aspire.Dashboard/Components/Controls/UserProfile.razor.css b/src/Aspire.Dashboard/Components/Controls/UserProfile.razor.css index 0eb22a38861..03d7b72d927 100644 --- a/src/Aspire.Dashboard/Components/Controls/UserProfile.razor.css +++ b/src/Aspire.Dashboard/Components/Controls/UserProfile.razor.css @@ -52,13 +52,19 @@ } .profile-menu-container ::deep .profile-menu-popover::part(dialog) { + position-anchor: var(--profile-menu-anchor); + /* Fluent sets the dialog insets inline; the anchor position must override them. */ + top: anchor(bottom) !important; + right: anchor(right) !important; + bottom: auto !important; + left: auto !important; + position-try-fallbacks: flip-block; box-sizing: border-box; min-width: 204px; min-height: 136px; padding: 16px; border: 0; border-radius: 0; - transform: translateX(calc(-100% + var(--profile-button-size))); } .profile-popover-content { diff --git a/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor b/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor index 7948e953531..3f07bd931a0 100644 --- a/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor +++ b/src/Aspire.Dashboard/Components/Dialogs/SettingsDialog.razor @@ -10,23 +10,17 @@ @bind-Value="@_currentSetting" @bind-Value:after="ThemeChangedAsync" Orientation="Orientation.Vertical"> - - - + + +
- @* Fluent's Label renders a standalone
diff --git a/src/Aspire.Dashboard/Components/Layout/DesktopToolbarDivider.razor b/src/Aspire.Dashboard/Components/Layout/DesktopToolbarDivider.razor index 9afd806552a..cc05a3eaaef 100644 --- a/src/Aspire.Dashboard/Components/Layout/DesktopToolbarDivider.razor +++ b/src/Aspire.Dashboard/Components/Layout/DesktopToolbarDivider.razor @@ -1,4 +1,4 @@ @if (ViewportInformation.IsDesktop) { - + } diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor b/src/Aspire.Dashboard/Components/Pages/Resources.razor index d871d9b39ec..82445b121f0 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor @@ -310,21 +310,19 @@ - @if (HasViewOptionsMenu()) - { - - } +
; } } diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs index 6f275658c5e..ce7ebec9251 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.cs @@ -476,25 +476,28 @@ private void UpdateMenuButtons() { _resourcesMenuItems.Clear(); - if (HasCollapsedResources()) + if (HasAnyChildResources()) { - _resourcesMenuItems.Add(new MenuButtonItem + if (HasCollapsedResources()) { - IsDisabled = false, - OnClick = _onToggleCollapseAllCallback.InvokeAsync, - Text = Loc[nameof(Dashboard.Resources.Resources.ResourceExpandAllChildren)], - Icon = new Icons.Regular.Size16.Eye() - }); - } - else - { - _resourcesMenuItems.Add(new MenuButtonItem + _resourcesMenuItems.Add(new MenuButtonItem + { + IsDisabled = false, + OnClick = _onToggleCollapseAllCallback.InvokeAsync, + Text = Loc[nameof(Dashboard.Resources.Resources.ResourceExpandAllChildren)], + Icon = new Icons.Regular.Size16.Eye() + }); + } + else { - IsDisabled = false, - OnClick = _onToggleCollapseAllCallback.InvokeAsync, - Text = Loc[nameof(Dashboard.Resources.Resources.ResourceCollapseAllChildren)], - Icon = new Icons.Regular.Size16.EyeOff() - }); + _resourcesMenuItems.Add(new MenuButtonItem + { + IsDisabled = false, + OnClick = _onToggleCollapseAllCallback.InvokeAsync, + Text = Loc[nameof(Dashboard.Resources.Resources.ResourceCollapseAllChildren)], + Icon = new Icons.Regular.Size16.EyeOff() + }); + } } if (_showResourceTypeColumn) @@ -831,13 +834,6 @@ private bool HasAnyChildResources() return _resourceByName.Values.Any(r => !string.IsNullOrEmpty(r.GetResourcePropertyValue(KnownProperties.Resource.ParentName))); } - private bool HasViewOptionsMenu() - { - // Show the menu if there are any child resources (for collapse/expand) - // OR if there are any hidden resources (for show/hide hidden resources) - return HasAnyChildResources() || _resourceByName.Values.Any(r => r.IsResourceHidden(showHiddenResources: false)); - } - private Task OnTabChangeAsync(FluentTab? newTab) { if (_isDisposing) diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor.css b/src/Aspire.Dashboard/Components/Pages/Resources.razor.css index f454eff96cd..bd59acf1243 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor.css +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor.css @@ -127,7 +127,13 @@ } .resources-filter-popup-container ::deep .resources-filter-popup::part(dialog) { - transform: translateX(calc(-100% + 32px)); + position-anchor: --resource-filter-button; + /* Fluent sets the dialog insets inline; the anchor position must override them. */ + top: anchor(bottom) !important; + right: anchor(right) !important; + bottom: auto !important; + left: auto !important; + position-try-fallbacks: flip-block; box-sizing: border-box; width: max-content; max-width: 400px; diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor.css b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor.css index f8be3cdc9ff..260e1f56e63 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor.css +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor.css @@ -66,10 +66,12 @@ text-overflow: ellipsis; } -/* Fluent positions the dialog below the anchor's left edge using an inline inset. Use the generated - overflow anchor name so the dialog remains right-aligned for any +N button width. */ +/* Fluent sets the dialog insets inline; override them using the generated anchor to keep any +N button aligned. */ ::deep .url-overflow-popover::part(dialog) { - left: anchor(var(--url-overflow-anchor) right) !important; - right: auto !important; - transform: translateX(-100%); + position-anchor: var(--url-overflow-anchor); + top: anchor(bottom) !important; + right: anchor(right) !important; + bottom: auto !important; + left: auto !important; + position-try-fallbacks: flip-block; } diff --git a/src/Aspire.Dashboard/Model/DimensionFilterViewModel.cs b/src/Aspire.Dashboard/Model/DimensionFilterViewModel.cs index 82a8ed52d50..bfc6c1833f8 100644 --- a/src/Aspire.Dashboard/Model/DimensionFilterViewModel.cs +++ b/src/Aspire.Dashboard/Model/DimensionFilterViewModel.cs @@ -3,6 +3,8 @@ using System.Collections.Immutable; using System.Diagnostics; +using System.IO.Hashing; +using System.Text; using Aspire.Dashboard.Extensions; namespace Aspire.Dashboard.Model; @@ -10,13 +12,13 @@ namespace Aspire.Dashboard.Model; [DebuggerDisplay("{DebuggerToString(),nq}")] public class DimensionFilterViewModel { + private string? _nameHash; private string? _sanitizedHtmlId; private ImmutableHashSet _selectedValues = []; public required string Name { get; init; } public List Values { get; } = []; public IReadOnlySet SelectedValues => Volatile.Read(ref _selectedValues); - public bool PopupVisible { get; set; } /// /// Invoked when the filter state is modified externally (e.g., from the popover) @@ -60,6 +62,9 @@ public bool? AreAllValuesSelected public string SanitizedHtmlId => _sanitizedHtmlId ??= StringExtensions.SanitizeHtmlId(Name); + // Hash the original name so distinct names that sanitize identically still have distinct anchors. + public string NameHash => _nameHash ??= Convert.ToHexString(XxHash3.Hash(Encoding.UTF8.GetBytes(Name))); + public void SetSelectedValues(IEnumerable dimensionValues) { Interlocked.Exchange(ref _selectedValues, dimensionValues.ToImmutableHashSet()); diff --git a/src/Aspire.Dashboard/wwwroot/css/controls.css b/src/Aspire.Dashboard/wwwroot/css/controls.css index 60af596d8de..34d789a7371 100644 --- a/src/Aspire.Dashboard/wwwroot/css/controls.css +++ b/src/Aspire.Dashboard/wwwroot/css/controls.css @@ -646,6 +646,17 @@ body { overflow: hidden; } +.aspire-menu-button { + display: flex; + align-items: center; + height: 100%; + position: relative; +} + +.aspire-menu-button > .aspire-menu-container { + position: absolute; +} + :is(.aspire-menu-container, [cell-type="columnheader"] > fluent-menu) { --colorNeutralBackground1Hover: var(--aspire-menu-item-hover-background); } @@ -667,6 +678,8 @@ body { grid-template-columns: minmax(32px, auto) 0 minmax(0, 1fr) minmax(32px, auto); height: 28px; min-height: 28px; + box-sizing: border-box; + inline-size: calc(100% - 8px); margin-inline: 4px; padding: 0; border-radius: var(--aspire-control-radius); diff --git a/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj b/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj index e601f00c1ae..55dd16e7ba9 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj +++ b/tests/Aspire.Dashboard.Components.Tests/Aspire.Dashboard.Components.Tests.csproj @@ -16,6 +16,11 @@ + + + diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs index 805271079bc..5282101e4b1 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ApplicationNameTests.cs @@ -25,7 +25,7 @@ public void Render_DashboardClientDisabled_Success() AddDashboardClientServices(); // Act - var cut = RenderComponent(); + var cut = Render(); // Assert cut.MarkupMatches("Aspire"); @@ -38,7 +38,7 @@ public void Render_With_Args() AddDashboardClientServices(); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "{0} traces"); builder.Add(p => p.Loc, new TestStringLocalizer()); @@ -58,7 +58,7 @@ public void Render_DashboardClientEnabled_HtmlInName_Success() Services.AddSingleton(new TestDashboardClient(applicationName: "An HTML title!")); // Act - var cut = RenderComponent(); + var cut = Render(); // Assert cut.MarkupMatches("<marquee>An HTML title!</marquee>"); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireFluentDataGridTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireFluentDataGridTests.cs index 7de66a4432c..b0bafebe269 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireFluentDataGridTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireFluentDataGridTests.cs @@ -111,7 +111,7 @@ public async Task PropertyGrid_SortSurvivesInPlaceSourceUpdateAndCanBeCleared() new("beta", "2", fromSpec: true), new("alpha", "1", fromSpec: true) }; - var cut = RenderComponent>(builder => builder.Add(component => component.Items, items)); + var cut = Render>(builder => builder.Add(component => component.Items, items)); var grid = cut.FindComponent>(); var nameColumn = cut.FindComponents>()[0]; @@ -119,7 +119,7 @@ public async Task PropertyGrid_SortSurvivesInPlaceSourceUpdateAndCanBeCleared() cut.WaitForAssertion(() => Assert.Equal(["alpha", "beta"], cut.FindAll("td.nameColumn").Select(cell => cell.TextContent.Trim()))); items.Add(new("aardvark", "0", fromSpec: true)); - cut.SetParametersAndRender(builder => builder.Add(component => component.Items, items)); + cut.Render(builder => builder.Add(component => component.Items, items)); cut.WaitForAssertion(() => Assert.Equal(["aardvark", "alpha", "beta"], cut.FindAll("td.nameColumn").Select(cell => cell.TextContent.Trim()))); await cut.InvokeAsync(grid.Instance.RemoveSortByColumnAsync); @@ -137,7 +137,7 @@ public async Task PropertyGrid_ValueSortUsesMaskedValuesAsNull() new EnvironmentVariableViewModel("visible", "a", fromSpec: true) { IsValueMasked = false }, new EnvironmentVariableViewModel("masked", "z", fromSpec: true) }; - var cut = RenderComponent>(builder => builder.Add(component => component.Items, items)); + var cut = Render>(builder => builder.Add(component => component.Items, items)); var grid = cut.FindComponent>(); var valueColumn = cut.FindComponents>()[1]; @@ -155,7 +155,7 @@ public void PropertyGrid_DisabledSortingPreservesSourceOrder() FluentUISetupHelpers.SetupFluentDataGrid(this); FluentUISetupHelpers.SetupFluentMenu(this); var items = new[] { new EnvironmentVariableViewModel("beta", "2", fromSpec: true), new EnvironmentVariableViewModel("alpha", "1", fromSpec: true) }; - var cut = RenderComponent>(builder => builder + var cut = Render>(builder => builder .Add(component => component.Items, items) .Add(component => component.IsNameSortable, false) .Add(component => component.IsValueSortable, false)); @@ -170,7 +170,7 @@ public void Render_LoadingContent_UsesCompactCenteredLayout() FluentUISetupHelpers.AddCommonDashboardServices(this); FluentUISetupHelpers.SetupFluentDataGrid(this); - var cut = RenderComponent>(builder => builder + var cut = Render>(builder => builder .Add(component => component.Loading, true)); var stack = cut.FindComponent().Instance; diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireLogoTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireLogoTests.cs index d70c7da17ae..2a502da5beb 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireLogoTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireLogoTests.cs @@ -7,12 +7,12 @@ namespace Aspire.Dashboard.Components.Tests.Controls; -public class AspireLogoTests : Bunit.TestContext +public class AspireLogoTests : Bunit.BunitContext { [Fact] public void Render_UsesCurrentBrandArtwork() { - var cut = RenderComponent(); + var cut = Render(); var svg = cut.Find("svg"); var paths = cut.FindAll("path"); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuButtonTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuButtonTests.cs index b8d07e78eae..4a27763c487 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuButtonTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuButtonTests.cs @@ -20,7 +20,7 @@ public void Disabled_DisablesButton() FluentUISetupHelpers.SetupFluentButton(this); FluentUISetupHelpers.SetupFluentMenu(this); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(component => component.MenuButtonId, "disabled-menu-button") .Add(component => component.ItemsProvider, () => [new MenuButtonItem { Text = "Item" }]) .Add(component => component.Disabled, true)); @@ -36,7 +36,7 @@ public void Render_AddsCollapsedMenuPopupAria() FluentUISetupHelpers.SetupFluentButton(this); FluentUISetupHelpers.SetupFluentMenu(this); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.MenuButtonId, "view-options-button"); builder.Add(p => p.Text, "View options"); @@ -58,7 +58,7 @@ public void Render_WithoutText_AddsIconOnlyAttribute() FluentUISetupHelpers.SetupFluentButton(this); FluentUISetupHelpers.SetupFluentMenu(this); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.MenuButtonId, "icon-only-button"); builder.Add(p => p.ItemsProvider, () => [new MenuButtonItem { Text = "Action" }]); @@ -75,7 +75,7 @@ public void Render_DefaultsIconColorToPrimaryAndAllowsOverride() FluentUISetupHelpers.SetupFluentButton(this); FluentUISetupHelpers.SetupFluentMenu(this); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.MenuButtonId, "icon-color-button"); builder.Add(p => p.ItemsProvider, () => [new MenuButtonItem { Text = "Action" }]); @@ -83,7 +83,7 @@ public void Render_DefaultsIconColorToPrimaryAndAllowsOverride() Assert.Equal(Color.Primary, cut.FindComponent>().Instance.Color); - cut.SetParametersAndRender(builder => builder.Add(p => p.IconColor, Color.Default)); + cut.Render(builder => builder.Add(p => p.IconColor, Color.Default)); Assert.Equal(Color.Default, cut.FindComponent>().Instance.Color); } @@ -96,7 +96,7 @@ public void Render_WithoutTextAndWithStartIcon_SizesForBothIcons() FluentUISetupHelpers.SetupFluentButton(this); FluentUISetupHelpers.SetupFluentMenu(this); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.MenuButtonId, "icon-only-button"); builder.Add(p => p.IconStart, new Icons.Regular.Size16.Delete()); @@ -127,7 +127,7 @@ public async Task ItemsProvider_AddsMenuWhenButtonIsClicked() FluentUISetupHelpers.SetupFluentMenu(this); var providerInvocationCount = 0; - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.MenuButtonId, "lazy-menu-button"); builder.Add(p => p.Text, "View options"); @@ -179,7 +179,7 @@ public void ItemsProvider_RefreshesOpenMenuWhenParametersChange() var itemText = "First item"; Func> itemsProvider = () => [new MenuButtonItem { Text = itemText }]; - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.MenuButtonId, "refresh-menu-button"); builder.Add(p => p.Text, "View options"); @@ -190,7 +190,7 @@ public void ItemsProvider_RefreshesOpenMenuWhenParametersChange() cut.WaitForAssertion(() => Assert.Equal("First item", cut.FindComponent().Instance.Label)); itemText = "Second item"; - cut.SetParametersAndRender(builder => builder.Add(p => p.Text, "Updated view options")); + cut.Render(builder => builder.Add(p => p.Text, "Updated view options")); cut.WaitForAssertion(() => Assert.Equal("Second item", cut.FindComponent().Instance.Label)); } diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs index 4cf91573f0b..a69b596281e 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs @@ -47,7 +47,7 @@ public async Task ClickSecondaryAction_DoesNotSelectItemAndRefreshesOpenMenu() return Task.CompletedTask; }; - var menuHost = RenderComponent(builder => + var menuHost = Render(builder => { builder.Add(p => p.Anchor, "menu-anchor"); builder.Add(p => p.Open, true); @@ -84,7 +84,7 @@ public async Task UnanchoredAspireMenu_RendersFluentMenuAtCursor() FluentUISetupHelpers.SetupFluentMenu(this); FluentUISetupHelpers.SetupFluentAnchoredRegion(this); - var menuHost = RenderComponent(builder => + var menuHost = Render(builder => { builder.Add(p => p.Anchor, "menu-anchor"); builder.Add(p => p.Anchored, false); @@ -105,6 +105,54 @@ public async Task UnanchoredAspireMenu_RendersFluentMenuAtCursor() Assert.Contains("top: 456px", cursorAnchor.GetAttribute("style")); } + [Fact] + public async Task UnanchoredAspireMenu_RepeatedOpenIgnoresResetCloseNotification() + { + FluentUISetupHelpers.AddCommonDashboardServices(this); + FluentUISetupHelpers.SetupFluentUIComponents(this); + FluentUISetupHelpers.SetupFluentMenu(this); + FluentUISetupHelpers.SetupFluentAnchoredRegion(this); + + var openChanges = new List(); + var menuHost = Render(builder => + { + builder.Add(p => p.Anchor, "menu-anchor"); + builder.Add(p => p.Anchored, false); + builder.Add(p => p.OpenChanged, EventCallback.Factory.Create(this, openChanges.Add)); + builder.Add(p => p.Items, new[] { new MenuButtonItem { Text = "Item" } }); + }); + + await menuHost.InvokeAsync(() => menuHost.Instance.OpenAsync(123, 456)); + menuHost.WaitForAssertion(() => Assert.Single(JSInterop.Invocations, + invocation => invocation.Identifier == "Microsoft.FluentUI.Blazor.Components.Menu.OpenMenu")); + var menu = menuHost.FindComponent().Instance; + await menuHost.InvokeAsync(() => menu.OnOpenedChangedAsync(true)); + var closeCount = JSInterop.Invocations.Count( + invocation => invocation.Identifier == "Microsoft.FluentUI.Blazor.Components.Menu.CloseMenu"); + + await menuHost.InvokeAsync(() => menuHost.Instance.OpenAsync(456, 789)); + + menuHost.WaitForAssertion(() => + { + Assert.Equal(2, JSInterop.Invocations.Count( + invocation => invocation.Identifier == "Microsoft.FluentUI.Blazor.Components.Menu.OpenMenu")); + Assert.Equal(closeCount + 1, JSInterop.Invocations.Count( + invocation => invocation.Identifier == "Microsoft.FluentUI.Blazor.Components.Menu.CloseMenu")); + }); + + var changeCount = openChanges.Count; + await menuHost.InvokeAsync(() => menu.OnOpenedChangedAsync(false)); + Assert.True(menuHost.Instance.Open); + Assert.Equal(changeCount, openChanges.Count); + + await menuHost.InvokeAsync(() => menu.OnOpenedChangedAsync(true)); + Assert.True(menuHost.Instance.Open); + + await menuHost.InvokeAsync(() => menu.OnOpenedChangedAsync(false)); + Assert.False(menuHost.Instance.Open); + Assert.False(openChanges[^1]); + } + [Fact] public async Task Header_LabelsMenuAndContributesToVerticalThreshold() { @@ -120,7 +168,7 @@ public async Task Header_LabelsMenuAndContributesToVerticalThreshold() Text = "Resource1", Icon = new Icons.Regular.Size16.Info() }; - var menuHost = RenderComponent(builder => + var menuHost = Render(builder => { builder.Add(p => p.Anchor, "menu-anchor"); builder.Add(p => p.Anchored, false); @@ -147,7 +195,7 @@ public void NestedAspireMenu_RendersItemsDirectlyInSubmenu() FluentUISetupHelpers.SetupFluentMenu(this); FluentUISetupHelpers.SetupFluentAnchoredRegion(this); - var menuHost = RenderComponent(builder => + var menuHost = Render(builder => { builder.Add(p => p.Anchor, "menu-anchor"); builder.Add(p => p.Items, new[] @@ -180,7 +228,7 @@ public async Task RemoveAspireMenu_RemovesFluentMenuFromHost() FluentUISetupHelpers.SetupFluentMenu(this); FluentUISetupHelpers.SetupFluentAnchoredRegion(this); - var menuHost = RenderComponent>(builder => + var menuHost = Render>(builder => { builder.Add(p => p.Value, false); builder.AddChildContent(menuBuilder => @@ -193,7 +241,7 @@ public async Task RemoveAspireMenu_RemovesFluentMenuFromHost() await menuHost.InvokeAsync(() => menuHost.FindComponent().Instance.OpenAsync(10, 10)); - menuHost.SetParametersAndRender(builder => + menuHost.Render(builder => { builder.Add(p => p.Value, false); builder.Add(p => p.ChildContent, (RenderFragment)(_ => { })); @@ -230,7 +278,7 @@ public async Task ClickItem_MenuButton_FocusesAnchorBeforeOnClick() } }; - var menuButton = RenderComponent(builder => + var menuButton = Render(builder => { builder.Add(p => p.MenuButtonId, anchor); builder.Add(p => p.Title, "View options"); @@ -271,7 +319,7 @@ public async Task ClickItem_MenuButtonWithFocusRestorationDisabled_DoesNotFocusA } }; - var menuButton = RenderComponent(builder => + var menuButton = Render(builder => { builder.Add(p => p.MenuButtonId, anchor); builder.Add(p => p.Title, "View options"); @@ -306,7 +354,7 @@ public void CheckableItems_RenderAccessibleRoleAndCheckedStateInDom() new() { Text = "Terminal", Role = MenuItemRole.Checkbox, Checked = true }, }; - var menuButton = RenderComponent(builder => + var menuButton = Render(builder => { builder.Add(p => p.MenuButtonId, anchor); builder.Add(p => p.Title, "View options"); @@ -352,7 +400,7 @@ public async Task ChangeCheckableItem_InvokesItemCallbackAndClosesMenu() return Task.CompletedTask; } }; - var menuHost = RenderComponent(builder => + var menuHost = Render(builder => { builder.Add(p => p.Anchor, "menu-anchor"); builder.Add(p => p.Open, true); @@ -396,7 +444,7 @@ public async Task ChangeRadioSelection_OnlyInvokesNewlyCheckedItemCallback() return Task.CompletedTask; } }; - var menuHost = RenderComponent(builder => + var menuHost = Render(builder => { builder.Add(p => p.Anchor, "menu-anchor"); builder.Add(p => p.Open, true); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireProgressRingTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireProgressRingTests.cs index 5e9d07ab7ae..f88a518a6e9 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireProgressRingTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireProgressRingTests.cs @@ -13,7 +13,7 @@ public class AspireProgressRingTests : DashboardTestContext [Fact] public void Render_WithoutValue_IsIndeterminate() { - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.AriaLabel, "Loading") .Add(p => p.Width, "20px")); @@ -31,7 +31,7 @@ public void Render_WithoutValue_IsIndeterminate() [Fact] public void Render_WithValue_IsDeterminate() { - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.Min, 10) .Add(p => p.Max, 30) .Add(p => p.Value, 20) @@ -53,7 +53,7 @@ public void Render_WithValue_IsDeterminate() [InlineData(101, "100")] public void Render_ValueOutsideRange_ClampsValue(double value, string expectedValue) { - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.AriaLabel, "Duration") .Add(p => p.Value, value)); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/AspirePropertyDetailsAccordionItemTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/AspirePropertyDetailsAccordionItemTests.cs index e221251ca98..27a9f9bdeec 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/AspirePropertyDetailsAccordionItemTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/AspirePropertyDetailsAccordionItemTests.cs @@ -16,7 +16,7 @@ public void Render_AddsHeaderBadgeMarkersAndContent() { FluentUISetupHelpers.SetupFluentUIComponents(this); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(component => component.Header, "Properties") .Add(component => component.Count, 3) .Add(component => component.Expanded, true) @@ -48,7 +48,7 @@ public async Task Expanded_UserChangeSurvivesParentRerender() { FluentUISetupHelpers.SetupFluentUIComponents(this); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(component => component.Header, "Properties") .Add(component => component.Expanded, true) .AddChildContent("Content")); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ChartFiltersTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ChartFiltersTests.cs index 4ba630609c1..84ad71fdd90 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ChartFiltersTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ChartFiltersTests.cs @@ -122,6 +122,22 @@ public void Render_MoreThanMaxTags_RendersBoundedPayloadAndHighlightsSelectedOve Assert.Contains("included-in-filters", moreButton.ClassList); } + [Fact] + public void Click_FilterValueOverflow_OpensPopover() + { + SetupChartFilters(); + var dimensionFilter = new DimensionFilterViewModel { Name = "http.status_code" }; + for (var i = 0; i < 30; i++) + { + dimensionFilter.Values.Add(new DimensionValueViewModel { Text = i.ToString(), Value = i.ToString() }); + } + var cut = RenderChartFilters(dimensionFilter); + + cut.Find(".dimension-overflow .fluent-overflow-more .filter-value-tag").Click(); + + Assert.Equal("true", cut.Find("fluent-popover-b.chart-filter-popover").GetAttribute("opened")); + } + [Fact] public void Render_PartiallySelectedValues_ShowsIndeterminateAllCheckbox() { @@ -169,7 +185,7 @@ void AssertButtonAppearance(bool highlighted) Assert.Equal(buttonId, button.Id); Assert.Equal(highlighted ? "primary" : "transparent", button.GetAttribute("appearance")); Assert.Equal(highlighted ? "Filtered tags" : "All tags", button.GetAttribute("aria-label")); - var icon = popover.FindComponent>().Instance; + var icon = cut.FindComponent>().Instance; Assert.Equal(Color.Custom, icon.Color); Assert.Equal(highlighted ? "currentColor" : "var(--colorBrandForeground1)", icon.CustomColor); }); @@ -187,7 +203,6 @@ public void Click_FilterButton_KeepsStableAnchorAndOpensPopover() button.Click(); - Assert.True(dimensionFilter.PopupVisible); Assert.Equal(buttonId, cut.Find(".chart-filter-button").Id); var popover = cut.Find("fluent-popover-b"); Assert.Equal(buttonId, popover.GetAttribute("anchor-id")); @@ -195,6 +210,57 @@ public void Click_FilterButton_KeepsStableAnchorAndOpensPopover() Assert.Contains("chart-filter-popover", popover.ClassList); } + [Fact] + public void Render_NamesWithSameSanitizedId_HaveDistinctPopoverAnchors() + { + SetupChartFilters(); + var firstFilter = new DimensionFilterViewModel { Name = "foo.bar" }; + var secondFilter = new DimensionFilterViewModel { Name = "foo/bar" }; + var cut = RenderChartFilters(firstFilter); + cut.Render(builder => builder.Add(component => component.DimensionFilters, [firstFilter, secondFilter])); + + var buttons = cut.FindAll(".chart-filter-button"); + var popovers = cut.FindAll("fluent-popover-b.chart-filter-popover"); + + Assert.Equal(2, buttons.Count); + Assert.Equal(2, popovers.Count); + Assert.NotEqual(buttons[0].Id, buttons[1].Id); + Assert.StartsWith("typeFilterButton-foo_bar-", buttons[0].Id); + Assert.StartsWith("typeFilterButton-foo_bar-", buttons[1].Id); + Assert.Equal(buttons[0].Id, popovers[0].GetAttribute("anchor-id")); + Assert.Equal(buttons[1].Id, popovers[1].GetAttribute("anchor-id")); + + buttons[1].Click(); + + Assert.Equal("false", cut.FindAll("fluent-popover-b.chart-filter-popover")[0].GetAttribute("opened")); + Assert.Equal("true", cut.FindAll("fluent-popover-b.chart-filter-popover")[1].GetAttribute("opened")); + Assert.Equal(buttons[0].Id, cut.FindAll(".chart-filter-button")[0].Id); + Assert.Equal(buttons[1].Id, cut.FindAll(".chart-filter-button")[1].Id); + } + + [Fact] + public void ReplaceFilterSnapshot_KeepsOpenPopoverMounted() + { + SetupChartFilters(); + var dimensionFilter = CreateDimensionFilter(); + var cut = RenderChartFilters(dimensionFilter); + var buttonId = cut.Find(".chart-filter-button").Id; + var popover = cut.FindComponent().Instance; + cut.Find(".chart-filter-button").Click(); + + var updatedFilter = CreateDimensionFilter(); + updatedFilter.Values.Add(new DimensionValueViewModel { Text = "PUT", Value = "PUT", }); + updatedFilter.SetSelectedValues(updatedFilter.Values); + cut.Render(builder => builder.Add(component => component.DimensionFilters, [updatedFilter])); + + Assert.Same(popover, cut.FindComponent().Instance); + Assert.Same(updatedFilter, popover.Filter); + Assert.Equal(buttonId, cut.Find(".chart-filter-button").Id); + Assert.Equal(buttonId, cut.Find("fluent-popover-b.chart-filter-popover").GetAttribute("anchor-id")); + Assert.Equal("true", cut.Find("fluent-popover-b.chart-filter-popover").GetAttribute("opened")); + Assert.Equal(4, cut.FindAll(".dimension-popup fluent-field").Count); + } + [Fact] public void Render_FilterValueTags_UseNativeButtons() { @@ -284,7 +350,7 @@ private IRenderedComponent RenderChartFilters( DimensionFilterViewModel dimensionFilter, Action? onDimensionValuesChanged = null) { - return RenderComponent(builder => + return Render(builder => { builder.Add(p => p.InstrumentType, OtlpInstrumentType.Sum); builder.Add(p => p.ShowCount, false); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs index 763d3c05e2d..8c1e80f24e4 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/GenAIVisualizerDialogTests.cs @@ -385,7 +385,7 @@ await GenAIVisualizerDialog.OpenDialogAsync( }); } - private Func SetUpDialog(out DashboardDialogService dialogService) + private Func> SetUpDialog(out DashboardDialogService dialogService) { FluentUISetupHelpers.SetupDialogInfrastructure(this); FluentUISetupHelpers.SetupFluentTab(this); @@ -393,11 +393,11 @@ private Func SetUpDialog(out DashboardDialogService dialogSer JSInterop.SetupModule("/Components/Controls/TextVisualizer.razor.js").SetupVoid(); JSInterop.SetupModule("/Components/Controls/MarkdownRenderer.razor.js").SetupVoid(); - IRenderedFragment? cut = null; + IRenderedComponent? cut = null; TestDialogService? testDialogService = null; testDialogService = new TestDialogService((content, _) => { - cut = RenderComponent>(builder => + cut = Render>(builder => { builder.Add(cascadingValue => cascadingValue.Value, testDialogService!.LastInstance!); builder.AddChildContent(childBuilder => diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/LogViewerTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/LogViewerTests.cs index 5ec6d8cf3c4..e12e4f773e4 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/LogViewerTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/LogViewerTests.cs @@ -20,7 +20,7 @@ public async Task LogViewer_EndAnchorLimitsInitialResultThenReturnsAllEntries() SetupLogViewerServices(); var logEntries = CreateLogEntries(); - var cut = RenderComponent(builder => builder.Add(p => p.LogEntries, logEntries)); + var cut = Render(builder => builder.Add(p => p.LogEntries, logEntries)); var virtualize = cut.FindComponent>().Instance; foreach (var message in new[] { "one", "two", "three", "four", "five", "six" }) @@ -51,7 +51,7 @@ public void LogViewer_VirtualizeUsesIdentityComparerAndEndAnchor() var rematerializedEntry = LogEntry.Create(timestamp, logMessage: "Repeated message", isErrorMessage: false); rematerializedEntry.LineNumber = entry.LineNumber; - var cut = RenderComponent(builder => builder.Add(p => p.LogEntries, logEntries)); + var cut = Render(builder => builder.Add(p => p.LogEntries, logEntries)); var virtualize = cut.FindComponent>().Instance; var comparer = virtualize.ItemComparer; @@ -97,7 +97,7 @@ public void LogViewer_RendersResourcePrefixWithGeneratedStyle() isErrorMessage: false, resourcePrefix: $"resource-{Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)}")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.LogEntries, logEntries); builder.Add(p => p.ShowResourcePrefix, true); @@ -122,7 +122,7 @@ public void LogViewer_FocusesAccessibleScrollContainerOnInitialRender() { SetupLogViewerServices(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.LogEntries, new LogEntries(maximumEntryCount: int.MaxValue)); }); @@ -154,7 +154,7 @@ public void LogViewer_FilterText_ShowsOnlyMatchingEntries() var logEntries = CreateLogEntries("apple log", "banana log", "cherry log"); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.LogEntries, logEntries); builder.Add(p => p.FilterText, "banana"); @@ -175,7 +175,7 @@ public void LogViewer_FilterText_IsCaseInsensitive() var logEntries = CreateLogEntries("Error connecting", "Information ready"); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.LogEntries, logEntries); builder.Add(p => p.FilterText, "ERROR"); @@ -196,7 +196,7 @@ public void LogViewer_FilterText_NoMatch_ShowsNoMatchMessage() var logEntries = CreateLogEntries("apple log", "banana log"); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.LogEntries, logEntries); builder.Add(p => p.FilterText, "no-such-text"); @@ -235,7 +235,7 @@ public void LogViewer_FilterText_ExcludesPauseEntries() isErrorMessage: false, resourcePrefix: null)); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.LogEntries, logEntries); builder.Add(p => p.FilterText, "banana"); @@ -268,7 +268,7 @@ public void LogViewer_FilterText_MatchesAnsiStrippedContent() isErrorMessage: false, resourcePrefix: null)); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.LogEntries, logEntries); builder.Add(p => p.FilterText, "info:"); @@ -288,7 +288,7 @@ public void LogViewer_FilterText_ChangingFilterUpdatesVisibleEntries() var logEntries = CreateLogEntries("apple log", "banana log", "cherry log"); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.LogEntries, logEntries); builder.Add(p => p.FilterText, "apple"); @@ -302,7 +302,7 @@ public void LogViewer_FilterText_ChangingFilterUpdatesVisibleEntries() // Changing the filter on an already-rendered component must invalidate the cached filtered // view and re-query Virtualize through the deferred RefreshDataAsync in OnAfterRenderAsync. - cut.SetParametersAndRender(builder => builder.Add(p => p.FilterText, "cherry")); + cut.Render(builder => builder.Add(p => p.FilterText, "cherry")); cut.WaitForAssertion(() => { @@ -318,7 +318,7 @@ public void LogViewer_FilterText_ClearingFilterShowsAllEntries() var logEntries = CreateLogEntries("apple log", "banana log", "cherry log"); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.LogEntries, logEntries); builder.Add(p => p.FilterText, "banana"); @@ -328,7 +328,7 @@ public void LogViewer_FilterText_ClearingFilterShowsAllEntries() // A whitespace-only filter is treated as empty and must short-circuit back to showing every // entry, restoring the unfiltered (live buffer) view. - cut.SetParametersAndRender(builder => builder.Add(p => p.FilterText, " ")); + cut.Render(builder => builder.Add(p => p.FilterText, " ")); cut.WaitForAssertion(() => { @@ -344,7 +344,7 @@ public async Task LogViewer_FilterText_RefreshAfterAppendShowsNewMatchingEntries var logEntries = CreateLogEntries("apple log", "banana log"); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.LogEntries, logEntries); builder.Add(p => p.FilterText, "banana"); @@ -385,7 +385,7 @@ public void LogViewer_FilterText_RespectsRenderedResourcePrefix() isErrorMessage: false, resourcePrefix: "frontend")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.LogEntries, logEntries); builder.Add(p => p.FilterText, "frontend"); @@ -394,7 +394,7 @@ public void LogViewer_FilterText_RespectsRenderedResourcePrefix() cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".log-content"))); - cut.SetParametersAndRender(builder => builder.Add(p => p.ShowResourcePrefix, true)); + cut.Render(builder => builder.Add(p => p.ShowResourcePrefix, true)); cut.WaitForAssertion(() => { @@ -417,7 +417,7 @@ public void LogViewer_FilterText_MatchesRenderedStderrBadge() isErrorMessage: true, resourcePrefix: null)); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.LogEntries, logEntries); builder.Add(p => p.FilterText, "stderr"); @@ -444,7 +444,7 @@ public void LogViewer_FilterText_UsesDisplayedTimestampOnlyWhenVisible() isErrorMessage: false, resourcePrefix: null)); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.LogEntries, logEntries); builder.Add(p => p.FilterText, "2024-01-01T01:00:00"); @@ -458,7 +458,7 @@ public void LogViewer_FilterText_UsesDisplayedTimestampOnlyWhenVisible() Assert.Contains("timestamped log", content.TextContent); }); - cut.SetParametersAndRender(builder => + cut.Render(builder => { builder.Add(p => p.FilterText, "2024-01-01T00:00:00Z"); builder.Add(p => p.ShowTimestamp, false); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/PlotlyChartTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/PlotlyChartTests.cs index 3ee9f828529..e79e18a2a4e 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/PlotlyChartTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/PlotlyChartTests.cs @@ -30,7 +30,7 @@ public void Render_NoInstrument_NoPlotlyInvocations() var model = new InstrumentViewModel(); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.InstrumentViewModel, model); builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); @@ -85,7 +85,7 @@ public async Task Render_HasInstrument_InitializeChartInvocation(TimeFormat time await model.UpdateDataAsync(instrumentSummary, [dimension]); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.InstrumentViewModel, model); builder.Add(p => p.Duration, TimeSpan.FromSeconds(1)); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs index 779c6923613..0b0f8591572 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs @@ -39,7 +39,7 @@ public async Task ClickMaskAllSwitch_UpdatedResource_MaskChanged() }.ToImmutableArray()); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ShowSpecOnlyToggle, true); builder.Add(p => p.Resource, resource1); @@ -95,7 +95,7 @@ public async Task ClickMaskAllSwitch_UpdatedResource_MaskChanged() new EnvironmentVariableViewModel("envvar3", "value!", fromSpec: true) }.ToImmutableArray()); - cut.SetParametersAndRender(builder => + cut.Render(builder => { builder.Add(p => p.Resource, resource2); }); @@ -133,7 +133,7 @@ public async Task ClickMaskAllSwitch_NewResource_MaskChanged() }.ToImmutableArray()); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ShowSpecOnlyToggle, true); builder.Add(p => p.Resource, resource1); @@ -187,7 +187,7 @@ public async Task ClickMaskAllSwitch_NewResource_MaskChanged() new EnvironmentVariableViewModel("envvar3", "value!", fromSpec: true) }.ToImmutableArray()); - cut.SetParametersAndRender(builder => + cut.Render(builder => { builder.Add(p => p.Resource, resource2); }); @@ -225,7 +225,7 @@ public async Task ClickMaskEnvVarSwitch_UpdatedResource_MaskChanged() }.ToImmutableArray()); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ShowSpecOnlyToggle, true); builder.Add(p => p.Resource, resource1); @@ -271,7 +271,7 @@ public async Task ClickMaskEnvVarSwitch_UpdatedResource_MaskChanged() new EnvironmentVariableViewModel("envvar3", "value!", fromSpec: true) }.ToImmutableArray()); - cut.SetParametersAndRender(builder => + cut.Render(builder => { builder.Add(p => p.Resource, resource2); }); @@ -309,7 +309,7 @@ public async Task ClickMaskEnvVarSwitch_NewResource_MaskChanged() }.ToImmutableArray()); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ShowSpecOnlyToggle, true); builder.Add(p => p.Resource, resource1); @@ -355,7 +355,7 @@ public async Task ClickMaskEnvVarSwitch_NewResource_MaskChanged() new EnvironmentVariableViewModel("envvar3", "value!", fromSpec: true) }.ToImmutableArray()); - cut.SetParametersAndRender(builder => + cut.Render(builder => { builder.Add(p => p.Resource, resource2); }); @@ -387,7 +387,7 @@ public void Render_StateDescription_ShowsAsResourceDetailEntry() resourceName: "app1", state: KnownResourceState.Waiting); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.ResourceByName, new ConcurrentDictionary([new KeyValuePair(resource.Name, resource)])); @@ -407,7 +407,7 @@ public void Render_NotStartedStateDescription_ShowsDescription() resourceName: "app1", state: KnownResourceState.NotStarted); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.ResourceByName, new ConcurrentDictionary([new KeyValuePair(resource.Name, resource)])); @@ -441,7 +441,7 @@ public void Render_StateDescription_ShowsWaitingForDependenciesAsResourceDetailE isHighlighted: false) }); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.ResourceByName, new ConcurrentDictionary([ @@ -480,7 +480,7 @@ public void Render_NullState_ShowsUnknownStateInResourceDetails() resourceName: "app1", properties: properties); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.ResourceByName, new ConcurrentDictionary([new KeyValuePair(resource.Name, resource)])); @@ -529,7 +529,7 @@ public async Task Render_HighlightedUnknownProperty_ShowsInDefaultResourceDetail stateStyle: "error", properties: properties); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.ResourceByName, new ConcurrentDictionary([new KeyValuePair(resource.Name, resource)])); @@ -597,7 +597,7 @@ public void Render_ProducerSuppliedSortOrder_OrdersUnknownHighlightedProperties( resourceName: "redis", properties: properties); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.ResourceByName, new ConcurrentDictionary([new KeyValuePair(resource.Name, resource)])); @@ -633,7 +633,7 @@ public void FilteredEnvironmentVariables_SortedByName() }.ToImmutableArray()); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ShowSpecOnlyToggle, true); builder.Add(p => p.Resource, resource); @@ -669,7 +669,7 @@ public void FilteredVolumes_SortedBySource() }.ToImmutableArray()); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ShowSpecOnlyToggle, true); builder.Add(p => p.Resource, resource); @@ -705,7 +705,7 @@ public void FilteredHealthReports_SortedByName() }.ToImmutableArray()); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ShowSpecOnlyToggle, true); builder.Add(p => p.Resource, resource); @@ -752,7 +752,7 @@ public async Task UnsetParameterResource_RendersValueNotSetButton_AndInvokesSetC commands: ImmutableArray.Create(setCommand)); CommandViewModel? capturedCommand = null; - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.ResourceByName, new ConcurrentDictionary([new KeyValuePair(resource.Name, resource)])); @@ -798,7 +798,7 @@ public async Task UnsetParameterResource_WhenSetCommandIsExecuting_DisablesValue commands: ImmutableArray.Create(setCommand)); CommandViewModel? capturedCommand = null; - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.ResourceByName, new ConcurrentDictionary([new KeyValuePair(resource.Name, resource)])); @@ -843,7 +843,7 @@ public void UnsetSecretParameterResource_RendersValueNotSetButton() properties: properties, commands: ImmutableArray.Create(setCommand)); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.ResourceByName, new ConcurrentDictionary([new KeyValuePair(resource.Name, resource)])); @@ -872,7 +872,7 @@ public void ErrorParameterResource_DisplaysErrorMessage_InsteadOfValueNotSetButt stateStyle: "error", properties: properties); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.ResourceByName, new ConcurrentDictionary([new KeyValuePair(resource.Name, resource)])); @@ -899,7 +899,7 @@ public void RunningParameterResource_DoesNotRenderValueNotSetButton() state: KnownResourceState.Running, properties: properties); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.ResourceByName, new ConcurrentDictionary([new KeyValuePair(resource.Name, resource)])); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceSelectTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceSelectTests.cs index 5533454c83f..d6d48de9772 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceSelectTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceSelectTests.cs @@ -25,7 +25,7 @@ public void SelectedResource_DisplaysResourceName() Name = "frontend" }; - var cut = RenderComponent(parameters => parameters + var cut = Render(parameters => parameters .Add(component => component.Resources, [selectedResource]) .Add(component => component.SelectedResource, selectedResource)); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/StructuredLogDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/StructuredLogDetailsTests.cs index 5e1f38a0fb9..48269002efa 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/StructuredLogDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/StructuredLogDetailsTests.cs @@ -54,7 +54,7 @@ public void Render_ManyDuplicateAttributes_NoDuplicateKeys() }; // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewModel, model); }); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs index 37eb113d8bd..830fcde0c87 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalViewTests.cs @@ -32,7 +32,7 @@ public void Initialization_ProvidesLocalizedCopyAndInputControlsWithoutHostSubsc var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); var initialization = module.Setup("initTerminal", _ => true); initialization.SetResult(1); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.ResourceName, "shell") .Add(p => p.SizeMemoryKey, "dock:shell")); @@ -58,7 +58,7 @@ public void Initialization_ProvidesLocalizedCopyAndInputControlsWithoutHostSubsc public void ChromeAndFooter_RespectSurfaceParameters(bool chromeless, bool showDimensions) { TerminalSetupHelpers.SetupTerminalView(this); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.ResourceName, "shell") .Add(p => p.Chromeless, chromeless) .Add(p => p.ShowDimensionsPicker, showDimensions)); @@ -83,7 +83,7 @@ public void NewWindowAction_IsOnlyOfferedInAnEnabledResourceTitlebar(bool chrome { TerminalSetupHelpers.SetupTerminalView(this); TerminalSetupHelpers.SetupTerminalWindows(this); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.ResourceName, "shell") .Add(p => p.Chromeless, chromeless) .Add(p => p.ShowOpenInWindow, showOpenInWindow)); @@ -92,7 +92,7 @@ public void NewWindowAction_IsOnlyOfferedInAnEnabledResourceTitlebar(bool chrome { Assert.True(cut.Find(".terminal-open-window").HasAttribute("disabled")); } - cut.SetParametersAndRender(builder => builder.Add(p => p.ResourceName, null)); + cut.Render(builder => builder.Add(p => p.ResourceName, null)); Assert.Empty(cut.FindAll(".terminal-open-window")); if (!chromeless) { @@ -105,7 +105,7 @@ public async Task InitialFontSize_SeedsMountWithoutResettingCurrentFont() { var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); module.Setup("initTerminal", _ => true).SetResult(1); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.ResourceName, "shell") .Add(p => p.InitialFontSize, 19)); Assert.Equal(19, cut.Instance.FontSize); @@ -117,7 +117,7 @@ await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalTool { TerminalId = 1, Generation = 1, Connected = true, FontPx = 21 })); - cut.SetParametersAndRender(builder => builder.Add(p => p.InitialFontSize, 17)); + cut.Render(builder => builder.Add(p => p.InitialFontSize, 17)); Assert.Equal(21, cut.Instance.FontSize); Assert.Single(module.Invocations, i => i.Identifier == "initTerminal"); } @@ -127,7 +127,7 @@ public async Task FitButton_UsesCurrentStateAndKeepsThePickerForDimensionsOnly() { var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); module.Setup("initTerminal", _ => true).SetResult(1); - var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "shell")); + var cut = Render(builder => builder.Add(p => p.ResourceName, "shell")); Assert.True(cut.Find(".terminal-fit").HasAttribute("disabled")); await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState @@ -156,14 +156,14 @@ public void AutoFit_ChangesDuringInitializationApplyWithoutReconnecting() { var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); var init = module.Setup("initTerminal", _ => true); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.ResourceName, "shell").Add(p => p.AutoFit, true)); Assert.True(Assert.IsType(Assert.Single(init.Invocations).Arguments[3]).AutoFit); - cut.SetParametersAndRender(builder => builder.Add(p => p.AutoFit, false)); + cut.Render(builder => builder.Add(p => p.AutoFit, false)); init.SetResult(1); cut.WaitForAssertion(() => Assert.Equal(new object?[] { 1, false }, Assert.Single(module.Invocations, i => i.Identifier == "setAutoFit").Arguments)); - cut.SetParametersAndRender(builder => builder.Add(p => p.AutoFit, true)); + cut.Render(builder => builder.Add(p => p.AutoFit, true)); Assert.Equal(["initTerminal", "getSizePresets", "setAutoFit", "setAutoFit"], module.Invocations.Select(i => i.Identifier)); } @@ -173,7 +173,7 @@ public async Task TerminalFooter_UsesCurrentDimensionsAndIgnoresStaleCallbacks() var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); var initialization = module.Setup("initTerminal", _ => true); initialization.SetResult(1); - var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "shell ")); + var cut = Render(builder => builder.Add(p => p.ResourceName, "shell ")); Assert.Equal("shell ", cut.Find(".terminal-title").TextContent); Assert.Empty(cut.FindAll(".terminal-dimensions")); @@ -210,7 +210,7 @@ await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalTool public async Task TerminalError_DisplaysLocalizedAlert(string error, string resourceKey) { TerminalSetupHelpers.SetupTerminalView(this); - var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "app")); + var cut = Render(builder => builder.Add(p => p.ResourceName, "app")); await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState { TerminalId = 1, Generation = 1, Error = error @@ -230,7 +230,7 @@ public async Task DismissActionError_PreservesTheConnection(string error) { var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); module.Setup("initTerminal", _ => true).SetResult(1); - var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "shell")); + var cut = Render(builder => builder.Add(p => p.ResourceName, "shell")); await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState { TerminalId = 1, Generation = 1, Connected = true, Error = error @@ -258,7 +258,7 @@ public void ReadOnly_InitialAndUpdatedStatePreservesConnection(bool initialReadO init.SetResult(1); var update = module.SetupVoid("setReadOnly", _ => true); update.SetVoidResult(); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=terminal") .Add(p => p.ReadOnly, initialReadOnly)); Assert.Equal(initialReadOnly, Assert.IsType(Assert.Single(init.Invocations).Arguments[3]).ReadOnly); @@ -268,10 +268,10 @@ public void ReadOnly_InitialAndUpdatedStatePreservesConnection(bool initialReadO Assert.Equal(initialReadOnly, session.ReadOnly); Assert.Empty(update.Invocations); - cut.SetParametersAndRender(builder => builder.Add(p => p.ReadOnly, !initialReadOnly)); + cut.Render(builder => builder.Add(p => p.ReadOnly, !initialReadOnly)); Assert.Equal(!initialReadOnly, session.ReadOnly); cut.WaitForAssertion(() => Assert.Equal(new object?[] { 1, !initialReadOnly }, Assert.Single(update.Invocations).Arguments)); - cut.SetParametersAndRender(builder => builder.Add(p => p.ReadOnly, initialReadOnly)); + cut.Render(builder => builder.Add(p => p.ReadOnly, initialReadOnly)); Assert.Equal(initialReadOnly, session.ReadOnly); cut.WaitForAssertion(() => Assert.Collection(update.Invocations, invocation => Assert.Equal(new object?[] { 1, !initialReadOnly }, invocation.Arguments), @@ -287,16 +287,16 @@ public void ReadOnly_ChangedDuringInitializationAndUpdateAppliesLatestValue() var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); var init = module.Setup("initTerminal", _ => true); var update = module.SetupVoid("setReadOnly", _ => true); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=terminal")); - cut.SetParametersAndRender(builder => builder.Add(p => p.ReadOnly, true)); + cut.Render(builder => builder.Add(p => p.ReadOnly, true)); var viewId = Assert.IsType(Assert.Single(init.Invocations).Arguments[3]).ViewId; Assert.True(Services.GetRequiredService().TryGet( viewId, "/api/apphost-terminal?terminalId=terminal", out var session)); Assert.True(session.ReadOnly, "Authoritative policy must update before JS initialization returns"); init.SetResult(1); cut.WaitForAssertion(() => Assert.Single(update.Invocations)); - cut.SetParametersAndRender(builder => builder.Add(p => p.ReadOnly, false)); + cut.Render(builder => builder.Add(p => p.ReadOnly, false)); update.SetVoidResult(); cut.WaitForAssertion(() => Assert.Collection(update.Invocations, invocation => Assert.Equal(new object?[] { 1, true }, invocation.Arguments), @@ -310,8 +310,8 @@ public void ReadOnlyUpdateFailure_IsVisibleWithoutReconnect() var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); module.Setup("initTerminal", _ => true).SetResult(1); module.SetupVoid("setReadOnly", _ => true).SetException(new JSException("Unsupported live input policy")); - var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "app")); - cut.SetParametersAndRender(builder => builder.Add(p => p.ReadOnly, true)); + var cut = Render(builder => builder.Add(p => p.ResourceName, "app")); + cut.Render(builder => builder.Add(p => p.ReadOnly, true)); cut.WaitForAssertion(() => Assert.Equal(Resources.TerminalStrings.TerminalMountFailed, cut.Find("[role=alert]").TextContent)); Assert.Equal(["initTerminal", "getSizePresets", "setReadOnly"], module.Invocations.Select(i => i.Identifier)); } @@ -324,15 +324,15 @@ public async Task InitializationFailure_OffersExplicitRetryWithoutRenderLoop() var initialization = module.Setup("initTerminal", _ => failed); initialization.SetException(new JSException("Worker unavailable")); var retry = module.Setup("initTerminal", _ => !failed); - var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "app")); + var cut = Render(builder => builder.Add(p => p.ResourceName, "app")); cut.WaitForAssertion(() => Assert.Equal(Resources.TerminalStrings.TerminalMountFailed, cut.Find("[role=alert]").TextContent)); - cut.SetParametersAndRender(builder => builder.Add(p => p.ResourceName, "app")); + cut.Render(builder => builder.Add(p => p.ResourceName, "app")); Assert.Single(initialization.Invocations); failed = false; var retrying = cut.Find(".terminal-error fluent-button").ClickAsync(new MouseEventArgs()); cut.WaitForAssertion(() => Assert.Single(retry.Invocations)); - cut.SetParametersAndRender(builder => builder.Add(p => p.ResourceName, "app")); + cut.Render(builder => builder.Add(p => p.ResourceName, "app")); Assert.Single(retry.Invocations); retry.SetResult(1); await retrying; @@ -345,9 +345,9 @@ public void PendingInitialization_ReconcilesChangedEndpoint(string? updatedEndpo { var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); var init = module.Setup("initTerminal", _ => true); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=first")); - cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, updatedEndpoint)); + cut.Render(builder => builder.Add(p => p.EndpointPathAndQuery, updatedEndpoint)); init.SetResult(1); cut.WaitForAssertion(() => { @@ -373,13 +373,13 @@ public async Task PendingExplicitRetry_ReconcilesChangedEndpoint(string? updated var failed = true; module.Setup("initTerminal", _ => failed).SetException(new JSException("Worker unavailable")); var retry = module.Setup("initTerminal", _ => !failed); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=first")); cut.WaitForAssertion(() => Assert.Single(cut.FindAll("[role=alert]"))); failed = false; var retrying = cut.Find(".terminal-error fluent-button").ClickAsync(new MouseEventArgs()); cut.WaitForAssertion(() => Assert.Single(retry.Invocations)); - cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, updatedEndpoint)); + cut.Render(builder => builder.Add(p => p.EndpointPathAndQuery, updatedEndpoint)); retry.SetResult(1); await retrying; cut.WaitForAssertion(() => @@ -403,9 +403,9 @@ public async Task EndpointDetached_IgnoresLateStateWithoutRecreatingTheView() var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); var init = module.Setup("initTerminal", _ => true); init.SetResult(1); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=first")); - cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, null)); + cut.Render(builder => builder.Add(p => p.EndpointPathAndQuery, null)); await cut.InvokeAsync(() => cut.Instance.OnTerminalStateChanged(new TerminalToolbarState { TerminalId = 1, Generation = 2, Error = "disconnected" @@ -420,13 +420,13 @@ public void EndpointRemovedAndRestored_DetachesAndInitializesAgain() var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); var init = module.Setup("initTerminal", _ => true); init.SetResult(1); - var cut = RenderComponent(); + var cut = Render(); Assert.Empty(init.Invocations); - cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=first")); + cut.Render(builder => builder.Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=first")); cut.WaitForAssertion(() => Assert.Single(init.Invocations)); - cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, null)); + cut.Render(builder => builder.Add(p => p.EndpointPathAndQuery, null)); cut.WaitForAssertion(() => Assert.Single(module.Invocations, i => i.Identifier == "disposeTerminal")); - cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=second")); + cut.Render(builder => builder.Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=second")); cut.WaitForAssertion(() => Assert.Equal(2, init.Invocations.Count)); } @@ -439,7 +439,7 @@ public void Initialization_PreservesPathBaseAndWebSocketScheme(string baseUri, s var module = TerminalSetupHelpers.SetupTerminalViewModule(this, modulePath); var init = module.Setup("initTerminal", _ => true); init.SetResult(1); - RenderComponent(builder => builder.Add(p => p.ResourceName, "app & name").Add(p => p.ReplicaIndex, 2)); + Render(builder => builder.Add(p => p.ResourceName, "app & name").Add(p => p.ReplicaIndex, 2)); var invocation = Assert.Single(init.Invocations); var options = Assert.IsType(invocation.Arguments[3]); Assert.Equal($"{socketUrl}&viewId={options.ViewId}", invocation.Arguments[1]); @@ -454,7 +454,7 @@ public void ExplicitEndpoint_RegistersTheResolvedPathAndQuery(string endpoint, s var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/aspire/Components/Controls/TerminalView.razor.js"); var init = module.Setup("initTerminal", _ => true); init.SetResult(1); - RenderComponent(builder => builder.Add(p => p.EndpointPathAndQuery, endpoint)); + Render(builder => builder.Add(p => p.EndpointPathAndQuery, endpoint)); var invocation = Assert.Single(init.Invocations); var options = Assert.IsType(invocation.Arguments[3]); Assert.Equal($"wss://dashboard.example{expectedPathAndQuery}&viewId={options.ViewId}", invocation.Arguments[1]); @@ -467,7 +467,7 @@ public async Task DisposalDuringInitialization_DisposesReturnedTerminalExactlyOn { var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); var init = module.Setup("initTerminal", _ => true); - var cut = RenderComponent(builder => builder.Add(p => p.ResourceName, "app")); + var cut = Render(builder => builder.Add(p => p.ResourceName, "app")); cut.WaitForAssertion(() => Assert.Single(init.Invocations)); var disposing = cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()); Assert.False(disposing.IsCompleted); @@ -483,13 +483,13 @@ public void EndpointChange_RotatesRegistrationAndDisablesOldInput() var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); var init = module.Setup("initTerminal", _ => true); init.SetResult(1); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=first")); var firstId = Assert.IsType(Assert.Single(init.Invocations).Arguments[3]).ViewId; var registry = Services.GetRequiredService(); Assert.True(registry.TryGet(firstId, "/api/apphost-terminal?terminalId=first", out var firstSession)); - cut.SetParametersAndRender(builder => builder.Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=second")); + cut.Render(builder => builder.Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=second")); cut.WaitForAssertion(() => Assert.Single(module.Invocations, i => i.Identifier == "reconnectTerminal")); var secondUrl = new Uri(Assert.IsType(Assert.Single(module.Invocations, i => i.Identifier == "reconnectTerminal").Arguments[1])); var secondId = QueryHelpers.ParseQuery(secondUrl.Query)["viewId"].ToString(); @@ -504,7 +504,7 @@ public void CompletionBeforeInitializationFinishes_DisablesInputWithoutDismissin { var module = TerminalSetupHelpers.SetupTerminalViewModule(this, "/Components/Controls/TerminalView.razor.js"); var init = module.Setup("initTerminal", _ => true); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.EndpointPathAndQuery, "/api/apphost-terminal?terminalId=terminal")); var viewId = Assert.IsType(Assert.Single(init.Invocations).Arguments[3]).ViewId; Assert.True(Services.GetRequiredService().TryGet( diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalWindowButtonTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalWindowButtonTests.cs index 8d41a5d94e9..75a04ec427d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalWindowButtonTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TerminalWindowButtonTests.cs @@ -37,7 +37,7 @@ public async Task Registration_WaitsUntilLaunchingIsAvailable(bool disabled, str { var module = TerminalSetupHelpers.SetupTerminalWindows(this); var registration = module.SetupVoid("registerTerminalWindowButton", _ => true); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.Label, "Open") .Add(p => p.Disabled, disabled) .Add(p => p.TerminalKey, key) @@ -50,7 +50,7 @@ public async Task Registration_WaitsUntilLaunchingIsAvailable(bool disabled, str Equals(invocation.Arguments[0], "/js/app-terminalwindow.js"))); Assert.Empty(module.Invocations); - cut.SetParametersAndRender(builder => builder + cut.Render(builder => builder .Add(p => p.Disabled, false) .Add(p => p.TerminalKey, "terminal") .Add(p => p.Url, "terminal-window/apphost/terminal") @@ -62,8 +62,8 @@ public async Task Registration_WaitsUntilLaunchingIsAvailable(bool disabled, str registration.SetVoidResult(); cut.WaitForAssertion(() => Assert.False(cut.FindComponent().Instance.Disabled)); - cut.SetParametersAndRender(builder => builder.Add(p => p.Disabled, true)); - cut.SetParametersAndRender(builder => builder.Add(p => p.Disabled, false)); + cut.Render(builder => builder.Add(p => p.Disabled, true)); + cut.Render(builder => builder.Add(p => p.Disabled, false)); Assert.Single(registration.Invocations); await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()); Assert.Single(module.Invocations, invocation => invocation.Identifier == "unregisterTerminalWindowButton"); @@ -73,7 +73,7 @@ public async Task Registration_WaitsUntilLaunchingIsAvailable(bool disabled, str public async Task Disposal_BeforeLaunchingIsAvailable_DoesNotLoadJavaScript() { var module = TerminalSetupHelpers.SetupTerminalWindows(this); - var cut = RenderComponent(builder => builder.Add(p => p.Label, "Open")); + var cut = Render(builder => builder.Add(p => p.Label, "Open")); await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()); @@ -88,7 +88,7 @@ public void RegistrationAndMetadata_AreRequiredBeforeEnablingNativeButton() Services.AddSingleton(new TestNavigationManager("http://localhost/aspire/nested/")); var module = TerminalSetupHelpers.SetupTerminalWindows(this, "/aspire/nested"); var registration = module.SetupVoid("registerTerminalWindowButton", _ => true); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.Label, "Open in new window") .Add(p => p.TerminalKey, "first") .Add(p => p.Url, "terminal-window/apphost/first") @@ -96,7 +96,7 @@ public void RegistrationAndMetadata_AreRequiredBeforeEnablingNativeButton() Assert.True(cut.FindComponent().Instance.Disabled); Assert.False(cut.FindComponent().Instance.OnClick.HasDelegate); - cut.SetParametersAndRender(builder => builder + cut.Render(builder => builder .Add(p => p.TerminalKey, "second #1/?%+") .Add(p => p.Url, "terminal-window/apphost/second%20%231%2F%3F%25%2B") .Add(p => p.FontSize, 23)); @@ -108,16 +108,16 @@ public void RegistrationAndMetadata_AreRequiredBeforeEnablingNativeButton() cut.Find("fluent-button").GetAttribute("data-terminal-window-url")); Assert.Single(registration.Invocations); - cut.SetParametersAndRender(builder => builder.Add(p => p.FontSize, null)); + cut.Render(builder => builder.Add(p => p.FontSize, null)); Assert.True(cut.FindComponent().Instance.Disabled); Assert.Null(cut.Find("fluent-button").GetAttribute("data-terminal-window-url")); - cut.SetParametersAndRender(builder => builder.Add(p => p.FontSize, 17).Add(p => p.Disabled, true)); + cut.Render(builder => builder.Add(p => p.FontSize, 17).Add(p => p.Disabled, true)); Assert.True(cut.FindComponent().Instance.Disabled); - cut.SetParametersAndRender(builder => builder.Add(p => p.Disabled, false).Add(p => p.TerminalKey, null)); + cut.Render(builder => builder.Add(p => p.Disabled, false).Add(p => p.TerminalKey, null)); Assert.True(cut.FindComponent().Instance.Disabled); - cut.SetParametersAndRender(builder => builder.Add(p => p.TerminalKey, "second").Add(p => p.Url, null)); + cut.Render(builder => builder.Add(p => p.TerminalKey, "second").Add(p => p.Url, null)); Assert.True(cut.FindComponent().Instance.Disabled); - cut.SetParametersAndRender(builder => builder.Add(p => p.Url, "terminal-window/apphost/second")); + cut.Render(builder => builder.Add(p => p.Url, "terminal-window/apphost/second")); Assert.False(cut.FindComponent().Instance.Disabled); } @@ -127,7 +127,7 @@ public async Task Notifications_UseCapturedKeyAndAreIgnoredAfterDisposal() var module = TerminalSetupHelpers.SetupTerminalWindows(this); var opened = new List<(string Key, TerminalWindowOpenResult Result)>(); var closed = new List(); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.Label, "Open") .Add(p => p.TerminalKey, "first") .Add(p => p.Url, "terminal-window/apphost/first") @@ -135,7 +135,7 @@ public async Task Notifications_UseCapturedKeyAndAreIgnoredAfterDisposal() .Add(p => p.OnWindowOpened, launch => opened.Add(launch)) .Add(p => p.OnWindowClosed, key => closed.Add(key))); var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut); - cut.SetParametersAndRender(builder => builder.Add(p => p.TerminalKey, "second")); + cut.Render(builder => builder.Add(p => p.TerminalKey, "second")); await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("first", "opened")); await cut.InvokeAsync(() => launcher.OnTerminalWindowOpenedAsync("first", "focused")); await cut.InvokeAsync(() => launcher.OnTerminalWindowClosedAsync("first")); @@ -159,7 +159,7 @@ public async Task DisposalDuringRegistration_UnregistersOnceWithoutEnablingButto { var module = TerminalSetupHelpers.SetupTerminalWindows(this); var registration = module.SetupVoid("registerTerminalWindowButton", _ => true); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.Label, "Open") .Add(p => p.TerminalKey, "terminal") .Add(p => p.Url, "terminal-window/apphost/terminal") @@ -180,8 +180,8 @@ public async Task DisposalDuringRegistration_UnregistersOnceWithoutEnablingButto public async Task BrowserFailure_ShowsActionableToast(string result, string resourceKey) { TerminalSetupHelpers.SetupTerminalWindows(this); - var toasts = RenderComponent(); - var cut = RenderComponent(builder => builder + var toasts = Render(); + var cut = Render(builder => builder .Add(p => p.Label, "Open") .Add(p => p.TerminalKey, "terminal") .Add(p => p.Url, "terminal-window/apphost/terminal") @@ -200,8 +200,8 @@ public void RegistrationFailure_StaysDisabledAndShowsActionableToast() { var module = TerminalSetupHelpers.SetupTerminalWindows(this); module.SetupVoid("registerTerminalWindowButton", _ => true).SetException(new JSException("Module registration failed")); - var toasts = RenderComponent(); - var cut = RenderComponent(builder => builder + var toasts = Render(); + var cut = Render(builder => builder .Add(p => p.Label, "Open") .Add(p => p.TerminalKey, "terminal") .Add(p => p.Url, "terminal-window/apphost/terminal") @@ -222,7 +222,7 @@ public async Task Adoption_RegistersWithoutViewerMetadataAndWaitsForReconciliati var adoption = module.SetupVoid("adoptTerminalWindows", _ => true); var opened = new List<(string Key, TerminalWindowOpenResult Result)>(); var checkedKeys = Channel.CreateUnbounded(); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.Label, "Open") .Add(p => p.Disabled, true) .Add(p => p.WindowKeysToAdopt, ["first", "inactive"]) @@ -254,7 +254,7 @@ await AsyncTestHelpers.AssertIsTrueRetryAsync( adoption.SetVoidResult(); Assert.Equal(["first", "inactive"], await checkedKeys.Reader.ReadAsync().AsTask().DefaultTimeout()); - cut.SetParametersAndRender(builder => builder.Add(p => p.WindowKeysToAdopt, ["inactive", "new"])); + cut.Render(builder => builder.Add(p => p.WindowKeysToAdopt, ["inactive", "new"])); Assert.Equal(["new"], await checkedKeys.Reader.ReadAsync().AsTask().DefaultTimeout()); Assert.Equal(2, adoption.Invocations.Count); Assert.Equal(["new"], Assert.IsType(adoption.Invocations.Last().Arguments[1])); @@ -268,7 +268,7 @@ public async Task Disposal_DuringAdoption_DoesNotSignalReadinessOrCloseWindows() var module = TerminalSetupHelpers.SetupTerminalWindows(this); var adoption = module.SetupVoid("adoptTerminalWindows", _ => true); var checkedKeys = new List(); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.Label, "Open") .Add(p => p.WindowKeysToAdopt, ["terminal"]) .Add(p => p.OnWindowsAdopted, keys => checkedKeys.Add(keys))); @@ -290,8 +290,8 @@ public void AdoptionFailure_DoesNotSignalReadinessOrRetryOnEveryRender() var adoption = module.SetupVoid("adoptTerminalWindows", _ => true); adoption.SetException(new JSException("Window reconciliation failed")); var checkedKeys = new List(); - var toasts = RenderComponent(); - var cut = RenderComponent(builder => builder + var toasts = Render(); + var cut = Render(builder => builder .Add(p => p.Label, "Open") .Add(p => p.WindowKeysToAdopt, ["terminal"]) .Add(p => p.OnWindowsAdopted, keys => checkedKeys.Add(keys))); @@ -313,15 +313,15 @@ public async Task TrackingFailure_ReportsNewTerminalsWithoutRetrying(string oper failure.SetException(new JSException("Browser storage unavailable")); var failedKeys = Channel.CreateUnbounded(); var checkedKeys = new List(); - RenderComponent(); - var cut = RenderComponent(builder => builder + Render(); + var cut = Render(builder => builder .Add(p => p.Label, "Open") .Add(p => p.WindowKeysToAdopt, ["first"]) .Add(p => p.OnWindowsAdopted, keys => checkedKeys.Add(keys)) .Add(p => p.OnWindowTrackingFailed, keys => { failedKeys.Writer.TryWrite(keys); })); Assert.Equal(["first"], await failedKeys.Reader.ReadAsync().AsTask().DefaultTimeout()); - cut.SetParametersAndRender(builder => builder.Add(p => p.WindowKeysToAdopt, ["first", "new"])); + cut.Render(builder => builder.Add(p => p.WindowKeysToAdopt, ["first", "new"])); Assert.Equal(["new"], await failedKeys.Reader.ReadAsync().AsTask().DefaultTimeout()); cut.Render(); Assert.False(failedKeys.Reader.TryPeek(out _)); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TextVisualizerDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TextVisualizerDialogTests.cs index aaff078ddf4..ba6c569f206 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TextVisualizerDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TextVisualizerDialogTests.cs @@ -124,7 +124,7 @@ public async Task Render_TextVisualizerDialog_FormatPicker_UsesFluentSelectAndPr Assert.Single(cut.FindAll(".text-visualizer-unformatted")); }); - cut.FindComponent().SetParametersAndRender(parameters => parameters.Add(p => p.Content, content)); + cut.FindComponent().Render(parameters => parameters.Add(p => p.Content, content)); cut.WaitForAssertion(() => { @@ -140,7 +140,7 @@ public void Render_TextVisualizer_DisplayUnformatted_AddsUnformattedClass() { SetUpDialog(out _); - var cut = RenderComponent(parameters => parameters + var cut = Render(parameters => parameters .Add(p => p.ViewModel, new TextVisualizerViewModel("""{"value":1}""", indentText: false)) .Add(p => p.DisplayUnformatted, true) .Add(p => p.Virtualize, false)); @@ -343,7 +343,7 @@ public async Task Render_TextVisualizerDialog_WithFixedJsonFormat_UsesJsonFormat Assert.True(instance.HasFixedFormat); } - private Func SetUpDialog(out DashboardDialogService dialogService, ThemeManager? themeManager = null, TestLocalStorage? localStorage = null) + private Func> SetUpDialog(out DashboardDialogService dialogService, ThemeManager? themeManager = null, TestLocalStorage? localStorage = null) { FluentUISetupHelpers.SetupDialogInfrastructure(this, themeManager: themeManager, localStorage: localStorage); @@ -355,11 +355,11 @@ private Func SetUpDialog(out DashboardDialogService dialogSer FluentUISetupHelpers.SetupFluentList(this); FluentUISetupHelpers.SetupFluentMenu(this); - IRenderedFragment? cut = null; + IRenderedComponent? cut = null; TestDialogService? testDialogService = null; testDialogService = new TestDialogService((content, _) => { - cut = RenderComponent>(builder => + cut = Render>(builder => { builder.Add(p => p.Value, testDialogService!.LastInstance!); builder.AddChildContent(childBuilder => diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/TotalItemsFooterTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/TotalItemsFooterTests.cs index 9ae86dd8cd5..88203c8895d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/TotalItemsFooterTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/TotalItemsFooterTests.cs @@ -12,7 +12,7 @@ public class TotalItemsFooterTests : DashboardTestContext [Fact] public async Task UpdateDisplayedCount_WithDisplayedItemCount_DisplaysPartialCount() { - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.TotalItemCount, 0) .Add(p => p.SingularText, "Showing {0} item") .Add(p => p.PluralText, "Showing {0} items") diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/UrlsColumnDisplayTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/UrlsColumnDisplayTests.cs index d5039d17577..55b4fa9e97f 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/UrlsColumnDisplayTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/UrlsColumnDisplayTests.cs @@ -25,7 +25,7 @@ public void Render_MoreThanMaxRenderedItems_RendersBoundedPayload() var resource = ModelTestHelpers.CreateResource(resourceName: "test-resource", resourceType: "Project", state: KnownResourceState.Running); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.HasMultipleReplicas, false); @@ -60,7 +60,7 @@ public void Render_ExactlyMaxUrls_RendersAllItems() var resource = ModelTestHelpers.CreateResource(resourceName: "test-resource", resourceType: "Project", state: KnownResourceState.Running); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.HasMultipleReplicas, false); diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/UserProfileTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/UserProfileTests.cs index e93d3e7d4bc..9e8c9b40326 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/UserProfileTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/UserProfileTests.cs @@ -6,7 +6,6 @@ using Aspire.Dashboard.Components.Tests.Shared; using Aspire.Dashboard.Configuration; using Bunit; -using Bunit.TestDoubles; using Microsoft.Extensions.DependencyInjection; using Microsoft.FluentUI.AspNetCore.Components; using Xunit; @@ -30,13 +29,13 @@ public void AuthenticatedUser_RendersAnchoredProfilePopover() }); FluentUISetupHelpers.SetupFluentButton(this); - var authorizationContext = this.AddTestAuthorization(); + var authorizationContext = this.AddAuthorization(); authorizationContext.SetAuthorized("Ada Lovelace"); authorizationContext.SetClaims( new Claim("name", "Ada Lovelace"), new Claim("preferred_username", "ada@example.com")); - var cut = RenderComponent(); + var cut = Render(); var button = cut.Find(".profile-menu-button"); var popover = cut.Find("fluent-popover-b"); diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/FilterDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/FilterDialogTests.cs index 8ccba24e5de..6269db34098 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/FilterDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/FilterDialogTests.cs @@ -23,7 +23,7 @@ public async Task Validate_StringFilter_EnforcesMaximumLength(int length, bool i { SetupFilterDialogServices(); - var cut = RenderComponent(builder => builder.Add(component => component.Content, + var cut = Render(builder => builder.Add(component => component.Content, CreateContent(new FieldTelemetryFilter { Field = KnownTraceFields.NameField, @@ -41,7 +41,7 @@ public void Render_DurationFilter_UsesNumericInputAndNumericConditions() { SetupFilterDialogServices(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Content, CreateContent(new FieldTelemetryFilter { @@ -71,7 +71,7 @@ public void Render_DurationFilter_DisplaysOnlyIntegerValues(string value, int? e { SetupFilterDialogServices(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Content, CreateContent(new FieldTelemetryFilter { @@ -89,7 +89,7 @@ public void Render_StringFilter_UsesComboboxAndStringConditions() { SetupFilterDialogServices(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Content, CreateContent(new FieldTelemetryFilter { @@ -142,7 +142,7 @@ public void Render_StringFilter_EmptyFieldValueIsNotDisplayed() }) }; - var cut = RenderComponent(builder => builder.Add(p => p.Content, content)); + var cut = Render(builder => builder.Add(p => p.Content, content)); var option = Assert.Single(cut.Find("fluent-dropdown[type='combobox']").QuerySelectorAll("fluent-option:not([freeform])")); Assert.Equal("trace-id", option.GetAttribute("text")); @@ -173,7 +173,7 @@ public async Task Render_PropertyKeysLoading_DisablesParameterSelectAndDisplaysP GetFieldValuesAsync = content.GetFieldValuesAsync }; - var cut = RenderComponent(builder => builder.Add(p => p.Content, content)); + var cut = Render(builder => builder.Add(p => p.Content, content)); await loadingStarted.Task.WaitAsync(DefaultWaitTimeout); Assert.True(cut.FindComponent, SelectViewModel>>().Instance.Disabled); @@ -215,7 +215,7 @@ public async Task Render_FieldValuesLoading_DisablesValueComboboxAndDisplaysProg } }; - var cut = RenderComponent(builder => builder.Add(p => p.Content, content)); + var cut = Render(builder => builder.Add(p => p.Content, content)); await loadingStarted.Task.WaitAsync(DefaultWaitTimeout); var valueCombobox = cut.Find("fluent-dropdown[type='combobox']"); @@ -256,7 +256,7 @@ public async Task Render_ChangingStringFieldAfterValuesLoad_LoadsNewValues() } }; - var cut = RenderComponent(builder => builder.Add(p => p.Content, content)); + var cut = Render(builder => builder.Add(p => p.Content, content)); var parameterSelect = cut.FindComponent, SelectViewModel>>(); var traceIdOption = parameterSelect.Instance.Items!.Single(item => item.Id == KnownTraceFields.TraceIdField); @@ -295,7 +295,7 @@ public async Task Render_FieldValueReadsCompleteOutOfOrder_LatestValuesDisplayed } }; - var cut = RenderComponent(builder => builder.Add(p => p.Content, content)); + var cut = Render(builder => builder.Add(p => p.Content, content)); var parameterSelect = cut.FindComponent, SelectViewModel>>(); var traceIdOption = parameterSelect.Instance.Items!.Single(item => item.Id == KnownTraceFields.TraceIdField); var spanIdOption = parameterSelect.Instance.Items!.Single(item => item.Id == KnownTraceFields.SpanIdField); @@ -350,7 +350,7 @@ public async Task Render_PropertyKeysReadFails_ClearsLoadingState() GetFieldValuesAsync = content.GetFieldValuesAsync }; - var cut = RenderComponent(builder => builder.Add(p => p.Content, content)); + var cut = Render(builder => builder.Add(p => p.Content, content)); await loadingStarted.Task.WaitAsync(DefaultWaitTimeout); Assert.Single(cut.FindComponents()); @@ -389,7 +389,7 @@ public async Task Render_FieldValuesReadFails_ClearsLoadingState() } }; - var cut = RenderComponent(builder => builder.Add(p => p.Content, content)); + var cut = Render(builder => builder.Add(p => p.Content, content)); await loadingStarted.Task.WaitAsync(DefaultWaitTimeout); Assert.True(cut.Find("fluent-dropdown[type='combobox']").HasAttribute("disabled")); @@ -428,7 +428,7 @@ public async Task DisposeAsync_InFlightRead_CancelsToken() GetFieldValuesAsync = content.GetFieldValuesAsync }; - var cut = RenderComponent(builder => builder.Add(p => p.Content, content)); + var cut = Render(builder => builder.Add(p => p.Content, content)); await loadingStarted.Task.WaitAsync(DefaultWaitTimeout); // Telemetry reads run against SQLite on the thread pool. Closing the dialog must cancel them so a scan @@ -446,7 +446,7 @@ public async Task Render_StringFilter_TypingFiltersAndHighlightsOptions() { SetupFilterDialogServices(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Content, CreateContent(new FieldTelemetryFilter { @@ -472,7 +472,7 @@ public async Task Render_StringFilter_TypingThenLosingFocus_PreservesValue(strin { SetupFilterDialogServices(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Content, CreateContent(new FieldTelemetryFilter { @@ -497,7 +497,7 @@ public void Render_DateFilterWithInvalidValue_DisplaysSingleValidationMessage() { SetupFilterDialogServices(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Content, CreateContent(new FieldTelemetryFilter { @@ -520,7 +520,7 @@ public void Render_DateFilter_DisplaysDatePickerButton() { SetupFilterDialogServices(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Content, CreateContent(new FieldTelemetryFilter { @@ -541,7 +541,7 @@ public void Render_StringFilterWithoutValue_MarksValueFieldInvalid() { SetupFilterDialogServices(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Content, CreateContent(new FieldTelemetryFilter { @@ -565,7 +565,7 @@ public async Task Render_StringFilterWithValidationError_TypingValueClearsError( { SetupFilterDialogServices(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Content, CreateContent(new FieldTelemetryFilter { diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/HelpDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/HelpDialogTests.cs index c4250f15aa9..7011db4c82a 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/HelpDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/HelpDialogTests.cs @@ -23,7 +23,7 @@ public void SiteWideNavigation_OnlyShowsTerminalShortcutWhenAvailable(bool isEna FluentUISetupHelpers.SetupDialogInfrastructure(this); Services.AddSingleton(new TestDashboardClient(isEnabled: isEnabled) { IsReadOnly = isReadOnly }); - var cut = RenderComponent(); + var cut = Render(); var heading = Assert.Single(cut.FindAll("h6"), h => h.TextContent == Resources.Dialogs.HelpDialogCategoryNavigation); var navigationShortcuts = cut.Find($"dl[aria-labelledby='{heading.Id}']"); diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionMessageBoxDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionMessageBoxDialogTests.cs index 4ac97837fd1..1617620d387 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionMessageBoxDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionMessageBoxDialogTests.cs @@ -101,16 +101,16 @@ await dialogService.ShowDialogAsync( } } - private Func SetUpDialog(out DashboardDialogService dialogService) + private Func> SetUpDialog(out DashboardDialogService dialogService) { FluentUISetupHelpers.SetupDialogInfrastructure(this); FluentUISetupHelpers.SetupFluentButton(this); - IRenderedFragment? cut = null; + IRenderedComponent? cut = null; TestDialogService? testDialogService = null; testDialogService = new TestDialogService((content, _) => { - cut = RenderComponent>(builder => + cut = Render>(builder => { builder.Add(component => component.Value, testDialogService!.LastInstance!); builder.AddChildContent(childBuilder => diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs index 8aa96584595..89f7b0ee987 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsInputDialogTests.cs @@ -685,7 +685,7 @@ public async Task Render_MultipleFileSelection_ValidatesMaximumFileCount(int fil } } - private Func SetUpDialog(out DashboardDialogService dialogService) + private Func> SetUpDialog(out DashboardDialogService dialogService) { FluentUISetupHelpers.SetupFluentInputLabel(this); FluentUISetupHelpers.SetupFluentTextField(this); diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsTerminalDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsTerminalDialogTests.cs index d91a43e294a..9dca6920ca9 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsTerminalDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/InteractionsTerminalDialogTests.cs @@ -93,7 +93,7 @@ public async Task Dispose_DisconnectsOnlyTheViewerAndUnsubscribesUpdates() // Remove the dialog through the renderer so Blazor disposes its entire component subtree. var host = Assert.IsAssignableFrom>>(cut); - host.SetParametersAndRender(parameters => parameters + host.Render(parameters => parameters .Add(p => p.Value, host.Instance.Value) .AddChildContent(string.Empty)); diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs index d73d693a607..e470a228f25 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/ManageDataDialogTests.cs @@ -46,7 +46,7 @@ public async Task Render_SelectionControlsExposeAccessibleNamesCheckboxRoleAndSt resourceChannelProvider: () => resourcesChannel); SetupManageDataDialogServices(dashboardClient); - var cut = RenderComponent(parameters => parameters.Add(dialog => dialog.Virtualize, false)); + var cut = Render(parameters => parameters.Add(dialog => dialog.Virtualize, false)); Assert.Single(cut.FindComponents()); cut.WaitForAssertion(() => @@ -140,7 +140,7 @@ public async Task IconCheckbox_DoesNotInvokeClickWhenDisabled() FluentUISetupHelpers.AddCommonDashboardServices(this); SetupIconCheckboxJs(); - var cut = RenderComponent(parameters => parameters + var cut = Render(parameters => parameters .Add(p => p.CheckState, IconCheckboxState.Checked) .Add(p => p.Disabled, true) .Add(p => p.AccessibleLabel, "All data") @@ -196,7 +196,7 @@ public async Task Render_ClearedSignals_PrunesSelectionsAndSupportsRemovingEmpty } }); - var cut = RenderComponent(parameters => parameters.Add(dialog => dialog.Virtualize, false)); + var cut = Render(parameters => parameters.Add(dialog => dialog.Virtualize, false)); cut.WaitForAssertion(() => { @@ -260,7 +260,7 @@ public void RemoveSelected_ConsoleLogs_ClearsPersistenceAndUpdatesFilter() resourceChannelProvider: () => resourcesChannel); SetupManageDataDialogServices(dashboardClient); - var cut = RenderComponent(); + var cut = Render(); cut.WaitForAssertion(() => AssertButtonDisabled(cut, "Remove selected", expectedDisabled: false)); cut.Find("fluent-button[aria-label='Remove selected']").Click(); diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/NotificationEntryComponentTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/NotificationEntryComponentTests.cs index cfe409af96e..69baa22b37d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/NotificationEntryComponentTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/NotificationEntryComponentTests.cs @@ -40,7 +40,7 @@ public async Task PrimaryAction_InvokesActionWithoutClosingNotificationCenter() } }; - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(component => component.Entry, entry) .Add(component => component.OnDismiss, () => dismissed = true)); diff --git a/tests/Aspire.Dashboard.Components.Tests/Dialogs/SettingsDialogTests.cs b/tests/Aspire.Dashboard.Components.Tests/Dialogs/SettingsDialogTests.cs index 4ba47894dd3..68a3b768e14 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Dialogs/SettingsDialogTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Dialogs/SettingsDialogTests.cs @@ -20,7 +20,7 @@ public async Task Render_RadioLabelsUseLocalizedResources() FluentUISetupHelpers.AddCommonDashboardServices(this, themeManager: themeManager); FluentUISetupHelpers.SetupFluentList(this); - var cut = RenderComponent(); + var cut = Render(); Assert.Collection( cut.FindAll("fluent-radio-group label"), diff --git a/tests/Aspire.Dashboard.Components.Tests/GridColumnManagerTests.cs b/tests/Aspire.Dashboard.Components.Tests/GridColumnManagerTests.cs index 9508487d0f1..d69eb63cb05 100644 --- a/tests/Aspire.Dashboard.Components.Tests/GridColumnManagerTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/GridColumnManagerTests.cs @@ -27,7 +27,7 @@ public void Returns_Correct_TemplateColumn_String() new GridColumn("NoDesktopWithIsVisibleTrue", null, "4fr", IsVisible: () => true) ]; - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(c => c.Columns, gridColumns); }); @@ -55,7 +55,7 @@ public void Returns_Right_Columns_IsVisible() new GridColumn("NoDesktopWithIsVisibleTrue", null, "4fr", IsVisible: () => true) ]; - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(c => c.Columns, gridColumns); }); @@ -92,7 +92,7 @@ public void WidthFraction_MobileViewOnResize() new GridColumn("NoDesktopWithIsVisibleTrue", null, "4fr", IsVisible: () => true) ]; - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(c => c.Columns, gridColumns); }); diff --git a/tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.Terminal.cs b/tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.Terminal.cs index 78be8896550..3dc5323efb1 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.Terminal.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.Terminal.cs @@ -35,7 +35,7 @@ public async Task ReceiveData_TerminalDialog_UsesDedicatedPresentationAndCancelR return Task.CompletedTask; }); SetupInteractionProviderServices(client, dialogs); - var cut = RenderComponent(); + var cut = Render(); await updates.Writer.WriteAsync(new WatchInteractionsResponseUpdate { InteractionId = 1, @@ -94,7 +94,7 @@ public async Task ReceiveData_TerminalDialogs_QueueUpdateReplayAndRemoveWithoutE return Task.CompletedTask; }); SetupInteractionProviderServices(client, dialogs); - var cut = RenderComponent(); + var cut = Render(); await updates.Writer.WriteAsync(new WatchInteractionsResponseUpdate { InteractionId = 1, PromptProgress = new InteractionPromptProgress() diff --git a/tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.cs b/tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.cs index 46e7197ef4d..58de1b05cf6 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Interactions/InteractionsProviderTests.cs @@ -42,7 +42,7 @@ public async Task Initialize_DashboardClientNotEnabled_ProviderDisabledAsync() SetupInteractionProviderServices(dashboardClient); // Act - var cut = RenderComponent(); + var cut = Render(); var instance = cut.Instance; @@ -66,7 +66,7 @@ public async Task Initialize_DashboardClientEnabled_ProviderEnabledAsync() SetupInteractionProviderServices(dashboardClient); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -114,7 +114,7 @@ public async Task Initialize_InteractionsUseLiveDashboardClientAsync() SetupInteractionProviderServices(liveDashboardClient, selectedDashboardClient: selectedDashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -156,7 +156,7 @@ public async Task ReceiveData_MessageBoxOpen_OpenDialog() SetupInteractionProviderServices(dashboardClient: dashboardClient, dialogService: dialogService); // Act 1 - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -209,7 +209,7 @@ public async Task ReceiveData_MessageBoxOpenAndCompletion_OpenAndCloseDialog() SetupInteractionProviderServices(dashboardClient: dashboardClient, dialogService: dialogService); // Act 1 - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -267,7 +267,7 @@ public async Task ReceiveData_InputDialogOpenAndCancel_OpenDialogAndSendCompleti SetupInteractionProviderServices(dashboardClient: dashboardClient, dialogService: dialogService); // Act 1 - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -327,7 +327,7 @@ public async Task ReceiveData_InputDialogOpenAndSubmit_OpenDialogAndSendCompleti SetupInteractionProviderServices(dashboardClient: dashboardClient, dialogService: dialogService); // Act 1 - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -382,7 +382,7 @@ public async Task ReceiveData_NotificationReceivedTwice_IgnoreReplayedNotificati SetupInteractionProviderServices(dashboardClient: dashboardClient); // Act 1 - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -451,7 +451,7 @@ public async Task ReceiveData_NotificationDismissed_SendCompletionAndRemoveMessa SetupInteractionProviderServices(dashboardClient: dashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -495,7 +495,7 @@ public async Task ReceiveData_MessageBoxReceivedTwice_IgnoreReplayedNotification SetupInteractionProviderServices(dashboardClient: dashboardClient, dialogService: dialogService); // Act 1 - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -576,7 +576,7 @@ public async Task ReceiveData_InputDialogWithMarkdownMessage_ExpectedResolvedMes SetupInteractionProviderServices(dashboardClient: dashboardClient, dialogService: dialogService); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -631,7 +631,7 @@ public async Task ReceiveData_ProgressDialogOpenAndCancel_OpenDialogAndSendResul SetupInteractionProviderServices(dashboardClient: dashboardClient, dialogService: dialogService); // Act 1 - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -684,7 +684,7 @@ public async Task ReceiveData_ProgressDialogOpenAndCompletion_OpenAndCloseDialog SetupInteractionProviderServices(dashboardClient: dashboardClient, dialogService: dialogService); // Act 1 - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -743,7 +743,7 @@ private void SetupInteractionProviderServices( Services.AddScoped(); Services.AddScoped(); - _messageBarProvider = RenderComponent(builder => + _messageBarProvider = Render(builder => { builder.Add(p => p.Section, DashboardUIHelpers.MessageBarSection); }); diff --git a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs index 7abc54b36e7..1e7dda95357 100644 --- a/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs +++ b/tests/Aspire.Dashboard.Components.Tests/JavaScript/TerminalView.test.mjs @@ -209,7 +209,9 @@ function mount({ visible = true, dotNetRef, options = {} } = {}) { } }, { label: "Localized terminal input", ...options, viewId }, template, footer); ids.push(id); - return { id, element, controls, footer, footerControls, viewId }; + return { + id, element, controls, footer, footerControls, viewId, + }; } async function settle() { @@ -555,7 +557,7 @@ test("a mount becoming ready after another terminal does not steal its focus", a assert.equal(attempts[0].client.focusCalls, 0); }); -test("inactive dock panes wait for activation before focusing and do not remount", async () => { +test("inactive dock panes wait for activation without stealing tab focus or remounting", async () => { const { id, element } = mount({ options: { showDimensions: false } }); const pane = {}; element.closest = selector => selector === "[inert]" ? pane : null; @@ -566,11 +568,11 @@ test("inactive dock panes wait for activation before focusing and do not remount element.closest = () => null; document.activeElement = { tagName: "BUTTON" }; terminal.setAutoFit(id, true); - assert.equal(attempts[0].client.focusCalls, 1); - assert.equal(document.activeElement, attempts[0].client.element); + assert.equal(attempts[0].client.focusCalls, 0); + assert.equal(document.activeElement.tagName, "BUTTON"); terminal.setAutoFit(id, true); observers[0].callback(); - assert.equal(attempts[0].client.focusCalls, 1); + assert.equal(attempts[0].client.focusCalls, 0); assert.equal(attempts.length, 1); }); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/DashboardDialogProviderTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/DashboardDialogProviderTests.cs index 1956f416ba0..26f14fcaf3b 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/DashboardDialogProviderTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/DashboardDialogProviderTests.cs @@ -19,7 +19,7 @@ public class DashboardDialogProviderTests : DashboardTestContext public async Task Navigation_CancelsDialogsAndDrawers(string destination) { FluentUISetupHelpers.SetupDialogInfrastructure(this); - var cut = RenderComponent(); + var cut = Render(); var navigation = Services.GetRequiredService(); var closed = new List(); var first = await OpenAsync(cut, "first", drawer: false, OnStateChange); @@ -57,7 +57,7 @@ void OnStateChange(DialogEventArgs args) public async Task Navigation_CancelsConfirmation() { FluentUISetupHelpers.SetupDialogInfrastructure(this); - var cut = RenderComponent(); + var cut = Render(); var service = Services.GetRequiredService(); Task result = null!; await cut.InvokeAsync(() => { result = service.ShowConfirmationAsync("Confirm navigation test"); }); @@ -75,9 +75,9 @@ public async Task Navigation_CancelsConfirmation() public async Task DisposedProvider_UnsubscribesFromNavigation() { FluentUISetupHelpers.SetupDialogInfrastructure(this); - var cut = RenderComponent(); - await cut.InvokeAsync(DisposeComponents); - var plainProvider = RenderComponent(); + var cut = Render(); + await DisposeComponentsAsync(); + var plainProvider = Render(); var result = await OpenAsync(plainProvider, "after-dispose", drawer: false, onStateChange: null); await plainProvider.InvokeAsync(() => Services.GetRequiredService().NavigateTo("/traces")); @@ -88,7 +88,7 @@ public async Task DisposedProvider_UnsubscribesFromNavigation() Assert.True(result.IsCompletedSuccessfully); } - private async Task> OpenAsync(IRenderedFragment cut, string id, bool drawer, Action? onStateChange) + private async Task> OpenAsync(IRenderedComponent cut, string id, bool drawer, Action? onStateChange) { var service = Services.GetRequiredService(); Task result = null!; @@ -104,7 +104,7 @@ await cut.InvokeAsync(() => return result; } - private static Task RaiseOpeningAsync(IRenderedFragment cut, string id) + private static Task RaiseOpeningAsync(IRenderedComponent cut, string id) { return cut.InvokeAsync(() => cut.Find($"#{id}").TriggerEvent("ondialogbeforetoggle", new DialogToggleEventArgs { diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs index f956c6a89d4..c4520ce4b43 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTerminalTests.cs @@ -41,7 +41,7 @@ public async Task TerminalDock_RequiresResourceService(bool isEnabled, bool isDe TerminalSetupHelpers.SetupTerminalDock(this); SetupMainLayoutServices(dashboardClient: client); - var cut = RenderComponent(builder => builder.Add(p => p.ViewportInformation, + var cut = Render(builder => builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: isDesktop, IsUltraLowHeight: false, IsUltraLowWidth: false))); var label = Services.GetRequiredService>()[ isDesktop ? nameof(Resources.TerminalStrings.MainLayoutToggleTerminalDock) : nameof(Resources.TerminalStrings.TerminalTitle)].Value; @@ -109,7 +109,7 @@ public async Task TerminalDock_RunSelection_OnlySubscribesWhileLive(bool startHi selection.SelectRun("historical"); } - var cut = RenderComponent(builder => builder.Add(p => p.ViewportInformation, + var cut = Render(builder => builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: isDesktop, IsUltraLowHeight: false, IsUltraLowWidth: false))); var shortcuts = Services.GetRequiredService(); var label = Services.GetRequiredService>()[ @@ -177,7 +177,7 @@ public async Task TerminalDock_MobileMenu_CanOpenCollapseAndReopenWithoutKeyboar TerminalSetupHelpers.SetupTerminalDock(this); SetupMainLayoutServices(dashboardClient: client); - var cut = RenderComponent(builder => builder.Add(p => p.ViewportInformation, + var cut = Render(builder => builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: false, IsUltraLowHeight: false, IsUltraLowWidth: false))); var label = Services.GetRequiredService>()[nameof(Resources.TerminalStrings.TerminalTitle)].Value; var dock = cut.FindComponent().Instance; diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs index c6f5ddf0eda..b63326d3686 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs @@ -65,7 +65,7 @@ public async Task OnInitialize_UnsecuredOtlp_NotDismissed_DisplayMessageBar() }; // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -102,7 +102,7 @@ public async Task OnInitialize_UnsecuredOtlp_Dismissed_NoMessageBar(bool unsecur }; // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -138,7 +138,7 @@ public async Task OnInitialize_UnsecuredOtlp_SuppressConfigured_NoMessageBar(boo }; // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -183,7 +183,7 @@ public async Task NavMenuExpanded_RestoresAndPersistsToggledState(bool storedExp SetupMainLayoutServices(localStorage: localStorage); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -214,7 +214,7 @@ public async Task HeaderDialogClose_RestoresFocusToLaunchButton(bool isDesktop, SetupMainLayoutServices(dialogService: dialogService); JSInterop.SetupVoid("focusElement", _ => true); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: isDesktop, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -255,7 +255,7 @@ public void DashboardRunSelect_Supported_IsDisplayedBeforeHeaderButtons(bool isD { SetupMainLayoutServices(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: isDesktop, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -295,7 +295,7 @@ public void DashboardRunSelect_PrunedHistoricalRunIsNotDisplayed() historicalRun ]); SetupMainLayoutServices(dashboardRunStore: runStore); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add( component => component.ViewportInformation, @@ -349,7 +349,7 @@ public void DashboardRunSelect_IncompatibleHistoricalRunIsDisplayedAndDisabled() var expectedRunText = FormatHelpers.FormatTimeWithOptionalDate( Services.GetRequiredService(), incompatibleRun.StartedAtUtc.UtcDateTime); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(component => component.SelectedRunId, currentRun.RunId); builder.Add(component => component.SelectedRunIsCurrent, true); @@ -403,7 +403,7 @@ public void DashboardRunSelect_Unsupported_IsHiddenAndStaleSelectionIsIgnored(bo var runSelection = Assert.IsType(Services.GetRequiredService()); var getRunsCallCount = runStore.GetRunsCallCount; - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add( component => component.ViewportInformation, @@ -452,7 +452,7 @@ public async Task DashboardRunSelect_ChangeStoresAndAppliesSelectionWithoutNavig JSInterop.SetupVoid("focusElement", _ => true).SetVoidResult(); var initializedCount = 0; var disposedCount = 0; - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(component => component.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); builder.Add(component => component.Body, bodyBuilder => @@ -634,7 +634,7 @@ public void DashboardRunSelect_SelectionFailure_KeepsCurrentRunAndCircuitActive( } }; - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(component => component.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); builder.Add(component => component.Body, bodyBuilder => bodyBuilder.AddMarkupContent(0, "
")); @@ -688,7 +688,7 @@ public void DashboardRunSelect_PinningFailure_KeepsMenuAndCircuitActive() var testSink = new TestSink(); Services.AddSingleton>(new TestLogger(new TestLoggerFactory(testSink, enabled: true))); SetupMainLayoutServices(dashboardRunStore: runStore); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(component => component.SelectedRunId, currentRun.RunId); builder.Add(component => component.SelectedRunIsCurrent, true); @@ -767,7 +767,7 @@ public void DashboardRunSelect_SortsHistoricalRunsByPinnedThenDateDescendingAndU .ThenByDescending(run => run.StartedAtUtc) .Select(run => FormatHelpers.FormatTimeWithOptionalDate(browserTimeProvider, run.StartedAtUtc.UtcDateTime)) .ToArray(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(component => component.SelectedRunId, currentRun.RunId); builder.Add(component => component.SelectedRunIsCurrent, true); @@ -823,7 +823,7 @@ public async Task RunSelectionPending_RendersCurrentRunWithoutLoadingRuns() var runSelection = Assert.IsType(Services.GetRequiredService()); var getRunsCallCount = runStore.GetRunsCallCount; - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); builder.Add(p => p.Body, bodyBuilder => bodyBuilder.AddMarkupContent(0, "
")); @@ -877,7 +877,7 @@ public async Task RunSelectionPending_StoredHistoricalRunReplacesCurrentRun() }; SetupMainLayoutServices(dashboardRunStore: runStore, sessionStorage: sessionStorage); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); builder.Add(p => p.Body, bodyBuilder => bodyBuilder.AddMarkupContent(0, "
")); @@ -942,7 +942,7 @@ public void StoredHistoricalRunFailure_FallsBackToCurrentRunAndClearsSelection() } }; - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -993,7 +993,7 @@ public async Task RunSelectionPending_UserSelectionWinsOverStoredSelection() SetupMainLayoutServices(dashboardRunStore: runStore, sessionStorage: sessionStorage); JSInterop.SetupVoid("focusElement", _ => true).SetVoidResult(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -1033,7 +1033,7 @@ public async Task HeaderDialogClose_AfterViewportChange_RestoresFocusToVisibleLa SetupMainLayoutServices(dialogService: dialogService); JSInterop.SetupVoid("focusElement", _ => true); - var cut = RenderComponent>(builder => + var cut = Render>(builder => { builder.Add(p => p.Value, new ViewportInformation(IsDesktop: initialIsDesktop, IsUltraLowHeight: false, IsUltraLowWidth: false)); builder.AddChildContent(); @@ -1057,7 +1057,7 @@ public async Task HeaderDialogClose_AfterViewportChange_RestoresFocusToVisibleLa Assert.NotNull(capturedParameters); Assert.Equal(expectedDialogId, capturedParameters.Id); - cut.SetParametersAndRender(parameters => + cut.Render(parameters => { parameters.Add(p => p.Value, new ViewportInformation(IsDesktop: closingIsDesktop, IsUltraLowHeight: false, IsUltraLowWidth: false)); parameters.AddChildContent(); @@ -1090,7 +1090,7 @@ public async Task HeaderDialogShortcutClose_RestoresFocusToLaunchButton(AspireKe SetupMainLayoutServices(dialogService: dialogService); JSInterop.SetupVoid("focusElement", _ => true); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -1157,7 +1157,7 @@ private void SetupMainLayoutServices( FluentUISetupHelpers.SetupFluentDivider(this); FluentUISetupHelpers.SetupFluentKeyCode(this); - _messageBarProvider = RenderComponent(builder => + _messageBarProvider = Render(builder => { builder.Add(p => p.Section, DashboardUIHelpers.MessageBarSection); }); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/MobileNavMenuTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/MobileNavMenuTests.cs index 77eb98ce6cf..e1fdb53bc30 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/MobileNavMenuTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/MobileNavMenuTests.cs @@ -52,8 +52,8 @@ public void MobileNavMenu_ConstrainedToRemainingViewport() Assert.Equal(MobileNavMenu.MobileNavMenuId, menu.Id); Assert.Equal(cut.FindAll("fluent-menu-item").Count, menu.QuerySelectorAll(":scope > fluent-menu-item").Length); Assert.Equal(cut.FindAll("fluent-menu-item").Count - 1, cut.FindAll("fluent-divider").Count); - Assert.Equal("fluent-menu-item", menu.Children.First().LocalName); - Assert.Equal("fluent-menu-item", menu.Children.Last().LocalName); + Assert.Equal("fluent-menu-item", menu.Children[0].LocalName); + Assert.Equal("fluent-menu-item", menu.Children[menu.Children.Length - 1].LocalName); Assert.Contains("max-height: calc(100dvh - var(--mobile-header-height) - var(--mobile-nav-menu-offset))", style); Assert.DoesNotContain("height: 100vh", style); @@ -116,7 +116,7 @@ private IRenderedComponent RenderMobileNavMenu(string currentUrl, var navigationManager = Services.GetRequiredService(); navigationManager.NavigateTo(currentUrl); - return RenderComponent(builder => + return Render(builder => { builder.Add(p => p.IsNavMenuOpen, isNavMenuOpen); builder.Add(p => p.CloseNavMenu, closeNavMenu ?? (() => { })); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourceServiceConnectionProviderTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourceServiceConnectionProviderTests.cs index 929714d3e08..3b027d06ba6 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/ResourceServiceConnectionProviderTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/ResourceServiceConnectionProviderTests.cs @@ -19,7 +19,7 @@ public async Task ConnectionStateChanged_CanceledJsInterop_DoesNotThrow() Services.AddSingleton(dashboardClient); JSInterop.SetupVoid("registerResourceServiceConnectionProvider", _ => true).SetVoidResult(); JSInterop.SetupVoid("updateResourceServiceConnectionState", _ => true).SetException(new TaskCanceledException()); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(() => dashboardClient.SetConnectionState(DashboardConnectionState.Disconnected)); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockInitializationTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockInitializationTests.cs index 157cccd7b71..424c4e72cfb 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockInitializationTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockInitializationTests.cs @@ -35,7 +35,7 @@ public async Task FirstOpening_DefersResourcesAndRenderingButPreservesRemoteActi OnTerminalUpdateProcessed = update => processed.Writer.TryWrite(update) }; TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); var renderCount = cut.RenderCount; WatchTerminalsUpdate[] metadata = [ @@ -96,7 +96,7 @@ public async Task DisabledOrReadOnly_DoesNotWatchOrInitialize(bool enabled, bool { var client = new TestDashboardClient(isEnabled: enabled, isReadOnly: readOnly); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await cut.InvokeAsync(() => cut.Instance.DisposeAsync().AsTask()).DefaultTimeout(); @@ -133,7 +133,7 @@ public async Task Initialization_OverlappingRendersRegisterListenersOnce(string { import.SetResult(module); } - var cut = RenderComponent(); + var cut = Render(); try { await cut.InvokeAsync(cut.Instance.ToggleAsync); @@ -199,7 +199,7 @@ public async Task Disposal_DuringInitialization_JoinsAndReleasesPartialRegistrat { import.SetResult(module); } - var cut = RenderComponent(); + var cut = Render(); Task disposing = Task.CompletedTask; try { @@ -250,7 +250,7 @@ public async Task InitializationFailure_PropagatesAndDisposalReleasesPartialStat BeforeInvokeAsync = identifier => identifier == failedStage ? Task.FromException(failure) : Task.CompletedTask }; var import = TestJSObjectReference.SetupImport(this, "./Components/Layout/TerminalDock.razor.js"); - var cut = RenderComponent(); + var cut = Render(); var component = cut.Instance; await cut.InvokeAsync(component.ToggleAsync); if (failedStage == "import") diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTelemetryTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTelemetryTests.cs index a354cf5dfaa..b27d754ec45 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTelemetryTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTelemetryTests.cs @@ -38,7 +38,7 @@ public async Task Telemetry_TracksVisibleIntervalsAndOpeningTrigger(string actio await telemetryService.InitializeAsync(); Assert.Equal(Enumerable.Repeat(TelemetryEndpoints.TelemetryPostProperty, telemetryEnabled ? telemetryService._defaultProperties.Count : 0), DrainTelemetryEvents(sender)); - var cut = RenderComponent(); + var cut = Render(); Assert.Null(cut.Instance.TelemetryContext); var renderCount = cut.RenderCount; diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs index f6bae239c30..73741675077 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockTests.cs @@ -51,7 +51,7 @@ public async Task EmptyDock_ListsResourceTerminalsWithReplicaAndPathBaseAwareLin Services.AddSingleton(new TestNavigationManager($"https://dashboard.example{pathBase}/")); TerminalSetupHelpers.SetupTerminalComponents(this, client, pathBase); Services.GetRequiredService().NavigateTo("consolelogs/resource/other"); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); cut.WaitForAssertion(() => @@ -89,7 +89,7 @@ public async Task ResourceUpdates_RefreshLinksAndStopOnDisposal() OnResourceSubscriptionDisposed = () => resourcesStopped.TrySetResult() }; TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); cut.WaitForAssertion(() => Assert.Equal(["first"], cut.FindAll(".terminal-dock-resource-links a").Select(link => link.TextContent))); @@ -138,7 +138,7 @@ public async Task AppHostTerminalEndpoint_UsesDashboardBaseUri(string pathBase, Services.AddSingleton(new TestNavigationManager($"https://dashboard.example{pathBase}/")); TerminalSetupHelpers.SetupTerminalComponents(this, client, pathBase); Services.GetRequiredService().NavigateTo("consolelogs/resource/other"); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot(terminalId)); @@ -159,7 +159,7 @@ public async Task ResizeDock_UpdatesAccessibleBoundsWithoutRemountingTerminals( var updates = Channel.CreateUnbounded(); var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); cut.WaitForAssertion(() => Assert.Equal(2, cut.FindComponents().Count)); @@ -198,7 +198,7 @@ public async Task ResizeDock_AfterDisposal_DoesNotUpdateState() { var client = TerminalSetupHelpers.CreateTerminalDashboardClient(); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); var height = cut.Find(".terminal-dock").GetAttribute("style"); @@ -217,7 +217,7 @@ public async Task WatchUpdates_ReplaceSnapshotAndSelectAppHostTerminals() var updates = Channel.CreateUnbounded(); var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); Assert.Equal("No docked terminals", cut.Find(".terminal-dock-panel-heading").TextContent); @@ -272,7 +272,7 @@ public async Task RecoverySnapshot_RevealsDockWithoutResurrectingRemovedTerminal var updates = Channel.CreateUnbounded(); var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); if (previouslyOpened) { await cut.InvokeAsync(cut.Instance.ToggleAsync); @@ -315,7 +315,7 @@ public async Task RecoverySnapshot_WithoutActivationDoesNotRevealDock(bool previ var processed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); client.OnTerminalUpdateProcessed = _ => processed.TrySetResult(); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); if (previouslyOpened) { await cut.InvokeAsync(cut.Instance.ToggleAsync); @@ -347,7 +347,7 @@ public async Task SelectTab_UpdatesAccessibleSelectionWithoutRemountingPanes() var updates = Channel.CreateUnbounded(); var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second", "third")); cut.WaitForAssertion(() => @@ -401,7 +401,7 @@ public async Task CloseActiveTab_WaitsForWatchRemovalAndSelectsAdjacentTab(int s terminalChannelProvider: () => updates, closeTerminal: (_, _) => completion.Task); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); string[] ids = ["first", "second", "third"]; await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot(ids)); @@ -434,7 +434,7 @@ public async Task LastTabRemoved_EmptyDockCanReceiveAnotherAppHostTerminal() var updates = Channel.CreateUnbounded(); var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Activated, "first")); cut.WaitForAssertion(() => Assert.Single(cut.FindAll("[role=tab]"))); @@ -463,7 +463,7 @@ public async Task CloseInactiveTab_DoesNotChangeSelection() var updates = Channel.CreateUnbounded(); var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second", "third")); cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll(".terminal-dock-tab").Count)); @@ -492,9 +492,9 @@ public async Task CloseTab_TimesOut_NotifiesEvenAfterTabRemoval(bool removeWhile terminalChannelProvider: () => updates, closeTerminal: (_, _) => completion.Task); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var toasts = RenderComponent(); + var toasts = Render(); var notifications = Services.GetRequiredService(); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); var snapshot = TerminalSetupHelpers.Snapshot("terminal-id"); snapshot.Snapshot.Terminals[0].Title = "Setup shell"; @@ -534,7 +534,7 @@ public async Task HideDock_IsInertWithoutClosingOrRemountingTerminals(bool hideW var updates = Channel.CreateUnbounded(); var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); cut.WaitForAssertion(() => @@ -603,7 +603,7 @@ public async Task CloseTab_ComponentDisposed_CancelsWaitWithoutNotification() return Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); }); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("terminal")); cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-dock-tab"))); @@ -628,8 +628,8 @@ public async Task CloseTab_ResponseAfterComponentDisposal_DoesNotNotify(StatusCo terminalChannelProvider: () => updates, closeTerminal: (_, _) => completion.Task); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var toasts = RenderComponent(); - var cut = RenderComponent(); + var toasts = Render(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("terminal")); cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-dock-tab"))); @@ -656,7 +656,7 @@ public async Task DetachActiveTerminal_CarriesItsFontAndReturnResumesAutoFit(str Services.AddSingleton(new TestNavigationManager($"http://localhost{pathBase}/")); TerminalSetupHelpers.SetupTerminalComponents(this, client, pathBase); Services.GetRequiredService().NavigateTo("consolelogs/resource/first"); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", terminalId)); cut.WaitForAssertion(() => Assert.Equal(2, cut.FindComponents().Count)); @@ -703,7 +703,7 @@ public async Task RecoverySnapshot_ClosesWindowForMissingTerminal() var updates = Channel.CreateUnbounded(); var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Activated, "detached")); cut.WaitForAssertion(() => Assert.Single(cut.FindComponents())); await cut.InvokeAsync(() => cut.FindComponent().Instance.OnTerminalStateChanged(new TerminalToolbarState @@ -743,7 +743,7 @@ public async Task DelayedDetachNotification_ReconcilesCapturedTerminalRatherThan var updates = Channel.CreateUnbounded(); var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("first", "second")); cut.WaitForAssertion(() => Assert.Equal(2, cut.FindComponents().Count)); @@ -786,7 +786,7 @@ public async Task BlockedDetach_KeepsInlineViewAndDisplaysFeedback() var updates = Channel.CreateUnbounded(); var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(); + var cut = Render(); await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Activated, "terminal")); cut.WaitForAssertion(() => Assert.Single(cut.FindComponents())); Assert.True(cut.Find(".terminal-dock-detach").HasAttribute("disabled")); diff --git a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs index a43baa202d8..1e7937ce62a 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Layout/TerminalDockWindowTrackingTests.cs @@ -36,8 +36,8 @@ public async Task ReloadRecovery_KeepsPlaceholderUntilExplicitReturnOrConfirmedC // JS reports failed durable revocation after unconditionally releasing its live popup handle. module.SetupVoid("closeTerminalWindow", _ => true).SetException(new JSException("Storage denied")); } - var toasts = RenderComponent(); - var cut = RenderComponent(); + var toasts = Render(); + var cut = Render(); await cut.InvokeAsync(cut.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("terminal")); var launcher = TerminalSetupHelpers.GetWindowLauncher(this, cut); @@ -89,7 +89,7 @@ public async Task ReplacementDock_AdoptsWindowsBeforeMountingAnyViewers(string p var client = TerminalSetupHelpers.CreateTerminalDashboardClient(terminalChannelProvider: () => updates); Services.AddSingleton(new TestNavigationManager($"https://dashboard.example{pathBase}/")); TerminalSetupHelpers.SetupTerminalComponents(this, client, pathBase); - var old = RenderComponent(); + var old = Render(); await old.InvokeAsync(old.Instance.ToggleAsync); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("active", "inactive")); old.WaitForAssertion(() => @@ -119,7 +119,7 @@ public async Task ReplacementDock_AdoptsWindowsBeforeMountingAnyViewers(string p } }; TestJSObjectReference.SetupImport(this, $"{pathBase}/js/app-terminalwindow.js").SetResult(module); - var cut = RenderComponent(); + var cut = Render(); TerminalWindowLauncher launcher; try { @@ -191,7 +191,7 @@ public async Task Adoption_ChangedSnapshotChecksNewIdentitiesAndDoesNotMountRemo } }; TestJSObjectReference.SetupImport(this, "/js/app-terminalwindow.js").SetResult(module); - var cut = RenderComponent(); + var cut = Render(); try { await cut.InvokeAsync(cut.Instance.ToggleAsync); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs index ece0d580091..c249b766b56 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTerminalTests.cs @@ -61,7 +61,7 @@ public async Task TerminalResource_Live_Selected_RendersBothViews_DefaultsToTerm var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "terminal-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -114,7 +114,7 @@ public async Task TerminalResource_OpenWindow_CarriesCurrentFontAndKeepsInlineVi Services.GetRequiredService().NavigateTo($"{pathBase}{DashboardUrls.ConsoleLogsUrl(resource: resource.Name)}"); var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); Services.GetRequiredService().InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.ResourceName, resource.Name) .Add(p => p.ViewportInformation, viewport)); cut.WaitForAssertion(() => Assert.Single(cut.FindComponents())); @@ -160,7 +160,7 @@ public async Task TerminalResource_ViewPicker_MarksActiveViewAsChecked() var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "terminal-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -231,7 +231,7 @@ public async Task TerminalResource_PausedConsoleLogs_DisplaysWarningOnTerminalVi var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "terminal-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -286,7 +286,7 @@ public async Task TerminalResource_NotLive_Selected_RendersBothViews_DefaultsToC var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "terminal-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -335,7 +335,7 @@ public async Task TerminalResource_NotLive_Selected_ThenGoesLive_StaysOnConsole( var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "terminal-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -388,7 +388,7 @@ public async Task TerminalResource_Live_ManualConsoleSelection_SurvivesFilterCha var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "terminal-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -442,7 +442,7 @@ public async Task HistoricalTerminalResource_Selected_DoesNotRenderTerminalView( var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "terminal-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -479,7 +479,7 @@ public async Task SwitchingFromTerminalToNonTerminalResource_TearsDownTerminalVi var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "terminal-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -498,7 +498,7 @@ public async Task SwitchingFromTerminalToNonTerminalResource_TearsDownTerminalVi // production selection-changed pipeline, not a direct parameter set. navigationManager.LocationChanged += (sender, e) => { - cut.SetParametersAndRender(builder => + cut.Render(builder => { builder.Add(m => m.ResourceName, "plain-resource"); }); @@ -541,7 +541,7 @@ public async Task SwitchingFromTerminalToNonTerminalResource_RestoresSearchFilte var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "terminal-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -561,7 +561,7 @@ public async Task SwitchingFromTerminalToNonTerminalResource_RestoresSearchFilte navigationManager.LocationChanged += (sender, e) => { - cut.SetParametersAndRender(builder => + cut.Render(builder => { builder.Add(m => m.ResourceName, "plain-resource"); }); @@ -623,7 +623,7 @@ public async Task TerminalResource_ViewToggle_RenderedDisplayStylesMatchActiveVi var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "terminal-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -662,7 +662,7 @@ public void TerminalView_InitialRender_ReconnectsWhenResourceChangesDuringInitia var reconnectTerminal = module.Setup("reconnectTerminal", _ => true); reconnectTerminal.SetResult(2); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "first-resource"); builder.Add(p => p.ReplicaIndex, 0); @@ -673,7 +673,7 @@ public void TerminalView_InitialRender_ReconnectsWhenResourceChangesDuringInitia builder.Add(p => p.FocusControlsHintLabel, "F6: Focus terminal controls"); }); - cut.SetParametersAndRender(builder => + cut.Render(builder => { builder.Add(p => p.ResourceName, "second-resource"); builder.Add(p => p.ReplicaIndex, 1); @@ -698,7 +698,7 @@ public void TerminalView_InitialRender_ReconnectsWhenResourceChangesDuringInitia Assert.Contains("replica=0", initUrl); Assert.Equal("Decrease font size", labels.GetProperty("decreaseFontSize").GetString()); Assert.Equal("Increase font size", labels.GetProperty("increaseFontSize").GetString()); - Assert.Equal("Terminal dimensions", labels.GetProperty("terminalDimensions").GetString()); + Assert.Equal("Terminal dimensions", cut.Find("fluent-dropdown").GetAttribute("placeholder")); Assert.Equal("Fit", labels.GetProperty("fit").GetString()); Assert.Equal("F6: Focus terminal controls", labels.GetProperty("focusControlsHint").GetString()); Assert.Equal(1, reconnect.Arguments[0]); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs index 307469d789c..32e516b948a 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs @@ -70,7 +70,7 @@ public async Task NoResourceName_SingleResource_RedirectToResource() dimensionManager.InvokeOnViewportInformationChanged(viewport); // Act 1 - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, viewport); }); @@ -115,7 +115,7 @@ public async Task ResourceName_SubscribeOnLoadAndChange_SubscribeConsoleLogsOnce dimensionManager.InvokeOnViewportInformationChanged(viewport); // Act 1 - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "test-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -143,7 +143,7 @@ public async Task ResourceName_SubscribeOnLoadAndChange_SubscribeConsoleLogsOnce var expectedUrl = DashboardUrls.ConsoleLogsUrl(resource: "test-resource2"); Assert.EndsWith(expectedUrl, e.Location); - cut.SetParametersAndRender(builder => + cut.Render(builder => { builder.Add(m => m.ResourceName, "test-resource2"); }); @@ -192,7 +192,7 @@ public async Task ToggleHiddenResources_HiddenResourceVisibilityAndSelection_Wor dimensionManager.InvokeOnViewportInformationChanged(viewport); // Act & Assert 1: Render component - initially hidden resource should not be visible - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, viewport); }); @@ -281,7 +281,7 @@ public void ToggleHiddenResourcesMenuItem_WhenSingleResourceSelected_NotShown() dimensionManager.InvokeOnViewportInformationChanged(viewport); // Act: Render component with a specific resource selected - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, viewport); builder.Add(p => p.ResourceName, "test-resource"); @@ -337,7 +337,7 @@ public async Task ResourceName_ViaUrlAndResourceLoaded_LogViewerUpdated() dimensionManager.InvokeOnViewportInformationChanged(viewport); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "test-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -514,7 +514,7 @@ public async Task ReadingLogs_ErrorDuringRead_SetStatusAndLog() dimensionManager.InvokeOnViewportInformationChanged(viewport); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "test-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -563,7 +563,7 @@ public async Task ReadingLogs_ErrorDuringReadAfterDispose_StatusUnchanged() dimensionManager.InvokeOnViewportInformationChanged(viewport); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "test-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -610,7 +610,7 @@ public async Task ClearLogEntries_AllResources_LogsFilteredOut() dimensionManager.InvokeOnViewportInformationChanged(viewport); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "test-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -714,7 +714,7 @@ public async Task ConsoleLogsManager_ClearLogs_LogsFilteredOutAsync() var consoleLogsManager = Services.GetRequiredService(); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "test-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -811,7 +811,7 @@ public void MenuButtons_SelectedResourceChanged_ButtonsUpdated() dimensionManager.InvokeOnViewportInformationChanged(viewport); // Act 1 - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "test-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -923,7 +923,7 @@ public async Task ExecuteCommand_DelayExecuting_IsExecutingReturnsTrueWhileRunni var dashboardCommandExecutor = Services.GetRequiredService(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "test-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -980,7 +980,7 @@ public void PauseResumeButton_TogglePauseResume_LogsPausedAndResumed() dimensionManager.InvokeOnViewportInformationChanged(viewport); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "test-resource"); builder.Add(p => p.ViewportInformation, viewport); @@ -1130,7 +1130,7 @@ private void SetupConsoleLogsServices(TestDashboardClient? dashboardClient = nul var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - return RenderComponent(builder => + return Render(builder => { builder.Add(p => p.ResourceName, resourceName); builder.Add(p => p.ViewportInformation, viewport); @@ -1142,7 +1142,7 @@ private static ViewportInformation CreateViewport(bool isDesktop) return new ViewportInformation(IsDesktop: isDesktop, IsUltraLowHeight: false, IsUltraLowWidth: false); } - private static IRenderedComponent OpenMobileToolbarAndFindSearch(IRenderedComponent cut, IRenderedFragment dialogProvider) + private static IRenderedComponent OpenMobileToolbarAndFindSearch(IRenderedComponent cut, IRenderedComponent dialogProvider) { cut.Find(".mobile-toolbar").Click(); @@ -1151,7 +1151,7 @@ private static IRenderedComponent OpenMobileToolbarAndFindSearc return Assert.Single(dialogProvider.FindComponents()); } - private IRenderedFragment RenderDialogProvider(ViewportInformation viewport) + private IRenderedComponent RenderDialogProvider(ViewportInformation viewport) { return Render(builder => { diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs index 5e60bd5b872..d44de8b2839 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/MetricsTests.cs @@ -46,7 +46,7 @@ public async Task SignalActions_UseTelemetryRepositoryReadOnlyState(bool telemet Services.AddSingleton(new TestDashboardClient(isReadOnly: dashboardClientIsReadOnly)); await FluentUISetupHelpers.ConfigureTelemetryRepository(this, telemetryRepositoryIsReadOnly, _ => Task.CompletedTask); - var cut = RenderComponent(builder => builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false))); + var cut = Render(builder => builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false))); Assert.Equal(telemetryRepositoryIsReadOnly, cut.FindComponent().Instance.Disabled); Assert.Equal(telemetryRepositoryIsReadOnly, cut.FindComponent().FindComponent().Instance.Disabled); @@ -105,7 +105,7 @@ public async Task ChartContainer_ParametersAndActiveView_OnlyRefreshAndRenderWhe }); var resource = telemetryRepository.GetResources().Single(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(component => component.ResourceKey, resource.ResourceKey); builder.Add(component => component.MeterName, "test-meter"); @@ -122,12 +122,10 @@ public async Task ChartContainer_ParametersAndActiveView_OnlyRefreshAndRenderWhe Assert.Single(cut.FindComponents()); Assert.Empty(cut.FindComponents()); }); - var dimensionFilters = cut.Instance.DimensionFilters; - var activities = new ConcurrentQueue(); using var listener = ActivityListenerHelper.Create(telemetryRepository.SqlActivitySource, onActivityStopped: activities.Enqueue); - cut.SetParametersAndRender(builder => builder.Add(component => component.ActiveView, MetricViewKind.Table)); + cut.Render(builder => builder.Add(component => component.ActiveView, MetricViewKind.Table)); Assert.Empty(cut.FindComponents()); Assert.Single(cut.FindComponents()); @@ -138,12 +136,18 @@ public async Task ChartContainer_ParametersAndActiveView_OnlyRefreshAndRenderWhe cut.WaitForAssertion(() => Assert.Contains("1Value increased", cut.FindAll("[role='gridcell']").Select(cell => cell.TextContent.Trim()))); Assert.Empty(activities); - cut.SetParametersAndRender(builder => builder.Add(component => component.ActiveView, MetricViewKind.Graph)); + cut.Render(builder => builder.Add(component => component.ActiveView, MetricViewKind.Graph)); Assert.Single(cut.FindComponents()); Assert.Empty(cut.FindComponents()); Assert.Empty(activities); + var chartFilters = cut.FindComponent().Instance; + var filterPopover = cut.FindComponent().Instance; + var filterButtonId = cut.Find(".chart-filter-button").Id; + cut.Find(".chart-filter-button").Click(); + Assert.Equal("true", cut.Find("fluent-popover-b.chart-filter-popover").GetAttribute("opened")); + await telemetryRepository.AddMetricsAsync(new AddContext(), new RepeatedField { new ResourceMetrics @@ -167,16 +171,19 @@ public async Task ChartContainer_ParametersAndActiveView_OnlyRefreshAndRenderWhe }); activities.Clear(); - cut.SetParametersAndRender(builder => builder.Add(component => component.Duration, TimeSpan.FromMinutes(5))); + cut.Render(builder => builder.Add(component => component.Duration, TimeSpan.FromMinutes(5))); Assert.Empty(activities); - cut.SetParametersAndRender(builder => builder.Add(component => component.Duration, TimeSpan.FromMinutes(1))); + cut.Render(builder => builder.Add(component => component.Duration, TimeSpan.FromMinutes(1))); cut.WaitForAssertion(() => { var updatedFilter = Assert.Single(cut.Instance.DimensionFilters); - Assert.NotSame(dimensionFilters, cut.Instance.DimensionFilters); + Assert.Same(chartFilters, cut.FindComponent().Instance); + Assert.Same(filterPopover, cut.FindComponent().Instance); + Assert.Equal(filterButtonId, cut.Find(".chart-filter-button").Id); + Assert.Equal("true", cut.Find("fluent-popover-b.chart-filter-popover").GetAttribute("opened")); Assert.Collection( updatedFilter.SelectedValues.Select(value => value.Value).Order(), value => Assert.Equal("GET", value), @@ -219,7 +226,7 @@ public async Task ChartContainer_InstrumentChanged_TableUpdates() var telemetryRepository = Services.GetRequiredService(); var resource = telemetryRepository.GetResources().Single(); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(component => component.ResourceKey, resource.ResourceKey); builder.Add(component => component.MeterName, "test-meter"); @@ -235,7 +242,7 @@ public async Task ChartContainer_InstrumentChanged_TableUpdates() table.Render(); table.WaitForAssertion(() => Assert.Contains("1Value increased", table.FindAll("[role='gridcell']").Select(cell => cell.TextContent.Trim()))); - cut.SetParametersAndRender(builder => builder.Add(component => component.InstrumentName, "instrument-2")); + cut.Render(builder => builder.Add(component => component.InstrumentName, "instrument-2")); cut.WaitForAssertion(() => { @@ -307,7 +314,7 @@ public async Task ChartContainer_TickUpdate_PreservesComponentCultureAndStopsOnD try { CultureInfo.CurrentCulture = componentCulture; - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(component => component.ResourceKey, resource.ResourceKey); builder.Add(component => component.MeterName, "test-meter"); @@ -381,7 +388,7 @@ public async Task InitialLoad_SingleResource_RedirectToResource() }); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -465,7 +472,7 @@ public async Task InitialLoad_HasSessionState_RedirectUsingState() }); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); }); @@ -516,7 +523,7 @@ public async Task InitialLoad_RouteSelection_SelectsTreeItem(string? instrumentN } }); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(component => component.ResourceName, "TestApp"); builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); @@ -572,7 +579,7 @@ public async Task PauseIncomingData_DisplaysPauseWarningInPageFooter() navigationManager.NavigateTo(DashboardUrls.MetricsUrl(resource: "TestApp", meter: "test-meter", instrument: "test-instrument")); var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(m => m.ResourceName, "TestApp"); builder.AddCascadingValue(viewport); @@ -636,7 +643,7 @@ public async Task MetricsTree_MetricsAdded_TreeUpdated() // Act 1 // Initial page load - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); builder.Add(m => m.ResourceName, "TestApp"); @@ -732,7 +739,7 @@ public async Task ReadOnly_ChartEndsAtLatestMetricTime() Services.GetRequiredService().NavigateTo( DashboardUrls.MetricsUrl(resource: "TestApp", meter: "test-meter", instrument: "test-instrument", duration: 5, view: MetricViewKind.Graph.ToString())); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(m => m.ResourceName, "TestApp"); builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); @@ -793,7 +800,7 @@ private async Task ChangeResourceAndAssertInstrument(string app1InstrumentName, }); // Act 1 - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(m => m.ResourceName, "TestApp"); builder.AddCascadingValue(new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false)); @@ -804,7 +811,7 @@ private async Task ChangeResourceAndAssertInstrument(string app1InstrumentName, var expectedUrl = DashboardUrls.MetricsUrl(resource: "TestApp2", meter: expectedMeterNameAfterChange, instrument: expectedInstrumentNameAfterChange, duration: 720, view: "Table"); Assert.EndsWith(expectedUrl, e.Location); - cut.SetParametersAndRender(builder => + cut.Render(builder => { builder.Add(m => m.ResourceName, "TestApp2"); }); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs index 7ca0579ef56..3bd5db15df8 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs @@ -46,7 +46,7 @@ public void ResourceOptions_SelectionChanges_UpdateAllCheckboxState() values["Container"] = true; values["Project"] = true; - var cut = RenderComponent>(builder => builder + var cut = Render>(builder => builder .Add(component => component.Values, values) .Add(component => component.OnAllValuesCheckedChangedAsync, () => Task.CompletedTask) .Add(component => component.OnValueVisibilityChangedAsync, (_, _) => Task.CompletedTask)); @@ -58,20 +58,20 @@ public void ResourceOptions_SelectionChanges_UpdateAllCheckboxState() Assert.NotNull(cut.Find("fluent-checkbox[title='Project']")); values["Container"] = false; - cut.SetParametersAndRender(builder => builder.Add(component => component.Values, values)); + cut.Render(builder => builder.Add(component => component.Values, values)); Assert.False(allCheckbox.Value); Assert.Null(allCheckbox.CheckState); values["Project"] = false; - cut.SetParametersAndRender(builder => builder.Add(component => component.Values, values)); + cut.Render(builder => builder.Add(component => component.Values, values)); Assert.False(allCheckbox.Value); Assert.False(allCheckbox.CheckState); values["Container"] = true; values["Project"] = true; - cut.SetParametersAndRender(builder => builder.Add(component => component.Values, values)); + cut.Render(builder => builder.Add(component => component.Values, values)); Assert.True(allCheckbox.Value); Assert.True(allCheckbox.CheckState); @@ -99,7 +99,7 @@ public async Task Resources_DefaultOrderIsTypeThenNameWithChildrenNested() }; var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: initialResources, resourceChannelProvider: Channel.CreateUnbounded>); ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); - var cut = RenderComponent(builder => builder.AddCascadingValue(viewport)); + var cut = Render(builder => builder.AddCascadingValue(viewport)); var result = await cut.InvokeAsync(() => cut.Instance.GetData(new GridItemsProviderRequest()).AsTask()); @@ -124,7 +124,7 @@ public async Task Resources_NameColumnSortsDescending() }; var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: initialResources, resourceChannelProvider: Channel.CreateUnbounded>); ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); - var cut = RenderComponent(builder => builder.AddCascadingValue(viewport)); + var cut = Render(builder => builder.AddCascadingValue(viewport)); var grid = cut.FindComponent>(); var nameColumn = Assert.Single( cut.FindComponents>(), @@ -177,7 +177,7 @@ public void ReadOnly_HighlightedCommandIsVisibleAndDisabled() isReadOnly: true); ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); builder.Add(component => component.CommandSelected, EventCallback.Factory.Create(this, _ => Task.CompletedTask)); @@ -212,7 +212,7 @@ public void UpdateResources_FiltersUpdated() viewport, dashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -310,7 +310,7 @@ public async Task FilterResources() viewport, dashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -374,7 +374,7 @@ public void ResourceGraph_MultipleRenders_InitializeOnce() navigationManager.NavigateTo(DashboardUrls.ResourcesUrl(view: "Graph")); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -425,7 +425,7 @@ public async Task ResourceGraphContextMenu_OpensWithoutWaitingForClose() var navigationManager = Services.GetRequiredService(); navigationManager.NavigateTo(DashboardUrls.ResourcesUrl(view: "Graph")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -457,7 +457,7 @@ public void TableView_FocusesAccessibleScrollContainerOnInitialRender() var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: [], resourceChannelProvider: Channel.CreateUnbounded>); ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -485,7 +485,7 @@ public void TableView_RestoresColumnOrderAfterMobileView() var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: [], resourceChannelProvider: Channel.CreateUnbounded>); ResourceSetupHelpers.SetupResourcesPage(this, desktopViewport, dashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(desktopViewport); }); @@ -513,7 +513,7 @@ public void DesktopFilterControls_AreLabeledGroup() var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: [], resourceChannelProvider: Channel.CreateUnbounded>); ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -547,7 +547,7 @@ public void ResourceTabs_OrientationRespondsToUltraLowWidth(bool isDesktop, bool viewport, dashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -595,7 +595,7 @@ public void ResourceFilters_ApplyExistingFiltersOnInitialRender() }; // Act and assert - var cut = RenderComponent(builder => { builder.AddCascadingValue(viewport); }); + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); Assert.Collection(cut.Instance.PageViewModel.ResourceTypesToVisibility.OrderBy(kvp => kvp.Key), kvp => @@ -669,7 +669,7 @@ public async Task ResourcesShouldRemainUnchangedWhenFilterDoesNotMatchUpdatedRes ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -714,7 +714,7 @@ public async Task UnreadLogErrorsBadge_StopsKeyboardPropagation() var resource = CreateResource(resourceKey.GetCompositeName(), "Type1", "Running", null); Assert.NotNull(telemetryRepository.GetResourceByCompositeName(resource.Name)); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.Resource, resource); builder.Add(p => p.UnviewedErrorCounts, unviewedErrorCounts); @@ -804,7 +804,7 @@ public void ViewOptionsMenu_WiresFocusRestorationWhenHiddenResourcesExist() dashboardClient); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -828,7 +828,7 @@ public void TableView_ExcludesParameters() ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -856,7 +856,7 @@ public void ParametersView_ShowsOnlyParameters() var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: initialResources, resourceChannelProvider: Channel.CreateUnbounded>); ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -886,7 +886,7 @@ public void ParametersView_IgnoresResourceTypeFilter() var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: initialResources, resourceChannelProvider: Channel.CreateUnbounded>); ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -923,7 +923,7 @@ public void GraphView_ExcludesParameters() resourceGraphModule.SetupVoid("updateResourcesGraph", _ => true); resourceGraphModule.SetupVoid("updateResourcesGraphSelected", _ => true); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -1000,7 +1000,7 @@ public void ParametersView_IncludesParametersWithValues() var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: initialResources, resourceChannelProvider: Channel.CreateUnbounded>); ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -1028,7 +1028,7 @@ public void GridValue_UrlValueStopsClickPropagation() FluentUISetupHelpers.AddCommonDashboardServices(this); var setCellTextClickHandler = JSInterop.SetupVoid("setCellTextClickHandler", _ => true); - RenderComponent(builder => + Render(builder => { builder.Add(p => p.Value, "https://example.com"); builder.Add(p => p.ValueDescription, "Parameter value"); @@ -1062,7 +1062,7 @@ public void ParametersView_IncludesUnresolvedParameters() var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: initialResources, resourceChannelProvider: Channel.CreateUnbounded>); ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -1106,7 +1106,7 @@ public void ParametersView_IncludesErrorParameters() var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: initialResources, resourceChannelProvider: Channel.CreateUnbounded>); ResourceSetupHelpers.SetupResourcesPage(this, viewport, dashboardClient); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -1168,7 +1168,7 @@ public void CollapsedResourceNames_FetchedAfterDashboardClientConnected_KeyInclu connectionTcs.SetResult(); // Act - Render the component - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs index 4c8b0667821..7f52e4c48a5 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/StructuredLogsTests.cs @@ -68,7 +68,7 @@ public async Task Render_ResourceInstanceHasDashes_AppKeyResolvedCorrectly() dimensionManager.InvokeOnViewportInformationChanged(viewport); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ResourceName, "TestApp"); builder.Add(p => p.ViewportInformation, viewport); @@ -98,7 +98,7 @@ public void Render_TraceIdAndSpanId_FilterAdded() dimensionManager.InvokeOnViewportInformationChanged(viewport); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, viewport); }); @@ -138,7 +138,7 @@ public void Render_DuplicateFilters_SingleFilterAdded() dimensionManager.InvokeOnViewportInformationChanged(viewport); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, viewport); }); @@ -176,7 +176,7 @@ public void Render_FiltersWithSpecialCharacters_SuccessfullyParsed() dimensionManager.InvokeOnViewportInformationChanged(viewport); // Act - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, viewport); }); @@ -215,7 +215,7 @@ public void Render_FocusesAccessibleScrollContainerOnInitialRender() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, viewport); }); @@ -273,7 +273,7 @@ public async Task ItemsProvider_ZeroCountRefresh_ReturnsData() }); var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); Services.GetRequiredService().InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => builder.Add(p => p.ViewportInformation, viewport)); + var cut = Render(builder => builder.Add(p => p.ViewportInformation, viewport)); var grid = cut.FindComponent>(); await grid.InvokeAsync(grid.Instance.RefreshDataAndRenderAsync); @@ -308,7 +308,7 @@ public void Render_FilterDisablesBrowserAutocomplete() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, viewport); }); @@ -327,7 +327,7 @@ public void PauseIncomingData_DisplaysPauseWarningImmediately() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.ViewportInformation, viewport); }); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs index 050cb2c098d..f9c1cb94ac6 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TerminalWindowTests.cs @@ -29,7 +29,7 @@ public async Task CoordinatedWindow_ChecksGenerationBeforeMountingAndDoesNotRevo var registration = module.Setup("registerDetachedTerminalWindow", _ => true); Services.GetRequiredService().NavigateTo( "terminal-window/apphost/terminal?fontSize=23&windowOwner=owner&windowGeneration=generation"); - var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "terminal")); + var cut = Render(builder => builder.Add(p => p.TerminalId, "terminal")); Assert.Empty(cut.FindComponents()); Assert.Equal([], JSInterop.Invocations.Where(i => i.Identifier == "initTerminal")); Assert.Single(registration.Invocations); @@ -59,7 +59,7 @@ public async Task CoordinatedWindow_RevocationRejectsStaleRegistrationAndRemoves TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient()); Services.GetRequiredService().NavigateTo( "terminal-window/apphost/terminal?windowOwner=owner&windowGeneration=generation"); - var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "terminal")); + var cut = Render(builder => builder.Add(p => p.TerminalId, "terminal")); cut.WaitForAssertion(() => Assert.Single(cut.FindComponents())); var registration = Assert.Single(JSInterop.Invocations, i => i.Identifier == "registerDetachedTerminalWindow"); var id = Assert.IsType(registration.Arguments[0]); @@ -78,7 +78,7 @@ public void CoordinatedWindow_StorageFailureDoesNotMountViewer() module.Setup("registerDetachedTerminalWindow", _ => true).SetException(new JSException("Storage denied")); Services.GetRequiredService().NavigateTo( "terminal-window/apphost/terminal?windowOwner=owner&windowGeneration=generation"); - var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "terminal")); + var cut = Render(builder => builder.Add(p => p.TerminalId, "terminal")); cut.WaitForAssertion(() => Assert.Equal(Resources.TerminalStrings.TerminalWindowTrackingFailed, cut.Find(".terminal-window-ended").TextContent)); Assert.Empty(cut.FindComponents()); @@ -97,7 +97,7 @@ public void AppHostTerminalEndpoint_UsesDashboardBaseUri(string pathBase, string TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient(terminalChannelProvider: () => updates), pathBase); Services.GetRequiredService().NavigateTo($"terminal-window/apphost/{escapedTerminalId}"); - var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, terminalId)); + var cut = Render(builder => builder.Add(p => p.TerminalId, terminalId)); cut.WaitForAssertion(() => TerminalSetupHelpers.AssertSingleTerminalConnection(this, $"wss://dashboard.example{pathBase}/api/apphost-terminal?terminalId={escapedTerminalId}")); @@ -112,7 +112,7 @@ public void OpeningWindow_AutoFitsUsingFontFromQuery(bool appHost) TerminalSetupHelpers.SetupTerminalComponents(this, new TestDashboardClient(terminalChannelProvider: () => updates)); var path = appHost ? "/terminal-window/apphost/terminal" : "/terminal-window/resource/shell/2"; Services.GetRequiredService().NavigateTo($"{path}?fontSize=19"); - var cut = RenderComponent(builder => builder + var cut = Render(builder => builder .Add(p => p.TerminalId, appHost ? "terminal" : null) .Add(p => p.ResourceName, appHost ? null : "shell") .Add(p => p.ReplicaIndex, appHost ? 0 : 2)); @@ -134,12 +134,12 @@ public async Task SameRoute_PreservesTitleEndedStateAndSubscription() var updates = Channel.CreateUnbounded(); var client = new TestDashboardClient(terminalChannelProvider: () => updates); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var head = RenderComponent(); - var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "terminal")); + var head = Render(); + var cut = Render(builder => builder.Add(p => p.TerminalId, "terminal")); await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "terminal", "Shell")); head.WaitForAssertion(() => Assert.Equal("Shell", head.Find("title").TextContent)); - cut.SetParametersAndRender(builder => builder + cut.Render(builder => builder .Add(p => p.TerminalId, "terminal") .Add(p => p.ResourceName, "unused") .Add(p => p.ReplicaIndex, 3)); @@ -148,7 +148,7 @@ public async Task SameRoute_PreservesTitleEndedStateAndSubscription() await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Removed, "terminal")); cut.WaitForAssertion(() => Assert.Equal("This terminal has ended.", cut.Find(".terminal-window-ended").TextContent)); - cut.SetParametersAndRender(builder => builder.Add(p => p.TerminalId, "terminal")); + cut.Render(builder => builder.Add(p => p.TerminalId, "terminal")); Assert.Equal("This terminal has ended.", cut.Find(".terminal-window-ended").TextContent); Assert.Equal(1, client.ActiveTerminalSubscriptionCount); } @@ -164,8 +164,8 @@ public async Task AppHostRouteChange_ReplacesWatchAndResetsEndedState(bool first var client = new TestDashboardClient(terminalChannelProvider: () => Interlocked.Increment(ref subscriptions) == 1 ? firstUpdates : secondUpdates); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var head = RenderComponent(); - var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "first")); + var head = Render(); + var cut = Render(builder => builder.Add(p => p.TerminalId, "first")); await firstUpdates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "first", "First shell")); head.WaitForAssertion(() => Assert.Equal("First shell", head.Find("title").TextContent)); if (firstTerminalEnded) @@ -202,8 +202,8 @@ public async Task ResourceRoute_CancelsAppHostWatchAndResetsRouteState(bool firs var updates = Channel.CreateUnbounded(); var client = new TestDashboardClient(terminalChannelProvider: () => updates); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var head = RenderComponent(); - var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "terminal")); + var head = Render(); + var cut = Render(builder => builder.Add(p => p.TerminalId, "terminal")); await updates.Writer.WriteAsync(TerminalSetupHelpers.Change(TerminalChangeType.Retitled, "terminal", "Shell")); head.WaitForAssertion(() => Assert.Equal("Shell", head.Find("title").TextContent)); if (firstTerminalEnded) @@ -212,7 +212,7 @@ public async Task ResourceRoute_CancelsAppHostWatchAndResetsRouteState(bool firs cut.WaitForAssertion(() => Assert.Single(cut.FindAll(".terminal-window-ended"))); } - cut.SetParametersAndRender(builder => builder + cut.Render(builder => builder .Add(p => p.TerminalId, null) .Add(p => p.ResourceName, "resource") .Add(p => p.ReplicaIndex, 2)); @@ -227,7 +227,7 @@ public async Task ResourceRoute_CancelsAppHostWatchAndResetsRouteState(bool firs Assert.Equal("resource #2", head.Find("title").TextContent); }); - cut.SetParametersAndRender(builder => builder.Add(p => p.ReplicaIndex, 3)); + cut.Render(builder => builder.Add(p => p.ReplicaIndex, 3)); Assert.Equal("resource #3", head.Find("title").TextContent); Assert.Equal(3, cut.FindComponent().Instance.ReplicaIndex); Assert.Equal(1, client.TerminalSubscriptionCount); @@ -270,8 +270,8 @@ public async Task RapidRouteChanges_IgnoreOldUpdatesAndOnlyWatchLatest(bool retu } }; TerminalSetupHelpers.SetupTerminalComponents(this, client); - var head = RenderComponent(); - var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "first")); + var head = Render(); + var cut = Render(builder => builder.Add(p => p.TerminalId, "first")); try { @@ -325,7 +325,7 @@ public async Task DisposeDuringRouteChange_JoinsOldWatchWithoutStartingReplaceme } }; TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "first")); + var cut = Render(builder => builder.Add(p => p.TerminalId, "first")); try { @@ -354,7 +354,7 @@ public async Task RecoverySnapshot_RemovesMissingTerminalAndDisposalCancelsWatch var updates = Channel.CreateUnbounded(); var client = new TestDashboardClient(terminalChannelProvider: () => updates); TerminalSetupHelpers.SetupTerminalComponents(this, client); - var cut = RenderComponent(builder => builder.Add(p => p.TerminalId, "terminal")); + var cut = Render(builder => builder.Add(p => p.TerminalId, "terminal")); await updates.Writer.WriteAsync(TerminalSetupHelpers.Snapshot("terminal")); cut.WaitForAssertion(() => Assert.Single(cut.FindComponents())); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs index 36c8aea66a8..ca0dae50cc7 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TraceDetailsTests.cs @@ -69,7 +69,7 @@ public async Task Render_HasTrace_SubscriptionRemovedOnDispose() // Act var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.TraceId, traceId); builder.AddCascadingValue(viewport); @@ -80,7 +80,7 @@ public async Task Render_HasTrace_SubscriptionRemovedOnDispose() Assert.Single(cut.FindComponents>()); cut.WaitForAssertion(() => Assert.Equal(1, telemetryRepository.TraceSubscriptionCount)); - await cut.InvokeAsync(DisposeComponents); + await DisposeComponentsAsync(); Assert.Equal(0, telemetryRepository.TraceSubscriptionCount); } @@ -130,7 +130,7 @@ public async Task Render_LogQueryPending_PageTitleRendered() }); var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.TraceId, traceId); builder.AddCascadingValue(viewport); @@ -174,7 +174,7 @@ public async Task Render_FocusesAccessibleScrollContainerOnInitialRender() }); var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.TraceId, traceId); builder.AddCascadingValue(viewport); @@ -244,7 +244,7 @@ public async Task Render_ChangeTrace_RowsRendered() // Act var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.TraceId, traceId); builder.AddCascadingValue(viewport); @@ -260,7 +260,7 @@ await AsyncTestHelpers.AssertIsTrueRetryAsync(() => }, "Expected rows to be rendered.", logger); traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("2")); - cut.SetParametersAndRender(builder => + cut.Render(builder => { builder.Add(p => p.TraceId, traceId); }); @@ -312,7 +312,7 @@ public async Task Render_TraceUpdateWithNewSpans_RowsRendered() // Act var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.TraceId, traceId); builder.AddCascadingValue(viewport); @@ -394,7 +394,7 @@ public async Task Render_UpdateDifferentTrace_TraceNotUpdated() // Act var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.TraceId, traceId); builder.AddCascadingValue(viewport); @@ -488,7 +488,7 @@ await telemetryRepository.AddTracesAsync(new AddContext(), // Act var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.TraceId, traceId); builder.AddCascadingValue(viewport); @@ -584,7 +584,7 @@ await telemetryRepository.AddTracesAsync(new AddContext(), }); var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.TraceId, traceId); builder.AddCascadingValue(viewport); @@ -711,7 +711,7 @@ await telemetryRepository.AddTracesAsync(new AddContext(), }); var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("2")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.TraceId, traceId); builder.AddCascadingValue(viewport); @@ -786,7 +786,7 @@ await telemetryRepository.AddTracesAsync(new AddContext(), }); var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.TraceId, traceId); builder.AddCascadingValue(viewport); @@ -869,7 +869,7 @@ await telemetryRepository.AddTracesAsync(new AddContext(), }); var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.TraceId, traceId); builder.AddCascadingValue(viewport); @@ -944,7 +944,7 @@ await telemetryRepository.AddTracesAsync(new AddContext(), }); var traceId = Convert.ToHexString(Encoding.UTF8.GetBytes("1")); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.Add(p => p.TraceId, traceId); builder.AddCascadingValue(viewport); diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs index 1891d2abc18..61e17850806 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/TracesTests.cs @@ -38,7 +38,7 @@ public void Render_FocusesAccessibleScrollContainerOnInitialRender() var dimensionManager = Services.GetRequiredService(); dimensionManager.InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); @@ -97,7 +97,7 @@ public async Task ItemsProvider_ZeroCountRefresh_RendersUpdatedTrace() }); var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); Services.GetRequiredService().InvokeOnViewportInformationChanged(viewport); - var cut = RenderComponent(builder => builder.AddCascadingValue(viewport)); + var cut = Render(builder => builder.AddCascadingValue(viewport)); var grid = cut.FindComponent>(); await grid.InvokeAsync(grid.Instance.RefreshDataAndRenderAsync); diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/DashboardPageTestContext.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/DashboardPageTestContext.cs index 56041f40a7d..f5b0c73bb6c 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/DashboardPageTestContext.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/DashboardPageTestContext.cs @@ -8,7 +8,7 @@ using System.Reflection; namespace Aspire.Dashboard.Components.Tests.Shared; -public abstract class DashboardTestContext : TestContext +public abstract class DashboardTestContext : BunitContext { private const string VirtualizeJsFunctionsPrefix = "Blazor._internal.Virtualize."; diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index 6fc9185aea5..22a6d676a11 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -31,7 +31,7 @@ private static string GetFluentFile(string filePath) return $"{filePath}?v={s_fluentUIVersion}"; } - public static void SetupFluentDialogProvider(TestContext context) + public static void SetupFluentDialogProvider(BunitContext context) { var dialogProviderModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Dialog/FluentDialogProvider.razor.js")); dialogProviderModule.SetupModule("getActiveElement", _ => true); @@ -40,7 +40,7 @@ public static void SetupFluentDialogProvider(TestContext context) context.JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Components.Dialog.FocusOnPreviousActiveElement", _ => true).SetVoidResult(); } - public static void SetupFluentMenu(TestContext context) + public static void SetupFluentMenu(BunitContext context) { context.JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Components.Menu.Initialize", _ => true).SetVoidResult(); context.JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Components.Menu.OpenMenu", _ => true).SetVoidResult(); @@ -48,19 +48,19 @@ public static void SetupFluentMenu(TestContext context) context.JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Components.Menu.Dispose", _ => true).SetVoidResult(); } - public static void SetupFluentOverflow(TestContext context) + public static void SetupFluentOverflow(BunitContext context) { var overflowModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Overflow/FluentOverflow.razor.js")); overflowModule.SetupVoid("fluentOverflowInitialize", _ => true); overflowModule.SetupVoid("fluentOverflowDispose", _ => true); } - public static void SetupFluentAnchor(TestContext context) + public static void SetupFluentAnchor(BunitContext context) { context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Anchor/FluentAnchor.razor.js")); } - public static void SetupFluentAnchoredRegion(TestContext context) + public static void SetupFluentAnchoredRegion(BunitContext context) { var module = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/AnchoredRegion/FluentAnchoredRegion.razor.js")); module.SetupVoid("goToNextFocusableElement", _ => true); @@ -68,13 +68,13 @@ public static void SetupFluentAnchoredRegion(TestContext context) module.SetupVoid("removeKeyboardNavigation", _ => true); } - public static void SetupFluentDivider(TestContext context) + public static void SetupFluentDivider(BunitContext context) { var dividerModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Divider/FluentDivider.razor.js")); dividerModule.SetupVoid("setDividerAriaOrientation"); } - public static void SetupFluentDataGrid(TestContext context) + public static void SetupFluentDataGrid(BunitContext context) { var dataGridModule = context.JSInterop.SetupModule("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/DataGrid/FluentDataGrid.razor.js"); dataGridModule.SetupVoid("Microsoft.FluentUI.Blazor.DataGrid.EnableColumnResizing", _ => true); @@ -83,41 +83,41 @@ public static void SetupFluentDataGrid(TestContext context) gridReference.SetupVoid("stop", _ => true); } - public static void SetupFluentSearch(TestContext context) + public static void SetupFluentSearch(BunitContext context) { var searchModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Search/FluentSearch.razor.js")); searchModule.SetupVoid("addAriaHidden", _ => true); } - public static void SetupFluentKeyCode(TestContext context) + public static void SetupFluentKeyCode(BunitContext context) { context.JSInterop.Setup("Microsoft.FluentUI.Blazor.Components.KeyCode.RegisterKeyCode", _ => true); context.JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Components.KeyCode.UnregisterKeyCode", _ => true).SetVoidResult(); } - public static void SetupFluentToolbar(TestContext context) + public static void SetupFluentToolbar(BunitContext context) { var toolbarModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Toolbar/FluentToolbar.razor.js")); toolbarModule.SetupVoid("removePreventArrowKeyNavigation", _ => true); } - public static void SetupFluentInputLabel(TestContext context) + public static void SetupFluentInputLabel(BunitContext context) { var inputLabelModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Label/FluentInputLabel.razor.js")); inputLabelModule.SetupVoid("setInputAriaLabel", _ => true); } - public static void SetupFluentList(TestContext context) + public static void SetupFluentList(BunitContext context) { context.JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Components.Select.Initialize", _ => true); } - public static void SetupFluentTab(TestContext context) + public static void SetupFluentTab(BunitContext context) { context.JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Components.Tabs.ObserveTabsChanged", _ => true); } - public static void SetupFluentCheckbox(TestContext context) + public static void SetupFluentCheckbox(BunitContext context) { context.JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Utilities.Attributes.observeAttributeChange", _ => true); var checkboxModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Checkbox/FluentCheckbox.razor.js")); @@ -125,19 +125,19 @@ public static void SetupFluentCheckbox(TestContext context) checkboxModule.SetupVoid("stop", _ => true); } - public static void SetupFluentTextField(TestContext context) + public static void SetupFluentTextField(BunitContext context) { context.JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Utilities.Attributes.observeAttributeChange", _ => true); context.JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Utilities.Attributes.applyShadowStyle", _ => true); } - public static void SetupFluentButton(TestContext context) + public static void SetupFluentButton(BunitContext context) { var buttonModule = context.JSInterop.SetupModule(GetFluentFile("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Button/FluentButton.razor.js")); buttonModule.SetupVoid("updateProxy", _ => true); } - public static void SetupFluentInputFile(TestContext context) + public static void SetupFluentInputFile(BunitContext context) { var inputFileModule = context.JSInterop.SetupModule("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/InputFile/FluentInputFile.razor.js"); inputFileModule.SetupVoid("Microsoft.FluentUI.Blazor.InputFile.AttachClickHandler", _ => true); @@ -145,14 +145,14 @@ public static void SetupFluentInputFile(TestContext context) dropZoneReference.SetupVoid("dispose", _ => true); } - public static void SetupFluentCombobox(TestContext context) + public static void SetupFluentCombobox(BunitContext context) { context.JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Utilities.Attributes.copyToShadow", _ => true); context.JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Components.Select.UpdateValue", _ => true); } public static async Task ConfigureTelemetryRepository( - TestContext context, + BunitContext context, bool readOnly, Func seed) { @@ -176,7 +176,7 @@ public static async Task ConfigureTelemetryRepository( } public static void AddCommonDashboardServices( - TestContext context, + BunitContext context, ILocalStorage? localStorage = null, ISessionStorage? sessionStorage = null, ThemeManager? themeManager = null, @@ -335,7 +335,7 @@ private sealed class TestRepositoryFactory( public IResourceRepository CreateResourceRepository(DashboardSqliteDatabase database) => dashboardClient; } - public static void SetupFluentUIComponents(TestContext context, bool setupAspireMenuButtonModule = true) + public static void SetupFluentUIComponents(BunitContext context, bool setupAspireMenuButtonModule = true) { context.Services.AddFluentUIComponents(); context.Services.AddScoped(); @@ -351,7 +351,7 @@ public static void SetupFluentUIComponents(TestContext context, bool setupAspire /// Registers the JS module imports when its menu is first opened. /// Tests that click a menu button need this even when they configure FluentUI piecemeal. ///
- public static void SetupAspireMenuButtonModule(TestContext context) + public static void SetupAspireMenuButtonModule(BunitContext context) { var menuButtonModule = context.JSInterop.SetupModule("./Components/Controls/AspireMenuButton.razor.js"); menuButtonModule.SetupVoid("prepareForFluentMenuInitialization", _ => true).SetVoidResult(); @@ -360,7 +360,7 @@ public static void SetupAspireMenuButtonModule(TestContext context) } public static void SetupDialogInfrastructure( - TestContext context, + BunitContext context, ThemeManager? themeManager = null, ILocalStorage? localStorage = null) { @@ -369,7 +369,7 @@ public static void SetupDialogInfrastructure( SetupFluentDialogProvider(context); } - public static IRenderedFragment RenderDialogProvider(TestContext context) + public static IRenderedComponent RenderDialogProvider(BunitContext context) { return context.Render(builder => { @@ -378,7 +378,7 @@ public static IRenderedFragment RenderDialogProvider(TestContext context) }); } - public static IRenderedFragment RenderMessageBarProviderWithPage(TestContext context, ViewportInformation viewport) + public static IRenderedComponent RenderMessageBarProviderWithPage(BunitContext context, ViewportInformation viewport) where TPage : IComponent { // Keep the provider and page in the same render tree, matching MainLayout so message bars diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/InteractionsSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/InteractionsSetupHelpers.cs index ac0a5ea9644..42148bc69f2 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/InteractionsSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/InteractionsSetupHelpers.cs @@ -17,18 +17,18 @@ namespace Aspire.Dashboard.Components.Tests.Shared; internal static class InteractionsSetupHelpers { - public static Func SetupDialog( - TestContext context, Expression> contentParameter, out DashboardDialogService dialogService) + public static Func> SetupDialog( + BunitContext context, Expression> contentParameter, out DashboardDialogService dialogService) where TDialog : ComponentBase { FluentUISetupHelpers.SetupDialogInfrastructure(context); FluentUISetupHelpers.SetupFluentButton(context); - IRenderedFragment? cut = null; + IRenderedComponent? cut = null; TestDialogService? testDialogService = null; testDialogService = new TestDialogService((content, _) => { - cut = context.RenderComponent>(builder => + cut = context.Render>(builder => { builder.Add(p => p.Value, testDialogService!.LastInstance!); builder.AddChildContent(childBuilder => diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/LayoutSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/LayoutSetupHelpers.cs index 62c5b411dcc..c89732d57e4 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/LayoutSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/LayoutSetupHelpers.cs @@ -7,7 +7,7 @@ namespace Aspire.Dashboard.Components.Tests.Shared; internal static class LayoutSetupHelpers { - public static void SetupMobileNavMenuKeyboardNavigation(TestContext context) + public static void SetupMobileNavMenuKeyboardNavigation(BunitContext context) { context.JSInterop.SetupModule(invocation => invocation.Identifier == "initializeMobileNavMenuKeyboardNavigation"); context.JSInterop.SetupVoid("disposeMobileNavMenuKeyboardNavigation", _ => true); diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/MetricsSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/MetricsSetupHelpers.cs index ad93071204d..6b6cc66c186 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/MetricsSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/MetricsSetupHelpers.cs @@ -18,7 +18,7 @@ namespace Aspire.Dashboard.Components.Tests.Shared; internal static class MetricsSetupHelpers { - public static void SetupChartContainer(TestContext context) + public static void SetupChartContainer(BunitContext context) { var metricTableModule = context.JSInterop.SetupModule("/Components/Controls/Chart/MetricTable.razor.js"); metricTableModule.SetupVoid("announceDataGridRows", _ => true); @@ -29,7 +29,7 @@ public static void SetupChartContainer(TestContext context) SetupPlotlyChart(context); } - internal static void SetupPlotlyChart(TestContext context) + internal static void SetupPlotlyChart(BunitContext context) { var module = context.JSInterop.SetupModule("/js/app-metrics.js"); module.SetupVoid("initializeChart", _ => true); @@ -38,7 +38,7 @@ internal static void SetupPlotlyChart(TestContext context) context.Services.AddSingleton(); } - internal static void SetupMetricsPage(TestContext context, ISessionStorage? sessionStorage = null) + internal static void SetupMetricsPage(BunitContext context, ISessionStorage? sessionStorage = null) { FluentUISetupHelpers.SetupFluentDivider(context); FluentUISetupHelpers.SetupFluentInputLabel(context); diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs index 2ddbb3548ae..f007bb685e8 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/ResourceSetupHelpers.cs @@ -22,7 +22,7 @@ namespace Aspire.Dashboard.Components.Tests.Shared; internal static class ResourceSetupHelpers { - public static void SetupResourceDetails(TestContext context) + public static void SetupResourceDetails(BunitContext context) { FluentUISetupHelpers.AddCommonDashboardServices(context); context.Services.AddSingleton(); @@ -43,7 +43,7 @@ public static void SetupResourceDetails(TestContext context) FluentUISetupHelpers.SetupFluentUIComponents(context); } - public static void SetupResourcesPage(TestContext context, ViewportInformation viewport, IDashboardClient? dashboardClient = null, ILocalStorage? localStorage = null) + public static void SetupResourcesPage(BunitContext context, ViewportInformation viewport, IDashboardClient? dashboardClient = null, ILocalStorage? localStorage = null) { FluentUISetupHelpers.SetupFluentDivider(context); FluentUISetupHelpers.SetupFluentInputLabel(context); diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/StructuredLogsSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/StructuredLogsSetupHelpers.cs index a5e2f35d358..06f4d091d7d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/StructuredLogsSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/StructuredLogsSetupHelpers.cs @@ -15,7 +15,7 @@ namespace Aspire.Dashboard.Components.Tests.Shared; internal static class StructuredLogsSetupHelpers { - public static void SetupStructuredLogsDetails(TestContext context) + public static void SetupStructuredLogsDetails(BunitContext context) { FluentUISetupHelpers.AddCommonDashboardServices(context); context.Services.AddSingleton(); diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs index 89325c72156..53633bbe5d5 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TerminalSetupHelpers.cs @@ -11,6 +11,7 @@ using Aspire.Tests.Shared.DashboardModel; using Bunit; using Google.Protobuf.WellKnownTypes; +using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.JSInterop; @@ -48,7 +49,7 @@ public static ResourceViewModel CreateTerminalResource( return ModelTestHelpers.CreateResource(resourceName, state, displayName, properties: properties, hidden: hidden); } - public static void SetupTerminalComponents(TestContext context, TestDashboardClient client, string pathBase = "") + public static void SetupTerminalComponents(BunitContext context, TestDashboardClient client, string pathBase = "") { FluentUISetupHelpers.AddCommonDashboardServices(context); FluentUISetupHelpers.SetupFluentUIComponents(context); @@ -59,14 +60,14 @@ public static void SetupTerminalComponents(TestContext context, TestDashboardCli SetupTerminalDock(context, pathBase); } - public static void SetupTerminalView(TestContext context, string pathBase = "") + public static void SetupTerminalView(BunitContext context, string pathBase = "") { var module = SetupTerminalViewModule(context, $"{pathBase}/Components/Controls/TerminalView.razor.js"); module.Setup("initTerminal", _ => true).SetResult(1); module.SetupVoid("setReadOnly", _ => true).SetVoidResult(); } - public static BunitJSModuleInterop SetupTerminalViewModule(TestContext context, string modulePath) + public static BunitJSModuleInterop SetupTerminalViewModule(BunitContext context, string modulePath) { context.Services.TryAddSingleton(); FluentUISetupHelpers.SetupFluentList(context); @@ -82,7 +83,7 @@ public static BunitJSModuleInterop SetupTerminalViewModule(TestContext context, return module; } - public static void SetupTerminalDock(TestContext context, string pathBase = "") + public static void SetupTerminalDock(BunitContext context, string pathBase = "") { var dock = context.JSInterop.SetupModule("./Components/Layout/TerminalDock.razor.js"); dock.SetupVoid("registerResizeHandle", _ => true).SetVoidResult(); @@ -93,7 +94,7 @@ public static void SetupTerminalDock(TestContext context, string pathBase = "") SetupTerminalWindows(context, pathBase); } - public static BunitJSModuleInterop SetupTerminalWindows(TestContext context, string pathBase = "") + public static BunitJSModuleInterop SetupTerminalWindows(BunitContext context, string pathBase = "") { var windows = context.JSInterop.SetupModule($"{pathBase}/js/app-terminalwindow.js"); windows.SetupVoid("registerTerminalWindowButton", _ => true).SetVoidResult(); @@ -107,7 +108,7 @@ public static BunitJSModuleInterop SetupTerminalWindows(TestContext context, str return windows; } - public static TerminalWindowLauncher GetWindowLauncher(TestContext context, IRenderedFragment component) + public static TerminalWindowLauncher GetWindowLauncher(BunitContext context, IRenderedComponent component) { // A terminal-watch update can render a child before that child's OnAfterRenderAsync registers its listener. component.WaitForAssertion(() => Assert.Single(context.JSInterop.Invocations, i => i.Identifier == "registerTerminalWindowButton")); @@ -115,7 +116,7 @@ public static TerminalWindowLauncher GetWindowLauncher(TestContext context, IRen return Assert.IsType>(registration.Arguments[2]).Value; } - public static void AssertSingleTerminalConnection(TestContext context, string expectedWebSocketUrl) + public static void AssertSingleTerminalConnection(BunitContext context, string expectedWebSocketUrl) { var invocation = Assert.Single(context.JSInterop.Invocations, invocation => invocation.Identifier == "initTerminal"); var options = Assert.IsType(invocation.Arguments[3]); diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/TestJSObjectReference.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/TestJSObjectReference.cs index 80c4c4c405f..e246bdf4ea8 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/TestJSObjectReference.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/TestJSObjectReference.cs @@ -15,7 +15,7 @@ internal sealed class TestJSObjectReference : IJSObjectReference public Func? BeforeInvokeAsync { get; init; } public int DisposeCount => Volatile.Read(ref _disposeCount); - public static JSRuntimeInvocationHandler SetupImport(TestContext context, string modulePath) + public static JSRuntimeInvocationHandler SetupImport(BunitContext context, string modulePath) { // A custom handler lets tests delay import and count disposal of the returned reference. var handler = new ImportHandler(modulePath); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/AccessibilityTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/AccessibilityTests.cs index 9997c6aeedc..33147998b5e 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/AccessibilityTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/AccessibilityTests.cs @@ -108,17 +108,17 @@ public Task DashboardDialog_HasNoSeriousOrCriticalWcagViolations(string surface, [Theory] [OuterloopTest("Resource-intensive Playwright browser test")] - [InlineData("Light", "fluent-text-input", "control")] - [InlineData("Light", "fluent-number-input", "control")] - [InlineData("Light", "fluent-text-area", "control")] - [InlineData("Dark", "fluent-text-input", "control")] - [InlineData("Dark", "fluent-number-input", "control")] - [InlineData("Dark", "fluent-text-area", "control")] - public async Task FluentDelegatedInput_ShowsVisibleFocusIndicator(string theme, string controlName, string partName) + [InlineData("Light", "fluent-text-input", "text", "root")] + [InlineData("Light", "fluent-text-input", "number", "root")] + [InlineData("Light", "fluent-textarea", null, "root")] + [InlineData("Dark", "fluent-text-input", "text", "root")] + [InlineData("Dark", "fluent-text-input", "number", "root")] + [InlineData("Dark", "fluent-textarea", null, "root")] + public async Task FluentDelegatedInput_ShowsVisibleFocusIndicator(string theme, string controlName, string? inputType, string partName) { var baseUrl = DashboardServerFixture.DashboardApp.FrontendSingleEndPointAccessor().GetResolvedAddress(); - await using var context = await PlaywrightFixture.Browser.NewContextAsync(new BrowserNewContextOptions + await using var context = await PlaywrightFixture.CreateContextAsync(new BrowserNewContextOptions { IgnoreHTTPSErrors = true, BaseURL = baseUrl, @@ -137,16 +137,18 @@ await page.WaitForSelectorAsync( await page.EvaluateAsync( """ - async controlName => { - await customElements.whenDefined(controlName); - const control = document.createElement(controlName); + async options => { + await customElements.whenDefined(options.controlName); + const control = document.createElement(options.controlName); control.id = 'delegated-focus-probe'; control.style.cssText = 'position:fixed;left:-99999px;top:0;'; + if (options.inputType) { + control.setAttribute('type', options.inputType); + } document.body.appendChild(control); - await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); } """, - controlName).DefaultTimeout(); + new { controlName, inputType }).DefaultTimeout(); var control = page.Locator("#delegated-focus-probe"); await AssertVisibleFocusIndicatorAsync(control, partName); @@ -158,7 +160,7 @@ public async Task DarkAccentButtonInteractionStates_MeetWcagAaContrast() { var baseUrl = DashboardServerFixture.DashboardApp.FrontendSingleEndPointAccessor().GetResolvedAddress(); - await using var context = await PlaywrightFixture.Browser.NewContextAsync(new BrowserNewContextOptions + await using var context = await PlaywrightFixture.CreateContextAsync(new BrowserNewContextOptions { IgnoreHTTPSErrors = true, BaseURL = baseUrl, @@ -230,12 +232,10 @@ private static async Task OpenSettingsFlyoutAsync(IPage page) // The settings button lives in the top header on every page (MainLayout.SettingsButtonId). await page.Locator("#dashboard-settings-button").ClickAsync(); - // The settings panel is a right-aligned fluent-dialog with a fixed id (MainLayout.SettingsDialogId). - // Wait on a light-DOM descendant rather than the host: Fluent projects the dialog - // body through a slot, so the custom-element host carries no layout box and never satisfies - // Playwright's visibility check even once the panel is fully open. .input-container wraps each - // settings group in SettingsDialog.razor and is a reliable "content has rendered" signal. - await Assertions.Expect(page.Locator("fluent-dialog#SettingsDialog .input-container").First).ToBeVisibleAsync(); + // Fluent v5 projects the dialog body outside the custom-element host's descendant tree. + // The role is carried by the native in shadow DOM and becomes visible only after + // the panel has opened, so it is the stable readiness signal across Fluent DOM revisions. + await Assertions.Expect(page.GetByRole(AriaRole.Dialog)).ToBeVisibleAsync(); } private static async Task OpenAddFilterDialogAsync(IPage page) @@ -245,9 +245,7 @@ private static async Task OpenAddFilterDialogAsync(IPage page) // regardless of test culture rather than hard-coding the English text. await page.Locator($"fluent-button[aria-label='{StructuredFiltering.AddFilter}']").ClickAsync(); - // FilterDialog opens as a right-aligned panel with no id, so wait for its distinctive - // .filter-button-container (see FilterDialog.razor) to confirm the dialog body has rendered. - await Assertions.Expect(page.Locator("fluent-dialog .filter-button-container")).ToBeVisibleAsync(); + await Assertions.Expect(page.GetByRole(AriaRole.Dialog)).ToBeVisibleAsync(); } /// @@ -265,7 +263,7 @@ public async Task CodeblockSyntaxColors_MeetWcagAaContrast(string theme) { var baseUrl = DashboardServerFixture.DashboardApp.FrontendSingleEndPointAccessor().GetResolvedAddress(); - await using var context = await PlaywrightFixture.Browser.NewContextAsync(new BrowserNewContextOptions + await using var context = await PlaywrightFixture.CreateContextAsync(new BrowserNewContextOptions { IgnoreHTTPSErrors = true, BaseURL = baseUrl, @@ -316,7 +314,7 @@ private async Task AssertNoBlockingWcagViolationsAsync(string relativeUrl, strin { var baseUrl = DashboardServerFixture.DashboardApp.FrontendSingleEndPointAccessor().GetResolvedAddress(); - await using var context = await PlaywrightFixture.Browser.NewContextAsync(new BrowserNewContextOptions + await using var context = await PlaywrightFixture.CreateContextAsync(new BrowserNewContextOptions { IgnoreHTTPSErrors = true, BaseURL = baseUrl, @@ -346,7 +344,7 @@ await page.WaitForSelectorAsync( } else { - await Assertions.Expect(page.Locator("main")).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator("main.custom-body-content")).ToBeVisibleAsync(); } await WaitForPageContentAsync(page, relativeUrl); @@ -511,7 +509,7 @@ private static async Task AssertVisibleFocusIndicatorAsync(ILocator host, string { var focusJson = await host.EvaluateAsync( """ - (element, partName) => { + async (element, partName) => { const focusTarget = element.shadowRoot?.querySelector( 'input, textarea, [role="combobox"], [tabindex]:not([tabindex="-1"])'); if (!focusTarget) { @@ -529,13 +527,19 @@ private static async Task AssertVisibleFocusIndicatorAsync(ILocator host, string `No ${partName} part found for ${element.localName}. Available parts: ${availableParts}.`); } - const style = getComputedStyle(part); + // Chromium suspends requestAnimationFrame for background pages when theory rows run + // concurrently. Timers still advance, so wait past Fluent's underline transition. + await new Promise(resolve => setTimeout(resolve, 500)); + + const style = getComputedStyle(part, '::after'); + const transform = style.transform; + const indicatorScale = transform === 'none' ? 1 : new DOMMatrixReadOnly(transform).a; return JSON.stringify({ focusWithin: element.matches(':focus-within'), focusVisible: element.matches(':focus-visible'), - outlineStyle: style.outlineStyle, - outlineWidth: Number.parseFloat(style.outlineWidth), - outlineColor: style.outlineColor + borderWidth: Number.parseFloat(style.borderBottomWidth), + borderColor: style.borderBottomColor, + indicatorScale: indicatorScale }); } """, @@ -543,16 +547,16 @@ private static async Task AssertVisibleFocusIndicatorAsync(ILocator host, string using var focus = JsonDocument.Parse(focusJson); var root = focus.RootElement; - var outlineStyle = root.GetProperty("outlineStyle").GetString(); - var outlineWidth = root.GetProperty("outlineWidth").GetDouble(); - var outlineColor = root.GetProperty("outlineColor").GetString(); - var hasOpaqueOutline = !string.Equals(outlineColor, "transparent", StringComparison.OrdinalIgnoreCase) - && !string.Equals(outlineColor, "rgba(0, 0, 0, 0)", StringComparison.OrdinalIgnoreCase); + var borderWidth = root.GetProperty("borderWidth").GetDouble(); + var borderColor = root.GetProperty("borderColor").GetString(); + var indicatorScale = root.GetProperty("indicatorScale").GetDouble(); + var hasOpaqueBorder = !string.Equals(borderColor, "transparent", StringComparison.OrdinalIgnoreCase) + && !string.Equals(borderColor, "rgba(0, 0, 0, 0)", StringComparison.OrdinalIgnoreCase); Assert.True(root.GetProperty("focusWithin").GetBoolean(), $"Expected {await host.EvaluateAsync("element => element.localName")} to contain delegated focus."); Assert.True( - !string.Equals(outlineStyle, "none", StringComparison.Ordinal) && outlineWidth >= 2 && hasOpaqueOutline, - $"Expected a visible focus outline on the {partName} part, but got style '{outlineStyle}', width {outlineWidth}px, color {outlineColor}, host :focus-visible={root.GetProperty("focusVisible").GetBoolean()}."); + borderWidth >= 2 && hasOpaqueBorder && indicatorScale >= 0.99, + $"Expected a visible focus indicator on the {partName} part, but got border width {borderWidth}px, color {borderColor}, scale {indicatorScale}, host :focus-visible={root.GetProperty("focusVisible").GetBoolean()}."); } private static async Task<((int R, int G, int B) Foreground, (int R, int G, int B) Background)> ReadFluentControlColorsAsync(ILocator host) @@ -575,12 +579,9 @@ function parseRgb(s) { throw new Error('unparseable color: ' + s); } - const control = element.shadowRoot?.querySelector('[part~="control"]'); - if (!control) { - throw new Error(`No control part found for ${element.localName}.`); - } - - const style = getComputedStyle(control); + // Fluent v5 applies the button palette directly to the custom-element host. Its + // previous shadow control part no longer exists, while slotted text inherits these colors. + const style = getComputedStyle(element); const foreground = parseRgb(style.color); const background = parseRgb(style.backgroundColor); return JSON.stringify({ @@ -696,7 +697,7 @@ async function readSurface(name, container, getBackgroundElement, line) { await readSurface( 'text visualizer', dialog, - () => dialog.shadowRoot?.querySelector('[part~="control"]'), + () => dialog.shadowRoot?.querySelector('[part~="dialog"]'), visualizerLine), await readSurface('rendered markdown', markdown, () => codeBlock, markdownCode) ] diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/AppBarTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/AppBarTests.cs index 9530bb54bb9..9aaf6773a3f 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/AppBarTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/AppBarTests.cs @@ -90,8 +90,8 @@ await RunTestAsync(async page => header => { const bounds = header.getBoundingClientRect(); const rect = selector => header.querySelector(selector).getBoundingClientRect(); - const brand = rect('.brand-logo'); - const application = rect('a.logo:not(.brand-logo)'); + const brand = rect('.brand-logo-container > a.logo'); + const application = rect('.application-name'); const title = rect('.page-title-slot'); const actions = [...header.querySelectorAll('.header-button')] .map(element => element.getBoundingClientRect()); @@ -125,7 +125,7 @@ await RunTestAsync(async page => Assert.True(root.GetProperty("brandRight").GetDouble() <= root.GetProperty("applicationLeft").GetDouble()); Assert.True(root.GetProperty("applicationRight").GetDouble() <= root.GetProperty("titleLeft").GetDouble()); Assert.True(root.GetProperty("titleRight").GetDouble() <= root.GetProperty("firstActionLeft").GetDouble()); - Assert.InRange(root.GetProperty("widestAction").GetDouble(), 38, 42); + Assert.InRange(root.GetProperty("widestAction").GetDouble(), 31, 33); Assert.InRange( root.GetProperty("headerRight").GetDouble() - root.GetProperty("lastActionRight").GetDouble(), 0, diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/AspireMenuLayoutTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/AspireMenuLayoutTests.cs index dcae018537f..304b599ba18 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/AspireMenuLayoutTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/AspireMenuLayoutTests.cs @@ -48,7 +48,7 @@ await RunTestAsync(async page => await page.SetViewportSizeAsync(1024, 400); await page.GotoAsync("/structuredlogs"); - var trigger = page.Locator(".grid-action-container > fluent-button[aria-label='Actions']").First; + var trigger = page.Locator(".grid-action-container fluent-button[title='Actions']").First; await Assertions.Expect(trigger).ToBeVisibleAsync(); var cell = trigger.Locator("xpath=ancestor::td"); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/BrowserTokenAuthenticationTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/BrowserTokenAuthenticationTests.cs index d6945e7e297..69d71fb56be 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/BrowserTokenAuthenticationTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/BrowserTokenAuthenticationTests.cs @@ -51,26 +51,26 @@ public async Task BrowserToken_LoginPage_Success_RedirectToResources() await RunTestAsync(async page => { // Act - var response = await page.GotoAsync("/").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + var response = await page.GotoAsync("/").DefaultTimeout(); var uri = new Uri(response!.Url); Assert.Equal("/login?returnUrl=%2F", uri.PathAndQuery); var tokenTextBox = page.GetByRole(AriaRole.Textbox); - await tokenTextBox.FillAsync("VALID_TOKEN").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + await tokenTextBox.FillAsync("VALID_TOKEN").DefaultTimeout(); - var submitButton = page.GetByRole(AriaRole.Button); - await submitButton.ClickAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + var submitButton = SubmitButton(page); + await submitButton.ClickAsync().DefaultTimeout(); // Wait for navigation to complete after successful login. // The page redirects from /login to / (resources page). - await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/").DefaultTimeout(); // Assert await Assertions .Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName)) .ToBeVisibleAsync() - .DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + .DefaultTimeout(); }); } @@ -82,24 +82,24 @@ public async Task BrowserToken_LoginPage_SuccessWithWhitespaceToken_RedirectToRe await RunTestAsync(async page => { // Act - var response = await page.GotoAsync("/").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + var response = await page.GotoAsync("/").DefaultTimeout(); var uri = new Uri(response!.Url); Assert.Equal("/login?returnUrl=%2F", uri.PathAndQuery); var tokenTextBox = page.GetByRole(AriaRole.Textbox); - await tokenTextBox.FillAsync(" VALID_TOKEN ").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + await tokenTextBox.FillAsync(" VALID_TOKEN ").DefaultTimeout(); - var submitButton = page.GetByRole(AriaRole.Button); - await submitButton.ClickAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + var submitButton = SubmitButton(page); + await submitButton.ClickAsync().DefaultTimeout(); - await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/").DefaultTimeout(); // Assert await Assertions .Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName)) .ToBeVisibleAsync() - .DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + .DefaultTimeout(); }); } @@ -111,22 +111,22 @@ public async Task BrowserToken_LoginPage_Failure_DisplayFailureMessage() await RunTestAsync(async page => { // Act - var response = await page.GotoAsync("/").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + var response = await page.GotoAsync("/").DefaultTimeout(); var uri = new Uri(response!.Url); Assert.Equal("/login?returnUrl=%2F", uri.PathAndQuery); var tokenTextBox = page.GetByRole(AriaRole.Textbox); - await tokenTextBox.FillAsync("INVALID_TOKEN").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + await tokenTextBox.FillAsync("INVALID_TOKEN").DefaultTimeout(); - var submitButton = page.GetByRole(AriaRole.Button); - await submitButton.ClickAsync().DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + var submitButton = SubmitButton(page); + await submitButton.ClickAsync().DefaultTimeout(); // Assert await Assertions .Expect(page.GetByText(Login.InvalidTokenErrorMessage)) .ToBeVisibleAsync() - .DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + .DefaultTimeout(); }); } @@ -138,13 +138,13 @@ public async Task BrowserToken_QueryStringToken_Success_RestrictToResources() await RunTestAsync(async page => { // Act - await page.GotoAsync("/login?t=VALID_TOKEN").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + await page.GotoAsync("/login?t=VALID_TOKEN").DefaultTimeout(); // Assert await Assertions .Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName)) .ToBeVisibleAsync() - .DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + .DefaultTimeout(); }); } @@ -156,15 +156,17 @@ public async Task BrowserToken_QueryStringToken_Failure_DisplayLoginPage() await RunTestAsync(async page => { // Act - await page.GotoAsync("/login?t=INVALID_TOKEN").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + await page.GotoAsync("/login?t=INVALID_TOKEN").DefaultTimeout(); - var submitButton = page.GetByRole(AriaRole.Button); - var name = await submitButton.GetAttributeAsync("name").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + var submitButton = SubmitButton(page); + var name = await submitButton.GetAttributeAsync("name").DefaultTimeout(); // Assert Assert.Equal("submit-token", name); }); } + + private static ILocator SubmitButton(IPage page) => page.Locator("fluent-button[name='submit-token']"); } [RequiresFeature(TestFeature.Playwright)] @@ -266,30 +268,31 @@ private async Task RunHttpsThenHttpAsync(IBrowser browser) { IgnoreHTTPSErrors = true }); + PlaywrightFixture.ConfigureTimeouts(context); try { var page = await context.NewPageAsync(); try { - await page.GotoAsync($"{httpsBaseUrl}/login?t=VALID_TOKEN").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + await page.GotoAsync($"{httpsBaseUrl}/login?t=VALID_TOKEN").DefaultTimeout(); await Assertions .Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName)) .ToBeVisibleAsync() - .DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + .DefaultTimeout(); - await page.GotoAsync($"{httpBaseUrl}/login?t=VALID_TOKEN").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + await page.GotoAsync($"{httpBaseUrl}/login?t=VALID_TOKEN").DefaultTimeout(); await Assertions .Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName)) .ToBeVisibleAsync() - .DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + .DefaultTimeout(); - await page.GotoAsync($"{httpBaseUrl}/structuredlogs").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + await page.GotoAsync($"{httpBaseUrl}/structuredlogs").DefaultTimeout(); Assert.Equal("/structuredlogs", new Uri(page.Url).AbsolutePath); await Assertions .Expect(page.GetByRole(AriaRole.Button, new() { Name = "submit-token" })) .Not .ToBeVisibleAsync() - .DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + .DefaultTimeout(); } finally { diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/DashboardInteractionsTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/DashboardInteractionsTests.cs index 07cc27aa421..05e96943082 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/DashboardInteractionsTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/DashboardInteractionsTests.cs @@ -458,7 +458,7 @@ await page.EvaluateAsync(""" await page.Locator("#scroll-owner").EvaluateAsync("owner => owner.scrollTop = 40"); await page.WaitForFunctionAsync(""" expectedBottom => Math.abs(document.querySelector('.scroll-buttons').getBoundingClientRect().bottom - expectedBottom) < 1 - """, originalBounds.Y + originalBounds.Height - 40).DefaultTimeout(); + """, (double)(originalBounds.Y + originalBounds.Height - 40)).DefaultTimeout(); Assert.Equal(originalBounds.Y, (await page.Locator(".scroll-buttons").BoundingBoxAsync())!.Y); Assert.Equal(originalRegionBounds.Y - 40, (await region.BoundingBoxAsync())!.Y); Assert.Equal(0, await region.EvaluateAsync("element => element.scrollTop")); @@ -473,7 +473,7 @@ await page.WaitForFunctionAsync(""" await Assertions.Expect(bottomButton).ToBeVisibleAsync(); await page.WaitForFunctionAsync(""" expectedBottom => Math.abs(document.querySelector('.scroll-buttons').getBoundingClientRect().bottom - expectedBottom) < 1 - """, originalBounds.Y + originalBounds.Height).DefaultTimeout(); + """, (double)(originalBounds.Y + originalBounds.Height)).DefaultTimeout(); Assert.Equal(originalRegionBounds.Y, (await region.BoundingBoxAsync())!.Y); Assert.Equal(0, await region.EvaluateAsync("element => element.scrollTop")); }); @@ -500,16 +500,20 @@ await page.EvaluateAsync(""" wrapper.style.cssText = 'width:400px;height:300px;overflow:visible;'; owner.appendChild(wrapper); wrapper.appendChild(region); + window.dispatchEvent(new Event('resize')); } """, overflow); var bottomButton = page.Locator(".scroll-to-bottom"); await Assertions.Expect(bottomButton).ToBeVisibleAsync(); var root = page.Locator(".scroll-buttons"); + await page.WaitForFunctionAsync(""" + () => document.querySelector('.scroll-buttons').style.left === '100px' + """).DefaultTimeout(); var bounds = (await root.BoundingBoxAsync())!; - Assert.Equal(110, bounds.X + bounds.Width / 2); + Assert.Equal(100, bounds.X + bounds.Width / 2); Assert.Equal(122, bounds.Y); - Assert.Equal(176, bounds.Height); + Assert.Equal(156, bounds.Height); // Keep the region in the viewport, but move it past the horizontal clip edge. await page.EvaluateAsync(""" diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs index f6492408ded..4e683b2a9a5 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs @@ -16,6 +16,10 @@ namespace Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; public class DashboardServerFixture : IAsyncLifetime { + // Keep tests sharing this fixture sequential through browser-context disposal so a previous + // Blazor circuit cannot continue using fixture services after the next test starts. + internal SemaphoreSlim TestGate { get; } = new(1, 1); + public Dictionary Configuration { get; } public DashboardWebApplication DashboardApp { get; private set; } = null!; @@ -132,7 +136,7 @@ private static Resource CreateResource(ResourceViewModel resource) public async ValueTask DisposeAsync() { - await DashboardApp.DisposeAsync(); await PlaywrightFixture.DisposeAsync(); + await DashboardApp.DisposeAsync(); } } diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/PlaywrightFixture.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/PlaywrightFixture.cs index 9a57b5eda1d..a4757e7d3b7 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/PlaywrightFixture.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/PlaywrightFixture.cs @@ -14,8 +14,7 @@ public class PlaywrightFixture : IAsyncLifetime public async ValueTask InitializeAsync() { - // Default timeout of 5000 ms could time out on slow CI servers. - Assertions.SetDefaultExpectTimeout(TestConstants.LongTimeoutDuration); + Assertions.SetDefaultExpectTimeout(TestConstants.DefaultTimeoutDuration); PlaywrightProvider.DetectAndSetInstalledPlaywrightDependenciesPath(); Browser = await PlaywrightProvider.CreateBrowserAsync(); @@ -26,6 +25,20 @@ public async ValueTask DisposeAsync() await Browser.CloseAsync(); } + public async Task CreateContextAsync(BrowserNewContextOptions options) + { + var context = await Browser.NewContextAsync(options); + ConfigureTimeouts(context); + return context; + } + + public static void ConfigureTimeouts(IBrowserContext context) + { + Assertions.SetDefaultExpectTimeout(TestConstants.DefaultTimeoutDuration); + context.SetDefaultTimeout(TestConstants.DefaultTimeoutDuration); + context.SetDefaultNavigationTimeout(TestConstants.DefaultTimeoutDuration); + } + public async Task GoToHomeAndWaitForDataGridLoad(IPage page) { await page.GotoAsync("/"); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/PlaywrightTestsBase.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/PlaywrightTestsBase.cs index 1412ed0ec4d..ecf50a81352 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/PlaywrightTestsBase.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/PlaywrightTestsBase.cs @@ -6,13 +6,14 @@ namespace Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; -public class PlaywrightTestsBase : IClassFixture, IAsyncDisposable +public class PlaywrightTestsBase : IClassFixture, IAsyncLifetime where TDashboardServerFixture : DashboardServerFixture { public DashboardServerFixture DashboardServerFixture { get; } public PlaywrightFixture PlaywrightFixture { get; } private IBrowserContext? _context; + private bool _ownsTestGate; public PlaywrightTestsBase(DashboardServerFixture dashboardServerFixture) { @@ -20,6 +21,12 @@ public PlaywrightTestsBase(DashboardServerFixture dashboardServerFixture) PlaywrightFixture = dashboardServerFixture.PlaywrightFixture; } + public async ValueTask InitializeAsync() + { + await DashboardServerFixture.TestGate.WaitAsync(); + _ownsTestGate = true; + } + public async Task RunTestAsync(Func test) { var page = await CreateNewPageAsync(); @@ -35,20 +42,34 @@ public async Task RunTestAsync(Func test) private async Task CreateNewPageAsync() { - _context ??= await PlaywrightFixture.Browser.NewContextAsync(new BrowserNewContextOptions + if (_context is null) { - IgnoreHTTPSErrors = true, - BaseURL = DashboardServerFixture.DashboardApp.FrontendSingleEndPointAccessor().GetResolvedAddress() - }); + _context = await PlaywrightFixture.CreateContextAsync(new BrowserNewContextOptions + { + IgnoreHTTPSErrors = true, + BaseURL = DashboardServerFixture.DashboardApp.FrontendSingleEndPointAccessor().GetResolvedAddress() + }); + } return await _context.NewPageAsync(); } public async ValueTask DisposeAsync() { - if (_context is not null) + try + { + if (_context is not null) + { + await _context.DisposeAsync(); + } + } + finally { - await _context.DisposeAsync(); + if (_ownsTestGate) + { + _ownsTestGate = false; + DashboardServerFixture.TestGate.Release(); + } } } } diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/MobileNavMenuTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/MobileNavMenuTests.cs index 817838d32f6..65fd912d9d4 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/MobileNavMenuTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/MobileNavMenuTests.cs @@ -23,7 +23,7 @@ public MobileNavMenuTests(DashboardServerFixture dashboardServerFixture) [OuterloopTest("Resource-intensive Playwright browser test")] public async Task MobileNavMenuClosesWhenFocusLeavesMenu() { - await using var context = await PlaywrightFixture.Browser.NewContextAsync(new BrowserNewContextOptions + await using var context = await PlaywrightFixture.CreateContextAsync(new BrowserNewContextOptions { IgnoreHTTPSErrors = true, BaseURL = DashboardServerFixture.DashboardApp.FrontendSingleEndPointAccessor().GetResolvedAddress(), @@ -60,7 +60,7 @@ await page.EvaluateAsync(""" [OuterloopTest("Resource-intensive Playwright browser test")] public async Task MobileNavFocusRemainsVisibleAtHighZoomViewport() { - await using var context = await PlaywrightFixture.Browser.NewContextAsync(new BrowserNewContextOptions + await using var context = await PlaywrightFixture.CreateContextAsync(new BrowserNewContextOptions { IgnoreHTTPSErrors = true, BaseURL = DashboardServerFixture.DashboardApp.FrontendSingleEndPointAccessor().GetResolvedAddress(), diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/NativeAotDashboardTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/NativeAotDashboardTests.cs index 1152a3063c8..5b669d41806 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/NativeAotDashboardTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/NativeAotDashboardTests.cs @@ -7,6 +7,7 @@ using System.Text; using Aspire.Dashboard.Otlp.Http; using Aspire.Dashboard.Resources; +using Aspire.Dashboard.Tests.Integration.Playwright.Infrastructure; using Aspire.Tests.Shared.Telemetry; using Aspire.TestUtilities; using Aspire.Templates.Tests; @@ -74,6 +75,7 @@ public async Task NativeDashboard_LoadsInteractivePageWithoutBrowserErrors() Height = 844 } }); + PlaywrightFixture.ConfigureTimeouts(context); var page = await context.NewPageAsync(); var browserErrors = new ConcurrentQueue(); page.Console += (_, message) => diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/ResourcesTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/ResourcesTests.cs index 81750553455..9924f42678c 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/ResourcesTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/ResourcesTests.cs @@ -28,12 +28,15 @@ await RunTestAsync(async page => { await PlaywrightFixture.GoToHomeAndWaitForDataGridLoad(page).DefaultTimeout(); - var viewOptionsButton = page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = Dashboard.Resources.Resources.ResourcesChangeViewOptions, Exact = true }); + var viewOptionsButton = page.Locator( + $"fluent-button[title='{Dashboard.Resources.Resources.ResourcesChangeViewOptions}']"); await Assertions.Expect(viewOptionsButton).ToHaveAttributeAsync("aria-expanded", "false"); await viewOptionsButton.ClickAsync(); await Assertions.Expect(viewOptionsButton).ToHaveAttributeAsync("aria-expanded", "true"); + await Assertions.Expect(page.GetByRole(AriaRole.Menuitem, new PageGetByRoleOptions { Name = Dashboard.Resources.Resources.ResourceCollapseAllChildren, Exact = true })).ToHaveCountAsync(0); + await Assertions.Expect(page.GetByRole(AriaRole.Menuitem, new PageGetByRoleOptions { Name = Dashboard.Resources.Resources.ResourceExpandAllChildren, Exact = true })).ToHaveCountAsync(0); var showResourceTypes = page.GetByRole(AriaRole.Menuitem, new PageGetByRoleOptions { Name = Dashboard.Resources.Resources.ResourcesShowTypes, Exact = true }); await showResourceTypes.ClickAsync(); await Assertions.Expect(viewOptionsButton).ToHaveAttributeAsync("aria-expanded", "false"); @@ -138,7 +141,7 @@ await RunTestAsync(async page => await page.SetViewportSizeAsync(360, 720); await PlaywrightFixture.GoToHomeAndWaitForDataGridLoad(page).DefaultTimeout(); - var tabs = page.Locator(".resources-tab-header[orientation='horizontal']"); + var tabs = page.Locator(".resources-tab-header > fluent-tablist[orientation='horizontal']"); await Assertions.Expect(tabs).ToBeVisibleAsync(); var tableTab = page.GetByRole(AriaRole.Tab, new PageGetByRoleOptions { Name = ControlsStrings.ResourcesContainerTableTab, Exact = true }); @@ -214,7 +217,7 @@ await Assertions.Expect(page.GetByRole( // Attempt a large drag from the cog. D3's drag behavior is attached to the ancestor // resource group, so this verifies the cog stops the initiating pointer event. - await page.WaitForTimeoutAsync(300); + await WaitForPositionToStabilizeAsync(node); var nodeBoundsBefore = await node.BoundingBoxAsync(); var cogBounds = await cog.BoundingBoxAsync(); Assert.NotNull(nodeBoundsBefore); @@ -239,9 +242,7 @@ await page.Mouse.MoveAsync( Assert.InRange(Math.Abs(nodeBoundsAfter.Y - nodeBoundsBefore.Y), 0, 5); Assert.False((await node.GetAttributeAsync("class"))?.Split(' ').Contains("resource-group-selected")); - var menu = page.GetByRole( - AriaRole.Menu, - new PageGetByRoleOptions { Name = "TestResource", Exact = true }); + var menu = page.Locator(".aspire-menu-container:has(#resource-context-menu-header) fluent-menu-list"); await Assertions.Expect(menu).ToBeHiddenAsync(); await node.HoverAsync(); @@ -250,6 +251,7 @@ await page.Mouse.MoveAsync( await Assertions.Expect(cog).ToHaveAttributeAsync("aria-expanded", "true"); Assert.False((await node.GetAttributeAsync("class"))?.Split(' ').Contains("resource-group-selected")); + await menu.GetByRole(AriaRole.Menuitem).First.FocusAsync(); await page.Keyboard.PressAsync("Escape"); await Assertions.Expect(menu).ToBeHiddenAsync(); await Assertions.Expect(cog).ToHaveAttributeAsync("aria-expanded", "false"); @@ -265,8 +267,9 @@ await page.Mouse.MoveAsync( await Assertions.Expect(header.Locator(".aspire-menu-header-text")).ToHaveTextAsync("TestResource"); var headerBounds = await header.BoundingBoxAsync(); Assert.NotNull(headerBounds); - Assert.InRange(headerBounds.Height, 39, 41); + Assert.InRange(headerBounds.Height, 35, 37); + await menu.GetByRole(AriaRole.Menuitem).First.FocusAsync(); await page.Keyboard.PressAsync("Escape"); await Assertions.Expect(menu).ToBeHiddenAsync(); await Assertions.Expect(cog).ToHaveAttributeAsync("aria-expanded", "false"); @@ -280,7 +283,7 @@ await page.GetByRole( Name = ControlsStrings.ActionViewDetailsText, Exact = true }).ClickAsync(); - await Assertions.Expect(page.Locator(".details-header-title")).ToHaveTextAsync("TestResource"); + await Assertions.Expect(page.Locator(".details-header-title")).ToHaveTextAsync("Project: TestResource"); await page.GetByRole( AriaRole.Button, @@ -294,6 +297,38 @@ await page.GetByRole( }); } + private static async Task WaitForPositionToStabilizeAsync(ILocator locator) + { + await locator.EvaluateAsync( + """ + element => new Promise(resolve => { + let previousBounds; + let stableFrames = 0; + + const checkPosition = () => { + const bounds = element.getBoundingClientRect(); + if (previousBounds && + Math.abs(bounds.x - previousBounds.x) <= 0.5 && + Math.abs(bounds.y - previousBounds.y) <= 0.5) { + stableFrames++; + } else { + stableFrames = 0; + } + + if (stableFrames >= 3) { + resolve(); + return; + } + + previousBounds = bounds; + requestAnimationFrame(checkPosition); + }; + + requestAnimationFrame(checkPosition); + }) + """).DefaultTimeout(); + } + [Fact] [OuterloopTest("Resource-intensive Playwright browser test")] public async Task GraphNode_RightClickOpensMenuAtCursorWithoutOverlay() @@ -322,7 +357,7 @@ await node.Locator("xpath=..").EvaluateAsync( } """); - var menu = page.Locator("fluent-menu.aspire-menu-container:not([trigger]) > fluent-menu-list"); + var menu = page.Locator(".aspire-menu-container:has(#resource-context-menu-header) fluent-menu-list"); await Assertions.Expect(menu).ToBeVisibleAsync(); var menuBounds = await menu.BoundingBoxAsync(); Assert.NotNull(menuBounds); @@ -334,6 +369,7 @@ await node.Locator("xpath=..").EvaluateAsync( "elements => elements.filter(element => element.getBoundingClientRect().width > 0 && element.getBoundingClientRect().height > 0).length"); Assert.Equal(0, visibleBlockingIndicators); + await menu.GetByRole(AriaRole.Menuitem).First.FocusAsync(); await page.Keyboard.PressAsync("Escape"); await Assertions.Expect(menu).ToBeHiddenAsync(); @@ -366,6 +402,11 @@ public sealed class ResourcesDashboardServerFixture : DashboardServerFixture resourceName: "basketcache", resourceType: KnownResourceTypes.Container, state: KnownResourceState.Running), + ModelTestHelpers.CreateResource( + resourceName: "hidden-resource", + resourceType: KnownResourceTypes.Container, + state: KnownResourceState.Running, + hidden: true), ModelTestHelpers.CreateResource( resourceName: "TestResource", resourceType: KnownResourceTypes.Project, diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs index 5fa3d3ff6b0..ebf21f305b9 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalDockTests.cs @@ -8,7 +8,6 @@ using Aspire.DashboardService.Proto.V1; using Aspire.TestUtilities; using Microsoft.AspNetCore.InternalTesting; -using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Playwright; using Xunit; @@ -19,80 +18,6 @@ namespace Aspire.Dashboard.Tests.Integration.Playwright; public sealed class TerminalDockTests(TerminalDockTests.TerminalDockDashboardServerFixture fixture) : PlaywrightTestsBase(fixture) { - [Theory] - [InlineData(false)] - [InlineData(true)] - [OuterloopTest("Resource-intensive Playwright browser test")] - public async Task AppHostWorkloadEnded_CompletionCloseKeepsTabUntilExplicitClose(bool beforeHandshake) - { - await RunTestAsync(async page => - { - var (updates, closes) = await fixture.StartSessionAsync(); - await page.Clock.InstallAsync(); - var parkedConnections = Channel.CreateUnbounded(); - var connectionCount = 0; - await page.RouteWebSocketAsync("**/api/apphost-terminal?*", route => - { - Interlocked.Increment(ref connectionCount); - if (beforeHandshake) - { - route.OnMessage(_ => { }); - } - else - { - route.ConnectToServer(); - } - parkedConnections.Writer.TryWrite(route); - }); - - await page.GotoAsync("/").DefaultTimeout(); - await updates.Writer.WriteAsync(Change(TerminalChangeType.Added, "ended")); - await updates.Writer.WriteAsync(Change(TerminalChangeType.Activated, "ended")); - await Assertions.Expect(Tab(page, "ended")).ToBeVisibleAsync(); - TestTerminalConnection? producer = null; - var parked = await parkedConnections.Reader.ReadAsync().AsTask().DefaultTimeout(); - if (!beforeHandshake) - { - producer = await fixture.TerminalResolver.AcceptConnectionAsync(CancellationToken.None).DefaultTimeout(); - await producer.WaitForPeerHandshakesAsync(CancellationToken.None).DefaultTimeout(); - await Assertions.Expect(page.Locator(".terminal-dock-pane.active") - .GetByRole(AriaRole.Button, new() { Name = "Decrease font size", Exact = true })).ToBeEnabledAsync(); - } - - var endpoint = new Uri(parked.Url); - var viewId = QueryHelpers.ParseQuery(endpoint.Query)["viewId"].ToString(); - Assert.True(fixture.DashboardApp.Services.GetRequiredService() - .TryGet(viewId, endpoint.PathAndQuery, out var session)); - session.MarkEnded(); - - // Transport tests cover the authoritative gRPC signal. Here both a pre-frame - // socket and a mounted presentation must observe Aspire's completion close. - var terminal = await page.Locator(".terminal-dock .terminal-container").ElementHandleAsync(); - Assert.NotNull(terminal); - await parked.CloseAsync(new() { Code = 4000, Reason = "Terminal ended" }); - await page.EvaluateAsync(""" - async () => { window.terminalModule = await import('/Components/Controls/TerminalView.razor.js'); } - """); - await page.WaitForFunctionAsync(""" - () => { - const state = window.terminalModule.getTerminalSnapshot(document.querySelector('.terminal-dock .terminal-container')); - return state?.ended && !state.connected; - } - """).DefaultTimeout(); - await page.Clock.FastForwardAsync(5_000); - await Assertions.Expect(Tab(page, "ended")).ToHaveAttributeAsync("aria-selected", "true"); - Assert.True(await terminal.EvaluateAsync("element => element.isConnected")); - Assert.Equal(1, producer?.ConnectionCount ?? Volatile.Read(ref connectionCount)); - Assert.Empty(fixture.Client.ClosedTerminals); - - await page.GetByRole(AriaRole.Button, new() { Name = "Close terminal 'ended'", Exact = true }).ClickAsync(); - Assert.Equal("ended", await closes.Reader.ReadAsync().AsTask().DefaultTimeout()); - await updates.Writer.WriteAsync(Change(TerminalChangeType.Removed, "ended")); - await Assertions.Expect(Tab(page, "ended")).ToHaveCountAsync(0); - Assert.False(await terminal.EvaluateAsync("element => element.isConnected")); - }); - } - [Fact] [OuterloopTest("Resource-intensive Playwright browser test")] public async Task EmptyDock_ResizingPreservesContentInPriorityOrder() @@ -404,7 +329,7 @@ await RunTestAsync(async page => else { await Tab(page, "second").ClickAsync(); - focusTarget = page.Locator(".terminal-dock-pane.active").GetByRole(AriaRole.Textbox); + focusTarget = Tab(page, "second"); } await focusTarget.FocusAsync(); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs index a282539ca66..ce20752483d 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/TerminalTests.cs @@ -41,6 +41,7 @@ await RunTestAsync(async page => await MountObserverAsync(page, requestPrimary: true); await connection.WaitForPeerHandshakesAsync(CancellationToken.None).DefaultTimeout(); var primaryId = connection.Presentation.PrimaryPeerId; + var connectionCount = connection.ConnectionCount; Assert.NotNull(primaryId); var terminal = page.GetByTestId("module-terminal"); @@ -56,7 +57,7 @@ await RunTestAsync(async page => await ExpectObserverTextAsync(page, "Output while read-only"); await Assertions.Expect(terminal.Locator("canvas")).ToBeVisibleAsync(); await page.EvaluateAsync("() => window.moduleTerminal.focus()"); - await page.Keyboard.TypeAsync("blocked-keyboard"); + await page.Keyboard.TypeAsync("q"); await PasteAsync(input, "blocked-paste"); Assert.Equal(["Terminal view does not accept input", "Terminal view does not accept input"], await page.EvaluateAsync(""" @@ -108,7 +109,7 @@ await page.EvaluateAsync(""" await SetReadOnlyAsync(page, terminalId, session, true); await ExpectReadOnlyAsync(page, true); await page.EvaluateAsync("() => window.moduleTerminal.focus()"); - await page.Keyboard.TypeAsync("blocked-again"); + await page.Keyboard.TypeAsync("z"); await PasteAsync(input, "blocked-paste-again"); await page.EvaluateAsync("() => window.terminalObserver.paste('still-active')"); Assert.Equal("still-active", await connection.ReadInputTextAsync("still-active".Length, CancellationToken.None).DefaultTimeout()); @@ -119,7 +120,7 @@ await page.EvaluateAsync(""" await ExpectObserverTextAsync(page, "Output after policy changes"); await WaitForConnectedAsync(page, terminalId); Assert.True(await terminalElement.EvaluateAsync("element => element.isConnected")); - Assert.Equal(2, connection.ConnectionCount); + Assert.Equal(connectionCount, connection.ConnectionCount); }); } @@ -132,7 +133,8 @@ await RunTestAsync(async page => await using var connection = await OpenTerminalAsync(page); var input = page.GetByRole(AriaRole.Textbox, new() { Name = "Interactive terminal input", Exact = true }); var decreaseFontButton = page.GetByRole(AriaRole.Button, new() { Name = "Decrease font size", Exact = true }); - var precedingControl = page.GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }); + var precedingControl = page.GetByLabel("Page toolbar", new() { Exact = true }) + .GetByRole(AriaRole.Button, new() { Name = "Settings", Exact = true }); var focusHint = page.Locator(".terminal-focus-hint"); await Assertions.Expect(decreaseFontButton).ToBeEnabledAsync(); @@ -199,10 +201,11 @@ await RunTestAsync(async page => await using var connection = await OpenTerminalAsync(page); await MountObserverAsync(page, requestPrimary: true); var primaryId = connection.Presentation.PrimaryPeerId; - var dimensions = page.GetByRole(AriaRole.Combobox, new() { Name = "Terminal dimensions", Exact = true }); + var dropdown = page.Locator("fluent-field.terminal-size-select fluent-dropdown"); + var dimensions = TerminalDimensions(page); await dimensions.ClickAsync(); - await page.GetByRole(AriaRole.Option, new() { Name = "80×24", Exact = true }).ClickAsync(); - await Assertions.Expect(dimensions).ToHaveJSPropertyAsync("value", "80x24"); + await dropdown.Locator("fluent-option[text='80×24']").ClickAsync(); + await Assertions.Expect(dimensions).ToHaveTextAsync("80×24"); Assert.NotNull(connection.Presentation.PrimaryPeerId); Assert.NotEqual(primaryId, connection.Presentation.PrimaryPeerId); await page.WaitForFunctionAsync("() => window.terminalObserver.geometry.columns === 80 && window.terminalObserver.geometry.rows === 24").DefaultTimeout(); @@ -226,7 +229,7 @@ await RunTestAsync(async page => await button.ClickAsync(); await Assertions.Expect(page.Locator(".terminal-font-size")).ToHaveTextAsync($"{fontSize}px"); } - await Assertions.Expect(button).ToBeDisabledAsync(); + await Assertions.Expect(button).ToHaveAttributeAsync("disabled", string.Empty); Assert.NotNull(connection.Presentation.PrimaryPeerId); Assert.NotEqual(primaryId, connection.Presentation.PrimaryPeerId); }); @@ -283,8 +286,11 @@ private async Task OpenTerminalAsync(IPage page) } private static Task ExpectProducerDimensionsAsync(IPage page) => - Assertions.Expect(page.GetByRole(AriaRole.Combobox, new() { Name = "Terminal dimensions", Exact = true })) - .ToHaveJSPropertyAsync("value", $"{TestTerminalConnection.Columns}x{TestTerminalConnection.Rows}"); + Assertions.Expect(TerminalDimensions(page)) + .ToHaveTextAsync($"{TestTerminalConnection.Columns}×{TestTerminalConnection.Rows}"); + + private static ILocator TerminalDimensions(IPage page) => + page.Locator("fluent-field.terminal-size-select [role='combobox']"); private static Task SetReadOnlyAsync(IPage page, int terminalId, TerminalViewSession session, bool readOnly) { diff --git a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs index 075af8d585c..ab61a686534 100644 --- a/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/DashboardClientTests.cs @@ -24,6 +24,7 @@ using Microsoft.AspNetCore.InternalTesting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; @@ -61,17 +62,8 @@ public async Task TerminalStream_EndedBeforeHandshakeClosesWithCompletionStatusA var dashboardClient = new TestDashboardClient(attachTerminal: (_, _) => Task.FromResult(stream)); var sessions = new TerminalViewSessionRegistry(); using var session = sessions.Create("/api/apphost-terminal?terminalId=terminal", readOnly: false); - using var server = new TestServer(new WebHostBuilder().Configure(app => - { - app.UseWebSockets(); - app.Run(context => - { - context.Request.Scheme = "https"; - context.Request.Host = new HostString("dashboard.example.com"); - return TerminalWebSocketProxy.HandleAppHostTerminalAsync(context, dashboardClient, sessions, NullLogger.Instance, "test"); - }); - })); - var client = server.CreateWebSocketClient(); + using var host = await BuildTerminalTestHostAsync(dashboardClient, sessions); + var client = host.GetTestServer().CreateWebSocketClient(); client.ConfigureRequest = request => request.Headers.Origin = "https://dashboard.example.com"; using var socket = await client.ConnectAsync( new Uri($"wss://dashboard.example.com/api/apphost-terminal?terminalId=terminal&viewId={session.Id}"), CancellationToken.None).DefaultTimeout(); @@ -189,17 +181,8 @@ public async Task TerminalStream_HandshakeFailurePreservesPermanentAndTransientS var dashboardClient = new TestDashboardClient(attachTerminal: (_, _) => Task.FromResult(stream)); var sessions = new TerminalViewSessionRegistry(); using var session = sessions.Create("/api/apphost-terminal?terminalId=terminal", readOnly: false); - using var server = new TestServer(new WebHostBuilder().Configure(app => - { - app.UseWebSockets(); - app.Run(context => - { - context.Request.Scheme = "https"; - context.Request.Host = new HostString("dashboard.example.com"); - return TerminalWebSocketProxy.HandleAppHostTerminalAsync(context, dashboardClient, sessions, NullLogger.Instance, "test"); - }); - })); - var client = server.CreateWebSocketClient(); + using var host = await BuildTerminalTestHostAsync(dashboardClient, sessions); + var client = host.GetTestServer().CreateWebSocketClient(); client.ConfigureRequest = request => request.Headers.Origin = "https://dashboard.example.com"; var uri = new Uri($"wss://dashboard.example.com/api/apphost-terminal?terminalId=terminal&viewId={session.Id}"); @@ -229,6 +212,24 @@ public async Task TerminalStream_HandshakeFailurePreservesPermanentAndTransientS Assert.False(stream.TerminalEnded); } + private static Task BuildTerminalTestHostAsync(IDashboardClient dashboardClient, TerminalViewSessionRegistry sessions) + { + return new HostBuilder() + .ConfigureWebHost(webBuilder => webBuilder + .UseTestServer() + .Configure(app => + { + app.UseWebSockets(); + app.Run(context => + { + context.Request.Scheme = "https"; + context.Request.Host = new HostString("dashboard.example.com"); + return TerminalWebSocketProxy.HandleAppHostTerminalAsync(context, dashboardClient, sessions, NullLogger.Instance, "test"); + }); + })) + .StartAsync(); + } + private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(builder => { builder.AddXunit(testOutputHelper, LogLevel.Trace, DateTimeOffset.UtcNow); diff --git a/tests/Directory.Packages.props b/tests/Directory.Packages.props index 86c1ffd8c90..a4f495b20dc 100644 --- a/tests/Directory.Packages.props +++ b/tests/Directory.Packages.props @@ -5,7 +5,7 @@ - + From 1e0c87346a06ee06720985200303ed3d2833570c Mon Sep 17 00:00:00 2001 From: "aspire-repo-bot[bot]" <268009190+aspire-repo-bot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:57:47 -0700 Subject: [PATCH 7/9] [release/13.6] Prepare Project v2 migration skill bundle integration (#20405) Backport of #20158 to release/13.6 /cc @karolz-ms ## Customer Impact ## Testing ## Risk ## Regression? --------- Co-authored-by: Karol Zadora-Przylecki Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../verify-telemetry-hook-changes.yml | 25 +- .../AspireSkills/AspireSkillsInstaller.cs | 2 +- .../Embedded/aspire-skills-v0.0.2.tgz | Bin 127470 -> 0 bytes .../Embedded/aspire-skills-v0.0.3.tgz | Bin 0 -> 139035 bytes .../Embedded/aspire-skills.metadata.json | 14 +- .../Agents/Hooks/TelemetryHookConfigurator.cs | 2 +- .../Agents/Hooks/track-telemetry.ps1 | 4 +- .../Agents/Hooks/track-telemetry.sh | 4 +- src/Aspire.Cli/Aspire.Cli.csproj | 2 +- src/Aspire.Hosting.Dotnet/README.md | 8 + .../Agents/AspireSkillsInstallerTests.cs | 32 ++- .../Agents/TelemetryHookScriptTests.cs | 55 +++-- .../Commands/AgentInitCommandTests.cs | 66 +++++- .../TestServices/FakePlaywrightServices.cs | 16 +- ...VerifyTelemetryHookChangesWorkflowTests.cs | 224 ++++++++++++++++++ 15 files changed, 416 insertions(+), 38 deletions(-) delete mode 100644 src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.2.tgz create mode 100644 src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.3.tgz create mode 100644 tests/Infrastructure.Tests/WorkflowScripts/VerifyTelemetryHookChangesWorkflowTests.cs diff --git a/.github/workflows/verify-telemetry-hook-changes.yml b/.github/workflows/verify-telemetry-hook-changes.yml index 6bf34fe1c67..975b7471bc6 100644 --- a/.github/workflows/verify-telemetry-hook-changes.yml +++ b/.github/workflows/verify-telemetry-hook-changes.yml @@ -31,6 +31,12 @@ jobs: 'src/Aspire.Cli/Agents/Hooks/track-telemetry.sh', 'src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1' ) + $bundleDirectory = 'src/Aspire.Cli/Agents/AspireSkills/Embedded' + $requiredBundlePaths = @( + 'src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs', + 'src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json', + 'src/Aspire.Cli/Aspire.Cli.csproj' + ) # Disable rename detection so both the removed and added paths are checked. $changedPaths = @(& git --no-pager diff --no-ext-diff --no-textconv --no-renames --name-only 'HEAD^1' HEAD -- @protectedPaths) @@ -39,7 +45,24 @@ jobs: } if ($changedPaths.Count -gt 0 -and $env:PR_HEAD_REF -cne $allowedHeadRef) { - throw "Telemetry hook scripts can only change in PRs whose head branch is '$allowedHeadRef'. Make changes in microsoft/aspire-skills and use the synchronization workflow instead of editing these copies manually." + # A feature PR may consume a newly published bundle directly, but only when it carries + # every generated companion change. The separate bundle-verification workflow checks + # the archive attestation and the hook bytes against the pinned aspire-skills commit. + $bundleChangedPaths = @(& git --no-pager diff --no-ext-diff --no-textconv --no-renames --name-only 'HEAD^1' HEAD -- @requiredBundlePaths $bundleDirectory) + if ($LASTEXITCODE -ne 0) { + throw 'Could not inspect the generated Aspire skills bundle changes.' + } + + $missingBundlePaths = @($requiredBundlePaths | Where-Object { $_ -notin $bundleChangedPaths }) + $archiveChanged = $bundleChangedPaths | Where-Object { + $_ -match '^src/Aspire\.Cli/Agents/AspireSkills/Embedded/aspire-skills-.*\.(zip|tar\.gz|tgz)$' + } + + if ($missingBundlePaths.Count -gt 0 -or -not $archiveChanged) { + throw "Telemetry hook scripts can only change on '$allowedHeadRef' or as part of a complete generated bundle update. Make changes in microsoft/aspire-skills and run the synchronization workflow instead of editing these copies manually." + } + + Write-Host "Telemetry hook changes are part of a complete generated bundle update; canonical content is checked separately." } Write-Host 'Telemetry hook branch policy satisfied. Canonical content is checked separately.' diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs index 81695234f80..a93ba4105e0 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs @@ -30,7 +30,7 @@ internal sealed class AspireSkillsInstaller( AspireCliTelemetry telemetry, ILogger logger) : IAspireSkillsInstaller { - internal const string Version = "0.0.2"; + internal const string Version = "0.0.3"; internal const string GitHubRepository = "microsoft/aspire-skills"; internal const string ExpectedSourceRepository = $"https://github.com/{GitHubRepository}"; internal const string ExpectedWorkflowPath = ".github/workflows/publish.yml"; diff --git a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.2.tgz b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.2.tgz deleted file mode 100644 index 0336dd345dbfca2e6ccef76d63136b015f0c2d23..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 127470 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%0DZ1mw2=5x5R3G{#FruRQwg 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 LJ_*%k05Ast|E|cX diff --git a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.3.tgz b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.3.tgz new file mode 100644 index 0000000000000000000000000000000000000000..90c023efc1decd2b21f450621aa0fdfc4b0c9108 GIT binary patch literal 139035 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 z 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 literal 0 HcmV?d00001 diff --git a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json index 0e4ae0c4d17..f9eeb0afcac 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json +++ b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json @@ -1,14 +1,14 @@ { - "version": "0.0.2", + "version": "0.0.3", "repository": "microsoft/aspire-skills", - "tag": "v0.0.2", - "assetName": "aspire-skills-v0.0.2.tgz", - "sha512": "8caa7a0639477a1d14da01483ff45d963ea157c6b2a6560df26d65eac991cb469e01cbdef1e3847d153573e61a4fffa15dd51c853a43afe4f1f1bd9fe0265102", + "tag": "v0.0.3", + "assetName": "aspire-skills-v0.0.3.tgz", + "sha512": "a5f8bd971b6aa0556994a6f6003d5a763056f6c64d1d9e6ae7a883ef9408c3d8a7689371c0499d980d4f82a9cd55e37cebd1e182e3cb71fc028380cec0ececad", "hooks": { - "commitSha": "842c4fb8d9ff5f89bf3e5f6f16739a728b5ba77e", + "commitSha": "a83ca78b9d35b8238f9c4fdba72cb779defe2a90", "files": { - "track-telemetry.sh": "73d44c910ac39c196b3cd4e0ff3993bbf05125a75a351e068e8d7a32d4c520f6ec8560397ccd722c21d2710949baeef40fcb25dcebed7314a7b03fd65af78e44", - "track-telemetry.ps1": "5311aba3e0087132d40c460276bd8cf889fc3a7c4dd9e110eea305ed95ef24dd5c837bcff276eb801c44270bba2ebe42e53b99244ec52ffac91f1ecb6b2a6618" + "track-telemetry.sh": "6fa64f350e2709a7abb52842232cc8d584e24d104b17a200a93ca4010d36a838dd1f6f73b83e92cd2ad0d8ad4abf9753323f840dc28625ccaf2f082097667181", + "track-telemetry.ps1": "303dc8d0eba181154222dacf6151d6bbe24614dd0e3144704fab1cb83c81f149e95cac47e87488357f0c04d232629a2f91f5551fe33e5c6c91f25a8f97aa0af3" } } } diff --git a/src/Aspire.Cli/Agents/Hooks/TelemetryHookConfigurator.cs b/src/Aspire.Cli/Agents/Hooks/TelemetryHookConfigurator.cs index 26dd4a6a5ff..e6fa8020682 100644 --- a/src/Aspire.Cli/Agents/Hooks/TelemetryHookConfigurator.cs +++ b/src/Aspire.Cli/Agents/Hooks/TelemetryHookConfigurator.cs @@ -66,7 +66,7 @@ public async Task ConfigureAsync( // configured here even though they are detected/marked. The Copilot App and CLI share the // same ~/.copilot hook location, so prefer the App display name when both are detected. // This identifies the configuration target, not the hook event's client-name: the canonical - // v0.0.2 scripts report App events as copilot-cli. See https://github.com/microsoft/aspire-skills/issues/71. + // scripts currently report App events as copilot-cli. See https://github.com/microsoft/aspire-skills/issues/71. var supported = new List(); if (detectedClients.Contains(AgentClientKind.CopilotApp)) { diff --git a/src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1 b/src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1 index 05b1a4ee67c..ff6c8b3f9a2 100644 --- a/src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1 +++ b/src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1 @@ -26,7 +26,7 @@ trap { # Allowlist of Aspire-owned skill names (keep in sync with github.com/microsoft/aspire-skills). # A shared .agents/skills directory can also contain third-party skills, so a path/name is only # treated as Aspire when its skill segment is one of these. -$AspireSkills = @('aspire', 'aspire-init', 'aspireify', 'aspire-orchestration', 'aspire-deployment', 'aspire-monitoring') +$AspireSkills = @('aspire', 'aspire-init', 'aspireify', 'aspire-project-v2-migration', 'aspire-orchestration', 'aspire-deployment', 'aspire-monitoring') $AspireMcpTools = @( 'doctor', @@ -65,6 +65,8 @@ $AspireReferenceFiles = @( 'aspire-orchestration/references/detection.md', 'aspire-orchestration/references/resource-management.md', 'aspire-orchestration/references/safety-guardrails.md', + 'aspire-project-v2-migration/references/compatibility-and-validation.md', + 'aspire-project-v2-migration/references/migration-patterns.md', 'aspire/references/aspire-13-3-breaking-changes.md', 'aspire/references/aspire-13-5-breaking-changes.md', 'aspireify/references/apphost-wiring.md', diff --git a/src/Aspire.Cli/Agents/Hooks/track-telemetry.sh b/src/Aspire.Cli/Agents/Hooks/track-telemetry.sh index 9ccd9755c49..a4f26ccb192 100644 --- a/src/Aspire.Cli/Agents/Hooks/track-telemetry.sh +++ b/src/Aspire.Cli/Agents/Hooks/track-telemetry.sh @@ -66,7 +66,7 @@ trap emit_continue EXIT # github.com/microsoft/aspire-skills). A shared .agents/skills directory can also contain # third-party skills (dotnet-inspect, playwright, ...), so a path/name is only treated as # Aspire when its skill segment is one of these. -ASPIRE_SKILLS="aspire aspire-init aspireify aspire-orchestration aspire-deployment aspire-monitoring" +ASPIRE_SKILLS="aspire aspire-init aspireify aspire-project-v2-migration aspire-orchestration aspire-deployment aspire-monitoring" ASPIRE_MCP_TOOLS="doctor execute_resource_command get_doc list_apphosts list_console_logs list_docs list_integrations list_resources list_structured_logs list_trace_structured_logs list_traces refresh_tools search_docs select_apphost" @@ -93,6 +93,8 @@ aspire-orchestration/references/app-commands.md aspire-orchestration/references/detection.md aspire-orchestration/references/resource-management.md aspire-orchestration/references/safety-guardrails.md +aspire-project-v2-migration/references/compatibility-and-validation.md +aspire-project-v2-migration/references/migration-patterns.md aspire/references/aspire-13-3-breaking-changes.md aspire/references/aspire-13-5-breaking-changes.md aspireify/references/apphost-wiring.md diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 47b1fa4919f..05d86d6fc3e 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -374,7 +374,7 @@ - + false diff --git a/src/Aspire.Hosting.Dotnet/README.md b/src/Aspire.Hosting.Dotnet/README.md index 9d5dcf8c07a..9c7b46b8646 100644 --- a/src/Aspire.Hosting.Dotnet/README.md +++ b/src/Aspire.Hosting.Dotnet/README.md @@ -229,6 +229,14 @@ Call `PublishAsDockerFile(...)` or `publishAsDockerFile(...)` to use an explicit container publishing. Call `ExcludeFromManifest()` or `excludeFromManifest()` when the resource is intentionally available only during local orchestration. +## Migrating from legacy project resources + +For C# and TypeScript AppHosts targeting Aspire 13.6 or newer, the +[`aspire-project-v2-migration` agent skill](https://github.com/microsoft/aspire-skills/tree/main/skills/aspire-project-v2-migration) +assesses existing project resources and proposes a migration for approval before editing. +It preserves supported resource configuration and identifies unsupported patterns, including +Azure Functions and custom integrations tied to `ProjectResource`. It does not upgrade the AppHost's Aspire version. + ## Additional documentation - https://aspire.dev/integrations/gallery/ diff --git a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs index 5b997d03124..8b99e2d3da0 100644 --- a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs @@ -1384,8 +1384,11 @@ public async Task InstallAsync_WhenRemoteFetchFeatureIsDisabled_SkipsGitHubAndUs try { + const string migrationSkillName = "aspire-project-v2-migration"; var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); - var embeddedBundleProvider = await CreateEmbeddedBundleProviderAsync(); + var embeddedBundleProvider = new EmbeddedAspireSkillsBundleProvider( + new AspireSkillsBundleProvider(executionContext), + NullLogger.Instance); var attestationVerifier = new TestGitHubArtifactAttestationVerifier(); // Throw on any HTTP call so we can prove the GitHub path was never invoked. var handler = new MockHttpMessageHandler(_ => throw new InvalidOperationException("HTTP must not be called when remote fetch is disabled.")); @@ -1401,8 +1404,33 @@ public async Task InstallAsync_WhenRemoteFetchFeatureIsDisabled_SkipsGitHubAndUs Assert.Equal(AspireSkillsInstallStatus.Installed, result.Status); Assert.NotNull(result.Bundle); - Assert.True(embeddedBundleProvider.CreateBundleCalled); + Assert.Equal(AspireSkillsInstaller.Version, result.Bundle.Version); Assert.False(attestationVerifier.VerifyCalled); + var migrationSkill = Assert.Single( + result.Bundle.GetSkillDefinitions(), + skill => skill.HasName(migrationSkillName)); + Assert.True(migrationSkill.IsDefault); + Assert.Empty(migrationSkill.ApplicableLanguages); + Assert.Equal(["evals"], migrationSkill.InstallExcludedRelativePaths); + + var migrationFiles = await result.Bundle.GetSkillFilesAsync(migrationSkill, CancellationToken.None); + Assert.Collection( + migrationFiles.OrderBy(file => file.RelativePath, StringComparer.Ordinal), + skillFile => + { + Assert.Equal("SKILL.md", skillFile.RelativePath); + Assert.Contains("# Aspire Project v2 migration", skillFile.Content, StringComparison.Ordinal); + }, + compatibilityReference => + { + Assert.Equal(Path.Combine("references", "compatibility-and-validation.md"), compatibilityReference.RelativePath); + Assert.Contains("# Project v2 compatibility and validation", compatibilityReference.Content, StringComparison.Ordinal); + }, + migrationPatternsReference => + { + Assert.Equal(Path.Combine("references", "migration-patterns.md"), migrationPatternsReference.RelativePath); + Assert.Contains("# Project v2 migration patterns", migrationPatternsReference.Content, StringComparison.Ordinal); + }); } finally { diff --git a/tests/Aspire.Cli.Tests/Agents/TelemetryHookScriptTests.cs b/tests/Aspire.Cli.Tests/Agents/TelemetryHookScriptTests.cs index fdb04caab2e..d740d3d4efe 100644 --- a/tests/Aspire.Cli.Tests/Agents/TelemetryHookScriptTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/TelemetryHookScriptTests.cs @@ -24,20 +24,24 @@ public class TelemetryHookScriptTests(ITestOutputHelper outputHelper) private const string CaptureFileEnvName = "ASPIRE_HOOK_TEST_CAPTURE_FILE"; private const string ContinueResponse = """{"continue":true}"""; - [Fact] + [Theory] + [InlineData("aspire")] + [InlineData("aspire-project-v2-migration")] [RequiresTools(["bash"])] [SkipOnPlatform(TestPlatforms.Windows, "The shell hook targets POSIX shells; the PowerShell hook covers Windows.")] - public async Task Bash_SkillInvocation_Copilot_ForwardsSkillName() + public async Task Bash_SkillInvocation_Copilot_ForwardsSkillName(string skillName) { + var payload = """{"toolName":"skill","sessionId":"11111111-2222-3333-4444-555555555555","toolArgs":{"skill":"__SKILL_NAME__"}}""" + .Replace("__SKILL_NAME__", skillName, StringComparison.Ordinal); var run = await RunBashHookAsync( - """{"toolName":"skill","sessionId":"11111111-2222-3333-4444-555555555555","toolArgs":{"skill":"aspire"}}""", + payload, new() { ["COPILOT_CLI"] = "1" }); AssertContinue(run); var args = AssertInvoked(run); AssertArg(args, "--event-type", "skill_invocation"); AssertArg(args, "--client-name", "copilot-cli"); - AssertArg(args, "--skill-name", "aspire"); + AssertArg(args, "--skill-name", skillName); AssertArg(args, "--session-id", "11111111-2222-3333-4444-555555555555"); } @@ -57,8 +61,7 @@ public async Task Bash_SkillInvocation_CopilotAppMarker_DoesNotProvideDistinctAt AssertContinue(run); var args = AssertInvoked(run); AssertArg(args, "--event-type", "skill_invocation"); - // v0.0.2 ignores AI_AGENT; App attribution must be added upstream, not in these copied scripts. - // https://github.com/microsoft/aspire-skills/issues/71 + // Keep this limitation explicit until https://github.com/microsoft/aspire-skills/issues/71 ships. AssertArg(args, "--client-name", "copilot-cli"); AssertArg(args, "--skill-name", "aspire"); } @@ -95,19 +98,23 @@ public async Task Bash_McpTool_Claude_ForwardsToolName() AssertArg(args, "--tool-name", "mcp__aspire__list_resources"); } - [Fact] + [Theory] + [InlineData("aspire-deployment/references/azure.md")] + [InlineData("aspire-project-v2-migration/references/compatibility-and-validation.md")] + [InlineData("aspire-project-v2-migration/references/migration-patterns.md")] [RequiresTools(["bash"])] [SkipOnPlatform(TestPlatforms.Windows, "The shell hook targets POSIX shells; the PowerShell hook covers Windows.")] - public async Task Bash_ReferenceFileRead_ForwardsRelativePath() + public async Task Bash_ReferenceFileRead_ForwardsRelativePath(string referencePath) { - var run = await RunBashHookAsync( - """{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":".agents/skills/aspire-deployment/references/azure.md"}}"""); + var payload = """{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":".agents/skills/__REFERENCE_PATH__"}}""" + .Replace("__REFERENCE_PATH__", referencePath, StringComparison.Ordinal); + var run = await RunBashHookAsync(payload); AssertContinue(run); var args = AssertInvoked(run); AssertArg(args, "--event-type", "reference_file_read"); // Only the repo-relative path after skills// is forwarded — never the absolute path. - AssertArg(args, "--file-reference", "aspire-deployment/references/azure.md"); + AssertArg(args, "--file-reference", referencePath); } [Theory] @@ -272,19 +279,23 @@ public async Task Bash_ReferenceFileRead_CopilotStringArgs_ForwardsRelativePath( AssertArg(args, "--file-reference", "aspire-deployment/references/azure.md"); } - [Fact] + [Theory] + [InlineData("aspire")] + [InlineData("aspire-project-v2-migration")] [RequiresTools(["pwsh"])] - public async Task Pwsh_SkillInvocation_Copilot_ForwardsSkillName() + public async Task Pwsh_SkillInvocation_Copilot_ForwardsSkillName(string skillName) { + var payload = """{"toolName":"skill","sessionId":"11111111-2222-3333-4444-555555555555","toolArgs":{"skill":"__SKILL_NAME__"}}""" + .Replace("__SKILL_NAME__", skillName, StringComparison.Ordinal); var run = await RunPwshHookAsync( - """{"toolName":"skill","sessionId":"11111111-2222-3333-4444-555555555555","toolArgs":{"skill":"aspire"}}""", + payload, new() { ["COPILOT_CLI"] = "1" }); AssertContinue(run); var args = AssertInvoked(run); AssertArg(args, "--event-type", "skill_invocation"); AssertArg(args, "--client-name", "copilot-cli"); - AssertArg(args, "--skill-name", "aspire"); + AssertArg(args, "--skill-name", skillName); AssertArg(args, "--session-id", "11111111-2222-3333-4444-555555555555"); } @@ -371,18 +382,22 @@ public async Task Pwsh_MalformedJson_DoesNotInvokeCli() AssertNotInvoked(run); } - [Fact] + [Theory] + [InlineData("aspire-deployment/references/azure.md")] + [InlineData("aspire-project-v2-migration/references/compatibility-and-validation.md")] + [InlineData("aspire-project-v2-migration/references/migration-patterns.md")] [RequiresTools(["pwsh"])] - public async Task Pwsh_ReferenceFileRead_ForwardsRelativePath() + public async Task Pwsh_ReferenceFileRead_ForwardsRelativePath(string referencePath) { - var run = await RunPwshHookAsync( - """{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":".agents/skills/aspire-deployment/references/azure.md"}}"""); + var payload = """{"hook_event_name":"PostToolUse","tool_name":"Read","tool_input":{"file_path":".agents/skills/__REFERENCE_PATH__"}}""" + .Replace("__REFERENCE_PATH__", referencePath, StringComparison.Ordinal); + var run = await RunPwshHookAsync(payload); AssertContinue(run); var args = AssertInvoked(run); AssertArg(args, "--event-type", "reference_file_read"); // Only the repo-relative path after skills// is forwarded — never the absolute path. - AssertArg(args, "--file-reference", "aspire-deployment/references/azure.md"); + AssertArg(args, "--file-reference", referencePath); } [Theory] diff --git a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs index 54cce1f0c97..ebaff125132 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs @@ -86,7 +86,7 @@ public async Task AgentInitCommand_SummarizesDefaultSkillsOnce() $" {string.Format( CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummarySkills, - $"{CommonAgentApplicators.AspireSkillName}, {CommonAgentApplicators.AspireDeploymentSkillName}, {FakeAspireSkillsInstaller.AspireInitSkillName}, {FakeAspireSkillsInstaller.AspireMonitoringSkillName}, {FakeAspireSkillsInstaller.AspireOrchestrationSkillName}, {CommonAgentApplicators.AspireifySkillName}")}", + $"{CommonAgentApplicators.AspireSkillName}, {CommonAgentApplicators.AspireDeploymentSkillName}, {FakeAspireSkillsInstaller.AspireInitSkillName}, {FakeAspireSkillsInstaller.AspireMonitoringSkillName}, {FakeAspireSkillsInstaller.AspireOrchestrationSkillName}, {FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName}, {CommonAgentApplicators.AspireifySkillName}")}", $" {string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_InstalledSkillsSummaryLocations, ".agents/skills, ~/.agents/skills")}"); var message = Assert.Single(interactionService.DisplayedMessages, displayedMessage => displayedMessage.Emoji.Equals(KnownEmojis.Robot)); Assert.Equal(expectedSummary, message.Message); @@ -155,7 +155,8 @@ public async Task AgentInitCommand_NonInteractive_WithAllLocationsAndSkills_Inst CommonAgentApplicators.AspireDeploymentSkillName, FakeAspireSkillsInstaller.AspireInitSkillName, FakeAspireSkillsInstaller.AspireMonitoringSkillName, - FakeAspireSkillsInstaller.AspireOrchestrationSkillName + FakeAspireSkillsInstaller.AspireOrchestrationSkillName, + FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName }; var expectedSkillDirectories = new[] { @@ -211,6 +212,7 @@ public async Task AgentInitCommand_InteractiveSkillPrompt_IncludesAllBundleSkill Assert.Contains(FakeAspireSkillsInstaller.AspireInitSkillName, promptedSkillNames); Assert.Contains(FakeAspireSkillsInstaller.AspireMonitoringSkillName, promptedSkillNames); Assert.Contains(FakeAspireSkillsInstaller.AspireOrchestrationSkillName, promptedSkillNames); + Assert.Contains(FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName, promptedSkillNames); } [Fact] @@ -387,6 +389,62 @@ public async Task AgentInitCommand_NonInteractive_WithSpecificBundleSkill_Instal AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".opencode", "skill"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AgentInitCommand_NonInteractive_InstallsProjectV2MigrationSkillWithReferences(bool selectByName) + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var homeDirectory = workspace.CreateDirectory("fake-home"); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CliExecutionContextFactory = _ => CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory); + }); + using var provider = services.BuildServiceProvider(); + + var installer = provider.GetRequiredService(); + var installation = await installer.InstallAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + Assert.NotNull(installation.Bundle); + var skill = Assert.Single( + installation.Bundle.GetSkillDefinitions(), + skill => skill.HasName(FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName)); + Assert.True(skill.IsDefault); + Assert.Empty(skill.ApplicableLanguages); + var expectedFiles = await installation.Bundle.GetSkillFilesAsync(skill, TestContext.Current.CancellationToken).DefaultTimeout(); + Assert.Equal( + [ + "SKILL.md", + Path.Combine("references", "compatibility-and-validation.md"), + Path.Combine("references", "migration-patterns.md") + ], + expectedFiles.Select(file => file.RelativePath)); + + var command = provider.GetRequiredService(); + var selection = selectByName ? $" --skills {skill.Name}" : string.Empty; + var result = command.Parse($"agent init --workspace-root \"{workspace.WorkspaceRoot.FullName}\" --skill-locations all{selection}"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + var skillDirectories = SkillLocation.All + .Select(location => Path.Combine(workspace.WorkspaceRoot.FullName, location.RelativeSkillDirectory, skill.Name)) + .Append(Path.Combine(homeDirectory.FullName, SkillLocation.Standard.RelativeSkillDirectory, skill.Name)); + foreach (var skillDirectory in skillDirectories) + { + Assert.Equal( + expectedFiles.Select(file => file.RelativePath), + Directory.EnumerateFiles(skillDirectory, "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(skillDirectory, path)) + .Order(StringComparer.Ordinal)); + foreach (var file in expectedFiles) + { + Assert.Equal( + file.Content, + await File.ReadAllTextAsync(Path.Combine(skillDirectory, file.RelativePath), TestContext.Current.CancellationToken).DefaultTimeout()); + } + } + } + [Fact] public async Task AgentInitCommand_NonInteractive_WithCliDefinedSkillDifferentCasing_DoesNotResolveBundle() { @@ -514,6 +572,7 @@ public async Task AgentInitCommand_NonInteractive_WithoutSkills_UsesDefaultSkill AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireInitSkillName); AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireOrchestrationSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName); AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireifySkillName); } @@ -616,6 +675,7 @@ public async Task AgentInitCommand_NonInteractive_WithoutWorkspaceRoot_UsesWorki AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireInitSkillName); AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireMonitoringSkillName); AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireOrchestrationSkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName); AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireifySkillName); } @@ -710,7 +770,9 @@ public async Task PromptAndChainAsync_PreSelectsBundleDefaultsIncludingAspireify Assert.Equal(CliExitCodes.Success, result.ExitCode); Assert.Contains(result.SelectedSkills, static skill => skill.HasName(CommonAgentApplicators.AspireifySkillName)); + Assert.Contains(result.SelectedSkills, static skill => skill.HasName(FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName)); AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), CommonAgentApplicators.AspireifySkillName); + AssertSkillFileExists(workspace.WorkspaceRoot, Path.Combine(".agents", "skills"), FakeAspireSkillsInstaller.AspireProjectV2MigrationSkillName); } [Fact] diff --git a/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs b/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs index 72807372e5a..130b999cab4 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs @@ -51,6 +51,7 @@ internal sealed class FakeAspireSkillsInstaller : IAspireSkillsInstaller internal const string AspireInitSkillName = "aspire-init"; internal const string AspireMonitoringSkillName = "aspire-monitoring"; internal const string AspireOrchestrationSkillName = "aspire-orchestration"; + internal const string AspireProjectV2MigrationSkillName = "aspire-project-v2-migration"; private readonly DirectoryInfo _bundleDirectory; private readonly AspireSkillsInstallResult? _result; @@ -143,7 +144,19 @@ private async Task EnsureBundleAsync(CancellationToken cancellationToken) --- # Aspire Orchestration + """, + [(AspireProjectV2MigrationSkillName, "SKILL.md")] = """ + --- + name: aspire-project-v2-migration + description: "Migrate approved project resources in Aspire 13.6 or newer AppHosts" + --- + + # Project v2 migration + """, + [(AspireProjectV2MigrationSkillName, Path.Combine("references", "migration-patterns.md"))] = "# Migration patterns", + [(AspireProjectV2MigrationSkillName, Path.Combine("references", "compatibility-and-validation.md"))] = "# Compatibility and validation", + [(AspireProjectV2MigrationSkillName, Path.Combine("evals", "eval.yaml"))] = "stimuli: []" }; foreach (var ((skillName, relativePath), content) in files) @@ -168,7 +181,8 @@ private async Task EnsureBundleAsync(CancellationToken cancellationToken) CreateSkill(CommonAgentApplicators.AspireDeploymentSkillName, ["evals"], files), CreateSkill(AspireInitSkillName, ["evals"], files), CreateSkill(AspireMonitoringSkillName, ["evals"], files), - CreateSkill(AspireOrchestrationSkillName, ["evals"], files) + CreateSkill(AspireOrchestrationSkillName, ["evals"], files), + CreateSkill(AspireProjectV2MigrationSkillName, ["evals"], files) ] }; diff --git a/tests/Infrastructure.Tests/WorkflowScripts/VerifyTelemetryHookChangesWorkflowTests.cs b/tests/Infrastructure.Tests/WorkflowScripts/VerifyTelemetryHookChangesWorkflowTests.cs new file mode 100644 index 00000000000..7d68363abaa --- /dev/null +++ b/tests/Infrastructure.Tests/WorkflowScripts/VerifyTelemetryHookChangesWorkflowTests.cs @@ -0,0 +1,224 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.TestUtilities; +using Xunit; +using YamlDotNet.RepresentationModel; + +namespace Infrastructure.Tests; + +public sealed class VerifyTelemetryHookChangesWorkflowTests(ITestOutputHelper output) +{ + private const string WorkflowRelativePath = ".github/workflows/verify-telemetry-hook-changes.yml"; + private const string ProtectedHookPath = "src/Aspire.Cli/Agents/Hooks/track-telemetry.sh"; + private const string AspireSkillsInstallerPath = "src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs"; + private const string AspireSkillsMetadataPath = "src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json"; + private const string AspireCliProjectPath = "src/Aspire.Cli/Aspire.Cli.csproj"; + private const string OldArchivePath = "src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.2.tgz"; + private const string NewArchivePath = "src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.3.tgz"; + private const string ArchiveChange = "archive"; + private const string SynchronizationBranch = "update-aspire-skills-bundle"; + + private static readonly string[] s_requiredBundlePaths = + [ + AspireSkillsInstallerPath, + AspireSkillsMetadataPath, + AspireCliProjectPath, + ]; + + [Fact] + public void WorkflowProvidesMergeResultHistoryAndPullRequestHead() + { + var root = LoadWorkflow(); + var job = Mapping(Mapping(root, "jobs"), "verify"); + var steps = Sequence(job, "steps").Children.Cast().ToList(); + + var checkout = Assert.Single(steps, step => ScalarOrNull(step, "uses")?.StartsWith("actions/checkout@", StringComparison.Ordinal) == true); + var checkoutOptions = Mapping(checkout, "with"); + Assert.Equal("${{ github.sha }}", Scalar(checkoutOptions, "ref")); + Assert.Equal("2", Scalar(checkoutOptions, "fetch-depth")); + Assert.Equal("false", Scalar(checkoutOptions, "persist-credentials")); + + var verify = GetVerifyStep(steps); + Assert.Equal("pwsh", Scalar(verify, "shell")); + Assert.Equal("${{ github.head_ref }}", Scalar(Mapping(verify, "env"), "PR_HEAD_REF")); + Assert.False(string.IsNullOrWhiteSpace(Scalar(verify, "run"))); + } + + [Fact] + [RequiresTools(["git", "pwsh"])] + public async Task FeatureBranchRejectsHookOnlyChange() + { + using var workspace = CreateRepository(); + ChangeFile(workspace, ProtectedHookPath); + CommitAll(workspace, "Change telemetry hook only"); + + var result = await RunPolicyAsync(workspace, "feature/hook-only"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("Telemetry hook scripts can only change", result.Output, StringComparison.Ordinal); + } + + [Fact] + [RequiresTools(["git", "pwsh"])] + public async Task FeatureBranchAcceptsCompleteBundleRefresh() + { + using var workspace = CreateRepository(); + ChangeFile(workspace, ProtectedHookPath); + foreach (var path in s_requiredBundlePaths) + { + ChangeFile(workspace, path); + } + ReplaceArchive(workspace); + CommitAll(workspace, "Refresh complete Aspire skills bundle"); + + var result = await RunPolicyAsync(workspace, "feature/bundle-refresh"); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("Telemetry hook changes are part of a complete generated bundle update", result.Output, StringComparison.Ordinal); + Assert.Contains("Telemetry hook branch policy satisfied", result.Output, StringComparison.Ordinal); + } + + [Theory] + [InlineData(AspireSkillsInstallerPath)] + [InlineData(AspireSkillsMetadataPath)] + [InlineData(AspireCliProjectPath)] + [InlineData(ArchiveChange)] + [RequiresTools(["git", "pwsh"])] + public async Task FeatureBranchRejectsIncompleteBundleRefresh(string omittedChange) + { + using var workspace = CreateRepository(); + ChangeFile(workspace, ProtectedHookPath); + foreach (var path in s_requiredBundlePaths) + { + if (!string.Equals(path, omittedChange, StringComparison.Ordinal)) + { + ChangeFile(workspace, path); + } + } + if (!string.Equals(omittedChange, ArchiveChange, StringComparison.Ordinal)) + { + ReplaceArchive(workspace); + } + CommitAll(workspace, $"Refresh Aspire skills bundle without {omittedChange}"); + + var result = await RunPolicyAsync(workspace, "feature/incomplete-bundle-refresh"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("as part of a complete generated bundle update", result.Output, StringComparison.Ordinal); + } + + [Fact] + [RequiresTools(["git", "pwsh"])] + public async Task SynchronizationBranchAcceptsHookChange() + { + using var workspace = CreateRepository(); + ChangeFile(workspace, ProtectedHookPath); + CommitAll(workspace, "Synchronize telemetry hook"); + + var result = await RunPolicyAsync(workspace, SynchronizationBranch); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("Telemetry hook branch policy satisfied", result.Output, StringComparison.Ordinal); + Assert.DoesNotContain("complete generated bundle update", result.Output, StringComparison.Ordinal); + } + + private TemporaryWorkspace CreateRepository() + { + var workspace = TemporaryWorkspace.Create(output); + GitCli.Run(workspace.Path, "init", "-q", "-b", "main"); + GitCli.Run(workspace.Path, "config", "user.email", "test@example.com"); + GitCli.Run(workspace.Path, "config", "user.name", "Test"); + GitCli.Run(workspace.Path, "config", "commit.gpgsign", "false"); + + WriteFile(workspace, ProtectedHookPath, "#!/usr/bin/env bash\necho baseline\n"); + WriteFile(workspace, "src/Aspire.Cli/Agents/Hooks/track-telemetry.ps1", "Write-Output baseline\n"); + foreach (var path in s_requiredBundlePaths) + { + WriteFile(workspace, path, $"baseline {path}\n"); + } + WriteFile(workspace, OldArchivePath, "baseline archive\n"); + CommitAll(workspace, "Create baseline"); + + return workspace; + } + + private async Task RunPolicyAsync(TemporaryWorkspace workspace, string headRef) + { + var scriptPath = Path.Combine(workspace.Path, "verify-telemetry-hook-changes.ps1"); + await File.WriteAllTextAsync(scriptPath, GetPolicyScript()); + + using var command = new PowerShellCommand(scriptPath, output) + .WithWorkingDirectory(workspace.Path) + .WithEnvironmentVariable("PR_HEAD_REF", headRef) + .WithTimeout(TimeSpan.FromMinutes(1)); + + return await command.ExecuteAsync(); + } + + private static void ReplaceArchive(TemporaryWorkspace workspace) + { + File.Delete(GetFullPath(workspace, OldArchivePath)); + WriteFile(workspace, NewArchivePath, "refreshed archive\n"); + } + + private static void ChangeFile(TemporaryWorkspace workspace, string relativePath) + { + File.AppendAllText(GetFullPath(workspace, relativePath), "changed\n"); + } + + private static void WriteFile(TemporaryWorkspace workspace, string relativePath, string contents) + { + var path = GetFullPath(workspace, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, contents); + } + + private static string GetFullPath(TemporaryWorkspace workspace, string relativePath) + => Path.Combine(workspace.Path, relativePath.Replace('/', Path.DirectorySeparatorChar)); + + private static void CommitAll(TemporaryWorkspace workspace, string message) + { + GitCli.Run(workspace.Path, "add", "-A"); + GitCli.Run(workspace.Path, "commit", "-q", "-m", message); + } + + private static string GetPolicyScript() + { + var steps = Sequence(Mapping(Mapping(LoadWorkflow(), "jobs"), "verify"), "steps") + .Children + .Cast() + .ToList(); + return Scalar(GetVerifyStep(steps), "run"); + } + + private static YamlMappingNode GetVerifyStep(IReadOnlyList steps) + => Assert.Single( + steps, + step => string.Equals( + ScalarOrNull(step, "name"), + "Verify telemetry scripts use the synchronization branch", + StringComparison.Ordinal)); + + private static YamlMappingNode LoadWorkflow() + { + using var reader = File.OpenText(Path.Combine(RepoRoot.Path, WorkflowRelativePath)); + var yaml = new YamlStream(); + yaml.Load(reader); + return Assert.IsType(Assert.Single(yaml.Documents).RootNode); + } + + private static YamlMappingNode Mapping(YamlMappingNode node, string key) + => Assert.IsType(node.Children[new YamlScalarNode(key)]); + + private static YamlSequenceNode Sequence(YamlMappingNode node, string key) + => Assert.IsType(node.Children[new YamlScalarNode(key)]); + + private static string Scalar(YamlMappingNode node, string key) + => Assert.IsType(node.Children[new YamlScalarNode(key)]).Value!; + + private static string? ScalarOrNull(YamlMappingNode node, string key) + => node.Children.TryGetValue(new YamlScalarNode(key), out var value) + ? Assert.IsType(value).Value + : null; +} From a68ee523e6f8ebf58059c4d5e4a15443012d3413 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:40:58 -0500 Subject: [PATCH 8/9] Fix duplicate inherited ATS method capabilities Reuse inherited method exports only when the same capability ID is owned by a base context included in the scan. Preserve namespace and ExposeMethods aliases, unscanned bases, overrides, and genuine duplicate diagnostics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit cbaabf400506c0b36d3fbc8748b6076dfe0673cd) --- .../AtsCapabilityScanner.cs | 88 ++++++- .../AtsCapabilityScannerTests.Inheritance.cs | 219 ++++++++++++++++++ .../AtsCapabilityScannerTests.cs | 2 +- 3 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.Inheritance.cs diff --git a/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs b/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs index b60045cacee..51805e2e57f 100644 --- a/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs +++ b/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs @@ -120,9 +120,30 @@ public void Add(Type enumType) public List GetEnumTypes() => [.. _enums.Values]; } - private sealed class AssemblyExportedTypeCache(HashSet exportedTypes) + private sealed class AssemblyExportedTypeCache( + HashSet exportedTypes, + HashSet assemblies, + Dictionary> contextTypeExports) { public bool Contains(Type type) => exportedTypes.Contains(type); + + public IEnumerable<(AspireExportData Export, string AssemblyName)> GetContextTypeExports(Type type) + { + if ((assemblies.Contains(type.Assembly) || + assemblies.Any(assembly => assembly.IsDynamic && assembly.GetType(type.FullName ?? type.Name) == type)) && + GetAspireExportAttribute(type) is { } export) + { + yield return (export, type.Assembly.GetName().Name!); + } + + if (contextTypeExports.TryGetValue(type, out var exports)) + { + foreach (var assemblyExport in exports) + { + yield return assemblyExport; + } + } + } } /// @@ -1924,6 +1945,11 @@ private static ContextTypeCapabilitiesResult CreateContextTypeCapabilities( methodCapabilityId = $"{package}/{camelCaseMethodName}"; } + if (IsInheritedMethodExportedByBaseType(contextType, method, methodCapabilityId, memberExportAttr, assemblyExportedTypeCache)) + { + continue; + } + // Build parameters (first parameter is the context/instance) var paramInfos = new List { @@ -2017,6 +2043,52 @@ private static ContextTypeCapabilitiesResult CreateContextTypeCapabilities( }; } + private static bool IsInheritedMethodExportedByBaseType( + Type contextType, + MethodInfo method, + string capabilityId, + AspireExportData? memberExport, + AssemblyExportedTypeCache assemblyExportedTypeCache) + { + var declaringType = method.DeclaringType; + if (declaringType is null || declaringType == contextType) + { + return false; + } + + // Reflection also returns hidden base methods. Reuse a base export only when + // this scan includes it with the same ID; distinct namespace/ExposeMethods + // aliases and methods from an unscanned or unexported base must remain available. + for (var baseType = contextType.BaseType; baseType is not null; baseType = baseType.BaseType) + { + if (!declaringType.IsAssignableFrom(baseType)) + { + continue; + } + + foreach (var (export, assemblyName) in assemblyExportedTypeCache.GetContextTypeExports(baseType)) + { + if (!ShouldExportMember(method.IsPublic, export.ExposeMethods, memberExport)) + { + continue; + } + + var fullName = baseType.FullName ?? baseType.Name; + var lastDot = fullName.LastIndexOf('.'); + var package = lastDot >= 0 ? fullName[..lastDot] : assemblyName; + var methodName = memberExport?.Id ?? (export.ExposeMethods + ? $"{baseType.Name}.{ToCamelCase(method.Name)}" + : ToCamelCase(method.Name)); + if (capabilityId == $"{package}/{methodName}") + { + return true; + } + } + } + + return false; + } + private static bool IsInheritedPropertyExportedByBaseType(Type contextType, PropertyInfo property) { var declaringType = property.DeclaringType; @@ -3447,20 +3519,32 @@ private static string ToCamelCase(string name) private static AssemblyExportedTypeCache CreateAssemblyExportedTypeCache(IEnumerable assemblies) { var currentExportedTypes = new HashSet(); + var scannedAssemblies = new HashSet(); + var contextTypeExports = new Dictionary>(); foreach (var assembly in assemblies) { + scannedAssemblies.Add(assembly); foreach (var export in AttributeDataReader.GetAspireExportDataAll(assembly)) { if (export.Type is { } exportedType) { currentExportedTypes.Add(exportedType); s_assemblyExportedTypeCache.TryAdd(exportedType, 0); + if (export.ExposeProperties || export.ExposeMethods) + { + if (!contextTypeExports.TryGetValue(exportedType, out var exports)) + { + exports = []; + contextTypeExports.Add(exportedType, exports); + } + exports.Add((export, assembly.GetName().Name!)); + } } } } - return new AssemblyExportedTypeCache(currentExportedTypes); + return new AssemblyExportedTypeCache(currentExportedTypes, scannedAssemblies, contextTypeExports); } /// diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.Inheritance.cs b/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.Inheritance.cs new file mode 100644 index 00000000000..27a5c84da32 --- /dev/null +++ b/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.Inheritance.cs @@ -0,0 +1,219 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; +using System.Reflection.Emit; +using Aspire.TypeSystem; +using Xunit; + +namespace Aspire.Hosting.RemoteHost.Tests; + +public partial class AtsCapabilityScannerTests +{ + [Fact] + public void ScanAssemblies_HiddenInternalMethods_KeepEachDeclaredExport() + { + var module = CreateInheritanceModule(); + var baseType = DefineMethodContext(module, "Generated.BaseRule", null, true, false, + ("AddTo", "BaseRule.addTo", MethodAttributes.Assembly), + ("ClearName", "BaseRule.clearName", MethodAttributes.Assembly)); + var first = DefineMethodContext(module, "Generated.FirstRule", baseType, true, false, + ("AddTo", "FirstRule.addTo", MethodAttributes.Assembly)); + var second = DefineMethodContext(module, "Generated.SecondRule", baseType, true, false, + ("AddTo", "SecondRule.addTo", MethodAttributes.Assembly)); + + var result = AtsCapabilityScanner.ScanAssemblies([module.Assembly]); + + Assert.Empty(result.Diagnostics); + Assert.Equal( + ["Generated/BaseRule.addTo", "Generated/BaseRule.clearName", "Generated/FirstRule.addTo", "Generated/SecondRule.addTo"], + result.Capabilities.Select(c => c.CapabilityId).Order()); + foreach (var type in new[] { baseType, first, second }) + { + var id = $"Generated/{type.Name}.addTo"; + var capability = Assert.Single(result.Capabilities, c => c.CapabilityId == id); + Assert.Equal("addTo", capability.MethodName); + Assert.Equal(AtsTypeMapping.DeriveTypeId(type), capability.TargetTypeId); + Assert.Equal(type, result.Methods[id].DeclaringType); + result.Methods[id].Invoke(Activator.CreateInstance(type), null); + } + Assert.Equal(baseType, result.Methods["Generated/BaseRule.clearName"].DeclaringType); + var clear = Assert.Single(result.Capabilities, c => c.CapabilityId == "Generated/BaseRule.clearName"); + Assert.Equal(AtsTypeMapping.DeriveTypeId(baseType), clear.TargetTypeId); + } + + [Fact] + public void ScanAssemblies_UnexportedAncestor_RetainsInheritedMethod() + { + var module = CreateInheritanceModule(); + var baseType = DefineMethodContext(module, "Generated.UnexportedBase", null, false, false, + ("AddTo", "base.addTo", MethodAttributes.Assembly)); + var leaf = DefineMethodContext(module, "Generated.Leaf", baseType, true, false); + + var result = AtsCapabilityScanner.ScanAssemblies([module.Assembly]); + + Assert.Empty(result.Diagnostics); + var capability = Assert.Single(result.Capabilities); + Assert.Equal("Generated/base.addTo", capability.CapabilityId); + Assert.Equal(AtsTypeMapping.DeriveTypeId(leaf), capability.TargetTypeId); + Assert.Equal(baseType, result.Methods[capability.CapabilityId].DeclaringType); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ScanAssemblies_IndependentDeclarationsWithSameId_StillReportDuplicate(bool inherited) + { + var module = CreateInheritanceModule(); + var baseType = DefineMethodContext(module, "Generated.Base", null, true, false, + ("AddTo", "duplicate", MethodAttributes.Assembly)); + DefineMethodContext(module, "Generated.Derived", inherited ? baseType : null, true, false, + ("AddTo", "duplicate", MethodAttributes.Assembly)); + + var result = AtsCapabilityScanner.ScanAssemblies([module.Assembly]); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal(AtsDiagnosticSeverity.Error, diagnostic.Severity); + Assert.Contains("Duplicate capability 'Generated/duplicate'", diagnostic.Message); + Assert.Single(result.Capabilities); + } + + [Fact] + public void ScanAssemblies_ExposeMethods_PreservesTypeQualifiedAliases() + { + var module = CreateInheritanceModule(); + var baseType = DefineMethodContext(module, "Generated.Base", null, true, true, + ("Run", null, MethodAttributes.Public)); + var derived = DefineMethodContext(module, "Generated.Derived", baseType, true, true); + + var result = AtsCapabilityScanner.ScanAssemblies([module.Assembly]); + + Assert.Empty(result.Diagnostics); + Assert.Equal(["Generated/Base.run", "Generated/Derived.run"], result.Capabilities.Select(c => c.CapabilityId).Order()); + Assert.Equal(baseType, result.Methods["Generated/Derived.run"].DeclaringType); + Assert.Equal(AtsTypeMapping.DeriveTypeId(derived), + Assert.Single(result.Capabilities, c => c.CapabilityId == "Generated/Derived.run").TargetTypeId); + } + + [Fact] + public void ScanAssemblies_VirtualOverride_PreservesOverrideDispatch() + { + var module = CreateInheritanceModule(); + var baseType = DefineMethodContext(module, "Generated.Base", null, true, false, + ("Run", "base.run", MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.NewSlot)); + var derived = DefineMethodContext(module, "Generated.Derived", baseType, true, false, + ("Run", "derived.run", MethodAttributes.Public | MethodAttributes.Virtual)); + + var result = AtsCapabilityScanner.ScanAssemblies([module.Assembly]); + + Assert.Empty(result.Diagnostics); + Assert.Equal(["Generated/base.run", "Generated/derived.run"], result.Capabilities.Select(c => c.CapabilityId).Order()); + var method = result.Methods["Generated/derived.run"]; + Assert.Equal(derived, method.DeclaringType); + Assert.Equal(baseType, method.GetBaseDefinition().DeclaringType); + method.Invoke(Activator.CreateInstance(derived), null); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void ScanAssemblies_InheritedMethod_RespectsNamespaceAndAssemblyScope(bool otherNamespace, bool includeBaseAssembly) + { + var baseModule = CreateInheritanceModule(); + var baseType = DefineMethodContext(baseModule, "Generated.Base", null, true, false, + ("Run", "base.run", MethodAttributes.Public)); + var derivedModule = CreateInheritanceModule(); + var derivedNamespace = otherNamespace ? "Other" : "Generated"; + var derived = DefineMethodContext(derivedModule, $"{derivedNamespace}.Derived", baseType, true, false); + Assembly[] assemblies = includeBaseAssembly + ? [derivedModule.Assembly, baseModule.Assembly] + : [derivedModule.Assembly]; + + foreach (var scanOrder in new[] { assemblies, assemblies.Reverse().ToArray() }) + { + var result = AtsCapabilityScanner.ScanAssemblies(scanOrder); + + Assert.Empty(result.Diagnostics); + string[] expectedIds = otherNamespace && includeBaseAssembly + ? ["Generated/base.run", "Other/base.run"] + : [$"{derivedNamespace}/base.run"]; + Assert.Equal(expectedIds, result.Capabilities.Select(c => c.CapabilityId).Order()); + var capability = Assert.Single(result.Capabilities, c => c.CapabilityId == $"{derivedNamespace}/base.run"); + var owner = includeBaseAssembly && !otherNamespace ? baseType : derived; + Assert.Equal(AtsTypeMapping.DeriveTypeId(owner), capability.TargetTypeId); + Assert.Equal(baseType, result.Methods[capability.CapabilityId].DeclaringType); + } + } + + [Fact] + public void ScanAssemblies_AssemblyLevelBaseExport_ReusesIncludedMethod() + { + var baseType = typeof(AssemblyExportedMethodBase); + var derivedModule = CreateInheritanceModule(); + DefineMethodContext(derivedModule, $"{baseType.Namespace}.Derived", baseType, true, false); + var exportModule = CreateInheritanceModule(); + ((AssemblyBuilder)exportModule.Assembly).SetCustomAttribute(new CustomAttributeBuilder( + typeof(AspireExportAttribute).GetConstructor([typeof(Type)])!, + [baseType], + [typeof(AspireExportAttribute).GetProperty(nameof(AspireExportAttribute.ExposeMethods))!], + [true])); + + var result = AtsCapabilityScanner.ScanAssemblies([derivedModule.Assembly, exportModule.Assembly]); + + Assert.Empty(result.Diagnostics); + var capability = Assert.Single(result.Capabilities); + Assert.Equal($"{baseType.Namespace}/base.run", capability.CapabilityId); + Assert.Equal(AtsTypeMapping.DeriveTypeId(baseType), capability.TargetTypeId); + } + + private static ModuleBuilder CreateInheritanceModule() + { + var name = $"Inheritance_{Guid.NewGuid():N}"; + return AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(name), AssemblyBuilderAccess.Run).DefineDynamicModule(name); + } + + private static Type DefineMethodContext( + ModuleBuilder module, + string name, + Type? baseType, + bool exported, + bool exposeMethods, + params (string Name, string? Id, MethodAttributes Attributes)[] methods) + { + var builder = module.DefineType(name, TypeAttributes.Public, baseType); + builder.DefineDefaultConstructor(MethodAttributes.Public); + if (exported) + { + builder.SetCustomAttribute(new CustomAttributeBuilder( + typeof(AspireExportAttribute).GetConstructor(Type.EmptyTypes)!, + [], + [typeof(AspireExportAttribute).GetProperty(nameof(AspireExportAttribute.ExposeMethods))!], + [exposeMethods])); + } + + foreach (var (methodName, id, attributes) in methods) + { + var method = builder.DefineMethod(methodName, attributes | MethodAttributes.HideBySig, typeof(void), Type.EmptyTypes); + if (id is not null) + { + method.SetCustomAttribute(new CustomAttributeBuilder( + typeof(AspireExportAttribute).GetConstructor([typeof(string)])!, + [id], + [typeof(AspireExportAttribute).GetProperty(nameof(AspireExportAttribute.MethodName))!], + [char.ToLowerInvariant(methodName[0]) + methodName[1..]])); + } + method.GetILGenerator().Emit(OpCodes.Ret); + } + return builder.CreateType()!; + } + + public class AssemblyExportedMethodBase + { + [AspireExport("base.run")] + public void Run() + { + } + } +} diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs index e8d181332fd..f6b4f8fe8f2 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs @@ -12,7 +12,7 @@ namespace Aspire.Hosting.RemoteHost.Tests; -public class AtsCapabilityScannerTests +public partial class AtsCapabilityScannerTests { #region MapToAtsTypeId Tests From 82dc035b97dd49dfba1f73039c1af91774d90e1a Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:11:37 -0500 Subject: [PATCH 9/9] Preserve target-specific ATS overloads on derived resources Extend existing concrete-target precedence through CLR inheritance. This prevents Radius CSharpAppResource collisions from globally dropping the generic IDotnetProgramResource export while preserving both wire IDs and guest method names. Cover derived targets, unrelated implementations, dispatch, and genuinely ambiguous interfaces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AtsCapabilityScanner.cs | 17 +++- .../AtsCapabilityScannerTests.cs | 87 ++++++++++++++++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs b/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs index 51805e2e57f..7959b955ac8 100644 --- a/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs +++ b/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs @@ -1024,7 +1024,7 @@ private static void AddToCompatibilityMap( /// /// Detects method name collisions after capability expansion. Since ATS doesn't support method /// overloading, each (TargetTypeId, MethodName) pair must be unique. When a concrete target has - /// a target-specific export, it shadows matching generic exports only for that target. Ambiguous + /// a target-specific export, it shadows matching generic exports for that target and its derived types. Ambiguous /// collisions still remove later capabilities and emit warnings. /// private static void FilterMethodNameCollisions(List capabilities, List diagnostics) @@ -1060,6 +1060,21 @@ private static void FilterMethodNameCollisions(List capabilit .Where(c => string.Equals(c.TargetTypeId, targetTypeId, StringComparison.Ordinal)) .ToList(); + // An inherited concrete-target overload is still more specific than an + // interface overload. Without this, a subclass collision can remove the + // generic capability even from unrelated implementations of that interface. + if (exactTargetCapabilities.Count == 0) + { + exactTargetCapabilities = collidingCapabilities + .Where(candidate => candidate.TargetType?.ClrType is { } candidateType && + collidingCapabilities.All(other => + other.CapabilityId == candidate.CapabilityId || + (other.TargetType?.ClrType is { } otherType && + otherType != candidateType && + otherType.IsAssignableFrom(candidateType)))) + .ToList(); + } + if (exactTargetCapabilities.Count == 1) { var exactTargetCapability = exactTargetCapabilities[0]; diff --git a/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs b/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs index f6b4f8fe8f2..e5fb3f339cc 100644 --- a/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs +++ b/tests/Aspire.Hosting.RemoteHost.Tests/AtsCapabilityScannerTests.cs @@ -611,6 +611,73 @@ public void ScanAssembly_TargetSpecificMethodShadowsGenericExpandedMethodOnlyFor && d.Message.Contains("has collisions", StringComparison.Ordinal)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ScanAssembly_BaseTargetMethodShadowsGenericMethodForDerivedTargets(bool scanAssemblies) + { + var assembly = typeof(AtsCapabilityScannerTests).Assembly; + var result = scanAssemblies + ? AtsCapabilityScanner.ScanAssemblies([assembly]) + : AtsCapabilityScanner.ScanAssembly(assembly); + var generic = Assert.Single(result.Capabilities, c => c.CapabilityId.EndsWith("/shadowedExporter", StringComparison.Ordinal)); + var specific = Assert.Single(result.Capabilities, c => c.CapabilityId.EndsWith("/specificShadowedExporter", StringComparison.Ordinal)); + var expectedTargets = new[] + { + typeof(ShadowedEnvironmentResource), + typeof(DerivedShadowedEnvironmentResource), + typeof(GrandchildShadowedEnvironmentResource) + }.Select(AtsTypeMapping.DeriveTypeId).Order().ToArray(); + + Assert.Equal(expectedTargets, specific.ExpandedTargetTypes.Select(t => t.TypeId).Order()); + Assert.Empty(generic.ExpandedTargetTypes.Select(t => t.TypeId).Intersect(expectedTargets)); + Assert.Contains(generic.ExpandedTargetTypes, t => t.TypeId == AtsTypeMapping.DeriveTypeId(typeof(OtherEnvironmentResource))); + Assert.DoesNotContain(result.Diagnostics, d => d.Severity != AtsDiagnosticSeverity.Info && + d.Message.Contains("shadowedExporter", StringComparison.Ordinal)); + + var application = DistributedApplication.CreateBuilder(); + var project = new GrandchildShadowedEnvironmentResource("project"); + var builder = application.AddResource(project); + Assert.Same(builder, result.Methods[specific.CapabilityId].Invoke(null, [builder])); + var other = new OtherEnvironmentResource("other"); + var otherBuilder = application.AddResource(other); + var genericMethod = result.Methods[generic.CapabilityId].MakeGenericMethod(typeof(OtherEnvironmentResource)); + Assert.Same(otherBuilder, genericMethod.Invoke(null, [otherBuilder])); + } + + [Fact] + public void ScanAssembly_UnrelatedInterfaceTargetsRemainAmbiguous() + { + var module = CreateInheritanceModule(); + var exports = module.DefineType("Ambiguous.Exports", TypeAttributes.Public | TypeAttributes.Abstract | TypeAttributes.Sealed); + foreach (var (id, target, name) in new[] + { + ("environment", typeof(IResourceWithEnvironment), "configure"), + ("arguments", typeof(IResourceWithArgs), "configure"), + ("probe", typeof(AmbiguousEnvironmentResource), "probe") + }) + { + var builderType = typeof(IResourceBuilder<>).MakeGenericType(target); + var method = exports.DefineMethod(id, MethodAttributes.Public | MethodAttributes.Static, builderType, [builderType]); + method.SetCustomAttribute(new CustomAttributeBuilder( + typeof(AspireExportAttribute).GetConstructor([typeof(string)])!, + [id], + [typeof(AspireExportAttribute).GetProperty(nameof(AspireExportAttribute.MethodName))!], + [name])); + var il = method.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ret); + } + exports.CreateType(); + + var result = AtsCapabilityScanner.ScanAssembly(module.Assembly); + + var warning = Assert.Single(result.Diagnostics, d => d.Severity == AtsDiagnosticSeverity.Warning); + Assert.Contains("has collisions", warning.Message); + Assert.Contains(AtsTypeMapping.DeriveTypeId(typeof(AmbiguousEnvironmentResource)), warning.Message); + Assert.Single(result.Capabilities, c => c.MethodName == "configure"); + } + #endregion #region Callback Parameter Type Resolution Tests @@ -866,7 +933,13 @@ public TestResource(string name) : base(name) } } - private sealed class ShadowedEnvironmentResource(string name) : Resource(name), IResourceWithEnvironment; + private class ShadowedEnvironmentResource(string name) : Resource(name), IResourceWithEnvironment; + + private class DerivedShadowedEnvironmentResource(string name) : ShadowedEnvironmentResource(name); + + private sealed class GrandchildShadowedEnvironmentResource(string name) : DerivedShadowedEnvironmentResource(name); + + private sealed class AmbiguousEnvironmentResource(string name) : Resource(name), IResourceWithEnvironment, IResourceWithArgs; private sealed class OtherEnvironmentResource(string name) : Resource(name), IResourceWithEnvironment; @@ -972,6 +1045,18 @@ public static IResourceBuilder OtherEnvironmentProbe(I { return builder; } + + [AspireExport("derivedEnvironmentProbe")] + public static IResourceBuilder DerivedEnvironmentProbe(IResourceBuilder builder) + { + return builder; + } + + [AspireExport("grandchildEnvironmentProbe")] + public static IResourceBuilder GrandchildEnvironmentProbe(IResourceBuilder builder) + { + return builder; + } } private static class InvalidExportedValues