Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 7 additions & 12 deletions Fgvm.Cli/Command/InstallCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ CancellationToken cancellationToken
{
logger.LogError(e, "Error downloading and installing Godot.");
console.MarkupLine(
Messages.SomethingWentWrong($"when trying to install Godot: {e.Message}", pathService)
Messages.SomethingWentWrong($"when trying to install Godot: {Markup.Escape(e.Message)}", pathService)
);

throw;
Expand Down Expand Up @@ -130,13 +130,14 @@ IHostSystem hostSystem
throw new ArgumentException(invalid.Message);

case Result<InstallationOutcome, InstallationError>.Failure(InstallationError.NotFound notFound):
throw new ArgumentException(Messages.InstallationNotFound(notFound.Version, hostSystem));
throw new ArgumentException(Markup.Remove(Messages.InstallationNotFound(notFound.Version, hostSystem)));

case Result<InstallationOutcome, InstallationError>.Failure(InstallationError.ChecksumMismatch mismatch):
throw new SecurityException(Messages.ChecksumMismatch(mismatch.FileName, mismatch.Expected, mismatch.Actual));
throw new SecurityException(
Markup.Remove(Messages.ChecksumMismatch(mismatch.FileName, mismatch.Expected, mismatch.Actual)));

case Result<InstallationOutcome, InstallationError>.Failure(InstallationError.Failed failed):
throw new InvalidOperationException(Messages.InstallationFailed(failed.Reason));
throw new InvalidOperationException(Markup.Remove(Messages.InstallationFailed(failed.Reason)));

default:
throw new Exception(Messages.UnknownInstallationResultType);
Expand All @@ -160,14 +161,8 @@ Result<TemplateInstallationOutcome, TemplateInstallationError> templateResult
return templateResult switch
{
Result<TemplateInstallationOutcome, TemplateInstallationError>.Success => null,
Result<TemplateInstallationOutcome, TemplateInstallationError>.Failure(TemplateInstallationError.InvalidQuery invalid) =>
invalid.Message,
Result<TemplateInstallationOutcome, TemplateInstallationError>.Failure(TemplateInstallationError.NotFound notFound) =>
Messages.TemplateInstallationNotFound(notFound.Version),
Result<TemplateInstallationOutcome, TemplateInstallationError>.Failure(TemplateInstallationError.ChecksumMismatch mismatch) =>
Messages.ChecksumMismatch(mismatch.FileName, mismatch.Expected, mismatch.Actual),
Result<TemplateInstallationOutcome, TemplateInstallationError>.Failure(TemplateInstallationError.Failed failed) =>
Messages.TemplateInstallationFailed(failed.Reason),
Result<TemplateInstallationOutcome, TemplateInstallationError>.Failure(var error) =>
TemplateInstallationResult.DescribeError(error),
_ => "Unknown template installation result type."
};
}
Expand Down
19 changes: 4 additions & 15 deletions Fgvm.Cli/Command/SearchCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using Fgvm.Cli.Error;
using Fgvm.Cli.ViewModels;
using Fgvm.Environment;
using Fgvm.Extensions;
using Fgvm.Godot;
using Fgvm.Types;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -63,22 +62,12 @@ Task<Result<IEnumerable<string>, NetworkError>> SearchAsync() =>
return;

case Result<IEnumerable<string>, NetworkError>.Failure(var error):
var errorMessage = error switch
{
NetworkError.RequestFailure(var url, var statusCode, _) =>
$"Request to {url} failed with status code {statusCode.Describe()}",
NetworkError.ConnectionFailure(var message, _) =>
$"Network error: {message}",
NetworkError.CacheReadFailure(var fileError) =>
$"Release cache read error: {fileError}",
NetworkError.CacheWriteFailure(var fileError) =>
$"Release cache write error: {fileError}",
_ => "Unknown network error"
};

var errorMessage = Messages.NetworkFailure(error);
logger.LogError("Error searching releases: {ErrorMessage}", errorMessage);
console.MarkupLine(
Messages.SomethingWentWrong("when trying to search releases", pathService)
Messages.SomethingWentWrong(
$"when trying to search releases: {Markup.Escape(errorMessage)}",
pathService)
);

throw new InvalidOperationException(errorMessage);
Expand Down
25 changes: 20 additions & 5 deletions Fgvm.Cli/Error/Messages.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Fgvm.Environment;
using Fgvm.Types;
using Spectre.Console;

namespace Fgvm.Cli.Error;
Expand Down Expand Up @@ -92,6 +93,15 @@ public static string SelectVersionsTo(string what) =>
public static string UserCancelled(string what) => $"[red]User cancelled {what} operation :prohibited:[/]";

