Skip to content
Open
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
34 changes: 33 additions & 1 deletion CosmosDBShell.Tests/Integration/ShellProcessTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,23 @@ public async Task StdinPipedScript_EchoCommand_WritesArgumentToStdOut()
Assert.Contains("hello from process", result.StdOut);
}

[Fact]
public async Task InvariantGlobalization_ExecuteCommand_LoadsEnglishCatalog()
{
var result = await RunShellAsync(
stdinScript: null,
extraArgs: ["--quiet", "-c", "echo hi"],
cancellationToken: TestContext.Current.CancellationToken,
environment: new Dictionary<string, string?>
{
["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1",
});

Assert.Equal(0, result.ExitCode);
Assert.Equal("hi", result.StdOut.Trim());
Assert.Empty(result.StdErr);
}

[Fact]
public async Task StdinPipedScript_MultipleCommands_AllRunAndLastOutputVisible()
{
Expand Down Expand Up @@ -432,7 +449,8 @@ private static async Task<ShellProcessResult> RunShellAsync(
private static async Task<ShellProcessResult> RunShellAsync(
string? stdinScript,
IEnumerable<string>? extraArgs,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
IReadOnlyDictionary<string, string?>? environment = null)
{
var argsList = extraArgs?.ToList();
var requiresOwnedStdin = stdinScript != null
Expand Down Expand Up @@ -481,6 +499,20 @@ private static async Task<ShellProcessResult> RunShellAsync(
startInfo.Environment["DOTNET_CLI_UI_LANGUAGE"] = "en";
startInfo.Environment.Remove("COSMOSDB_SHELL_FORMAT");
startInfo.Environment["COSMOSDB_SHELL_CONFIG_DIR"] = isolatedConfigDir;
if (environment != null)
{
foreach (var (key, value) in environment)
{
if (value == null)
{
startInfo.Environment.Remove(key);
}
else
{
startInfo.Environment[key] = value;
}
}
}

using var process = new Process { StartInfo = startInfo };
var stdOut = new StringBuilder();
Expand Down
25 changes: 25 additions & 0 deletions CosmosDBShell.Tests/MessageServiceCultureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace CosmosShell.Tests;

using System.Globalization;
using Azure.Data.Cosmos.Shell.Util;
using Fluent.Net;

public class MessageServiceCultureTests
{
Expand All @@ -27,4 +28,28 @@ public void GetCultureFallbacks_ReturnsSpecificToNeutralCultures(string cultureN
{
Assert.Equal(expected, MessageService.GetCultureFallbacks(CultureInfo.GetCultureInfo(cultureName)));
}

[Fact]
public void CreateMessageContext_FallsBackToInvariantCulture_WhenLocaleIsNotSupported()
{
var options = new MessageContextOptions { UseIsolating = false };
var attempts = new List<string>();

var context = MessageService.CreateMessageContext(
"en",
options,
locale =>
{
attempts.Add(locale);
if (locale == "en")
{
throw new CultureNotFoundException("name", locale, "unsupported");
}

return new MessageContext(locale, options);
});

Assert.Equal(["en", CultureInfo.InvariantCulture.Name], attempts);
Assert.NotNull(context);
}
}
107 changes: 107 additions & 0 deletions CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// ------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// ------------------------------------------------------------

namespace CosmosShell.Tests.Shell;

using Azure.Data.Cosmos.Shell.Core;
using Spectre.Console;

[Collection(ThemeStateTestCollection.Name)]
public class NonAnsiFallbackTests
{
[Fact]
public async Task RunAsync_WhenLineEditorCannotBeCreated_UsesPromptFallbackAndExitsOnEof()
{
var configPath = CreateConfigPath();
var savedConsole = AnsiConsole.Console;
var savedIn = Console.In;
var savedOut = Console.Out;
var savedError = Console.Error;
using var input = new StringReader("echo hi" + Environment.NewLine);
using var output = new StringWriter();
using var error = new StringWriter();
try
{
AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.No,
ColorSystem = ColorSystemSupport.NoColors,
Out = new AnsiConsoleOutput(output),
});
Console.SetIn(input);
Console.SetOut(output);
Console.SetError(error);

using var shell = new ShellInterpreter(configPath)
{
IsInteractiveSession = static () => false,
};

await shell.RunAsync().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);

Assert.Null(shell.Editor);
Assert.False(shell.IsRunning);
Assert.Contains("hi", output.ToString(), StringComparison.Ordinal);
}
finally
{
Console.SetIn(savedIn);
Console.SetOut(savedOut);
Console.SetError(savedError);
AnsiConsole.Console = savedConsole;
DeleteConfigPath(configPath);
}
}

[Fact]
public void Editor_WhenLineEditorCannotBeCreated_CachesFailure()
{
var configPath = CreateConfigPath();
var savedConsole = AnsiConsole.Console;
var savedError = Console.Error;
using var output = new StringWriter();
using var error = new StringWriter();
try
{
AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings
{
Ansi = AnsiSupport.No,
ColorSystem = ColorSystemSupport.NoColors,
Out = new AnsiConsoleOutput(output),
});
Console.SetError(error);

using var shell = new ShellInterpreter(configPath);

Assert.Null(shell.Editor);
var errorAfterFirstAccess = error.ToString();

Assert.Null(shell.Editor);
Assert.Equal(errorAfterFirstAccess, error.ToString());
Assert.Contains("ANSI", errorAfterFirstAccess, StringComparison.OrdinalIgnoreCase);
Assert.Single(errorAfterFirstAccess.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries));
}
finally
{
Console.SetError(savedError);
AnsiConsole.Console = savedConsole;
DeleteConfigPath(configPath);
}
}

private static string CreateConfigPath()
{
var configPath = Path.Join(AppContext.BaseDirectory, "test-config", $"non-ansi-{Guid.NewGuid():N}");
Directory.CreateDirectory(configPath);
return configPath;
}

private static void DeleteConfigPath(string configPath)
{
if (Directory.Exists(configPath))
{
Directory.Delete(configPath, recursive: true);
}
}
}
29 changes: 25 additions & 4 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ public partial class ShellInterpreter : IDisposable

private LineEditor? lineEditor;

private bool lineEditorCreationAttempted;

private CosmosShellPrompt? cosmosShellPrompt;

private System.Text.StringBuilder? pendingMultiLineBuffer;
Expand Down Expand Up @@ -136,7 +138,19 @@ internal ShellInterpreter(string? configPath = null)
/// <summary>
/// Gets the line editor instance used by the shell, or <c>null</c> if not available.
/// </summary>
public LineEditor? Editor { get => this.lineEditor ??= this.CreateLineEditor(); }
public LineEditor? Editor
{
get
{
if (!this.lineEditorCreationAttempted)
{
this.lineEditorCreationAttempted = true;
this.lineEditor = this.CreateLineEditor();
Comment on lines +145 to +148
}

return this.lineEditor;
}
}

/// <summary>
/// Gets or sets a value indicating whether the shell is currently running.
Expand Down Expand Up @@ -954,7 +968,14 @@ internal async Task<int> RunAsync()
try
{
this.ClearHighlightStatements();
var input = this.Editor != null ? await this.Editor.ReadLine(this.editorCancelTokenSource.Token) : PromptFallback();
var editor = this.Editor;
var input = editor != null ? await editor.ReadLine(this.editorCancelTokenSource.Token) : PromptFallback();
if (editor == null && input == null)
{
this.IsRunning = false;
break;
}

var command = ProcessInteractiveLine(
input,
ref this.pendingMultiLineBuffer,
Expand Down Expand Up @@ -2234,7 +2255,7 @@ internal static CosmosClientOptions CreateClientOptions(ConnectionMode requested
return options;
}

private LineEditor CreateLineEditor()
private LineEditor? CreateLineEditor()
{
try
{
Expand Down Expand Up @@ -2274,7 +2295,7 @@ private LineEditor CreateLineEditor()
catch (Exception e)
{
Console.Error.WriteLine(e.Message);
return new LineEditor();
return null;
}
}

Expand Down
19 changes: 18 additions & 1 deletion CosmosDBShell/Azure.Data.Cosmos.Shell.Util/MessageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ public static string GetArgsString(string id, params object[] args)
}

using var sr = new StreamReader(stream);
var mc = new MessageContext(resourceName, options);
var mc = CreateMessageContext(resourceName, options);
var errors = mc.AddMessages(sr);
if (errors.Any())
{
Expand All @@ -181,4 +181,21 @@ public static string GetArgsString(string id, params object[] args)

return null;
}

internal static MessageContext CreateMessageContext(
string locale,
MessageContextOptions options,
Func<string, MessageContext>? factory = null)
{
factory ??= name => new MessageContext(name, options);

try
{
return factory(locale);
}
catch (CultureNotFoundException) when (!string.IsNullOrEmpty(locale))
{
return factory(CultureInfo.InvariantCulture.Name);
}
}
}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ MCP command execution is serialized with the shell, and destructive confirmation

The .NET runtime alone is not enough for the commands below. `dotnet run` and `dotnet tool install` are SDK commands. To verify the SDK is installed, run `dotnet --list-sdks`.

In Linux containers without ICU, the shell also supports .NET globalization-invariant mode and falls back to its bundled English messages.

```bash
dotnet run --project CosmosDBShell
```
Expand Down
Loading