Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
77d2c6d
fix: use user-first workload install with sudo fallback on Linux
agneszitte Mar 29, 2026
d35a945
fix: add 'inadequate permissions' and 'elevated privileges' to sudo r…
agneszitte Mar 29, 2026
32d6a48
fix: use sudo -n in CI/non-interactive mode for workload install fall…
agneszitte Mar 29, 2026
22334a2
fix: proactive SDK writability check and TTY-safe sudo fallback
agneszitte Apr 26, 2026
200c149
fix: use sudo -S piped stdin to avoid macOS PTY hang
agneszitte Apr 29, 2026
3c714f2
fix: harden sudo helpers per review feedback
agneszitte Apr 30, 2026
9c00295
fix: harden FlushAndCloseInput and fix doc comment per review
agneszitte Apr 30, 2026
4a6c3c0
fix: correct RetryWithSudo doc comment to describe sudo -S piping
agneszitte Apr 30, 2026
624a830
fix: check cancellation before blocking password prompt and remove te…
agneszitte Apr 30, 2026
9c4b05d
docs: expand ReadPasswordFromConsole XML doc to cover all null-return…
agneszitte Apr 30, 2026
5c4d85c
fix: pre-cache sudo credentials before workload install spinner so pr…
ajpinedam May 1, 2026
946b46b
fix: union user and sudo workload lists so mixed/elevated installs ar…
ajpinedam May 4, 2026
0d3a4e6
fix: capture interactive sudo output so workload install failure diag…
ajpinedam May 4, 2026
9518a8b
test: cover sudo-backed GetInstalledWorkloads merge logic with regres…
ajpinedam May 4, 2026
7ff82aa
test: cover ShellProcessRunner FlushAndCloseInput and redirected-inpu…
ajpinedam May 4, 2026
7b83dba
fix: quote dotnet path passed to interactive sudo so spaces don't bre…
ajpinedam May 4, 2026
30efe37
fix: shell-double-quote dotnet path in WrapShellCommandWithSudo so SD…
ajpinedam May 4, 2026
e33d369
docs: clarify sudo-elevation spec — fallback path still captures pass…
ajpinedam May 4, 2026
9e2afd0
fix: abort workload install when sudo pre-handshake fails so the hidd…
ajpinedam May 6, 2026
c98e71e
fix: re-handshake sudo before workload install so a long DOTNET_FORCE…
ajpinedam May 6, 2026
3d8ed2d
fix: surface JsonException from ParseInstalledWorkloadIds so unparsea…
ajpinedam May 6, 2026
ff8fb2a
fix: catch Win32Exception from sudo launch in WrapShellCommandWithSud…
ajpinedam May 6, 2026
297fe93
fix: catch Win32Exception from sudo -v in EnsureSudoCredentialsCached…
ajpinedam May 6, 2026
39b3d8f
fix: broaden sudo-handshake-failed message so users aren't pushed tow…
ajpinedam May 6, 2026
e826cfb
fix: treat redirected stdout as non-interactive in ReadPasswordFromCo…
ajpinedam May 6, 2026
e22be93
fix: pin DOTNET_CLI_UI_LANGUAGE=en-US for dotnet workload invocations…
ajpinedam May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
331 changes: 331 additions & 0 deletions UnoCheck.Tests/DotNetWorkloadFeedbackTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using DotNetCheck;
using DotNetCheck.Checkups;
using DotNetCheck.DotNet;

Expand Down Expand Up @@ -106,4 +107,334 @@ public void BuildCliFailureMessage_WhenGenericFailure_UsesRelevantFailureLine()
Assert.Contains("Workload installation failed with exit code 1", message);
Assert.Contains("dotnet workload install", message);
}

[Theory]
[InlineData("error: Permission denied", true)]
[InlineData("Access to the path '/usr/share/dotnet' is denied.", true)]
[InlineData("EACCES: permission denied, mkdir '/usr/share/dotnet'", true)]
[InlineData("Administrator privileges are required to perform this operation.", true)]
[InlineData("Inadequate permissions. Run the command with elevated privileges.", true)]
[InlineData("Run the command with elevated privileges.", true)]
[InlineData("No space left on device", false)]
[InlineData("", false)]
public void ShouldRetryWithSudo_ReturnsExpectedValue(string output, bool expected)
{
var actual = DotNetWorkloadManager.ShouldRetryWithSudo(output);

Assert.Equal(expected, actual);
}