// Errors
public static string NetworkFailure(NetworkError error) => error switch
{
NetworkError.RequestFailure requestFailure => requestFailure.ToString(),
NetworkError.ConnectionFailure(var message, _) => $"Network error: {message}",
NetworkError.CacheReadFailure(var fileError) => $"Release cache read error: {fileError}",
NetworkError.CacheWriteFailure(var fileError) => $"Release cache write error: {fileError}",
_ => "Unknown network error"
};

public static string GodotLaunchFailed(string reason) => $"[red]Could not run Godot: {reason.EscapeMarkup()}[/]";

public static string GodotExited(int exitCode) => $"[red]Godot exited with code {exitCode}.[/]";
Expand All @@ -113,9 +123,10 @@ public static string InvalidSymlinkWarn(string symlinkPath) =>
public static string AlreadyInstalled(string releaseNameWithRuntime) => $"[yellow]{releaseNameWithRuntime} is already installed[/]";

public static string InstallationNotFound(string version, IHostSystem hostSystem) =>
$"[red]Version {version} could not be found for {hostSystem.SystemInfo.CurrentOS.ToDisplayString()} {hostSystem.SystemInfo.CurrentArch.ToDisplayString()}[/]";
$"[red]Version {Markup.Escape(version)} could not be found for " +
$"{hostSystem.SystemInfo.CurrentOS.ToDisplayString()} {hostSystem.SystemInfo.CurrentArch.ToDisplayString()}[/]";

public static string InstallationFailed(string reason) => $"[red]Installation failed: {reason}[/]";
public static string InstallationFailed(string reason) => $"[red]Installation failed: {Markup.Escape(reason)}[/]";
public static string InstallationSuccessBase(string releaseNameWithRuntime) =>
$"[green]Finished installing {releaseNameWithRuntime}! :party_popper:[/]";
public static string SuccessfullyInstalled(string releaseNameWithRuntime) =>
Expand All @@ -131,7 +142,7 @@ public static string SuccessfullyInstalled(string releaseNameWithRuntime) =>
public static string VersionResolutionNotFound(string version, IHostSystem hostSystem) =>
$"[red]Version {version} could not be found for {hostSystem.SystemInfo.CurrentOS.ToDisplayString()} {hostSystem.SystemInfo.CurrentArch.ToDisplayString()}[/]";

public static string VersionResolutionFailed(string reason) => $"[red]Version resolution failed: {reason}[/]";
public static string VersionResolutionFailed(string reason) => $"[red]Version resolution failed: {Markup.Escape(reason)}[/]";
public static string InvalidVersion(string version) => $"[red]The version '{version}' is invalid[/]";
public static string InvalidProjectVersion(string compatibleVersion) => $"[red]Invalid project version: {compatibleVersion}[/]";
public static string SuccessfullySetVersion(string releaseNameWithRuntime) =>
Expand Down Expand Up @@ -198,7 +209,10 @@ public static string ChecksumUnavailable(string releaseNameWithRuntime) =>
$"[orange1]Warning: Checksum unavailable for {releaseNameWithRuntime}. Installation continued without verification.[/]";

public static string ChecksumMismatch(string fileName, string expected, string actual) =>
$"[red]Checksum mismatch for {fileName}![/]\n[red]Expected: {expected}[/]\n[red]Actual: {actual}[/]\n[red]This could indicate a corrupted download or security issue.[/]";
$"[red]Checksum mismatch for {Markup.Escape(fileName)}![/]\n" +
$"[red]Expected: {Markup.Escape(expected)}[/]\n" +
$"[red]Actual: {Markup.Escape(actual)}[/]\n" +
"[red]This could indicate a corrupted download or security issue.[/]";

