diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c15e399..a0ea1ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,10 +73,12 @@ jobs: - name: Test UI against native backend if: runner.os == 'Linux' run: npm --prefix ui run test:browser - - name: Install browser for screenshot comparison - if: matrix.os == 'macos-latest' + - name: Install browser + if: runner.os != 'Linux' working-directory: ui run: npx playwright install chromium + - name: Exercise the real features end to end + run: npm --prefix ui run test:e2e - name: Compare screenshots if: matrix.os == 'macos-latest' run: npm --prefix ui run test:visual @@ -96,10 +98,10 @@ jobs: path: artifacts/dotnet-cockpit-${{ matrix.rid }}.* if-no-files-found: error - name: Upload browser failure traces - if: failure() && runner.os == 'Linux' + if: failure() uses: actions/upload-artifact@v7 with: - name: browser-test-results + name: browser-test-results-${{ matrix.rid }} path: ui/test-results/ if-no-files-found: ignore diff --git a/AGENTS.md b/AGENTS.md index b30fc93..3021db0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,9 @@ - Support dates, end-of-life state and the timeline come only from the release catalog. When it is unavailable, say the status is unknown instead of guessing. - Preferences live in `SettingsStore`, not browser storage, because the free port changes the origin on every launch. - Styles use the tokens in `ui/src/styles/tokens.css`: six type sizes, three radii, semantic colors. Add no literal colors, other sizes or `!important`. Palettes in `palettes.css` override color roles only and must pass the contrast test in both themes. -- `/setup` may detect, install, update, and remove only the official global `dotnet-core-uninstall` installation for Windows and macOS. Global installs use the platform paths and must surface administrator approval. Release discovery must follow every page of the official GitHub Releases API, filter stable releases to those with a compatible asset, and treat an incomplete/failed response as unavailable rather than presenting a partial list. Do not download arbitrary URLs or claim Linux support. +- `/setup` may detect, install, update, and remove only the official global `dotnet-core-uninstall` installation for Windows and macOS. Global installs use the platform paths and must surface administrator approval. Release discovery must follow every page of the official GitHub Releases API, filter stable releases to those with a compatible asset, and treat an incomplete/failed response as unavailable rather than presenting a partial list. Do not download arbitrary URLs or claim Linux support. Install .NET offers `.pkg` on macOS and `.exe` on Windows; on Linux it only links to Microsoft's install guide. - The CLI's bracketed path is a base directory. Display `InstallationPath` (base path plus version) for an individual SDK or runtime; retain `Path` in the JSON API for compatibility. -- Run `dotnet test DotnetCockpit.sln`, publish the native binary, run `node scripts/smoke.mjs `, and run the browser tests for UI changes. For visual changes also run `npm --prefix ui run test:visual`; after an intended change, update the baselines with `npm --prefix ui run test:visual:update` and review them. +- Run `dotnet test DotnetCockpit.sln`, publish the native binary, run `node scripts/smoke.mjs `, and run the browser tests for UI changes. `npm --prefix ui run test:e2e` runs the real features against the native binary; it keeps tools in a temporary CLI home, and only touches .NET itself on a Windows CI runner. For visual changes also run `npm --prefix ui run test:visual`; after an intended change, update the baselines with `npm --prefix ui run test:visual:update` and review them. - Pages read machine state through `LiveData`, which answers from memory and revalidates in the background; never make a page wait for a process or the network. Refresh it after every change. Measure with `npm --prefix ui run bench` against the native AOT binary; results from other builds do not count. - Use `gh` for GitHub operations. Commit messages follow Conventional Commits and contain no AI attribution. - Keep changes focused and avoid speculative abstractions or functionality. diff --git a/docs/Features.md b/docs/Features.md index 0ec2438..cd541fb 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -24,7 +24,7 @@ A local declaration is not proof that its package has been restored. Cockpit nev On macOS and Windows, Cockpit can detect, install, update, and remove the official global `dotnet-core-uninstall` installation. These operations request administrator approval where the platform requires it. Linux is not supported by the official tool. -On macOS, **Versions → Install .NET** lists official `.pkg` installers for the host architecture. The catalog comes from Microsoft's release metadata and includes SDKs and standalone .NET Runtimes, release channels, support status, release dates, SHA-512 checksums, and release CVEs where Microsoft provides them. Downloading a package is separate from installing it: open the downloaded package in macOS Installer to complete installation. +**Versions → Install .NET** lists official installers for the host architecture: `.pkg` on macOS and `.exe` on Windows. Microsoft offers no installer for Linux, so there Cockpit shows versions, support status and tools, and links to the official install guide. The catalog comes from Microsoft's release metadata and includes SDKs and standalone .NET Runtimes, release channels, support status, release dates, SHA-512 checksums, and release CVEs where Microsoft provides them. Downloading a package is separate from installing it: open the downloaded package in macOS Installer to complete installation. The SDK installer includes the matching .NET Runtime and ASP.NET Core Runtime. Use the standalone Runtime package only when you do not need the SDK. diff --git a/src/DotnetCockpit/Installation/DotnetInstallationService.cs b/src/DotnetCockpit/Installation/DotnetInstallationService.cs index f593e88..d063fa4 100644 --- a/src/DotnetCockpit/Installation/DotnetInstallationService.cs +++ b/src/DotnetCockpit/Installation/DotnetInstallationService.cs @@ -155,10 +155,7 @@ private static bool IsQueryFailure(Exception exception) => private static string? FindUninstallTool() { var executable = OperatingSystem.IsWindows() ? "dotnet-core-uninstall.exe" : "dotnet-core-uninstall"; - var globalDirectory = OperatingSystem.IsWindows() - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet-core-uninstall") - : "/usr/local/bin"; - var candidates = new[] { Path.Combine(globalDirectory, executable) } + var candidates = new[] { Path.Combine(Setup.DotnetUninstallToolService.GetGlobalDirectory(), executable) } .Concat((Environment.GetEnvironmentVariable("PATH") ?? "") .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) .Select(directory => Path.Combine(directory.Trim('"'), executable))); diff --git a/src/DotnetCockpit/InstallationPage.cs b/src/DotnetCockpit/InstallationPage.cs index 784bc4a..4b42e05 100644 --- a/src/DotnetCockpit/InstallationPage.cs +++ b/src/DotnetCockpit/InstallationPage.cs @@ -537,8 +537,19 @@ static bool IsDownloadEol(SdkDownload download, DateOnly date) => .ToArray(); + var hasInstallers = InstallerPlatform.Current.HasInstallers; string resultsHtml; - if (sdkSnapshot.Status != "ready" || curated.Length == 0) + if (!hasInstallers) + { + resultsHtml = $""" +
+

Install .NET with your distribution's packages

+

Microsoft offers no installer for Linux, so Cockpit shows versions and support status here but does not install or remove them.

+ {Icon("external", "xs")} Official install guide +
+ """; + } + else if (sdkSnapshot.Status != "ready" || curated.Length == 0) { resultsHtml = $"""
@@ -702,7 +713,7 @@ static bool IsDownloadEol(SdkDownload download, DateOnly date) => {{Icon("x", "sm")}}
-
+
@@ -981,7 +992,11 @@ private static string GetInstallerFileName(string? url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) ? System.IO.Path.GetFileName(uri.LocalPath) : ""; private static string FormatVerifyInstallCommand(string? sha512, string fileName) => - !string.IsNullOrEmpty(sha512) + OperatingSystem.IsWindows() + ? !string.IsNullOrEmpty(sha512) + ? $"if ((Get-FileHash {fileName} -Algorithm SHA512).Hash -ne \"{sha512}\") {{ throw \"Checksum mismatch\" }}\n.\\{fileName} /install /quiet /norestart" + : $".\\{fileName} /install /quiet /norestart" + : !string.IsNullOrEmpty(sha512) ? $"echo \"{sha512} {fileName}\" \\\n | shasum -a 512 -c - \\\n && sudo installer -pkg {fileName} \\\n -target /" : $"sudo installer -pkg {fileName} \\\n -target /"; diff --git a/src/DotnetCockpit/PageLayout.cs b/src/DotnetCockpit/PageLayout.cs index 08993ee..e95d297 100644 --- a/src/DotnetCockpit/PageLayout.cs +++ b/src/DotnetCockpit/PageLayout.cs @@ -108,7 +108,7 @@ public static string Render(string content, string currentPath, string title = " {{Icon("download", "sm")}} -
Install new .NET SDK or Runtime · Official macOS .pkg catalog
+
Install new .NET SDK or Runtime · Official Microsoft releases
Download
diff --git a/src/DotnetCockpit/SdkDownloads/SdkDownloadCatalog.cs b/src/DotnetCockpit/SdkDownloads/SdkDownloadCatalog.cs index 39178ff..30e1790 100644 --- a/src/DotnetCockpit/SdkDownloads/SdkDownloadCatalog.cs +++ b/src/DotnetCockpit/SdkDownloads/SdkDownloadCatalog.cs @@ -9,10 +9,27 @@ internal sealed record SdkDownload(string Product, string Version, string Instal internal sealed record SdkDownloadSnapshot(string Status, SdkDownload[] Downloads, string? Error = null, ChannelLifecycle[]? Channels = null); +/// Which official installer fits this machine. Microsoft offers none for Linux, only archives and package feeds. +internal sealed record InstallerPlatform(string Rid, string? Extension) +{ + public static readonly InstallerPlatform Mac = new("osx", ".pkg"); + public static readonly InstallerPlatform Windows = new("win", ".exe"); + public static readonly InstallerPlatform Linux = new("linux", null); + + public static InstallerPlatform Current => + OperatingSystem.IsMacOS() ? Mac : OperatingSystem.IsWindows() ? Windows : Linux; + + public bool HasInstallers => Extension is not null; + + public const string LinuxInstallGuide = "https://learn.microsoft.com/en-us/dotnet/core/install/linux"; +} + internal sealed record StoredCatalog(SdkDownload[] Downloads, ChannelLifecycle[] Channels); -internal sealed class SdkDownloadCatalog(ILogger logger, HttpClient httpClient, string? storeDirectory = null) +internal sealed class SdkDownloadCatalog(ILogger logger, HttpClient httpClient, string? storeDirectory = null, InstallerPlatform? platform = null) { + private readonly InstallerPlatform installerPlatform = platform ?? InstallerPlatform.Current; + private const string IndexUrl = "https://builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json"; private static readonly Uri OfficialHost = new("https://builds.dotnet.microsoft.com/"); private (SdkDownload[] Downloads, ChannelLifecycle[] Channels)? cachedCatalog; @@ -193,7 +210,7 @@ private async Task RefreshInBackgroundAsync() return (downloads.ToArray(), lifecycle); } - private static void AddPackages(JsonElement release, string productProperty, string productName, string packagePrefix, + private void AddPackages(JsonElement release, string productProperty, string productName, string packagePrefix, string channel, string releaseType, string supportPhase, string? endOfSupportDate, string releaseDate, SdkCve[] cves, List downloads) { if (!release.TryGetProperty(productProperty, out var product) || product.ValueKind != JsonValueKind.Object) @@ -201,7 +218,7 @@ private static void AddPackages(JsonElement release, string productProperty, str AddProductElement(product, productProperty, productName, packagePrefix, channel, releaseType, supportPhase, endOfSupportDate, releaseDate, cves, downloads); } - private static void AddProductElement(JsonElement product, string productProperty, string productName, string packagePrefix, + private void AddProductElement(JsonElement product, string productProperty, string productName, string packagePrefix, string channel, string releaseType, string supportPhase, string? endOfSupportDate, string releaseDate, SdkCve[] cves, List downloads) { if (!product.TryGetProperty("files", out var files) || files.ValueKind != JsonValueKind.Array) @@ -212,11 +229,18 @@ private static void AddProductElement(JsonElement product, string productPropert var installationVersion = GetString(product, "version") ?? version; var includedRuntimeVersion = productProperty == "sdk" ? GetString(product, "runtime-version") : null; + // Without an installer the release still counts: patches and security fixes are reported the same way. + if (!installerPlatform.HasInstallers) + { + downloads.Add(new(productName, version, installationVersion, includedRuntimeVersion, channel, releaseType, supportPhase, endOfSupportDate, releaseDate, cves, "", "")); + return; + } + var architecture = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x64"; foreach (var file in files.EnumerateArray()) { var name = GetString(file, "name"); - if (!string.Equals(name, $"{packagePrefix}-osx-{architecture}.pkg", StringComparison.Ordinal)) continue; + if (!string.Equals(name, $"{packagePrefix}-{installerPlatform.Rid}-{architecture}{installerPlatform.Extension}", StringComparison.Ordinal)) continue; var url = GetString(file, "url"); if (!IsOfficialUrl(url)) continue; downloads.Add(new(productName, version, installationVersion, includedRuntimeVersion, channel, releaseType, supportPhase, endOfSupportDate, releaseDate, cves, url!, GetString(file, "hash") ?? "")); diff --git a/src/DotnetCockpit/Setup/DotnetUninstallToolService.cs b/src/DotnetCockpit/Setup/DotnetUninstallToolService.cs index e966b7c..9f0d7d9 100644 --- a/src/DotnetCockpit/Setup/DotnetUninstallToolService.cs +++ b/src/DotnetCockpit/Setup/DotnetUninstallToolService.cs @@ -106,7 +106,10 @@ public async Task InstallAsync(string? version, CancellationT await InstallWindowsAsync(downloadBase, assetName, temporaryDirectory, timeout.Token); if (!Read().UninstallTool.Installed) + { + logger.LogWarning("The dotnet-core-uninstall installer completed, but the executable is not in {Directory}.", GetGlobalDirectory()); return new(false, "The installer completed, but the dotnet-core-uninstall executable was not found at the expected path."); + } return new(true); } catch (Exception exception) when (exception is HttpRequestException or IOException or UnauthorizedAccessException or InvalidDataException or Win32Exception or InvalidOperationException) @@ -221,15 +224,14 @@ private async Task InstallMacOsAsync(string downloadBase, string assetName, stri var target = GetGlobalDirectory(); var command = $"/bin/mkdir -p {ShellQuote(target)} && /bin/cp -f {ShellQuote(staging)}/* {ShellQuote(target)}/ && /bin/chmod 755 {ShellQuote(Path.Combine(target, ExecutableName()))}"; var script = $"do shell script {AppleScriptLiteral(command)} with administrator privileges"; - await RunProcessAsync("/usr/bin/osascript", ["-e", script], cancellationToken, useShellExecute: false); + await RunProcessAsync("/usr/bin/osascript", ["-e", script], cancellationToken); } private async Task InstallWindowsAsync(string downloadBase, string assetName, string temporaryDirectory, CancellationToken cancellationToken) { var installer = Path.Combine(temporaryDirectory, "dotnet-core-uninstall.msi"); await DownloadAsync($"{downloadBase}{assetName}", installer, cancellationToken); - var target = Path.Combine(GetGlobalDirectory(), ""); - await RunProcessAsync("msiexec.exe", ["/i", installer, "/qn", $"DOTNETCOREUNINSTALL={target}"], cancellationToken, useShellExecute: true, elevate: true); + await RunElevatedAsync("msiexec.exe", MsiInstallArguments(installer), cancellationToken); } private async Task RemoveGlobalInstallationAsync(string executablePath, CancellationToken cancellationToken) @@ -238,16 +240,23 @@ private async Task RemoveGlobalInstallationAsync(string executablePath, Cancella { var macCommand = $"/bin/rm -f {ShellQuote(executablePath)}"; var script = $"do shell script {AppleScriptLiteral(macCommand)} with administrator privileges"; - await RunProcessAsync("/usr/bin/osascript", ["-e", script], cancellationToken, useShellExecute: false); + await RunProcessAsync("/usr/bin/osascript", ["-e", script], cancellationToken); return; } var globalDirectory = GetGlobalDirectory(); var detectedDirectory = Path.GetDirectoryName(executablePath); - var windowsCommand = detectedDirectory is not null && IsSamePath(detectedDirectory, globalDirectory) - ? $"if exist {QuoteArgument(globalDirectory)} rmdir /s /q {QuoteArgument(globalDirectory)}" - : $"del /f /q {QuoteArgument(executablePath)}"; - await RunProcessAsync("cmd.exe", ["/d", "/c", windowsCommand], cancellationToken, useShellExecute: true, elevate: true); + var arguments = detectedDirectory is not null && IsSamePath(detectedDirectory, globalDirectory) + ? WindowsRemovalArguments(globalDirectory, $"rmdir /s /q {QuoteArgument(globalDirectory)}") + : WindowsRemovalArguments(executablePath, $"del /f /q {QuoteArgument(executablePath)}"); + await RunElevatedAsync("cmd.exe", arguments, cancellationToken); + } + + /// Windows cannot delete a program while it runs, and Cockpit itself reads the tool's list in the background. Retrying inside the one elevated run avoids a second approval. + internal static string WindowsRemovalArguments(string path, string removeCommand) + { + var target = QuoteArgument(path); + return $"/d /c for /l %i in (1,1,15) do @if exist {target} ({removeCommand} & if exist {target} ping -n 2 127.0.0.1 > nul)"; } private async Task RemoveWithElevationAsync(string executablePath, IReadOnlyList<(string Version, string Filter)> removals, CancellationToken cancellationToken) @@ -255,14 +264,17 @@ private async Task RemoveWithElevationAsync(string executablePath, IReadOnlyList if (OperatingSystem.IsMacOS()) { var script = $"do shell script {AppleScriptLiteral(BuildBatchRemovalCommand(executablePath, removals))} with administrator privileges"; - await RunProcessAsync("/usr/bin/osascript", ["-e", script], cancellationToken, useShellExecute: false); + await RunProcessAsync("/usr/bin/osascript", ["-e", script], cancellationToken); return; } foreach (var (version, filter) in removals) - await RunProcessAsync(executablePath, ["remove", version, filter, "--yes"], cancellationToken, useShellExecute: true, elevate: true); + await RunElevatedAsync(executablePath, $"remove {QuoteArgument(version)} {QuoteArgument(filter)} --yes", cancellationToken); } + /// msiexec does not accept quoted switches and answers them with a help dialog that waits forever, so only the path is quoted. + internal static string MsiInstallArguments(string installer) => $"/i {QuoteArgument(installer)} /qn"; + /// One shell line for all removals. Every item is attempted, and the exit code reports whether any failed. internal static string BuildBatchRemovalCommand(string executablePath, IEnumerable<(string Version, string Filter)> removals) { @@ -465,14 +477,19 @@ private static async Task ExtractTarGzAsync(string archive, string destination, } } - private static async Task RunProcessAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken, bool useShellExecute, bool elevate = false) + private static async Task RunProcessAsync(string fileName, IReadOnlyList arguments, CancellationToken cancellationToken) + { + var startInfo = new ProcessStartInfo { FileName = fileName, UseShellExecute = false }; + foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); + await RunToCompletionAsync(startInfo, cancellationToken); + } + + /// Windows hands the elevated process one command line, written exactly as that program expects it. + private static Task RunElevatedAsync(string fileName, string arguments, CancellationToken cancellationToken) => + RunToCompletionAsync(new ProcessStartInfo { FileName = fileName, Arguments = arguments, UseShellExecute = true, Verb = "runas" }, cancellationToken); + + private static async Task RunToCompletionAsync(ProcessStartInfo startInfo, CancellationToken cancellationToken) { - var startInfo = new ProcessStartInfo { FileName = fileName, UseShellExecute = useShellExecute }; - if (elevate) startInfo.Verb = "runas"; - if (useShellExecute) - startInfo.Arguments = string.Join(' ', arguments.Select(QuoteArgument)); - else - foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("The installer process could not be started."); await process.WaitForExitAsync(cancellationToken); if (process.ExitCode != 0) throw new InvalidOperationException($"The installer exited with code {process.ExitCode}."); @@ -488,8 +505,9 @@ private IEnumerable FindOnPath() } } - private static string GetGlobalDirectory() => OperatingSystem.IsWindows() - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet-core-uninstall") + /// The official Windows package is a 32-bit installer, so it installs under Program Files (x86) on every machine. + internal static string GetGlobalDirectory() => OperatingSystem.IsWindows() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "dotnet-core-uninstall") : "/usr/local/bin"; private static string ExecutableName() => OperatingSystem.IsWindows() ? "dotnet-core-uninstall.exe" : "dotnet-core-uninstall"; diff --git a/src/DotnetCockpit/SetupPage.cs b/src/DotnetCockpit/SetupPage.cs index 6687cdc..cb510ee 100644 --- a/src/DotnetCockpit/SetupPage.cs +++ b/src/DotnetCockpit/SetupPage.cs @@ -141,7 +141,7 @@ public static string Render(SetupSnapshot snapshot, SdkDownloadSnapshot sdkDownl {Icon("globe", "sm")}
Microsoft release metadata -

Official release index, lifecycle dates, CVEs, SHA-512 checksums, and .pkg installers.

+

Official release index, lifecycle dates, CVEs, SHA-512 checksums, and installers.

builds.dotnet.microsoft.com @@ -183,7 +183,7 @@ public static string Render(SetupSnapshot snapshot, SdkDownloadSnapshot sdkDownl

About .NET Cockpit

-

Choose a package in Versions → Install .NET to download it, then open the .pkg file in macOS Installer to complete installation. The SDK also installs the ASP.NET Core Runtime; Microsoft provides the standalone .NET Runtime as a separate package. The .NET Uninstall Tool uses official dotnet/cli-lab release assets. Review the Microsoft documentation for the tool's limitations and commands.

+

{InstallGuidance} The .NET Uninstall Tool uses official dotnet/cli-lab release assets. Review the Microsoft documentation for the tool's limitations and commands.

@@ -218,7 +218,7 @@ private static string RenderToolCard(SetupSnapshot snapshot) {Icon("lock", "sm")}
Administrator approval -

Installing, updating and removing this tool — and removing .NET versions with it — asks for your {Encode(snapshot.OperatingSystem)} administrator password.

+

Installing, updating and removing this tool — and removing .NET versions with it — asks for administrator approval.

{RenderReleaseList(snapshot)} @@ -301,6 +301,11 @@ private static string RenderReleaseList(SetupSnapshot snapshot) return $"
Available releases
    {releases}
"; } + private static string InstallGuidance => + OperatingSystem.IsMacOS() ? "Choose a package in Versions → Install .NET to download it, then open the .pkg file in macOS Installer to complete installation. The SDK also installs the ASP.NET Core Runtime; Microsoft provides the standalone .NET Runtime as a separate package." + : OperatingSystem.IsWindows() ? "Choose an installer in Versions → Install .NET to download it, then run it to complete installation. The SDK also installs the ASP.NET Core Runtime; Microsoft provides the standalone .NET Runtime as a separate installer." + : "On Linux, Cockpit shows versions, support status and tools. Install or remove .NET with your distribution's packages or Microsoft's install guide."; + private static string FormatReleaseDate(string raw) => DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dto) ? dto.ToString("MMM d, yyyy", CultureInfo.InvariantCulture) diff --git a/tests/DotnetCockpit.Tests/BatchRemovalTests.cs b/tests/DotnetCockpit.Tests/BatchRemovalTests.cs index 93dfce9..a4fb903 100644 --- a/tests/DotnetCockpit.Tests/BatchRemovalTests.cs +++ b/tests/DotnetCockpit.Tests/BatchRemovalTests.cs @@ -17,6 +17,21 @@ public void BuildsOneCommandForAllRemovalsAndQuotesEveryValue() Assert.EndsWith("exit $status", command); } + [Fact] + public void QuotesOnlyTheInstallerPathForMsiexec() + { + Assert.Equal("/i \"C:\\Temp dir\\tool.msi\" /qn", DotnetUninstallToolService.MsiInstallArguments("C:\\Temp dir\\tool.msi")); + } + + [Fact] + public void RetriesRemovingAToolThatIsStillRunningWithinOneElevatedCommand() + { + const string directory = @"C:\Program Files (x86)\dotnet-core-uninstall"; + Assert.Equal( + $"/d /c for /l %i in (1,1,15) do @if exist \"{directory}\" (rmdir /s /q \"{directory}\" & if exist \"{directory}\" ping -n 2 127.0.0.1 > nul)", + DotnetUninstallToolService.WindowsRemovalArguments(directory, $"rmdir /s /q \"{directory}\"")); + } + [Fact] public async Task AttemptsEveryItemAndReportsAFailureAtTheEnd() { diff --git a/tests/DotnetCockpit.Tests/SdkDownloadCatalogTests.cs b/tests/DotnetCockpit.Tests/SdkDownloadCatalogTests.cs index 9cbeef9..6f07a95 100644 --- a/tests/DotnetCockpit.Tests/SdkDownloadCatalogTests.cs +++ b/tests/DotnetCockpit.Tests/SdkDownloadCatalogTests.cs @@ -12,7 +12,7 @@ public sealed class SdkDownloadCatalogTests public async Task ReadsOfficialPackagesForTheCurrentMacArchitecture() { using var client = new HttpClient(new CatalogHandler()); - var catalog = new SdkDownloadCatalog(NullLogger.Instance, client); + var catalog = new SdkDownloadCatalog(NullLogger.Instance, client, platform: InstallerPlatform.Mac); var snapshot = await catalog.ReadAsync(CancellationToken.None); @@ -26,11 +26,35 @@ public async Task ReadsOfficialPackagesForTheCurrentMacArchitecture() Assert.Equal(2, snapshot.Downloads.Length); } + [Fact] + public async Task OffersWindowsInstallersOnWindows() + { + using var client = new HttpClient(new CatalogHandler()); + var catalog = new SdkDownloadCatalog(NullLogger.Instance, client, platform: InstallerPlatform.Windows); + + var snapshot = await catalog.ReadAsync(CancellationToken.None); + + Assert.All(snapshot.Downloads, download => Assert.EndsWith(".exe", download.Url)); + Assert.Equal(2, snapshot.Downloads.Length); + } + + [Fact] + public async Task KnowsReleasesButOffersNoInstallerOnLinux() + { + using var client = new HttpClient(new CatalogHandler()); + var catalog = new SdkDownloadCatalog(NullLogger.Instance, client, platform: InstallerPlatform.Linux); + + var snapshot = await catalog.ReadAsync(CancellationToken.None); + + Assert.Equal("10.0.100", Assert.Single(snapshot.Downloads, download => download.Product == "SDK").Version); + Assert.All(snapshot.Downloads, download => Assert.Empty(download.Url)); + } + [Fact] public async Task DoesNotUseAnUntrustedReleaseMetadataUrl() { using var client = new HttpClient(new UntrustedCatalogHandler()); - var catalog = new SdkDownloadCatalog(NullLogger.Instance, client); + var catalog = new SdkDownloadCatalog(NullLogger.Instance, client, platform: InstallerPlatform.Mac); var snapshot = await catalog.ReadAsync(CancellationToken.None); @@ -55,7 +79,7 @@ private static string ReleaseJson() var architecture = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "arm64" : "x64"; var otherArchitecture = architecture == "arm64" ? "x64" : "arm64"; return """ - {"releases":[{"release-date":"2026-11-01","cve-list":[{"cve-id":"CVE-2026-0001","cve-url":"https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-0001"}],"sdk":{"version-display":"10.0.100","version":"10.0.100","runtime-version":"10.0.0","files":[{"name":"dotnet-sdk-osx-ARCH.pkg","url":"https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.100/dotnet-sdk-osx-ARCH.pkg","hash":"sdk-hash"},{"name":"dotnet-sdk-osx-OTHER_ARCH.pkg","url":"https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.100/dotnet-sdk-osx-OTHER_ARCH.pkg","hash":"other-hash"}]},"runtime":{"version-display":"10.0.0","version":"10.0.0","files":[{"name":"dotnet-runtime-osx-ARCH.pkg","url":"https://builds.dotnet.microsoft.com/dotnet/Runtime/10.0.0/dotnet-runtime-osx-ARCH.pkg","hash":"runtime-hash"}]}}]} + {"releases":[{"release-date":"2026-11-01","cve-list":[{"cve-id":"CVE-2026-0001","cve-url":"https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-0001"}],"sdk":{"version-display":"10.0.100","version":"10.0.100","runtime-version":"10.0.0","files":[{"name":"dotnet-sdk-win-ARCH.exe","url":"https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.100/dotnet-sdk-win-ARCH.exe","hash":"win-hash"},{"name":"dotnet-sdk-osx-ARCH.pkg","url":"https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.100/dotnet-sdk-osx-ARCH.pkg","hash":"sdk-hash"},{"name":"dotnet-sdk-osx-OTHER_ARCH.pkg","url":"https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.100/dotnet-sdk-osx-OTHER_ARCH.pkg","hash":"other-hash"}]},"runtime":{"version-display":"10.0.0","version":"10.0.0","files":[{"name":"dotnet-runtime-win-ARCH.exe","url":"https://builds.dotnet.microsoft.com/dotnet/Runtime/10.0.0/dotnet-runtime-win-ARCH.exe","hash":"win-hash"},{"name":"dotnet-runtime-osx-ARCH.pkg","url":"https://builds.dotnet.microsoft.com/dotnet/Runtime/10.0.0/dotnet-runtime-osx-ARCH.pkg","hash":"runtime-hash"}]}}]} """ .Replace("OTHER_ARCH", otherArchitecture, StringComparison.Ordinal) .Replace("ARCH", architecture, StringComparison.Ordinal); diff --git a/ui/package.json b/ui/package.json index 8dc66df..bd24965 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,7 +8,8 @@ "test:browser": "playwright test", "test:visual": "node tests/visual/render-fixtures.mjs", "test:visual:update": "node tests/visual/render-fixtures.mjs --update-snapshots=all", - "bench": "node tests/perf/bench.mjs" + "bench": "node tests/perf/bench.mjs", + "test:e2e": "playwright test --config playwright.e2e.config.mjs" }, "devDependencies": { "@playwright/test": "1.64.1", diff --git a/ui/playwright.e2e.config.mjs b/ui/playwright.e2e.config.mjs new file mode 100644 index 0000000..b6481fa --- /dev/null +++ b/ui/playwright.e2e.config.mjs @@ -0,0 +1,33 @@ +import { defineConfig } from '@playwright/test'; +import { mkdirSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +// Workers load this file again, so the directories are created once and passed on through the environment. +process.env.COCKPIT_E2E_DIR ||= mkdtempSync(join(tmpdir(), 'cockpit-e2e-')); +const root = process.env.COCKPIT_E2E_DIR; +const workspace = join(root, 'workspace'); +mkdirSync(workspace, { recursive: true }); +process.env.COCKPIT_E2E_WORKSPACE = workspace; +// A separate CLI home keeps global tools away from the machine's real ones, also when run locally. +process.env.DOTNET_CLI_HOME = join(root, 'cli-home'); +process.env.DOTNET_COCKPIT_CONFIG_DIR = join(root, 'config'); + +const executable = process.platform === 'win32' ? 'dotnet-cockpit.exe' : 'dotnet-cockpit'; + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '*.e2e.mjs', + workers: 1, + timeout: 10 * 60_000, + expect: { timeout: 15_000 }, + reporter: [['list']], + use: { baseURL: 'http://127.0.0.1:5240', viewport: { width: 1440, height: 900 }, trace: 'retain-on-failure' }, + webServer: { + command: `"${resolve('../artifacts/native', executable)}" --no-browser --port 5240`, + cwd: workspace, + url: 'http://127.0.0.1:5240/api/health', + timeout: 60_000, + stdout: 'pipe', + }, +}); diff --git a/ui/tests/e2e/features.e2e.mjs b/ui/tests/e2e/features.e2e.mjs new file mode 100644 index 0000000..b3fe332 --- /dev/null +++ b/ui/tests/e2e/features.e2e.mjs @@ -0,0 +1,202 @@ +// Starts the real native binary on every operating system and uses each feature the way a person would. +// Tools really get installed, into a temporary CLI home. Only a Windows CI runner installs and removes .NET itself. +import { expect, test } from '@playwright/test'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const TOOL = 'mdu'; +const ACTION_TIMEOUT = 5 * 60_000; + +test.describe.configure({ mode: 'serial' }); + +function dotnet(...args) { + const result = spawnSync('dotnet', args, { encoding: 'utf8', shell: process.platform === 'win32' }); + expect(result.status, `dotnet ${args.join(' ')}\n${result.stderr}`).toBe(0); + return result.stdout; +} + +/** Confirms the shared dialog, checks what it says it will run, and waits for the success message. */ +async function confirmAndWait(page, command, success) { + const dialog = page.locator('#confirm-dialog'); + await expect(dialog).toBeVisible(); + if (command) await expect(page.locator('#confirm-dialog-command')).toContainText(command); + await page.locator('#confirm-dialog-submit').click(); + // A failure shows a notice instead of a message, which should end the wait straight away. + const outcome = page.locator('.toast, .action-notice.error-state'); + await expect(outcome).toBeVisible({ timeout: ACTION_TIMEOUT }); + await expect(outcome).toContainText(success); + // A newly installed tool's details arrive just after the page, and the page then reloads itself once to show them. + await page.waitForLoadState('networkidle'); +} + +async function searchAndInstall(page, scope) { + await page.locator('#open-add-tool-btn').click(); + const sheet = page.locator('#add-tool-dialog'); + await expect(sheet).toBeVisible(); + if (scope === 'local') await sheet.locator('[data-sheet-scope="local"]').click(); + await page.locator('#sheet-search-input').fill(TOOL); + const row = sheet.locator(`[data-pkg-id="${TOOL}"]`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.locator('button[type="submit"]').click(); + await confirmAndWait(page, `dotnet tool install --${scope}`, 'Tool installed.'); +} + +test('every page opens and works without errors', async ({ page }) => { + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto('/overview'); + await expect(page.getByRole('heading', { name: 'Overview', level: 1 })).toBeVisible(); + await expect(page.locator('.hero-headline')).not.toBeEmpty(); + + await page.locator('a.nav-link[href="/"]').click(); + await expect(page.getByRole('heading', { name: 'Versions', level: 1 })).toBeVisible(); + const rows = page.locator('tr[data-kind]'); + await expect(rows.first()).toBeVisible(); + const total = await rows.count(); + + await page.locator('[data-version-kind="sdks"]').click(); + await expect(page.locator('tr[data-kind="runtime"]:not([hidden])')).toHaveCount(0); + await page.locator('[data-version-kind="sdks"]').click(); + await expect(page.locator('tr[data-kind]:not([hidden])')).toHaveCount(total); + + await rows.first().locator('[data-inspect]').click(); + await expect(page.locator('#version-drawer')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.locator('#version-drawer')).toBeHidden(); + + await page.locator('a.nav-link[href="/tools"]').click(); + await expect(page.getByRole('heading', { name: '.NET Tools', level: 1 })).toBeVisible(); + await page.locator('a.nav-link[href="/setup"]').click(); + await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); + + await page.locator('[data-palette-pref="github"]').click(); + await expect(page.locator('html')).toHaveAttribute('data-palette', 'github'); + await page.locator('header [data-theme-pref="light"]').click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await page.waitForLoadState('networkidle'); + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-palette', 'github'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + + await page.goto('/preflight'); + await expect(page.getByRole('heading', { name: 'Pre-flight check' })).toBeVisible(); + expect(errors).toEqual([]); +}); + +test('Install .NET offers what fits this operating system', async ({ page }) => { + await page.goto('/?install=1'); + const sheet = page.locator('#install-dotnet-dialog'); + await expect(sheet).toBeVisible(); + const html = await page.content(); + if (process.platform === 'linux') { + await expect(page.locator('#linux-install-guide')).toBeVisible(); + await expect(sheet.locator('[data-install-row]')).toHaveCount(0); + expect(html).not.toContain('.pkg'); + expect(html).not.toContain('sudo installer'); + return; + } + const extension = process.platform === 'win32' ? '.exe' : '.pkg'; + const first = sheet.locator('[data-install-row]:not([hidden])').first(); + await expect(first).toBeVisible(); + await expect(first.locator('a[download]')).toHaveAttribute('href', new RegExp(`\\${extension}$`)); + expect(html).not.toContain(process.platform === 'win32' ? 'sudo installer' : '/install /quiet'); +}); + +test('installs, inspects and removes a global tool', async ({ page }) => { + await page.goto('/tools'); + await searchAndInstall(page, 'global'); + const row = page.locator(`tr[data-drawer-key="global:${TOOL}"]`); + await expect(row).toBeVisible(); + expect(dotnet('tool', 'list', '--global')).toContain(TOOL); + + await row.locator('a[data-inspect]').click(); + await expect(page.locator('#tool-drawer-title')).toHaveText(TOOL); + await page.keyboard.press('Escape'); + + await row.getByRole('button', { name: `Remove ${TOOL}` }).click(); + await confirmAndWait(page, `dotnet tool uninstall --global ${TOOL}`, 'Tool removed.'); + await expect(row).toHaveCount(0); + expect(dotnet('tool', 'list', '--global')).not.toContain(`${TOOL} `); +}); + +test('initializes a workspace, installs a local tool and removes both', async ({ page }) => { + const manifest = join(process.env.COCKPIT_E2E_WORKSPACE, '.config', 'dotnet-tools.json'); + await page.goto('/tools?scope=local'); + await page.getByRole('button', { name: 'Initialize local tools' }).click(); + await confirmAndWait(page, 'dotnet new tool-manifest', 'Local tools are initialized'); + expect(existsSync(manifest)).toBe(true); + + await searchAndInstall(page, 'local'); + await page.goto('/tools?scope=local'); + const row = page.locator(`tr[data-drawer-key="local:${TOOL}"]`); + await expect(row).toBeVisible(); + + await row.getByRole('button', { name: `Remove ${TOOL}` }).click(); + await confirmAndWait(page, `dotnet tool uninstall --local ${TOOL}`, 'Tool removed.'); + await page.goto('/tools?scope=local'); + await expect(row).toHaveCount(0); + + await page.getByRole('button', { name: 'Remove local tools' }).click(); + await confirmAndWait(page, null, 'Local manifest removed'); + expect(existsSync(manifest)).toBe(false); +}); + +test('Linux explains that removing .NET is not available', async ({ page }) => { + test.skip(process.platform !== 'linux', 'Only Linux has no uninstall tool.'); + await page.goto('/'); + await expect(page.locator('tr[data-kind]').first()).toBeVisible(); + await expect(page.locator('tr[data-kind] button.remove')).toHaveCount(0); + await expect(page.locator('[data-remove-channel]')).toHaveCount(0); + await page.goto('/setup'); + await expect(page.getByRole('heading', { name: 'This platform is not supported' })).toBeVisible(); +}); + +test('Windows really installs and removes a .NET runtime', async ({ page }) => { + test.skip(process.platform !== 'win32' || !process.env.CI, 'This changes the machine, so it only runs on a Windows CI runner.'); + + // The uninstall tool itself, through the Settings page + await page.goto('/setup'); + const install = page.locator('form[data-confirm-post="/setup/install"] button[type="submit"]'); + if (await install.count()) { + await install.click(); + await confirmAndWait(page, null, 'was installed'); + } + + // An older runtime that is not installed yet, the way the Install .NET sheet offers it + await page.goto('/?install=1&product=runtime&view=supported'); + const candidates = page.locator('[data-install-row][data-product=".NET Runtime"]:not([hidden])'); + await expect(candidates.first()).toBeVisible(); + const choice = await candidates.evaluateAll(rows => { + const free = rows.filter(row => !row.querySelector('.v-ok') && row.dataset.preview !== 'true'); + const row = free.find(candidate => candidate.dataset.channel === '9') ?? free[0]; + return { version: row.dataset.version, url: row.querySelector('a[download]').href, sha: row.querySelector('.drawer-sha').dataset.copy }; + }); + expect(choice.url).toMatch(/\.exe$/); + + const response = await fetch(choice.url); + expect(response.ok).toBe(true); + const bytes = Buffer.from(await response.arrayBuffer()); + expect(createHash('sha512').update(bytes).digest('hex').toLowerCase()).toBe(choice.sha.toLowerCase()); + const installer = join(process.env.COCKPIT_E2E_DIR, 'runtime-installer.exe'); + writeFileSync(installer, bytes); + const run = spawnSync(installer, ['/install', '/quiet', '/norestart'], { stdio: 'inherit' }); + expect([0, 3010]).toContain(run.status); + expect(dotnet('--list-runtimes')).toContain(`Microsoft.NETCore.App ${choice.version}`); + + // Cockpit notices it, and removes it again + await page.goto('/?refresh=1'); + const row = page.locator(`tr[data-drawer-key="${choice.version}"]`); + await expect(row).toBeVisible(); + await row.locator('button.remove').click(); + await confirmAndWait(page, `dotnet-core-uninstall remove ${choice.version}`, '.NET installation was removed'); + await expect(row).toHaveCount(0); + expect(dotnet('--list-runtimes')).not.toContain(`Microsoft.NETCore.App ${choice.version}`); + + // And the uninstall tool goes again, through the Settings page + await page.goto('/setup'); + await page.locator('form[data-confirm-post="/setup/uninstall"] button[type="submit"]').click(); + await confirmAndWait(page, null, 'was removed'); + await expect(page.locator('form[data-confirm-post="/setup/install"] button[type="submit"]').first()).toBeVisible(); +}); diff --git a/ui/tests/installations.spec.mjs b/ui/tests/installations.spec.mjs index d4f004e..0646815 100644 --- a/ui/tests/installations.spec.mjs +++ b/ui/tests/installations.spec.mjs @@ -156,8 +156,10 @@ test('opens detail drawer when inspecting a version and displays equivalent comm await expect(page.locator('#drawer-command')).toContainText(`dotnet-core-uninstall remove ${firstSdk.version} \\`); await expect(page.locator('#drawer-command')).toContainText('--sdk \\'); } - await expect(page.locator('#version-drawer .drawer-patch-box')).toBeVisible(); - await expect(page.locator('#version-drawer .drawer-primary-btn')).toBeVisible(); + // Linux has no installer to download, so the drawer offers none. + const downloads = process.platform === 'linux' ? 0 : 1; + await expect(page.locator('#version-drawer .drawer-patch-box')).toHaveCount(downloads); + await expect(page.locator('#version-drawer .drawer-primary-btn')).toHaveCount(downloads); // Close drawer await page.locator('#drawer-close').click(); @@ -178,7 +180,12 @@ test('opens the Install .NET side-drawer with Latest filter active and filters S const dialog = page.locator('#install-dotnet-dialog'); await expect(dialog).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Install .NET' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Install .NET', exact: true })).toBeVisible(); + if (process.platform === 'linux') { + await expect(page.locator('#linux-install-guide')).toHaveAttribute('href', /learn\.microsoft\.com/); + await expect(page.locator('[data-install-row]')).toHaveCount(0); + return; + } await expect(page.locator('[data-install-lifecycle="latest"]')).toHaveClass(/active/); await expect(page.locator('[data-install-lifecycle="lts"]')).toHaveText('LTS'); const firstCommand = dialog.locator('[data-install-row]:not([hidden]) .row-command').first(); @@ -320,13 +327,15 @@ test('every control works without JavaScript', async ({ browser }) => { await page.goto(path); await expect(page.locator('button[type="button"]:visible'), path).toHaveCount(0); } - await page.goto('/?install=1'); - await page.locator('[data-install-product=".NET Runtime"]').click(); - await expect(page).toHaveURL(/product=runtime/); - await expect(page.locator('#install-dotnet-dialog')).toBeVisible(); - await expect(page.locator('[data-install-row]:not([hidden])').first()).toContainText('.NET Runtime'); - await page.locator('[data-install-lifecycle="all"]').click(); - await expect(page).toHaveURL(/view=all/); + if (process.platform !== 'linux') { + await page.goto('/?install=1'); + await page.locator('[data-install-product=".NET Runtime"]').click(); + await expect(page).toHaveURL(/product=runtime/); + await expect(page.locator('#install-dotnet-dialog')).toBeVisible(); + await expect(page.locator('[data-install-row]:not([hidden])').first()).toContainText('.NET Runtime'); + await page.locator('[data-install-lifecycle="all"]').click(); + await expect(page).toHaveURL(/view=all/); + } await page.goto('/'); await page.locator('header [data-theme-pref="dark"]').click(); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); @@ -361,7 +370,10 @@ test('overlays return focus and keep the keyboard inside', async ({ page }) => { const install = page.locator('#open-install-dotnet-btn'); await install.focus(); await page.keyboard.press('Enter'); - await expect(page.locator('#install-dotnet-search-input')).toBeFocused(); + // Linux has only the link to the guide, so there is no search field to start in. + if (process.platform === 'linux') await expect(page.locator('#install-dotnet-dialog')).toBeVisible(); + else await expect(page.locator('#install-dotnet-search-input')).toBeFocused(); + expect(await page.evaluate(() => Boolean(document.activeElement.closest('#install-dotnet-dialog')))).toBe(true); await page.keyboard.press('Escape'); await expect(install).toBeFocused(); diff --git a/ui/tests/visual/baselines/darwin/codex-mixed-overview-dark.png b/ui/tests/visual/baselines/darwin/codex-mixed-overview-dark.png index 2650177..2f6b82b 100644 Binary files a/ui/tests/visual/baselines/darwin/codex-mixed-overview-dark.png and b/ui/tests/visual/baselines/darwin/codex-mixed-overview-dark.png differ diff --git a/ui/tests/visual/baselines/darwin/codex-mixed-overview-light.png b/ui/tests/visual/baselines/darwin/codex-mixed-overview-light.png index 0c24f6c..0fa4c11 100644 Binary files a/ui/tests/visual/baselines/darwin/codex-mixed-overview-light.png and b/ui/tests/visual/baselines/darwin/codex-mixed-overview-light.png differ diff --git a/ui/tests/visual/baselines/darwin/codex-mixed-versions-dark.png b/ui/tests/visual/baselines/darwin/codex-mixed-versions-dark.png index a7245a9..6e3db1d 100644 Binary files a/ui/tests/visual/baselines/darwin/codex-mixed-versions-dark.png and b/ui/tests/visual/baselines/darwin/codex-mixed-versions-dark.png differ diff --git a/ui/tests/visual/baselines/darwin/codex-mixed-versions-light.png b/ui/tests/visual/baselines/darwin/codex-mixed-versions-light.png index b0c12d7..d772b8f 100644 Binary files a/ui/tests/visual/baselines/darwin/codex-mixed-versions-light.png and b/ui/tests/visual/baselines/darwin/codex-mixed-versions-light.png differ diff --git a/ui/tests/visual/baselines/darwin/empty-overview.png b/ui/tests/visual/baselines/darwin/empty-overview.png index bb1fd67..250d2ff 100644 Binary files a/ui/tests/visual/baselines/darwin/empty-overview.png and b/ui/tests/visual/baselines/darwin/empty-overview.png differ diff --git a/ui/tests/visual/baselines/darwin/empty-tools.png b/ui/tests/visual/baselines/darwin/empty-tools.png index e7abdf3..6a49b72 100644 Binary files a/ui/tests/visual/baselines/darwin/empty-tools.png and b/ui/tests/visual/baselines/darwin/empty-tools.png differ diff --git a/ui/tests/visual/baselines/darwin/empty-versions.png b/ui/tests/visual/baselines/darwin/empty-versions.png index ac7c03a..b2991ad 100644 Binary files a/ui/tests/visual/baselines/darwin/empty-versions.png and b/ui/tests/visual/baselines/darwin/empty-versions.png differ diff --git a/ui/tests/visual/baselines/darwin/github-mixed-overview-dark.png b/ui/tests/visual/baselines/darwin/github-mixed-overview-dark.png index 7d5bfdd..e585b56 100644 Binary files a/ui/tests/visual/baselines/darwin/github-mixed-overview-dark.png and b/ui/tests/visual/baselines/darwin/github-mixed-overview-dark.png differ diff --git a/ui/tests/visual/baselines/darwin/github-mixed-overview-light.png b/ui/tests/visual/baselines/darwin/github-mixed-overview-light.png index c6d35bc..1a9871a 100644 Binary files a/ui/tests/visual/baselines/darwin/github-mixed-overview-light.png and b/ui/tests/visual/baselines/darwin/github-mixed-overview-light.png differ diff --git a/ui/tests/visual/baselines/darwin/github-mixed-versions-dark.png b/ui/tests/visual/baselines/darwin/github-mixed-versions-dark.png index 5a3d084..588d260 100644 Binary files a/ui/tests/visual/baselines/darwin/github-mixed-versions-dark.png and b/ui/tests/visual/baselines/darwin/github-mixed-versions-dark.png differ diff --git a/ui/tests/visual/baselines/darwin/github-mixed-versions-light.png b/ui/tests/visual/baselines/darwin/github-mixed-versions-light.png index 5a82331..f296817 100644 Binary files a/ui/tests/visual/baselines/darwin/github-mixed-versions-light.png and b/ui/tests/visual/baselines/darwin/github-mixed-versions-light.png differ diff --git a/ui/tests/visual/baselines/darwin/healthy-overview.png b/ui/tests/visual/baselines/darwin/healthy-overview.png index b7cc141..3ce838c 100644 Binary files a/ui/tests/visual/baselines/darwin/healthy-overview.png and b/ui/tests/visual/baselines/darwin/healthy-overview.png differ diff --git a/ui/tests/visual/baselines/darwin/healthy-versions.png b/ui/tests/visual/baselines/darwin/healthy-versions.png index c6f3408..73986e9 100644 Binary files a/ui/tests/visual/baselines/darwin/healthy-versions.png and b/ui/tests/visual/baselines/darwin/healthy-versions.png differ diff --git a/ui/tests/visual/baselines/darwin/macos-mixed-overview-dark.png b/ui/tests/visual/baselines/darwin/macos-mixed-overview-dark.png index 521baaa..e5ccd16 100644 Binary files a/ui/tests/visual/baselines/darwin/macos-mixed-overview-dark.png and b/ui/tests/visual/baselines/darwin/macos-mixed-overview-dark.png differ diff --git a/ui/tests/visual/baselines/darwin/macos-mixed-overview-light.png b/ui/tests/visual/baselines/darwin/macos-mixed-overview-light.png index cba79fa..82be6d3 100644 Binary files a/ui/tests/visual/baselines/darwin/macos-mixed-overview-light.png and b/ui/tests/visual/baselines/darwin/macos-mixed-overview-light.png differ diff --git a/ui/tests/visual/baselines/darwin/macos-mixed-versions-dark.png b/ui/tests/visual/baselines/darwin/macos-mixed-versions-dark.png index 78d1825..0ef93c4 100644 Binary files a/ui/tests/visual/baselines/darwin/macos-mixed-versions-dark.png and b/ui/tests/visual/baselines/darwin/macos-mixed-versions-dark.png differ diff --git a/ui/tests/visual/baselines/darwin/macos-mixed-versions-light.png b/ui/tests/visual/baselines/darwin/macos-mixed-versions-light.png index 4c6c262..5827e1f 100644 Binary files a/ui/tests/visual/baselines/darwin/macos-mixed-versions-light.png and b/ui/tests/visual/baselines/darwin/macos-mixed-versions-light.png differ diff --git a/ui/tests/visual/baselines/darwin/mixed-overview-1024.png b/ui/tests/visual/baselines/darwin/mixed-overview-1024.png index a86e25f..2b2dfd4 100644 Binary files a/ui/tests/visual/baselines/darwin/mixed-overview-1024.png and b/ui/tests/visual/baselines/darwin/mixed-overview-1024.png differ diff --git a/ui/tests/visual/baselines/darwin/mixed-overview-dark.png b/ui/tests/visual/baselines/darwin/mixed-overview-dark.png index ddf4d74..fb012ce 100644 Binary files a/ui/tests/visual/baselines/darwin/mixed-overview-dark.png and b/ui/tests/visual/baselines/darwin/mixed-overview-dark.png differ diff --git a/ui/tests/visual/baselines/darwin/mixed-overview-light.png b/ui/tests/visual/baselines/darwin/mixed-overview-light.png index da811a7..8755db1 100644 Binary files a/ui/tests/visual/baselines/darwin/mixed-overview-light.png and b/ui/tests/visual/baselines/darwin/mixed-overview-light.png differ diff --git a/ui/tests/visual/baselines/darwin/mixed-settings-dark.png b/ui/tests/visual/baselines/darwin/mixed-settings-dark.png index b5b6479..6f94311 100644 Binary files a/ui/tests/visual/baselines/darwin/mixed-settings-dark.png and b/ui/tests/visual/baselines/darwin/mixed-settings-dark.png differ diff --git a/ui/tests/visual/baselines/darwin/mixed-settings-light.png b/ui/tests/visual/baselines/darwin/mixed-settings-light.png index dae0b1f..aade294 100644 Binary files a/ui/tests/visual/baselines/darwin/mixed-settings-light.png and b/ui/tests/visual/baselines/darwin/mixed-settings-light.png differ diff --git a/ui/tests/visual/baselines/darwin/mixed-tools-1024.png b/ui/tests/visual/baselines/darwin/mixed-tools-1024.png index 1f12e0a..e4f19dc 100644 Binary files a/ui/tests/visual/baselines/darwin/mixed-tools-1024.png and b/ui/tests/visual/baselines/darwin/mixed-tools-1024.png differ diff --git a/ui/tests/visual/baselines/darwin/mixed-tools-dark.png b/ui/tests/visual/baselines/darwin/mixed-tools-dark.png index c3b4d2f..c47652b 100644 Binary files a/ui/tests/visual/baselines/darwin/mixed-tools-dark.png and b/ui/tests/visual/baselines/darwin/mixed-tools-dark.png differ diff --git a/ui/tests/visual/baselines/darwin/mixed-tools-light.png b/ui/tests/visual/baselines/darwin/mixed-tools-light.png index b79cdf2..8e3af37 100644 Binary files a/ui/tests/visual/baselines/darwin/mixed-tools-light.png and b/ui/tests/visual/baselines/darwin/mixed-tools-light.png differ diff --git a/ui/tests/visual/baselines/darwin/mixed-versions-1024.png b/ui/tests/visual/baselines/darwin/mixed-versions-1024.png index 81bfbc1..ce67657 100644 Binary files a/ui/tests/visual/baselines/darwin/mixed-versions-1024.png and b/ui/tests/visual/baselines/darwin/mixed-versions-1024.png differ diff --git a/ui/tests/visual/baselines/darwin/mixed-versions-dark.png b/ui/tests/visual/baselines/darwin/mixed-versions-dark.png index 1b2065a..40942b3 100644 Binary files a/ui/tests/visual/baselines/darwin/mixed-versions-dark.png and b/ui/tests/visual/baselines/darwin/mixed-versions-dark.png differ diff --git a/ui/tests/visual/baselines/darwin/mixed-versions-light.png b/ui/tests/visual/baselines/darwin/mixed-versions-light.png index bcffb88..308baaf 100644 Binary files a/ui/tests/visual/baselines/darwin/mixed-versions-light.png and b/ui/tests/visual/baselines/darwin/mixed-versions-light.png differ diff --git a/ui/tests/visual/baselines/darwin/overlay-cleanup.png b/ui/tests/visual/baselines/darwin/overlay-cleanup.png index 0844d7c..367ab0a 100644 Binary files a/ui/tests/visual/baselines/darwin/overlay-cleanup.png and b/ui/tests/visual/baselines/darwin/overlay-cleanup.png differ diff --git a/ui/tests/visual/baselines/darwin/overlay-confirm.png b/ui/tests/visual/baselines/darwin/overlay-confirm.png index 925b752..593cce0 100644 Binary files a/ui/tests/visual/baselines/darwin/overlay-confirm.png and b/ui/tests/visual/baselines/darwin/overlay-confirm.png differ diff --git a/ui/tests/visual/baselines/darwin/overlay-version-drawer.png b/ui/tests/visual/baselines/darwin/overlay-version-drawer.png index f38f6ff..ee62896 100644 Binary files a/ui/tests/visual/baselines/darwin/overlay-version-drawer.png and b/ui/tests/visual/baselines/darwin/overlay-version-drawer.png differ