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/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 8ecf73d4007..3c2fe3c78e8 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 @@ -69,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/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/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 0336dd345db..00000000000 Binary files a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.2.tgz and /dev/null differ 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 00000000000..90c023efc1d Binary files /dev/null and b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills-v0.0.3.tgz differ 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 7257ea20e11..05d86d6fc3e 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 @@ + + + + + + + + @@ -318,7 +374,7 @@ - + false 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.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/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/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/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/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/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/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/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/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/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/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs b/src/Aspire.Hosting.RemoteHost/AtsCapabilityScanner.cs index b60045cacee..7959b955ac8 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; + } + } + } } /// @@ -1003,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) @@ -1039,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]; @@ -1924,6 +1960,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 +2058,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 +3534,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/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/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/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/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/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/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/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.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 5f4ba45592f..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); }); @@ -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); @@ -1168,14 +1168,13 @@ public void CollapsedResourceNames_FetchedAfterDashboardClientConnected_KeyInclu connectionTcs.SetResult(); // Act - Render the component - var cut = RenderComponent(builder => + var cut = Render(builder => { builder.AddCascadingValue(viewport); }); // 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.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/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..fc7260a950b 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] @@ -357,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() { @@ -375,6 +430,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 +552,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/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/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 9fa4b6ea115..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,64 @@ 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)] +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)] @@ -219,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 d442f6889c6..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!; @@ -46,15 +50,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 +85,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) @@ -124,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 5065fff1454..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); @@ -569,6 +570,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 +1047,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 +1104,7 @@ public override AsyncUnaryCall GetApplicationInf return new AsyncUnaryCall( Task.FromResult(new ApplicationInformationResponse { - ApplicationName = "TestApplication", + ApplicationName = ApplicationName, MinDashboardVersion = MinDashboardVersion }), Task.FromResult(new Metadata()), @@ -1262,21 +1279,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 +1302,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 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 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/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..e5fb3f339cc 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 @@ -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 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/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 @@ - + 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; +} 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