// Export
public static string ExportingTarget(string preset) => $"[dim]Exporting {Markup.Escape(preset)}...[/]";
Expand Down Expand Up @@ -226,7 +240,8 @@ public static string LocalTemplateInstallationFailed(string releaseNameWithRunti
public static string LocalTemplateInstallationCancelled(string releaseNameWithRuntime) =>
$"[orange1]Set local version to {Markup.Escape(releaseNameWithRuntime)}, but export template installation was cancelled.[/]";
public static string OptionalTemplateInstallationFailed(string releaseNameWithRuntime, string reason) =>
$"[orange1]Godot {releaseNameWithRuntime} is installed, but export template installation failed: {reason}[/]";
$"[orange1]Godot {Markup.Escape(releaseNameWithRuntime)} is installed, but export template installation failed: " +
$"{Markup.Escape(reason)}[/]";
public static string OptionalTemplateInstallationCancelled(string releaseNameWithRuntime) =>
$"[orange1]Godot {releaseNameWithRuntime} is installed. Export templates were skipped.[/]";
public static string NoTemplatesMatchingQuery(string query) =>
Expand Down
5 changes: 3 additions & 2 deletions Fgvm.Cli/Services/InstallationOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,9 @@ private async Task<string[]> FetchReleaseNames(CancellationToken cancellationTok
Result<string[], NetworkError>.Success(var releaseNames) => releaseNames,
Result<string[], NetworkError>.Failure(NetworkError.ManifestRefreshFailure(var releaseNames)) =>
UseCachedReleaseNames(releaseNames),
Result<string[], NetworkError>.Failure =>
throw new InvalidOperationException("Unable to fetch available Godot releases."),
Result<string[], NetworkError>.Failure(var error) =>
throw new InvalidOperationException(
$"Unable to fetch available Godot releases: {Messages.NetworkFailure(error)}"),
_ => throw new InvalidOperationException("Unexpected Result type")
};
}
Expand Down
2 changes: 1 addition & 1 deletion Fgvm.Cli/Services/VersionManagementService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,7 @@ CancellationToken cancellationToken
break;
case Result<InstallationOutcome, InstallationError>.Failure(InstallationError.NotFound notFound):
outputConsole.MarkupLine(Messages.FailedToInstallMatching(string.Join(" ", query)));
throw new ArgumentException(Messages.InstallationNotFound(notFound.Version, hostSystem));
throw new ArgumentException(Markup.Remove(Messages.InstallationNotFound(notFound.Version, hostSystem)));
case Result<InstallationOutcome, InstallationError>.Failure(InstallationError.InvalidQuery invalidQuery):
outputConsole.MarkupLine(Messages.FailedToInstallMatching(string.Join(" ", query)));
throw new ArgumentException(invalidQuery.Message);
Expand Down
21 changes: 21 additions & 0 deletions Fgvm.Tests/Command/GodotCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,27 @@ public async Task Launch_InteractiveWithoutInstallations_ReportsFailureAndDoesNo
_registry.Verify(x => x.RecordLaunch(It.IsAny<string>(), It.IsAny<DateTimeOffset?>()), Times.Never);
}

[Fact]
public async Task Launch_ProjectAutoInstallNetworkFailure_RendersBracketedDetails()
{
const string reason =
"Unable to fetch available Godot releases: Request failed with 403 (Forbidden). Response: API rate limit exceeded [shared IP]";
_versionService.Setup(x => x.ResolveVersionForLaunchExplicitAsync(false, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Result<VersionResolutionOutcome, VersionResolutionError>.Failure(
new VersionResolutionError.Failed(reason)));

var exception = await Assert.ThrowsAsync<ProcessExitCodeException>(() => CreateCommand().Launch());

Assert.Equal(ExitCodes.GeneralError, exception.ExitCode);
Assert.Contains(reason, _diagnostics.Output, StringComparison.Ordinal);
Assert.DoesNotContain("[red]", _diagnostics.Output, StringComparison.Ordinal);
_launcher.Verify(x => x.LaunchAsync(
It.IsAny<GodotLaunchRequest>(),
It.IsAny<Action<GodotLaunchOutput>?>(),
It.IsAny<CancellationToken>()), Times.Never);
_registry.Verify(x => x.RecordLaunch(It.IsAny<string>(), It.IsAny<DateTimeOffset?>()), Times.Never);
}

[Fact]
public async Task Launch_NonZeroExit_PropagatesGodotExitCode()
{
Expand Down
102 changes: 100 additions & 2 deletions Fgvm.Tests/Command/InstallCommandTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Runtime.InteropServices;
using System.Security;
using Fgvm.Cli.Command;
using Fgvm.Cli.Services;
using Fgvm.Environment;
Expand Down Expand Up @@ -102,13 +103,80 @@ public async Task Install_WithTemplates_WarnsWhenTemplateInstallFails()
false,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new Result<TemplateInstallationOutcome, TemplateInstallationError>.Failure(
new TemplateInstallationError.Failed("Download failed for export templates.")));
new TemplateInstallationError.Failed(
"Request failed with 403 (Forbidden). Response: API rate limit exceeded [shared IP]")));
var command = CreateCommand(installationOrchestrator.Object, templateOrchestrator.Object, out var console);
console.Profile.Width = 500;

await command.Install(withTemplates: true, cancellationToken: CancellationToken.None, query: query);

Assert.Contains($"Godot {releaseName} is installed", console.Output);
Assert.Contains("Export template installation failed", console.Output);
Assert.Contains("export template installation failed", console.Output);
Assert.Contains("API rate limit exceeded [shared IP]", console.Output, StringComparison.Ordinal);
}

