Files
Andy Jordan 06e3b4bcb6 Add fixture to enable existing tests for screen reader mode
Tests that depend on supported features such as menu completions, list
view, inline predictions, continuation prompt, and colors are skipped.

The "helper" module for running tests from the command-line and in
AppVeyor is minimally updated to not skip the new fixture.

A `BlankRestOfBuffer()` function implements the semantics of the `0J`
control sequence in the mock console.
2025-08-27 17:46:47 -07:00

697 lines
26 KiB
C#

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<string> commandHistory;
internal bool? lastCommandRunStatus;
internal Guid acceptedPredictorId;
internal string acceptedSuggestion;
internal string helpContentRendered;
internal Dictionary<Guid, Tuple<uint, int>> displayedSuggestions = new();
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<List<PredictionResult>> PredictInputAsync(Ast ast, Token[] tokens)
{
var result = ReadLine.MockedPredictInput(ast, tokens);
var source = new TaskCompletionSource<List<PredictionResult>>();
source.SetResult(result);
return source.Task;
}
public void OnCommandLineAccepted(IReadOnlyList<string> 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;
internal virtual bool ScreenReaderModeEnabled => false;
internal int ContinuationPromptLength => _continuationPromptLength;
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<ConsoleKeyInfo?, object> handler)
{
this.Chord = chord;
this.Handler = handler;
}
public string Chord { get; }
public Action<ConsoleKeyInfo?, object> 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<CHAR_INFO>();
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<ConsoleColor, ConsoleColor> 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<object>();
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<object> 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<Action>(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);
if (!ScreenReaderModeEnabled)
{
// Changing colors is not supported in screen reader mode,
// and this is the simplest way to disable checking that in all the tests.
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 bool _oneTimeInitCompleted;
private object _psrlInstance;
private FieldInfo _psrlConsole, _psrlMockableMethods;
private int _continuationPromptLength;
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();
if (_psrlInstance is null)
{
Type psrlType = typeof(PSConsoleReadLine);
_psrlInstance = psrlType.GetField("_singleton", BindingFlags.Static | BindingFlags.NonPublic).GetValue(null);
_psrlConsole = psrlType.GetField("_console", BindingFlags.Instance | BindingFlags.NonPublic);
_psrlMockableMethods = psrlType.GetField("_mockableMethods", BindingFlags.Instance | BindingFlags.NonPublic);
}
_psrlConsole.SetValue(_psrlInstance, _console);
_psrlMockableMethods.SetValue(_psrlInstance, _mockedMethods);
_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
EnableScreenReaderMode = ScreenReaderModeEnabled,
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);
// Cache the continuation prompt length for use in tests
_continuationPromptLength = ScreenReaderModeEnabled
? 0
: PSConsoleReadLine.GetOptions().ContinuationPrompt.Length;
if (!_oneTimeInitCompleted)
{
typeof(PSConsoleReadLine).GetMethod("Initialize", BindingFlags.Instance | BindingFlags.NonPublic)
.Invoke(_psrlInstance, new object[] { /* Runspace */ null, /* EngineIntrinsics */ null, });
_oneTimeInitCompleted = true;
}
}
}
public class en_US_Windows : Test.ReadLine, IClassFixture<ConsoleFixture>
{
public en_US_Windows(ConsoleFixture fixture, ITestOutputHelper output)
: base(fixture, output, "en-US", "windows")
{
}
}
public class fr_FR_Windows : Test.ReadLine, IClassFixture<ConsoleFixture>
{
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;
}
public class ScreenReader : Test.ReadLine, IClassFixture<ConsoleFixture>
{
public ScreenReader(ConsoleFixture fixture, ITestOutputHelper output)
: base(fixture, output, "en-US", "windows")
{
}
internal override bool ScreenReaderModeEnabled => true;
}
}