[Theory]
[InlineData("Restoring NuGet packages...\nDetermining projects to restore...\nerror: Permission denied\nRestore failed.", true)]
[InlineData("Installing workload manifest microsoft.net.sdk.android...\nAccess to the path '/usr/local/share/dotnet/sdk-manifests' is denied.\nInstallation failed.", true)]
[InlineData("Downloading microsoft.android.sdk.linux version 35.0.105...\nEACCES: permission denied, open '/usr/share/dotnet/packs/foo'\nWorkload installation failed.", true)]
[InlineData("Downloading microsoft.android.sdk.linux version 35.0.105 failed\nThe feed 'https://api.nuget.org/v3/index.json' lists package", false)]
public void ShouldRetryWithSudo_WithVerboseOutput_MatchesPermissionPatterns(string output, bool expected)
Comment thread
ajpinedam marked this conversation as resolved.
{
var actual = DotNetWorkloadManager.ShouldRetryWithSudo(output);

Assert.Equal(expected, actual);
}

[Fact]
public void IsSdkPathWritable_WritableDirectory_ReturnsTrue()
{
var tempDir = Path.Combine(Path.GetTempPath(), "uno-check-test-" + Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(tempDir);
try
{
Assert.True(DotNetWorkloadManager.IsSdkPathWritable(tempDir));
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}

[Fact]
public void IsSdkPathWritable_NonExistentDirectory_ReturnsFalse()
{
var nonExistentDir = Path.Combine(Path.GetTempPath(), "uno-check-nonexistent-" + Guid.NewGuid().ToString("N"));

Assert.False(DotNetWorkloadManager.IsSdkPathWritable(nonExistentDir));
}

[Fact]
public async Task PrepareForInstallAsync_WhenSdkPathWritable_CompletesWithoutPrompt()
{
var tempDir = Path.Combine(Path.GetTempPath(), "uno-check-prep-" + Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(tempDir);
try
{
var manager = new DotNetWorkloadManager(tempDir, "10.0.103");

// The SDK path is writable, so PrepareForInstallAsync must early-return
// true (proceed) and never invoke sudo.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
Assert.True(await manager.PrepareForInstallAsync(cts.Token));
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}

[Fact]
public async Task PrepareForInstallAsync_WhenSdkPathNotWritableAndNonInteractive_DoesNotPrompt()
{
var nonExistentPath = Path.Combine(Path.GetTempPath(), "uno-check-noexist-" + Guid.NewGuid().ToString("N"));
var previous = DotNetCheck.Util.NonInteractive;
try
{
DotNetCheck.Util.NonInteractive = true;
var manager = new DotNetWorkloadManager(nonExistentPath, "10.0.103");

// Non-existent path → not writable. With NonInteractive=true, the helper
// must early-return true (the install will surface its own error if
// elevation truly isn't available) rather than invoke `sudo -v`, which
// would block waiting for input on /dev/tty.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
Assert.True(await manager.PrepareForInstallAsync(cts.Token));
}
finally
{
DotNetCheck.Util.NonInteractive = previous;
}
}

[Fact]
public void ParseInstalledWorkloadIds_ParsesMachineReadableJson()
{
var ids = DotNetWorkloadManager.ParseInstalledWorkloadIds(
"{\"installed\":[\"wasm-tools\",\"android\"],\"updateAvailable\":[]}");

Assert.Equal(["wasm-tools", "android"], ids);
}

[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("{\"installed\":null}")]
public void ParseInstalledWorkloadIds_ReturnsEmptyOnEmptyOrNullInstalled(string json)
{
// Whitespace-only inputs and a JSON document that explicitly sets `installed`
// to null both legitimately mean "no workloads installed" — return empty.
var ids = DotNetWorkloadManager.ParseInstalledWorkloadIds(json);

Assert.Empty(ids);
}

[Theory]
[InlineData("not json at all")]
[InlineData("{\"installed\":")]
[InlineData("{\"installed\":[\"wasm-tools\"")]
public void ParseInstalledWorkloadIds_ThrowsOnMalformedJson(string json)
{
// Malformed CLI output must NOT be silently mapped to "no workloads
// installed" — that would hide CLI format drift behind false missing-workload
// reports and trigger spurious install/repair attempts.
var ex = Assert.Throws<InvalidDataException>(
() => DotNetWorkloadManager.ParseInstalledWorkloadIds(json));
Assert.Contains("Could not parse", ex.Message);
}

[Fact]
public void CombineInstalledWorkloads_UserOnlySucceeds_ReturnsUserList()
{
var ids = DotNetWorkloadManager.CombineInstalledWorkloads(
userContextSucceeded: true,
userInstalled: ["wasm-tools"],
sudoContextAttempted: true,
sudoContextSucceeded: false,
sudoInstalled: null,
failingCommand: "dotnet workload list --machine-readable");

Assert.Equal(["wasm-tools"], ids);
}

[Fact]
public void CombineInstalledWorkloads_SudoOnlySucceeds_ReturnsSudoList()
{
// Repro for the "Inadequate permissions" case: user-context call returns
// non-zero before producing parseable JSON, but the sudo-context probe
// discovers the root-owned workloads.
var ids = DotNetWorkloadManager.CombineInstalledWorkloads(
userContextSucceeded: false,
userInstalled: null,
sudoContextAttempted: true,
sudoContextSucceeded: true,
sudoInstalled: ["android", "ios"],
failingCommand: "dotnet workload list --machine-readable");

Assert.Equal(["android", "ios"], ids.OrderBy(x => x).ToArray());
}

[Fact]
public void CombineInstalledWorkloads_MixedUserAndSudoInstalls_UnionsBoth()
{
// Repro for mixed installs: one workload installed for the current user,
// another previously installed via sudo. The combined result must include
// both, otherwise the checkup flags the root-owned workload as missing.
var ids = DotNetWorkloadManager.CombineInstalledWorkloads(
userContextSucceeded: true,
userInstalled: ["wasm-tools", "maui"],
sudoContextAttempted: true,
sudoContextSucceeded: true,
sudoInstalled: ["maui", "android"],
failingCommand: "dotnet workload list --machine-readable");

Assert.Equal(["android", "maui", "wasm-tools"], ids.OrderBy(x => x).ToArray());
}

[Fact]
public void CombineInstalledWorkloads_DeduplicatesCaseInsensitively()
{
var ids = DotNetWorkloadManager.CombineInstalledWorkloads(
userContextSucceeded: true,
userInstalled: ["WASM-tools"],
sudoContextAttempted: true,
sudoContextSucceeded: true,
sudoInstalled: ["wasm-tools"],
failingCommand: "dotnet workload list --machine-readable");

Assert.Single(ids);
}

[Fact]
public void CombineInstalledWorkloads_WindowsSkipsSudo_ReturnsUserList()
{
// On Windows the sudo probe is never attempted; combining must still
// return the user list rather than throwing.
var ids = DotNetWorkloadManager.CombineInstalledWorkloads(
userContextSucceeded: true,
userInstalled: ["wasm-tools"],
sudoContextAttempted: false,
sudoContextSucceeded: false,
sudoInstalled: null,
failingCommand: "dotnet workload list --machine-readable");

Assert.Equal(["wasm-tools"], ids);
}

[Fact]
public void CombineInstalledWorkloads_BothProbesFail_ThrowsWithCommand()
{
var ex = Assert.Throws<Exception>(() => DotNetWorkloadManager.CombineInstalledWorkloads(
userContextSucceeded: false,
userInstalled: null,
sudoContextAttempted: true,
sudoContextSucceeded: false,
sudoInstalled: null,
failingCommand: "dotnet workload list --machine-readable"));

Assert.Contains("Workload command failed", ex.Message);
Assert.Contains("dotnet workload list --machine-readable", ex.Message);
}

[Fact]
public void CombineInstalledWorkloads_UserFailsOnWindows_Throws()
{
// Windows has no sudo fallback; if the user-context call fails, the
// method must throw rather than silently returning an empty list.
Assert.Throws<Exception>(() => DotNetWorkloadManager.CombineInstalledWorkloads(
userContextSucceeded: false,
userInstalled: null,
sudoContextAttempted: false,
sudoContextSucceeded: false,
sudoInstalled: null,
failingCommand: "dotnet workload list --machine-readable"));
}

[Theory]
[InlineData("dotnet", "dotnet")]
[InlineData("/usr/local/share/dotnet/dotnet", "/usr/local/share/dotnet/dotnet")]
[InlineData("", "\"\"")]
public void QuoteForProcessArgs_LeavesArgumentsWithoutSpecialCharsUnchanged(string input, string expected)
{
Assert.Equal(expected, DotNetCheck.Util.QuoteForProcessArgs(input));
}

[Theory]
[InlineData("/Users/My Name/.dotnet/dotnet", "\"/Users/My Name/.dotnet/dotnet\"")]
[InlineData("path\twith tab", "\"path\twith tab\"")]
public void QuoteForProcessArgs_WrapsArgumentsContainingWhitespace(string input, string expected)
{
// Repro for the sudo-retry quoting bug: dotnet executable paths under directories
// that contain a space (e.g., "/Users/My Name/...") must be wrapped so the .NET
// argv tokenizer keeps them as a single token instead of splitting on whitespace.
Assert.Equal(expected, DotNetCheck.Util.QuoteForProcessArgs(input));
}

[Theory]
[InlineData("a\"b", "\"a\\\"b\"")]
[InlineData("a\\\"b", "\"a\\\\\\\"b\"")]
[InlineData("ends-with\\", "\"ends-with\\\\\"")]
[InlineData("trailing\\\\", "\"trailing\\\\\\\\\"")]
public void QuoteForProcessArgs_EscapesEmbeddedQuotesAndBackslashes(string input, string expected)
{
Assert.Equal(expected, DotNetCheck.Util.QuoteForProcessArgs(input));
}

[Theory]
[InlineData("dotnet", "\"dotnet\"")]
[InlineData("/usr/local/share/dotnet/dotnet", "\"/usr/local/share/dotnet/dotnet\"")]
[InlineData("/Users/me/My SDK/dotnet", "\"/Users/me/My SDK/dotnet\"")]
[InlineData("", "\"\"")]
public void ShellDoubleQuote_WrapsArgumentInDoubleQuotes(string input, string expected)
{
// Repro for the user-configurable DOTNET_ROOT case: a path with spaces inside
// the outer single-quoted shell block must be wrapped so the inner shell doesn't
// split on the embedded space.
Assert.Equal(expected, DotNetCheck.Util.ShellDoubleQuote(input));
}

[Theory]
[InlineData("a$b", "\"a\\$b\"")]
[InlineData("a`b", "\"a\\`b\"")]
[InlineData("a\"b", "\"a\\\"b\"")]
[InlineData("a\\b", "\"a\\\\b\"")]
[InlineData("$HOME/dotnet", "\"\\$HOME/dotnet\"")]
public void ShellDoubleQuote_EscapesShellSpecialChars(string input, string expected)
{
Assert.Equal(expected, DotNetCheck.Util.ShellDoubleQuote(input));
}

[Fact]
public void DotNetWorkloadManager_PinsDotnetUiLanguageToEnglishGlobally()
{
// The matchers in ShouldRetryWithSudo and BuildCliFailureMessage look for English
// substrings like "permission denied" / "no space left on device". On a non-en-US
// host the dotnet CLI localizes those, so the checks would silently miss. The class
// pins DOTNET_CLI_UI_LANGUAGE=en-US two ways: the static dict (used inline at the
// sudo call sites to outflank sudo's env_reset) and a static-ctor side effect that
// adds the same pair to Util.EnvironmentVariables so the non-sudo path inherits it.
// Guard against either being dropped or renamed.

// Touching the static field forces the type initializer to run.
var pin = DotNetWorkloadManager.WorkloadCliEnv;

Assert.Equal("en-US", pin["DOTNET_CLI_UI_LANGUAGE"]);
Assert.Equal("en-US", Util.EnvironmentVariables["DOTNET_CLI_UI_LANGUAGE"]);
}

[Fact]
public async Task EnsureSudoCredentialsCachedAsync_NonInteractive_DoesNotBlockOnConsole()
{
var previous = DotNetCheck.Util.NonInteractive;
try
{
DotNetCheck.Util.NonInteractive = true;

// In non-interactive mode the helper must never invoke `sudo -v`
// (which would block on /dev/tty). Result depends on whether the
// `sudo -n true` probe finds cached creds on the runner — the
// assertion here is only that the call returns within the timeout.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await DotNetCheck.Util.EnsureSudoCredentialsCachedAsync(cts.Token);
}
finally
{
DotNetCheck.Util.NonInteractive = previous;
}
}
}
Loading
Loading