using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Management.Automation; using System.Management.Automation.Language; using System.Management.Automation.Runspaces; using System.Management.Automation.Subsystem.Prediction; using System.Threading.Tasks; using System.Reflection; using System.Runtime.InteropServices; using Microsoft.PowerShell; using Microsoft.PowerShell.Internal; using Xunit; using Xunit.Abstractions; [assembly: CollectionBehavior(DisableTestParallelization = true)] namespace Test { internal class MockedMethods : IPSConsoleReadLineMockableMethods { internal bool didDing; internal IReadOnlyList commandHistory; internal bool? lastCommandRunStatus; internal Guid acceptedPredictorId; internal string acceptedSuggestion; internal string helpContentRendered; internal Dictionary> displayedSuggestions = new Dictionary>(); internal void ClearPredictionFields() { commandHistory = null; lastCommandRunStatus = null; acceptedPredictorId = Guid.Empty; acceptedSuggestion = null; displayedSuggestions.Clear(); } public void Ding() { didDing = true; } public CommandCompletion CompleteInput(string input, int cursorIndex, Hashtable options, PowerShell powershell) { return ReadLine.MockedCompleteInput(input, cursorIndex, options, powershell); } public bool RunspaceIsRemote(Runspace runspace) { return false; } public Task> PredictInputAsync(Ast ast, Token[] tokens) { var result = ReadLine.MockedPredictInput(ast, tokens); var source = new TaskCompletionSource>(); source.SetResult(result); return source.Task; } public void OnCommandLineAccepted(IReadOnlyList history) { commandHistory = history; } public void OnCommandLineExecuted(string commandLine, bool success) { lastCommandRunStatus = success; } public void OnSuggestionDisplayed(Guid predictorId, uint session, int countOrIndex) { displayedSuggestions[predictorId] = Tuple.Create(session, countOrIndex); } public void OnSuggestionAccepted(Guid predictorId, uint session, string suggestionText) { acceptedPredictorId = predictorId; acceptedSuggestion = suggestionText; } public object GetDynamicHelpContent(string commandName, string parameterName, bool isFullHelp) { return ReadLine.GetDynamicHelpContent(commandName, parameterName, isFullHelp); } public void RenderFullHelp(string content, string regexPatternToScrollTo) { helpContentRendered = content; } } public enum TokenClassification { None, Comment, Keyword, String, Operator, Variable, Command, Parameter, Type, Number, Member, Selection, InlinePrediction, ListPrediction, ListPredictionSelected, } public abstract partial class ReadLine { protected ReadLine(ConsoleFixture fixture, ITestOutputHelper output, string lang, string os) { Output = output; Fixture = fixture; Fixture.Initialize(lang, os); } internal dynamic _ => Fixture.KbLayout; internal ConsoleFixture Fixture { get; } internal ITestOutputHelper Output { get; } internal virtual bool KeyboardHasLessThan => true; internal virtual bool KeyboardHasGreaterThan => true; internal virtual bool KeyboardHasCtrlRBracket => true; internal virtual bool KeyboardHasCtrlAt => true; static ReadLine() { var iss = InitialSessionState.CreateDefault(); var rs = RunspaceFactory.CreateRunspace(iss); rs.Open(); Runspace.DefaultRunspace = rs; } private enum KeyMode { Cmd, Emacs, Vi }; // These colors are random - we just use these colors instead of the defaults // so the tests aren't sensitive to tweaks to the default colors. internal static readonly ConsoleColor[] Colors = new [] { /*None*/ ConsoleColor.DarkRed, /*Comment*/ ConsoleColor.Blue, /*Keyword*/ ConsoleColor.Cyan, /*String*/ ConsoleColor.Gray, /*Operator*/ ConsoleColor.Green, /*Variable*/ ConsoleColor.Magenta, /*Command*/ ConsoleColor.Red, /*Parameter*/ ConsoleColor.White, /*Type*/ ConsoleColor.Yellow, /*Number*/ ConsoleColor.DarkBlue, /*Member*/ ConsoleColor.DarkMagenta, /*Selection*/ ConsoleColor.Black, /*InlinePrediction*/ ConsoleColor.DarkGreen, /*ListPrediction*/ ConsoleColor.Yellow, /*ListPredictionSelected*/ ConsoleColor.Gray, }; internal static readonly ConsoleColor[] BackgroundColors = new[] { /*None*/ ConsoleColor.DarkGray, /*Comment*/ ConsoleColor.DarkBlue, /*Keyword*/ ConsoleColor.DarkCyan, /*String*/ ConsoleColor.DarkGray, /*Operator*/ ConsoleColor.DarkGreen, /*Variable*/ ConsoleColor.DarkMagenta, /*Command*/ ConsoleColor.DarkRed, /*Parameter*/ ConsoleColor.DarkYellow, /*Type*/ ConsoleColor.Black, /*Number*/ ConsoleColor.Gray, /*Member*/ ConsoleColor.Yellow, /*Selection*/ ConsoleColor.Gray, /*InlinePrediction*/ ConsoleColor.Cyan, /*ListPrediction*/ ConsoleColor.Red, /*ListPredictionSelected*/ ConsoleColor.DarkBlue, }; class KeyHandler { public KeyHandler(string chord, Action handler) { this.Chord = chord; this.Handler = handler; } public string Chord { get; } public Action Handler { get; } } private void AssertCursorLeftTopIs(int left, int top) { AssertCursorLeftIs(left); AssertCursorTopIs(top); } private void AssertCursorLeftIs(int expected) { Assert.Equal(expected, _console.CursorLeft); } private void AssertCursorTopIs(int expected) { Assert.Equal(expected, _console.CursorTop); } private void AssertLineIs(string expected) { PSConsoleReadLine.GetBufferState(out var input, out var unused); Assert.Equal(expected, input); } static readonly MethodInfo ClipboardGetTextMethod = typeof(PSConsoleReadLine).Assembly.GetType("Microsoft.PowerShell.Internal.Clipboard") .GetMethod("GetText", BindingFlags.Static | BindingFlags.Public); static readonly MethodInfo ClipboardSetTextMethod = typeof(PSConsoleReadLine).Assembly.GetType("Microsoft.PowerShell.Internal.Clipboard") .GetMethod("SetText", BindingFlags.Static | BindingFlags.Public); private static string GetClipboardText() { return (string)ClipboardGetTextMethod.Invoke(null, null); } private static void SetClipboardText(string text) { ClipboardSetTextMethod.Invoke(null, new[] { text }); } private void AssertClipboardTextIs(string text) { var fromClipboard = GetClipboardText(); Assert.Equal(text, fromClipboard); } private void AssertScreenCaptureClipboardIs(params string[] lines) { var fromClipboard = GetClipboardText(); var newline = Environment.NewLine; var text = string.Join(Environment.NewLine, lines); if (!text.EndsWith(newline)) { text = text + newline; } Assert.Equal(text, fromClipboard); } private class NextLineToken { } static readonly NextLineToken NextLine = new NextLineToken(); private class SelectionToken { public string _text; } private static SelectionToken Selected(string s) { return new SelectionToken {_text = s}; } private CHAR_INFO[] CreateCharInfoBuffer(int lines, params object[] items) { var result = new List(); var fg = _console.ForegroundColor; var bg = _console.BackgroundColor; bool isLastItemNextLineToken = false; foreach (var i in items) { var item = i; if (item is not NextLineToken) { isLastItemNextLineToken = false; } if (item is char c1) { result.Add(new CHAR_INFO(c1, fg, bg)); continue; } if (item is SelectionToken st) { result.AddRange(st._text.Select(c => new CHAR_INFO(c, ConsoleColor.Black, ConsoleColor.Gray))); continue; } if (item is NextLineToken) { fg = _console.ForegroundColor; bg = _console.BackgroundColor; bool localCopy = isLastItemNextLineToken; isLastItemNextLineToken = true; if (localCopy) { // So this is the 2nd (or 3rd, 4th, and etc.) 'NextLineToken' in a row, // and that means an empty line is requested. item = _emptyLine; } else { int lineLen = result.Count % _console.BufferWidth; if (lineLen == 0 && result.Count > 0) { // The existing content is right at the end of a physical line, // so there is no need to pad. continue; } // Padding is needed. Fall through to the string case. item = new string(' ', _console.BufferWidth - lineLen); } } if (item is string str) { result.AddRange(str.Select(c => new CHAR_INFO(c, fg, bg))); continue; } if (item is TokenClassification) { fg = Colors[(int)(TokenClassification)item]; bg = BackgroundColors[(int)(TokenClassification)item]; continue; } if (item is Tuple tuple) { fg = tuple.Item1; bg = tuple.Item2; continue; } throw new ArgumentException("Unexpected type"); } var extraSpacesNeeded = (lines * _console.BufferWidth) - result.Count; if (extraSpacesNeeded > 0) { var space = new CHAR_INFO(' ', _console.ForegroundColor, _console.BackgroundColor); result.AddRange(Enumerable.Repeat(space, extraSpacesNeeded)); } return result.ToArray(); } static Action CheckThat(Action action) { // Syntactic sugar - saves a couple parens when calling Keys return action; } private class KeyPlaceholder {} private static readonly KeyPlaceholder InputAcceptedNow = new KeyPlaceholder(); private object[] Keys(params object[] input) { bool autoAddEnter = true; var list = new List(); foreach (var t in input) { if (t is IEnumerable enumerable && !(t is string)) { foreach (var i in enumerable) { NewAddSingleKeyToList(i, list); } continue; } if (t == InputAcceptedNow) { autoAddEnter = false; continue; } NewAddSingleKeyToList(t, list); } if (autoAddEnter) { // Make sure we have Enter as the last key. for (int i = list.Count - 1; i >= 0; i--) { // Actions after the last key are fine, they'll // get called. if (list[i] is Action) { continue; } // We've skipped any actions at the end, this is // the last key. If it's not Enter, add Enter at the // end for convenience. var consoleKeyInfo = (ConsoleKeyInfo) list[i]; if (consoleKeyInfo.Key != ConsoleKey.Enter || consoleKeyInfo.Modifiers != 0) { list.Add(_.Enter); } break; } } return list.ToArray(); } private void NewAddSingleKeyToList(object t, List list) { switch (t) { case string s: foreach (var c in s) { list.Add(_[c]); } break; case ConsoleKeyInfo _: list.Add(t); break; case char _: list.Add(_[(char)t]); break; default: Assert.IsAssignableFrom(t); list.Add(t); break; } } private void AssertScreenIs(int lines, params object[] items) { AssertScreenIs(top: 0, lines, items); } private void AssertScreenIs(int top, int lines, params object[] items) { var consoleBuffer = _console.ReadBufferLines(top, lines); var expectedBuffer = CreateCharInfoBuffer(lines, items); Assert.Equal(expectedBuffer.Length, consoleBuffer.Length); for (var i = 0; i < expectedBuffer.Length; i++) { // Comparing CHAR_INFO should work, but randomly some attributes are set // that shouldn't be and aren't ever set by any code in PSReadLine, so we'll // ignore those bits and just check the stuff we do set. Assert.Equal(expectedBuffer[i].UnicodeChar, consoleBuffer[i].UnicodeChar); Assert.Equal(expectedBuffer[i].ForegroundColor, consoleBuffer[i].ForegroundColor); Assert.Equal(expectedBuffer[i].BackgroundColor, consoleBuffer[i].BackgroundColor); } } private void SetPrompt(string prompt) { var options = new SetPSReadLineOption {ExtraPromptLineCount = 0}; if (string.IsNullOrEmpty(prompt)) { options.PromptText = new [] {""}; PSConsoleReadLine.SetOptions(options); return; } int i; for (i = prompt.Length - 1; i >= 0; i--) { if (!char.IsWhiteSpace(prompt[i])) break; } options.PromptText = new [] { prompt.Substring(i) }; var lineCount = 1 + prompt.Count(c => c == '\n'); if (lineCount > 1) { options.ExtraPromptLineCount = lineCount - 1; } PSConsoleReadLine.SetOptions(options); _console.Write(prompt); } [ExcludeFromCodeCoverage] private void Test(string expectedResult, object[] items) { Test(expectedResult, items, resetCursor: true, prompt: null, mustDing: false); } [ExcludeFromCodeCoverage] private void Test(string expectedResult, object[] items, string prompt) { Test(expectedResult, items, resetCursor: true, prompt: prompt, mustDing: false); } [ExcludeFromCodeCoverage] private void Test(string expectedResult, object[] items, bool resetCursor) { Test(expectedResult, items, resetCursor: resetCursor, prompt: null, mustDing: false); } [ExcludeFromCodeCoverage] private void Test(string expectedResult, object[] items, bool resetCursor, string prompt, bool mustDing) { if (resetCursor) { _console.Clear(); } SetPrompt(prompt); _console.Init(items); var result = PSConsoleReadLine.ReadLine( runspace: null, engineIntrinsics: null, lastRunStatus: true); if (_console.validationFailure != null) { throw new Exception("", _console.validationFailure); } while (_console.index < _console.inputOrValidateItems.Length) { var item = _console.inputOrValidateItems[_console.index++]; ((Action)item)(); } Assert.Equal(expectedResult, result); if (mustDing) { Assert.True(_mockedMethods.didDing); } } private void TestMustDing(string expectedResult, object[] items) { Test(expectedResult, items, resetCursor: true, prompt: null, mustDing: true); } private string _emptyLine; private TestConsole _console; private MockedMethods _mockedMethods; private static string MakeCombinedColor(ConsoleColor fg, ConsoleColor bg) => VTColorUtils.AsEscapeSequence(fg) + VTColorUtils.AsEscapeSequence(bg, isBackground: true); private void TestSetup(KeyMode keyMode, params KeyHandler[] keyHandlers) { TestSetup(console: null, keyMode, keyHandlers); } private void TestSetup(TestConsole console, KeyMode keyMode, params KeyHandler[] keyHandlers) { Skip.If(WindowsConsoleFixtureHelper.GetKeyboardLayout() != this.Fixture.Lang, $"Keyboard layout must be set to {this.Fixture.Lang}"); _console = console ?? new TestConsole(_); _mockedMethods = new MockedMethods(); var instance = (PSConsoleReadLine)typeof(PSConsoleReadLine) .GetField("_singleton", BindingFlags.Static | BindingFlags.NonPublic).GetValue(null); typeof(PSConsoleReadLine) .GetField("_mockableMethods", BindingFlags.Instance | BindingFlags.NonPublic) .SetValue(instance, _mockedMethods); typeof(PSConsoleReadLine) .GetField("_console", BindingFlags.Instance | BindingFlags.NonPublic) .SetValue(instance, _console); _emptyLine ??= new string(' ', _console.BufferWidth); PSConsoleReadLine.ClearHistory(); PSConsoleReadLine.ClearKillRing(); var options = new SetPSReadLineOption { AddToHistoryHandler = PSConsoleReadLineOptions.DefaultAddToHistoryHandler, AnsiEscapeTimeout = 0, BellStyle = PSConsoleReadLineOptions.DefaultBellStyle, CompletionQueryItems = PSConsoleReadLineOptions.DefaultCompletionQueryItems, ContinuationPrompt = PSConsoleReadLineOptions.DefaultContinuationPrompt, DingDuration = 1, // Make tests virtually silent when they ding DingTone = 37, // Make tests virtually silent when they ding ExtraPromptLineCount = PSConsoleReadLineOptions.DefaultExtraPromptLineCount, HistoryNoDuplicates = PSConsoleReadLineOptions.DefaultHistoryNoDuplicates, HistorySaveStyle = HistorySaveStyle.SaveNothing, HistorySearchCaseSensitive = PSConsoleReadLineOptions.DefaultHistorySearchCaseSensitive, HistorySearchCursorMovesToEnd = PSConsoleReadLineOptions.DefaultHistorySearchCursorMovesToEnd, MaximumHistoryCount = PSConsoleReadLineOptions.DefaultMaximumHistoryCount, MaximumKillRingCount = PSConsoleReadLineOptions.DefaultMaximumKillRingCount, ShowToolTips = PSConsoleReadLineOptions.DefaultShowToolTips, WordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters, PromptText = new [] {""}, Colors = new Hashtable { { "ContinuationPrompt", MakeCombinedColor(_console.ForegroundColor, _console.BackgroundColor) }, { "Emphasis", MakeCombinedColor(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor) }, { "Error", MakeCombinedColor(ConsoleColor.Red, ConsoleColor.DarkRed) }, } }; switch (keyMode) { case KeyMode.Cmd: options.EditMode = EditMode.Windows; break; case KeyMode.Emacs: options.EditMode = EditMode.Emacs; break; case KeyMode.Vi: options.EditMode = EditMode.Vi; break; } PSConsoleReadLine.SetOptions(options); foreach (var keyHandler in keyHandlers) { PSConsoleReadLine.SetKeyHandler(new [] {keyHandler.Chord}, keyHandler.Handler, "", ""); } var tokenTypes = new[] { "Default", "Comment", "Keyword", "String", "Operator", "Variable", "Command", "Parameter", "Type", "Number", "Member", "Selection", "InlinePrediction", "ListPrediction", "ListPredictionSelected" }; var colors = new Hashtable(); for (var i = 0; i < tokenTypes.Length; i++) { colors.Add(tokenTypes[i], MakeCombinedColor(Colors[i], BackgroundColors[i])); } var colorOptions = new SetPSReadLineOption {Colors = colors}; PSConsoleReadLine.SetOptions(colorOptions); } } public class en_US_Windows : Test.ReadLine, IClassFixture { public en_US_Windows(ConsoleFixture fixture, ITestOutputHelper output) : base(fixture, output, "en-US", "windows") { } } public class fr_FR_Windows : Test.ReadLine, IClassFixture { public fr_FR_Windows(ConsoleFixture fixture, ITestOutputHelper output) : base(fixture, output, "fr-FR", "windows") { } // I don't think this is actually true for real French keyboard, but on my US keyboard, // I have to use Alt 6 0 for `<` and Alt 6 2 for `>` and that means the Alt+< and Alt+> // bindings can't work. internal override bool KeyboardHasLessThan => false; internal override bool KeyboardHasGreaterThan => false; // These are most likely an issue with .Net on Windows - AltGr turns into Ctrl+Alt and `]` or `@` // requires AltGr, so you can't tell the difference b/w `]` and `Ctrl+]`. internal override bool KeyboardHasCtrlRBracket => false; internal override bool KeyboardHasCtrlAt => false; } }