[Fact]
public async Task Install_WhenInstallationFails_DoesNotRenderMessageMarkupLiterally()
{
var query = new[] { "4.6.2" };
var installationOrchestrator = CreateFailedInstallationOrchestrator(
query,
new InstallationError.Failed("Download from [mirror] failed"));
var command = CreateCommand(
installationOrchestrator.Object,
new Mock<ITemplateOrchestrator>().Object,
out var console);
console.Profile.Width = 500;

var error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
command.Install(cancellationToken: CancellationToken.None, query: query));

Assert.Equal("Installation failed: Download from [mirror] failed", error.Message);
Assert.Contains("Installation failed: Download from [mirror] failed", console.Output, StringComparison.Ordinal);
Assert.DoesNotContain("[red]", console.Output, StringComparison.Ordinal);
}

[Fact]
public async Task Install_WhenVersionIsNotFound_ThrowsPlainTextArgumentException()
{
var query = new[] { "[9.999]" };
var installationOrchestrator = CreateFailedInstallationOrchestrator(
query,
new InstallationError.NotFound(query[0]));
var command = CreateCommand(
installationOrchestrator.Object,
new Mock<ITemplateOrchestrator>().Object,
out _);

var error = await Assert.ThrowsAsync<ArgumentException>(() =>
command.Install(cancellationToken: CancellationToken.None, query: query));

Assert.Equal("Version [9.999] could not be found for Linux x64", error.Message);
Assert.DoesNotContain("[red]", error.Message, StringComparison.Ordinal);
}

[Fact]
public async Task Install_WhenChecksumMismatches_DoesNotRenderMessageMarkupLiterally()
{
var query = new[] { "4.6.2" };
var installationOrchestrator = CreateFailedInstallationOrchestrator(
query,
new InstallationError.ChecksumMismatch("expected", "actual", "Godot [fixture].zip"));
var command = CreateCommand(
installationOrchestrator.Object,
new Mock<ITemplateOrchestrator>().Object,
out var console);
console.Profile.Width = 500;

var error = await Assert.ThrowsAsync<SecurityException>(() =>
command.Install(cancellationToken: CancellationToken.None, query: query));

Assert.Contains("Checksum mismatch for Godot [fixture].zip!", error.Message, StringComparison.Ordinal);
Assert.Contains("Expected: expected", error.Message, StringComparison.Ordinal);
Assert.Contains("Actual: actual", error.Message, StringComparison.Ordinal);
Assert.Contains("corrupted download or security issue", error.Message, StringComparison.Ordinal);
Assert.Contains("Checksum mismatch for Godot [fixture].zip", console.Output, StringComparison.Ordinal);
Assert.DoesNotContain("[red]", console.Output, StringComparison.Ordinal);
}

[Fact]
Expand All @@ -130,6 +198,28 @@ await Assert.ThrowsAsync<OperationCanceledException>(() =>
Times.Never);
}

[Fact]
public async Task Install_WhenReleaseLookupFails_ShowsTheFailureDetails()
{
var query = new[] { "4.6" };
var installationOrchestrator = new Mock<IInstallationOrchestrator>();
installationOrchestrator.Setup(x => x.InstallAsync(query, false, false, It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException(
"Unable to fetch available Godot releases: Request to https://[::1] failed with 403 (Forbidden)"));
var command = CreateCommand(
installationOrchestrator.Object,
new Mock<ITemplateOrchestrator>().Object,
out var console);
console.Profile.Width = 500;

await Assert.ThrowsAsync<InvalidOperationException>(() =>
command.Install(cancellationToken: CancellationToken.None, query: query));

Assert.Contains("Unable to fetch available Godot releases", console.Output, StringComparison.Ordinal);
Assert.Contains("https://[::1]", console.Output, StringComparison.Ordinal);
Assert.Contains("403 (Forbidden)", console.Output, StringComparison.Ordinal);
}

[Fact]
public async Task Install_WhenTemplateInstallationIsCanceled_KeepsTheEditorAndSucceeds()
{
Expand Down Expand Up @@ -164,6 +254,14 @@ private static Mock<IInstallationOrchestrator> CreateInstallationOrchestrator(st
return installationOrchestrator;
}

private static Mock<IInstallationOrchestrator> CreateFailedInstallationOrchestrator(string[] query, InstallationError error)
{
var installationOrchestrator = new Mock<IInstallationOrchestrator>();
installationOrchestrator.Setup(x => x.InstallAsync(query, false, false, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Result<InstallationOutcome, InstallationError>.Failure(error));
return installationOrchestrator;
}

private static Mock<ITemplateOrchestrator> CreateSuccessfulTemplateOrchestrator(string releaseName, bool verbose = false)
{
var templateOrchestrator = new Mock<ITemplateOrchestrator>();
Expand Down
Loading