diff --git a/src/PowerShellEditorServices/Server/PsesDebugServer.cs b/src/PowerShellEditorServices/Server/PsesDebugServer.cs index 6fbecce9c..9f48a0f2d 100644 --- a/src/PowerShellEditorServices/Server/PsesDebugServer.cs +++ b/src/PowerShellEditorServices/Server/PsesDebugServer.cs @@ -120,7 +120,8 @@ public async Task StartAsync() response.SupportsDelayedStackTraceLoading = true; return Task.CompletedTask; - }); + }) + ; }).ConfigureAwait(false); } diff --git a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs index bb794516f..7f9939a4d 100644 --- a/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/DebugAdapterProtocolMessageTests.cs @@ -2,140 +2,133 @@ // Licensed under the MIT License. using System; -using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; -using System.Threading; using System.Threading.Tasks; +using Nerdbank.Streams; using OmniSharp.Extensions.DebugAdapter.Client; using DapStackFrame = OmniSharp.Extensions.DebugAdapter.Protocol.Models.StackFrame; -using OmniSharp.Extensions.DebugAdapter.Protocol.Events; +using OmniSharp.Extensions.DebugAdapter.Protocol.Client; using OmniSharp.Extensions.DebugAdapter.Protocol.Models; using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; -using OmniSharp.Extensions.JsonRpc.Server; using Xunit; using Xunit.Abstractions; -using Microsoft.Extensions.Logging.Abstractions; +using OmniSharp.Extensions.JsonRpc.Server; +using OmniSharp.Extensions.DebugAdapter.Protocol.Events; namespace PowerShellEditorServices.Test.E2E { - public class XunitOutputTraceListener(ITestOutputHelper output) : TraceListener - { - public override void Write(string message) => output.WriteLine(message); - public override void WriteLine(string message) => output.WriteLine(message); - } [Trait("Category", "DAP")] - public class DebugAdapterProtocolMessageTests : IAsyncLifetime, IDisposable + // ITestOutputHelper is injected by XUnit + // https://xunit.net/docs/capturing-output + public class DebugAdapterProtocolMessageTests(ITestOutputHelper output) : IAsyncLifetime { - private static readonly bool s_isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - private static readonly string s_testOutputPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + // After initialization, use this client to send messages for E2E tests and check results + private IDebugAdapterClient client; - private readonly ITestOutputHelper _output; - private DebugAdapterClient PsesDebugAdapterClient; - private PsesStdioProcess _psesProcess; + private static readonly bool s_isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); /// - /// Completes when the debug adapter is started. + /// Test scripts output here, where the output can be read to verify script progress against breakpointing /// - public TaskCompletionSource Started { get; } = new TaskCompletionSource(); + private static readonly string testScriptLogPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + private readonly PsesStdioLanguageServerProcessHost psesHost = new(isDebugAdapter: true); + + private readonly TaskCompletionSource initializedLanguageClientTcs = new(); /// - /// Completes when the first breakpoint is reached. + /// This task is useful for waiting until the client is initialized (but before Server Initialized is sent) /// - public TaskCompletionSource Stopped { get; } = new TaskCompletionSource(); + private Task initializedLanguageClient => initializedLanguageClientTcs.Task; + + /// + /// Is used to read the script log file to verify script progress against breakpointing. + private StreamReader scriptLogReader; + private TaskCompletionSource nextStoppedTcs = new(); /// - /// Constructor. The ITestOutputHelper is injected by xUnit and used to write diagnostic logs. + /// This task is useful for waiting until a breakpoint is hit in a test. /// - /// - public DebugAdapterProtocolMessageTests(ITestOutputHelper output) => _output = output; + private Task nextStopped => nextStoppedTcs.Task; public async Task InitializeAsync() { - // NOTE: To see debug logger output, add this line to your test - - _psesProcess = new PsesStdioProcess(new NullLoggerFactory(), true); - await _psesProcess.Start(); + // Cleanup testScriptLogPath if it exists due to an interrupted previous run + if (File.Exists(testScriptLogPath)) + { + File.Delete(testScriptLogPath); + } - TaskCompletionSource initialized = new(); + (StreamReader stdout, StreamWriter stdin) = await psesHost.Start(); - _psesProcess.ProcessExited += (sender, args) => - { - initialized.TrySetException(new ProcessExitedException("Initialization failed due to process failure", args.ExitCode, args.ErrorMessage)); - Started.TrySetException(new ProcessExitedException("Startup failed due to process failure", args.ExitCode, args.ErrorMessage)); - }; + // Splice the streams together and enable debug logging of all messages sent and received + DebugOutputStream psesStream = new( + FullDuplexStream.Splice(stdout.BaseStream, stdin.BaseStream) + ); - PsesDebugAdapterClient = DebugAdapterClient.Create(options => + /* + PSES follows the following DAP flow: + Receive a Initialize request + Run Initialize handler and send response back + Receive a Launch/Attach request + Run Launch/Attach handler and send response back + PSES sends the initialized event at the end of the Launch/Attach handler + + This is to spec, but the omnisharp client has a flaw where it does not complete the await until after + Server Initialized has been received, when it should in fact return once the Client Initialize (aka + capabilities) response is received. Per the DAP spec, we can send Launch/Attach before Server Initialized + and PSES relies on this behavior, but if we await the standard client initialization From method, it would + deadlock the test because it won't return until Server Initialized is received from PSES, which it won't + send until a launch is sent. + + HACK: To get around this, we abuse the OnInitialized handler to return the client "early" via the + `InitializedLanguageClient` once the Client Initialize response has been received. + see https://github.com/OmniSharp/csharp-language-server-protocol/issues/1408 + */ + Task dapClientInitializeTask = DebugAdapterClient.From(options => { options - .WithInput(_psesProcess.OutputStream) - .WithOutput(_psesProcess.InputStream) - // The OnStarted delegate gets run when we receive the _Initialized_ event from the server: - // https://microsoft.github.io/debug-adapter-protocol/specification#Events_Initialized - .OnStarted((_, _) => + .WithInput(psesStream) + .WithOutput(psesStream) + // The "early" return mentioned above + .OnInitialized(async (dapClient, _, _, _) => initializedLanguageClientTcs.SetResult(dapClient)) + // This TCS is useful to wait for a breakpoint to be hit + .OnStopped(async (StoppedEvent e) => { - Started.SetResult(true); - return Task.CompletedTask; + nextStoppedTcs.SetResult(e); + nextStoppedTcs = new(); }) - // We use this to create a task we can await to test debugging after a breakpoint has been received. - .OnNotification(null, (stoppedEvent, _) => - { - Console.WriteLine("StoppedEvent received"); - Stopped.SetResult(stoppedEvent); - return Task.CompletedTask; - }) - // The OnInitialized delegate gets run when we first receive the _Initialize_ response: - // https://microsoft.github.io/debug-adapter-protocol/specification#Requests_Initialize - .OnInitialized((_, _, _, _) => - { - initialized.SetResult(true); - return Task.CompletedTask; - }); - - options.OnUnhandledException = (exception) => - { - initialized.SetException(exception); - Started.SetException(exception); - }; + ; }); - // PSES follows the following flow: - // Receive a Initialize request - // Run Initialize handler and send response back - // Receive a Launch/Attach request - // Run Launch/Attach handler and send response back - // PSES sends the initialized event at the end of the Launch/Attach handler - - // The way that the Omnisharp client works is that this Initialize method doesn't return until - // after OnStarted is run... which only happens when Initialized is received from the server. - // so if we would await this task, it would deadlock. - // To get around this, we run the Initialize() without await but use a `TaskCompletionSource` - // that gets completed when we receive the response to Initialize - // This tells us that we are ready to send messages to PSES... but are not stuck waiting for - // Initialized. -#pragma warning disable CS4014 - PsesDebugAdapterClient.Initialize(CancellationToken.None); -#pragma warning restore CS4014 - await initialized.Task; + // This ensures any unhandled exceptions get addressed if it fails to start before our early return completes. + // Under normal operation the initializedLanguageClient will always return first. + await Task.WhenAny( + initializedLanguageClient, + dapClientInitializeTask + ); + + client = await initializedLanguageClient; } public async Task DisposeAsync() { - await PsesDebugAdapterClient.RequestDisconnect(new DisconnectArguments + await client.RequestDisconnect(new DisconnectArguments { Restart = false, TerminateDebuggee = true }); - await _psesProcess.Stop(); - } + client?.Dispose(); + psesHost.Stop(); - public void Dispose() - { - GC.SuppressFinalize(this); - PsesDebugAdapterClient?.Dispose(); - _psesProcess?.Dispose(); + scriptLogReader?.Dispose(); //Also disposes the underlying filestream + if (File.Exists(testScriptLogPath)) + { + File.Delete(testScriptLogPath); + } } private static string NewTestFile(string script, bool isPester = false) @@ -147,7 +140,14 @@ private static string NewTestFile(string script, bool isPester = false) return filePath; } - private string GenerateScriptFromLoggingStatements(params string[] logStatements) + /// + /// Given an array of strings, generate a PowerShell script that writes each string to our test script log path + /// so it can be read back later to verify script progress against breakpointing. + /// + /// A list of statements that for which a script will be generated to write each statement to a testing log that can be read by . The strings are double quoted in Powershell, so variables such as $($PSScriptRoot) etc. can be used + /// A script string that should be written to disk and instructed by PSES to execute + /// + private string GenerateLoggingScript(params string[] logStatements) { if (logStatements.Length == 0) { @@ -155,9 +155,9 @@ private string GenerateScriptFromLoggingStatements(params string[] logStatements } // Clean up side effects from other test runs. - if (File.Exists(s_testOutputPath)) + if (File.Exists(testScriptLogPath)) { - File.Delete(s_testOutputPath); + File.Delete(testScriptLogPath); } // Have script create file first with `>` (but don't rely on overwriting). @@ -166,7 +166,7 @@ private string GenerateScriptFromLoggingStatements(params string[] logStatements .Append("Write-Output \"") .Append(logStatements[0]) .Append("\" > '") - .Append(s_testOutputPath) + .Append(testScriptLogPath) .AppendLine("'"); for (int i = 1; i < logStatements.Length; i++) @@ -176,88 +176,110 @@ private string GenerateScriptFromLoggingStatements(params string[] logStatements .Append("Write-Output \"") .Append(logStatements[i]) .Append("\" >> '") - .Append(s_testOutputPath) + .Append(testScriptLogPath) .AppendLine("'"); } - _output.WriteLine("Script is:"); - _output.WriteLine(builder.ToString()); + output.WriteLine("Script is:"); + output.WriteLine(builder.ToString()); return builder.ToString(); } - private static async Task GetLog() + /// + /// Reads the next output line from the test script log file. Useful in assertions to verify script progress against breakpointing. + /// + private async Task ReadScriptLogLineAsync() { - for (int i = 0; !File.Exists(s_testOutputPath) && i < 60; i++) + while (scriptLogReader is null) { - await Task.Delay(1000); + try + { + scriptLogReader = new StreamReader( + new FileStream( + testScriptLogPath, + FileMode.OpenOrCreate, + FileAccess.Read, // Because we use append, its OK to create the file ahead of the script + FileShare.ReadWrite + ) + ); + } + catch (IOException) //Sadly there does not appear to be a xplat way to wait for file availability, but luckily this does not appear to fire often. + { + await Task.Delay(500); + } + } + + // return valid lines only + string nextLine = string.Empty; + while (nextLine is null || nextLine.Length == 0) + { + nextLine = await scriptLogReader.ReadLineAsync(); //Might return null if at EOF because we created it above but the script hasn't written to it yet } - // Sleep one more time after the file exists so whatever is writing can finish. - await Task.Delay(1000); - return File.ReadLines(s_testOutputPath).ToArray(); + return nextLine; } [Fact] public void CanInitializeWithCorrectServerSettings() { - Assert.True(PsesDebugAdapterClient.ServerSettings.SupportsConditionalBreakpoints); - Assert.True(PsesDebugAdapterClient.ServerSettings.SupportsConfigurationDoneRequest); - Assert.True(PsesDebugAdapterClient.ServerSettings.SupportsFunctionBreakpoints); - Assert.True(PsesDebugAdapterClient.ServerSettings.SupportsHitConditionalBreakpoints); - Assert.True(PsesDebugAdapterClient.ServerSettings.SupportsLogPoints); - Assert.True(PsesDebugAdapterClient.ServerSettings.SupportsSetVariable); + Assert.True(client.ServerSettings.SupportsConditionalBreakpoints); + Assert.True(client.ServerSettings.SupportsConfigurationDoneRequest); + Assert.True(client.ServerSettings.SupportsFunctionBreakpoints); + Assert.True(client.ServerSettings.SupportsHitConditionalBreakpoints); + Assert.True(client.ServerSettings.SupportsLogPoints); + Assert.True(client.ServerSettings.SupportsSetVariable); + Assert.True(client.ServerSettings.SupportsDelayedStackTraceLoading); } [Fact] public async Task UsesDotSourceOperatorAndQuotesAsync() { - string filePath = NewTestFile(GenerateScriptFromLoggingStatements("$($MyInvocation.Line)")); - await PsesDebugAdapterClient.LaunchScript(filePath, Started); - ConfigurationDoneResponse configDoneResponse = await PsesDebugAdapterClient.RequestConfigurationDone(new ConfigurationDoneArguments()); + string filePath = NewTestFile(GenerateLoggingScript("$($MyInvocation.Line)")); + await client.LaunchScript(filePath); + ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); Assert.NotNull(configDoneResponse); - Assert.Collection(await GetLog(), - (i) => Assert.StartsWith(". '", i)); + string actual = await ReadScriptLogLineAsync(); + Assert.StartsWith(". '", actual); } [Fact] public async Task UsesCallOperatorWithSettingAsync() { - string filePath = NewTestFile(GenerateScriptFromLoggingStatements("$($MyInvocation.Line)")); - await PsesDebugAdapterClient.LaunchScript(filePath, Started, executeMode: "Call"); - ConfigurationDoneResponse configDoneResponse = await PsesDebugAdapterClient.RequestConfigurationDone(new ConfigurationDoneArguments()); + string filePath = NewTestFile(GenerateLoggingScript("$($MyInvocation.Line)")); + await client.LaunchScript(filePath, executeMode: "Call"); + ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); Assert.NotNull(configDoneResponse); - Assert.Collection(await GetLog(), - (i) => Assert.StartsWith("& '", i)); + string actual = await ReadScriptLogLineAsync(); + Assert.StartsWith("& '", actual); } [Fact] public async Task CanLaunchScriptWithNoBreakpointsAsync() { - string filePath = NewTestFile(GenerateScriptFromLoggingStatements("works")); + string filePath = NewTestFile(GenerateLoggingScript("works")); - await PsesDebugAdapterClient.LaunchScript(filePath, Started); + await client.LaunchScript(filePath); - ConfigurationDoneResponse configDoneResponse = await PsesDebugAdapterClient.RequestConfigurationDone(new ConfigurationDoneArguments()); + ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); Assert.NotNull(configDoneResponse); - Assert.Collection(await GetLog(), - (i) => Assert.Equal("works", i)); + Assert.Equal("works", await ReadScriptLogLineAsync()); } [SkippableFact] public async Task CanSetBreakpointsAsync() { - Skip.If(PsesStdioProcess.RunningInConstrainedLanguageMode, + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, "Breakpoints can't be set in Constrained Language Mode."); - string filePath = NewTestFile(GenerateScriptFromLoggingStatements( + string filePath = NewTestFile(GenerateLoggingScript( "before breakpoint", "at breakpoint", "after breakpoint" )); - await PsesDebugAdapterClient.LaunchScript(filePath, Started); + await client.LaunchScript(filePath); // {"command":"setBreakpoints","arguments":{"source":{"name":"dfsdfg.ps1","path":"/Users/tyleonha/Code/PowerShell/Misc/foo/dfsdfg.ps1"},"lines":[2],"breakpoints":[{"line":2}],"sourceModified":false},"type":"request","seq":3} - SetBreakpointsResponse setBreakpointsResponse = await PsesDebugAdapterClient.SetBreakpoints(new SetBreakpointsArguments + SetBreakpointsResponse setBreakpointsResponse = await client.SetBreakpoints(new SetBreakpointsArguments { Source = new Source { Name = Path.GetFileName(filePath), Path = filePath }, Breakpoints = new SourceBreakpoint[] { new SourceBreakpoint { Line = 2 } }, @@ -269,31 +291,40 @@ public async Task CanSetBreakpointsAsync() Assert.Equal(filePath, breakpoint.Source.Path, ignoreCase: s_isWindows); Assert.Equal(2, breakpoint.Line); - ConfigurationDoneResponse configDoneResponse = await PsesDebugAdapterClient.RequestConfigurationDone(new ConfigurationDoneArguments()); + ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); Assert.NotNull(configDoneResponse); - Assert.Collection(await GetLog(), - (i) => Assert.Equal("before breakpoint", i)); - File.Delete(s_testOutputPath); - ContinueResponse continueResponse = await PsesDebugAdapterClient.RequestContinue( - new ContinueArguments { ThreadId = 1 }); + // Wait until we hit the breakpoint + StoppedEvent stoppedEvent = await nextStopped; + Assert.Equal("breakpoint", stoppedEvent.Reason); + + // The code before the breakpoint should have already run + Assert.Equal("before breakpoint", await ReadScriptLogLineAsync()); + + // Assert that the stopped breakpoint is the one we set + StackTraceResponse stackTraceResponse = await client.RequestStackTrace(new StackTraceArguments { ThreadId = 1 }); + DapStackFrame stoppedTopFrame = stackTraceResponse.StackFrames.First(); + Assert.Equal(2, stoppedTopFrame.Line); + + _ = await client.RequestContinue(new ContinueArguments { ThreadId = 1 }); - Assert.NotNull(continueResponse); - Assert.Collection(await GetLog(), - (i) => Assert.Equal("at breakpoint", i), - (i) => Assert.Equal("after breakpoint", i)); + Assert.Equal("at breakpoint", await ReadScriptLogLineAsync()); + Assert.Equal("after breakpoint", await ReadScriptLogLineAsync()); } [SkippableFact] public async Task FailsIfStacktraceRequestedWhenNotPaused() { - Skip.If(PsesStdioProcess.RunningInConstrainedLanguageMode, + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, "Breakpoints can't be set in Constrained Language Mode."); - string filePath = NewTestFile(GenerateScriptFromLoggingStatements( - "labelTestBreakpoint" + + // We want a long running script that never hits the next breakpoint + string filePath = NewTestFile(GenerateLoggingScript( + "$(sleep 10)", + "Should fail before we get here" )); - // Set a breakpoint - await PsesDebugAdapterClient.SetBreakpoints( + + await client.SetBreakpoints( new SetBreakpointsArguments { Source = new Source { Name = Path.GetFileName(filePath), Path = filePath }, @@ -303,12 +334,11 @@ await PsesDebugAdapterClient.SetBreakpoints( ); // Signal to start the script - await PsesDebugAdapterClient.RequestConfigurationDone(new ConfigurationDoneArguments()); - await PsesDebugAdapterClient.LaunchScript(filePath, Started); + await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + await client.LaunchScript(filePath); - - // Get the stacktrace for the breakpoint - await Assert.ThrowsAsync(() => PsesDebugAdapterClient.RequestStackTrace( + // Try to get the stacktrace. If we are not at a breakpoint, this should fail. + await Assert.ThrowsAsync(() => client.RequestStackTrace( new StackTraceArguments { } )); } @@ -316,19 +346,17 @@ await Assert.ThrowsAsync(() => PsesDebugAdapterClient.RequestS [SkippableFact] public async Task SendsInitialLabelBreakpointForPerformanceReasons() { - Skip.If(PsesStdioProcess.RunningInConstrainedLanguageMode, + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, "Breakpoints can't be set in Constrained Language Mode."); - string filePath = NewTestFile(GenerateScriptFromLoggingStatements( + string filePath = NewTestFile(GenerateLoggingScript( "before breakpoint", - "at breakpoint", - "after breakpoint" + "label breakpoint" )); - //TODO: This is technically wrong per the spec, configDone should be completed BEFORE launching, but this is how the vscode client does it today and we really need to fix that. - await PsesDebugAdapterClient.LaunchScript(filePath, Started); + // Trigger a launch. Note that per DAP spec, launch doesn't actually begin until ConfigDone finishes. + await client.LaunchScript(filePath); - // {"command":"setBreakpoints","arguments":{"source":{"name":"dfsdfg.ps1","path":"/Users/tyleonha/Code/PowerShell/Misc/foo/dfsdfg.ps1"},"lines":[2],"breakpoints":[{"line":2}],"sourceModified":false},"type":"request","seq":3} - SetBreakpointsResponse setBreakpointsResponse = await PsesDebugAdapterClient.SetBreakpoints(new SetBreakpointsArguments + SetBreakpointsResponse setBreakpointsResponse = await client.SetBreakpoints(new SetBreakpointsArguments { Source = new Source { Name = Path.GetFileName(filePath), Path = filePath }, Breakpoints = new SourceBreakpoint[] { new SourceBreakpoint { Line = 2 } }, @@ -340,24 +368,25 @@ public async Task SendsInitialLabelBreakpointForPerformanceReasons() Assert.Equal(filePath, breakpoint.Source.Path, ignoreCase: s_isWindows); Assert.Equal(2, breakpoint.Line); - ConfigurationDoneResponse configDoneResponse = await PsesDebugAdapterClient.RequestConfigurationDone(new ConfigurationDoneArguments()); + _ = client.RequestConfigurationDone(new ConfigurationDoneArguments()); - // FIXME: I think there is a race condition here. If you remove this, the following line Stack Trace fails because the breakpoint hasn't been hit yet. I think the whole getLog process just works long enough for ConfigurationDone to complete and for the breakpoint to be hit. + // Wait for the breakpoint to be hit + StoppedEvent stoppedEvent = await nextStopped; + Assert.Equal("breakpoint", stoppedEvent.Reason); - // I've tried to do this properly by waiting for a StoppedEvent, but that doesn't seem to work, I'm probably just not wiring it up right in the handler. - Assert.NotNull(configDoneResponse); - Assert.Collection(await GetLog(), - (i) => Assert.Equal("before breakpoint", i)); - File.Delete(s_testOutputPath); + // The code before the breakpoint should have already run + Assert.Equal("before breakpoint", await ReadScriptLogLineAsync()); // Get the stacktrace for the breakpoint - StackTraceResponse stackTraceResponse = await PsesDebugAdapterClient.RequestStackTrace( + StackTraceResponse stackTraceResponse = await client.RequestStackTrace( new StackTraceArguments { ThreadId = 1 } ); DapStackFrame firstFrame = stackTraceResponse.StackFrames.First(); + + // Our synthetic label breakpoint should be present Assert.Equal( - firstFrame.PresentationHint, - StackFramePresentationHint.Label + StackFramePresentationHint.Label, + firstFrame.PresentationHint ); } @@ -375,21 +404,21 @@ public async Task SendsInitialLabelBreakpointForPerformanceReasons() [SkippableFact] public async Task CanStepPastSystemWindowsForms() { - Skip.IfNot(PsesStdioProcess.IsWindowsPowerShell, + Skip.IfNot(PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, "Windows Forms requires Windows PowerShell."); - Skip.If(PsesStdioProcess.RunningInConstrainedLanguageMode, + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, "Breakpoints can't be set in Constrained Language Mode."); string filePath = NewTestFile(string.Join(Environment.NewLine, new[] { - "Add-Type -AssemblyName System.Windows.Forms", - "$global:form = New-Object System.Windows.Forms.Form", - "Write-Host $form" - })); + "Add-Type -AssemblyName System.Windows.Forms", + "$global:form = New-Object System.Windows.Forms.Form", + "Write-Host $form" + })); - await PsesDebugAdapterClient.LaunchScript(filePath, Started); + await client.LaunchScript(filePath); - SetFunctionBreakpointsResponse setBreakpointsResponse = await PsesDebugAdapterClient.SetFunctionBreakpoints( + SetFunctionBreakpointsResponse setBreakpointsResponse = await client.SetFunctionBreakpoints( new SetFunctionBreakpointsArguments { Breakpoints = new FunctionBreakpoint[] @@ -399,11 +428,11 @@ public async Task CanStepPastSystemWindowsForms() Breakpoint breakpoint = setBreakpointsResponse.Breakpoints.First(); Assert.True(breakpoint.Verified); - ConfigurationDoneResponse configDoneResponse = await PsesDebugAdapterClient.RequestConfigurationDone(new ConfigurationDoneArguments()); + ConfigurationDoneResponse configDoneResponse = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); Assert.NotNull(configDoneResponse); await Task.Delay(5000); - VariablesResponse variablesResponse = await PsesDebugAdapterClient.RequestVariables( + VariablesResponse variablesResponse = await client.RequestVariables( new VariablesArguments { VariablesReference = 1 }); Variable form = variablesResponse.Variables.FirstOrDefault(v => v.Name == "$form"); @@ -418,24 +447,25 @@ public async Task CanStepPastSystemWindowsForms() [Fact] public async Task CanLaunchScriptWithCommentedLastLineAsync() { - string script = GenerateScriptFromLoggingStatements("$($MyInvocation.Line)") + "# a comment at the end"; + string script = GenerateLoggingScript("$($MyInvocation.Line)", "$(1+1)") + "# a comment at the end"; Assert.EndsWith(Environment.NewLine + "# a comment at the end", script); // NOTE: This is horribly complicated, but the "script" parameter here is assigned to // PsesLaunchRequestArguments.Script, which is then assigned to // DebugStateService.ScriptToLaunch in that handler, and finally used by the // ConfigurationDoneHandler in LaunchScriptAsync. - await PsesDebugAdapterClient.LaunchScript(script, Started); + await client.LaunchScript(script); + + _ = await client.RequestConfigurationDone(new ConfigurationDoneArguments()); - ConfigurationDoneResponse configDoneResponse = await PsesDebugAdapterClient.RequestConfigurationDone(new ConfigurationDoneArguments()); - Assert.NotNull(configDoneResponse); // We can check that the script was invoked as expected, which is to dot-source a script // block with the contents surrounded by newlines. While we can't check that the last // line was a curly brace by itself, we did check that the contents ended with a // comment, so if this output exists then the bug did not recur. - Assert.Collection(await GetLog(), - (i) => Assert.Equal(". {", i), - (i) => Assert.Equal("", i)); + Assert.Equal(". {", await ReadScriptLogLineAsync()); + + // Verifies that the script did run and the body was evaluated + Assert.Equal("2", await ReadScriptLogLineAsync()); } [SkippableFact] @@ -458,24 +488,24 @@ public async Task CanRunPesterTestFile() await Task.Delay(1000); } await Task.Delay(15000); - _output.WriteLine(File.ReadAllText(pesterLog)); + output.WriteLine(File.ReadAllText(pesterLog)); */ string pesterTest = NewTestFile(@" - Describe 'A' { - Context 'B' { - It 'C' { - { throw 'error' } | Should -Throw - } - It 'D' { - " + GenerateScriptFromLoggingStatements("pester") + @" - } + Describe 'A' { + Context 'B' { + It 'C' { + { throw 'error' } | Should -Throw + } + It 'D' { + " + GenerateLoggingScript("pester") + @" } - }", isPester: true); + } + }", isPester: true); - await PsesDebugAdapterClient.LaunchScript($"Invoke-Pester -Script '{pesterTest}'", Started); - await PsesDebugAdapterClient.RequestConfigurationDone(new ConfigurationDoneArguments()); - Assert.Collection(await GetLog(), (i) => Assert.Equal("pester", i)); + await client.LaunchScript($"Invoke-Pester -Script '{pesterTest}'"); + await client.RequestConfigurationDone(new ConfigurationDoneArguments()); + Assert.Equal("pester", await ReadScriptLogLineAsync()); } } } diff --git a/test/PowerShellEditorServices.Test.E2E/Hosts/DebugOutputStream.cs b/test/PowerShellEditorServices.Test.E2E/Hosts/DebugOutputStream.cs new file mode 100644 index 000000000..8ad31d80b --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/Hosts/DebugOutputStream.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#nullable enable + +using System.Diagnostics; +using System.IO; +using System.Text; +using Nerdbank.Streams; + +namespace PowerShellEditorServices.Test.E2E; + +/// +/// A stream that logs all data read and written to the debug stream which is visible in the debug console when a +/// debugger is attached. +/// +internal class DebugOutputStream : MonitoringStream +{ + public DebugOutputStream(Stream? underlyingStream) + : base(underlyingStream ?? new MemoryStream()) + { + +#if DEBUG + DidRead += (_, segment) => + { + if (segment.Array is null) { return; } + LogData("⬅️", segment.Array, segment.Offset, segment.Count); + }; + + DidWrite += (_, segment) => + { + if (segment.Array is null) { return; } + LogData("➡️", segment.Array, segment.Offset, segment.Count); + }; +#endif + + } + + private static void LogData(string header, byte[] buffer, int offset, int count) + { + // If debugging, the raw traffic will be visible in the debug console + if (Debugger.IsAttached) + { + string data = Encoding.UTF8.GetString(buffer, offset, count); + Debug.WriteLine($"{header} {data}"); + } + } +} diff --git a/test/PowerShellEditorServices.Test.E2E/Hosts/IAsyncLanguageServerHost.cs b/test/PowerShellEditorServices.Test.E2E/Hosts/IAsyncLanguageServerHost.cs new file mode 100644 index 000000000..149b9bd1b --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/Hosts/IAsyncLanguageServerHost.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#nullable enable + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace PowerShellEditorServices.Test.E2E; + +/// +/// Represents a debug adapter server host that can be started and stopped and provides streams for communication. +/// +public interface IAsyncLanguageServerHost : IAsyncDisposable +{ + // Start the host and return when the host is ready to communicate. It should return a tuple of a stream Reader and stream Writer for communication with the LSP. The underlying streams can be retrieved via baseStream propertyif needed. + Task<(StreamReader, StreamWriter)> Start(CancellationToken token = default); + // Stops the host and returns when the host has fully stopped. It should be idempotent, such that if called while the host is already stopping/stopped, it will have the same result + Task Stop(CancellationToken token = default); + + // Optional to implement if more is required than a simple stop + async ValueTask IAsyncDisposable.DisposeAsync() => await Stop(); +} + diff --git a/test/PowerShellEditorServices.Test.E2E/DebugAdapterClientExtensions.cs b/test/PowerShellEditorServices.Test.E2E/Hosts/IDebugAdapterClientExtensions.cs similarity index 63% rename from test/PowerShellEditorServices.Test.E2E/DebugAdapterClientExtensions.cs rename to test/PowerShellEditorServices.Test.E2E/Hosts/IDebugAdapterClientExtensions.cs index dfcb0bbc7..e37be268d 100644 --- a/test/PowerShellEditorServices.Test.E2E/DebugAdapterClientExtensions.cs +++ b/test/PowerShellEditorServices.Test.E2E/Hosts/IDebugAdapterClientExtensions.cs @@ -4,14 +4,14 @@ using System; using System.Threading.Tasks; using Microsoft.PowerShell.EditorServices.Handlers; -using OmniSharp.Extensions.DebugAdapter.Client; +using OmniSharp.Extensions.DebugAdapter.Protocol.Client; using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; namespace PowerShellEditorServices.Test.E2E { - public static class DebugAdapterClientExtensions + public static class IDebugAdapterClientExtensions { - public static async Task LaunchScript(this DebugAdapterClient debugAdapterClient, string script, TaskCompletionSource started, string executeMode = "DotSource") + public static async Task LaunchScript(this IDebugAdapterClient debugAdapterClient, string script, string executeMode = "DotSource") { _ = await debugAdapterClient.Launch( new PsesLaunchRequestArguments @@ -22,9 +22,6 @@ public static async Task LaunchScript(this DebugAdapterClient debugAdapterClient CreateTemporaryIntegratedConsole = false, ExecuteMode = executeMode, }) ?? throw new Exception("Launch response was null."); - - // This will check to see if we received the Initialized event from the server. - await started.Task; } } } diff --git a/test/PowerShellEditorServices.Test.E2E/Hosts/PsesStdioLanguageServerProcessHost.cs b/test/PowerShellEditorServices.Test.E2E/Hosts/PsesStdioLanguageServerProcessHost.cs new file mode 100644 index 000000000..08a29443c --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/Hosts/PsesStdioLanguageServerProcessHost.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace PowerShellEditorServices.Test.E2E; + +/// +/// A is responsible for launching or attaching to a language server, providing access to its input and output streams, and tracking its lifetime. +/// +internal class PsesStdioLanguageServerProcessHost(bool isDebugAdapter) +: StdioLanguageServerProcessHost(PwshExe, GeneratePsesArguments(isDebugAdapter)) +{ + protected static readonly string s_binDir = + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + + private static readonly string s_bundledModulePath = new FileInfo(Path.Combine( + s_binDir, "..", "..", "..", "..", "..", "module")).FullName; + + private static readonly string s_sessionDetailsPath = Path.Combine( + s_binDir, $"pses_test_sessiondetails_{Path.GetRandomFileName()}"); + + private static readonly string s_logPath = Path.Combine( + s_binDir, $"pses_test_logs_{Path.GetRandomFileName()}"); + + private const string s_logLevel = "Diagnostic"; + private static readonly string[] s_featureFlags = { "PSReadLine" }; + private const string s_hostName = "TestHost"; + private const string s_hostProfileId = "TestHost"; + private const string s_hostVersion = "1.0.0"; + + // Adjust the environment variable if wanting to test with 5.1 or a specific pwsh path + public static string PwshExe { get; } = Environment.GetEnvironmentVariable("PWSH_EXE_NAME") ?? "pwsh"; + public static bool IsWindowsPowerShell { get; } = PwshExe.EndsWith("powershell"); + public static bool RunningInConstrainedLanguageMode { get; } = + Environment.GetEnvironmentVariable("__PSLockdownPolicy", EnvironmentVariableTarget.Machine) != null; + + private static string[] GeneratePsesArguments(bool isDebugAdapter) + { + List args = new() + { + "&", + SingleQuoteEscape(Path.Combine(s_bundledModulePath, "PowerShellEditorServices", "Start-EditorServices.ps1")), + "-LogPath", + SingleQuoteEscape(s_logPath), + "-LogLevel", + s_logLevel, + "-SessionDetailsPath", + SingleQuoteEscape(s_sessionDetailsPath), + "-FeatureFlags", + string.Join(',', s_featureFlags), + "-HostName", + s_hostName, + "-HostProfileId", + s_hostProfileId, + "-HostVersion", + s_hostVersion, + "-BundledModulesPath", + SingleQuoteEscape(s_bundledModulePath), + "-Stdio" + }; + + if (isDebugAdapter) + { + args.Add("-DebugServiceOnly"); + } + + string base64Str = Convert.ToBase64String( + System.Text.Encoding.Unicode.GetBytes(string.Join(' ', args))); + + return + [ + "-NoLogo", + "-NoProfile", + "-EncodedCommand", + base64Str + ]; + } + + private static string SingleQuoteEscape(string str) => $"'{str.Replace("'", "''")}'"; +} + diff --git a/test/PowerShellEditorServices.Test.E2E/Hosts/StdioLanguageServerProcessHost.cs b/test/PowerShellEditorServices.Test.E2E/Hosts/StdioLanguageServerProcessHost.cs new file mode 100644 index 000000000..447ae9b44 --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/Hosts/StdioLanguageServerProcessHost.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace PowerShellEditorServices.Test.E2E; + +/// +/// Hosts a language server process that communicates over stdio +/// +internal class StdioLanguageServerProcessHost(string fileName, IEnumerable argumentList) : IAsyncLanguageServerHost +{ + // The PSES process that will be started and managed + private readonly Process process = new() + { + EnableRaisingEvents = true, + StartInfo = new ProcessStartInfo(fileName, argumentList) + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + // Track the state of the startup + private TaskCompletionSource<(StreamReader, StreamWriter)>? startTcs; + private TaskCompletionSource? stopTcs; + + // Starts the process. Returns when the process has started and streams are available. + public async Task<(StreamReader, StreamWriter)> Start(CancellationToken token = default) + { + // Runs this once upon process exit to clean up the state. + EventHandler? exitHandler = null; + exitHandler = (sender, e) => + { + // Complete the stopTcs task when the process finally exits, allowing stop to complete + stopTcs?.TrySetResult(true); + stopTcs = null; + startTcs = null; + process.Exited -= exitHandler; + }; + process.Exited += exitHandler; + + if (stopTcs is not null) + { + throw new InvalidOperationException("The process is currently stopping and cannot be started."); + } + + // Await the existing task if we have already started, making this operation idempotent + if (startTcs is not null) + { + return await startTcs.Task; + } + + // Initiate a new startTcs to track the startup + startTcs = new(); + + token.ThrowIfCancellationRequested(); + + // Should throw if there are any startup problems such as invalid path, etc. + process.Start(); + + // According to the source the streams should be allocated synchronously after the process has started, however it's not super clear so we will put this here in case there is an explicit race condition. + if (process.StandardInput.BaseStream is null || process.StandardOutput.BaseStream is null) + { + throw new InvalidOperationException("The process has started but the StandardInput or StandardOutput streams are not available. This should never happen and is probably a race condition, please report it to PowerShellEditorServices."); + } + + startTcs.SetResult(( + process.StandardOutput, + process.StandardInput + )); + + // Return the result of the completion task + return await startTcs.Task; + } + + public async Task WaitForExit(CancellationToken token = default) + { + AssertStarting(); + await process.WaitForExitAsync(token); + } + + /// + /// Determines if the process is in the starting state and throws if not. + /// + private void AssertStarting() + { + if (startTcs is null) + { + throw new InvalidOperationException("The process is not starting/started, use Start() first."); + } + } + + public async Task Stop(CancellationToken token = default) + { + AssertStarting(); + if (stopTcs is not null) + { + return await stopTcs.Task; + } + stopTcs = new(); + token.ThrowIfCancellationRequested(); + process.Kill(); + await process.WaitForExitAsync(token); + return true; + } +} diff --git a/test/PowerShellEditorServices.Test.E2E/LSPTestsFixures.cs b/test/PowerShellEditorServices.Test.E2E/LSPTestsFixtures.cs similarity index 86% rename from test/PowerShellEditorServices.Test.E2E/LSPTestsFixures.cs rename to test/PowerShellEditorServices.Test.E2E/LSPTestsFixtures.cs index 9017eab4f..6eb8e4569 100644 --- a/test/PowerShellEditorServices.Test.E2E/LSPTestsFixures.cs +++ b/test/PowerShellEditorServices.Test.E2E/LSPTestsFixtures.cs @@ -8,9 +8,9 @@ using System.Reflection; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; using Microsoft.PowerShell.EditorServices.Logging; using Microsoft.PowerShell.EditorServices.Services.Configuration; +using Nerdbank.Streams; using Newtonsoft.Json.Linq; using OmniSharp.Extensions.LanguageServer.Client; using OmniSharp.Extensions.LanguageServer.Protocol; @@ -38,14 +38,16 @@ public class LSPTestsFixture : IAsyncLifetime internal List TelemetryEvents = new(); public ITestOutputHelper Output { get; set; } - protected PsesStdioProcess _psesProcess; - public int ProcessId => _psesProcess.Id; + internal PsesStdioLanguageServerProcessHost _psesHost = new(IsDebugAdapterTests); public async Task InitializeAsync() { - LoggerFactory factory = new(); - _psesProcess = new PsesStdioProcess(factory, IsDebugAdapterTests); - await _psesProcess.Start(); + (StreamReader stdout, StreamWriter stdin) = await _psesHost.Start(); + + // Splice the streams together and enable debug logging of all messages sent and received + DebugOutputStream psesStream = new( + FullDuplexStream.Splice(stdout.BaseStream, stdin.BaseStream) + ); DirectoryInfo testDir = Directory.CreateDirectory(Path.Combine(s_binDir, Path.GetRandomFileName())); @@ -53,12 +55,13 @@ public async Task InitializeAsync() PsesLanguageClient = LanguageClient.PreInit(options => { options - .WithInput(_psesProcess.OutputStream) - .WithOutput(_psesProcess.InputStream) + .WithInput(psesStream) + .WithOutput(psesStream) .WithWorkspaceFolder(DocumentUri.FromFileSystemPath(testDir.FullName), "testdir") .WithInitializationOptions(new { EnableProfileLoading = false }) .OnPublishDiagnostics(diagnosticParams => Diagnostics.AddRange(diagnosticParams.Diagnostics.Where(d => d != null))) - .OnLogMessage(logMessageParams => { + .OnLogMessage(logMessageParams => + { Output?.WriteLine($"{logMessageParams.Type}: {logMessageParams.Message}"); Messages.Add(logMessageParams); }) @@ -98,7 +101,7 @@ public async Task InitializeAsync() public async Task DisposeAsync() { await PsesLanguageClient.Shutdown(); - await _psesProcess.Stop(); + await _psesHost.Stop(); PsesLanguageClient?.Dispose(); } } diff --git a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs index 6e8d3ff62..c1112e5cc 100644 --- a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs @@ -48,7 +48,7 @@ public LanguageServerProtocolMessageTests(ITestOutputHelper output, LSPTestsFixt Messages.Clear(); Diagnostics = data.Diagnostics; Diagnostics.Clear(); - PwshExe = PsesStdioProcess.PwshExe; + PwshExe = PsesStdioLanguageServerProcessHost.PwshExe; } public void Dispose() @@ -139,7 +139,7 @@ function CanSendWorkspaceSymbolRequest { [SkippableFact] public async Task CanReceiveDiagnosticsFromFileOpenAsync() { - Skip.If(PsesStdioProcess.RunningInConstrainedLanguageMode && PsesStdioProcess.IsWindowsPowerShell, + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode && PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, "Windows PowerShell doesn't trust PSScriptAnalyzer by default so it won't load."); NewTestFile("$a = 4"); @@ -161,7 +161,7 @@ public async Task WontReceiveDiagnosticsFromFileOpenThatIsNotPowerShellAsync() [SkippableFact] public async Task CanReceiveDiagnosticsFromFileChangedAsync() { - Skip.If(PsesStdioProcess.RunningInConstrainedLanguageMode && PsesStdioProcess.IsWindowsPowerShell, + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode && PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, "Windows PowerShell doesn't trust PSScriptAnalyzer by default so it won't load."); string filePath = NewTestFile("$a = 4"); @@ -212,7 +212,7 @@ public async Task CanReceiveDiagnosticsFromFileChangedAsync() [SkippableFact] public async Task CanReceiveDiagnosticsFromConfigurationChangeAsync() { - Skip.If(PsesStdioProcess.RunningInConstrainedLanguageMode && PsesStdioProcess.IsWindowsPowerShell, + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode && PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, "Windows PowerShell doesn't trust PSScriptAnalyzer by default so it won't load."); PsesLanguageClient.SendNotification("workspace/didChangeConfiguration", @@ -312,7 +312,7 @@ await PsesLanguageClient [SkippableFact] public async Task CanSendFormattingRequestAsync() { - Skip.If(PsesStdioProcess.RunningInConstrainedLanguageMode && PsesStdioProcess.IsWindowsPowerShell, + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode && PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, "Windows PowerShell doesn't trust PSScriptAnalyzer by default so it won't load."); string scriptPath = NewTestFile(@" @@ -348,7 +348,7 @@ public async Task CanSendFormattingRequestAsync() [SkippableFact] public async Task CanSendRangeFormattingRequestAsync() { - Skip.If(PsesStdioProcess.RunningInConstrainedLanguageMode && PsesStdioProcess.IsWindowsPowerShell, + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode && PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, "Windows PowerShell doesn't trust PSScriptAnalyzer by default so it won't load."); string scriptPath = NewTestFile(@" @@ -977,7 +977,7 @@ enum MyEnum { [SkippableFact] public async Task CanSendCodeActionRequestAsync() { - Skip.If(PsesStdioProcess.RunningInConstrainedLanguageMode && PsesStdioProcess.IsWindowsPowerShell, + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode && PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, "Windows PowerShell doesn't trust PSScriptAnalyzer by default so it won't load."); string filePath = NewTestFile("gci"); @@ -1034,7 +1034,7 @@ await PsesLanguageClient public async Task CanSendCompletionAndCompletionResolveRequestAsync() { Skip.If(IsLinux, "This depends on the help system, which is flaky on Linux."); - Skip.If(PsesStdioProcess.IsWindowsPowerShell, "This help system isn't updated in CI."); + Skip.If(PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, "This help system isn't updated in CI."); string filePath = NewTestFile("Write-H"); CompletionList completionItems = await PsesLanguageClient.TextDocument.RequestCompletion( @@ -1133,7 +1133,7 @@ await PsesLanguageClient public async Task CanSendHoverRequestAsync() { Skip.If(IsLinux, "This depends on the help system, which is flaky on Linux."); - Skip.If(PsesStdioProcess.IsWindowsPowerShell, "This help system isn't updated in CI."); + Skip.If(PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, "This help system isn't updated in CI."); string filePath = NewTestFile("Write-Host"); Hover hover = await PsesLanguageClient.TextDocument.RequestHover( @@ -1215,7 +1215,7 @@ await PsesLanguageClient [SkippableFact] public async Task CanSendGetCommentHelpRequestAsync() { - Skip.If(PsesStdioProcess.RunningInConstrainedLanguageMode && PsesStdioProcess.IsWindowsPowerShell, + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode && PsesStdioLanguageServerProcessHost.IsWindowsPowerShell, "Windows PowerShell doesn't trust PSScriptAnalyzer by default so it won't load."); string scriptPath = NewTestFile(@" @@ -1285,7 +1285,7 @@ await PsesLanguageClient [SkippableFact] public async Task CanSendExpandAliasRequestAsync() { - Skip.If(PsesStdioProcess.RunningInConstrainedLanguageMode, + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, "The expand alias request doesn't work in Constrained Language Mode."); ExpandAliasResult expandAliasResult = diff --git a/test/PowerShellEditorServices.Test.E2E/Processes/LoggingStream.cs b/test/PowerShellEditorServices.Test.E2E/Processes/LoggingStream.cs deleted file mode 100644 index 9a29452b0..000000000 --- a/test/PowerShellEditorServices.Test.E2E/Processes/LoggingStream.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.Diagnostics; -using System.IO; -using System.Text; - -namespace PowerShellEditorServices.Test.E2E -{ - internal class LoggingStream : Stream - { - private static readonly string s_banner = new('=', 20); - - private readonly Stream _underlyingStream; - - public LoggingStream(Stream underlyingStream) => _underlyingStream = underlyingStream; - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - if (disposing) - { - _underlyingStream.Dispose(); - } - } - - public override bool CanRead => _underlyingStream.CanRead; - - public override bool CanSeek => _underlyingStream.CanSeek; - - public override bool CanWrite => _underlyingStream.CanWrite; - - public override long Length => _underlyingStream.Length; - - public override long Position { get => _underlyingStream.Position; set => _underlyingStream.Position = value; } - - public override void Flush() => _underlyingStream.Flush(); - - public override int Read(byte[] buffer, int offset, int count) - { - int actualCount = _underlyingStream.Read(buffer, offset, count); - LogData("READ", buffer, offset, actualCount); - return actualCount; - } - - public override long Seek(long offset, SeekOrigin origin) => _underlyingStream.Seek(offset, origin); - - public override void SetLength(long value) => _underlyingStream.SetLength(value); - - public override void Write(byte[] buffer, int offset, int count) - { - LogData("WRITE", buffer, offset, count); - _underlyingStream.Write(buffer, offset, count); - } - - private static void LogData(string header, byte[] buffer, int offset, int count) - { - Debug.WriteLine($"{header} |{s_banner.Substring(0, Math.Max(s_banner.Length - header.Length - 2, 0))}"); - string data = Encoding.UTF8.GetString(buffer, offset, count); - Debug.WriteLine(data); - Debug.WriteLine(s_banner); - Debug.WriteLine("\n"); - } - } -} diff --git a/test/PowerShellEditorServices.Test.E2E/Processes/PsesStdioProcess.cs b/test/PowerShellEditorServices.Test.E2E/Processes/PsesStdioProcess.cs deleted file mode 100644 index 0aa651205..000000000 --- a/test/PowerShellEditorServices.Test.E2E/Processes/PsesStdioProcess.cs +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Reflection; -using Microsoft.Extensions.Logging; - -namespace PowerShellEditorServices.Test.E2E -{ - /// - /// A is responsible for launching or attaching to a language server, providing access to its input and output streams, and tracking its lifetime. - /// - public class PsesStdioProcess : StdioServerProcess - { - protected static readonly string s_binDir = - Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - - #region private static or constants members - - private static readonly string s_bundledModulePath = new FileInfo(Path.Combine( - s_binDir, "..", "..", "..", "..", "..", "module")).FullName; - - private static readonly string s_sessionDetailsPath = Path.Combine( - s_binDir, $"pses_test_sessiondetails_{Path.GetRandomFileName()}"); - - private static readonly string s_logPath = Path.Combine( - s_binDir, $"pses_test_logs_{Path.GetRandomFileName()}"); - - private const string s_logLevel = "Diagnostic"; - private static readonly string[] s_featureFlags = { "PSReadLine" }; - private const string s_hostName = "TestHost"; - private const string s_hostProfileId = "TestHost"; - private const string s_hostVersion = "1.0.0"; - - #endregion - - #region public static properties - - // NOTE: Just hard-code this to "powershell" when testing with the code lens. - public static string PwshExe { get; } = Environment.GetEnvironmentVariable("PWSH_EXE_NAME") ?? "pwsh"; - public static bool IsWindowsPowerShell { get; } = PwshExe.EndsWith("powershell"); - public static bool RunningInConstrainedLanguageMode { get; } = - Environment.GetEnvironmentVariable("__PSLockdownPolicy", EnvironmentVariableTarget.Machine) != null; - - #endregion - - #region ctor - - public PsesStdioProcess(ILoggerFactory loggerFactory, bool isDebugAdapter) : base(loggerFactory, GeneratePsesStartInfo(isDebugAdapter)) - { - } - - #endregion - - #region helper private methods - - private static ProcessStartInfo GeneratePsesStartInfo(bool isDebugAdapter) - { - ProcessStartInfo processStartInfo = new() - { - FileName = PwshExe - }; - - foreach (string arg in GeneratePsesArguments(isDebugAdapter)) - { - processStartInfo.ArgumentList.Add(arg); - } - - return processStartInfo; - } - - private static string[] GeneratePsesArguments(bool isDebugAdapter) - { - List args = new() - { - "&", - SingleQuoteEscape(Path.Combine(s_bundledModulePath, "PowerShellEditorServices", "Start-EditorServices.ps1")), - "-LogPath", - SingleQuoteEscape(s_logPath), - "-LogLevel", - s_logLevel, - "-SessionDetailsPath", - SingleQuoteEscape(s_sessionDetailsPath), - "-FeatureFlags", - string.Join(',', s_featureFlags), - "-HostName", - s_hostName, - "-HostProfileId", - s_hostProfileId, - "-HostVersion", - s_hostVersion, - "-BundledModulesPath", - SingleQuoteEscape(s_bundledModulePath), - "-Stdio" - }; - - if (isDebugAdapter) - { - args.Add("-DebugServiceOnly"); - } - - string base64Str = Convert.ToBase64String( - System.Text.Encoding.Unicode.GetBytes(string.Join(' ', args))); - - return new string[] - { - "-NoLogo", - "-NoProfile", - "-EncodedCommand", - base64Str - }; - } - - private static string SingleQuoteEscape(string str) => $"'{str.Replace("'", "''")}'"; - - #endregion - } -} diff --git a/test/PowerShellEditorServices.Test.E2E/Processes/ServerProcess.cs b/test/PowerShellEditorServices.Test.E2E/Processes/ServerProcess.cs deleted file mode 100644 index 90ecaaf5c..000000000 --- a/test/PowerShellEditorServices.Test.E2E/Processes/ServerProcess.cs +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.IO; -using System.Reactive.Subjects; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; - -namespace PowerShellEditorServices.Test.E2E -{ - /// - /// A is responsible for launching or attaching to a language server, providing access to its input and output streams, and tracking its lifetime. - /// - public abstract class ServerProcess : IDisposable - { - private readonly ISubject _exitedSubject; - - private readonly Lazy _inStreamLazy; - - private readonly Lazy _outStreamLazy; - - /// - /// Create a new . - /// - /// - /// The factory for loggers used by the process and its components. - /// - protected ServerProcess(ILoggerFactory loggerFactory) - { - LoggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - Log = LoggerFactory.CreateLogger(categoryName: GetType().FullName); - - ServerStartCompletion = new TaskCompletionSource(); - - ServerExitCompletion = new TaskCompletionSource(); - ServerExitCompletion.SetResult(null); // Start out as if the server has already exited. - - Exited = _exitedSubject = new AsyncSubject(); - - _inStreamLazy = new Lazy(GetInputStream); - _outStreamLazy = new Lazy(GetOutputStream); - } - - /// - /// Finalizer for . - /// - ~ServerProcess() - { - Dispose(false); - } - - /// - /// Dispose of resources being used by the launcher. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Dispose of resources being used by the launcher. - /// - /// - /// Explicit disposal? - /// - protected virtual void Dispose(bool disposing) - { - } - - /// - /// The factory for loggers used by the process and its components. - /// - protected ILoggerFactory LoggerFactory { get; } - - /// - /// The process's logger. - /// - protected ILogger Log { get; } - - /// - /// The used to signal server startup. - /// - protected TaskCompletionSource ServerStartCompletion { get; set; } - - /// - /// The used to signal server exit. - /// - protected TaskCompletionSource ServerExitCompletion { get; set; } - - /// - /// Event raised when the server has exited. - /// - public IObservable Exited { get; } - - /// - /// Is the server running? - /// - public abstract bool IsRunning { get; } - - /// - /// A that completes when the server has started. - /// - public Task HasStarted => ServerStartCompletion.Task; - - /// - /// A that completes when the server has exited. - /// - public Task HasExited => ServerExitCompletion.Task; - - protected abstract Stream GetInputStream(); - - protected abstract Stream GetOutputStream(); - - /// - /// The server's input stream. - /// - /// - /// The connection will write to the server's input stream, and read from its output stream. - /// - public Stream InputStream => _inStreamLazy.Value; - - /// - /// The server's output stream. - /// - /// - /// The connection will read from the server's output stream, and write to its input stream. - /// - public Stream OutputStream => _outStreamLazy.Value; - - /// - /// Start or connect to the server. - /// - public abstract Task Start(); - - /// - /// Stop or disconnect from the server. - /// - public abstract Task Stop(); - - /// - /// Raise the event. - /// - protected virtual void OnExited() - { - _exitedSubject.OnNext(System.Reactive.Unit.Default); - _exitedSubject.OnCompleted(); - } - } -} diff --git a/test/PowerShellEditorServices.Test.E2E/Processes/StdioServerProcess.cs b/test/PowerShellEditorServices.Test.E2E/Processes/StdioServerProcess.cs deleted file mode 100644 index 9cc19810b..000000000 --- a/test/PowerShellEditorServices.Test.E2E/Processes/StdioServerProcess.cs +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.Diagnostics; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; - -namespace PowerShellEditorServices.Test.E2E -{ - /// - /// A is a that launches its server as an external process and communicates with it over STDIN / STDOUT. - /// - public class StdioServerProcess : ServerProcess - { - /// - /// A that describes how to start the server. - /// - private readonly ProcessStartInfo _serverStartInfo; - - /// - /// The current server process (if any). - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "It is diposed but with a lock.")] - private Process _serverProcess; - - /// - /// Create a new . - /// - /// - /// The factory for loggers used by the process and its components. - /// - /// - /// A that describes how to start the server. - /// - public StdioServerProcess(ILoggerFactory loggerFactory, ProcessStartInfo serverStartInfo) - : base(loggerFactory) => _serverStartInfo = serverStartInfo ?? throw new ArgumentNullException(nameof(serverStartInfo)); - - public int ProcessId => _serverProcess.Id; - - /// - /// The process ID of the server process, useful for attaching a debugger. - /// - public int Id => _serverProcess.Id; - - /// - /// Dispose of resources being used by the launcher. - /// - /// - /// Explicit disposal? - /// - protected override void Dispose(bool disposing) - { - if (disposing) - { - Process serverProcess = Interlocked.Exchange(ref _serverProcess, null); - if (serverProcess is not null) - { - if (!serverProcess.HasExited) - { - serverProcess.Kill(); - } - - serverProcess.Dispose(); - } - } - base.Dispose(disposing); - } - - /// - /// Is the server running? - /// - public override bool IsRunning => !ServerExitCompletion.Task.IsCompleted; - - /// - /// The server's input stream. - /// - protected override Stream GetInputStream() => _serverProcess?.StandardInput?.BaseStream; - - /// - /// The server's output stream. - /// - protected override Stream GetOutputStream() => _serverProcess?.StandardOutput?.BaseStream; - - /// - /// Start or connect to the server. - /// - public override Task Start() - { - ServerExitCompletion = new TaskCompletionSource(); - - _serverStartInfo.CreateNoWindow = true; - _serverStartInfo.UseShellExecute = false; - _serverStartInfo.RedirectStandardInput = true; - _serverStartInfo.RedirectStandardOutput = true; - _serverStartInfo.RedirectStandardError = true; - - Process serverProcess = _serverProcess = new Process - { - StartInfo = _serverStartInfo, - EnableRaisingEvents = true - }; - serverProcess.Exited += ServerProcess_Exit; - - if (!serverProcess.Start()) - { - throw new InvalidOperationException("Failed to launch language server ."); - } - - ServerStartCompletion.TrySetResult(null); - - return Task.CompletedTask; - } - - /// - /// Stop or disconnect from the server. - /// - public override Task Stop() - { - Process serverProcess = Interlocked.Exchange(ref _serverProcess, null); - ServerExitCompletion.TrySetResult(null); - if (serverProcess?.HasExited == false) - { - serverProcess.Kill(); - } - return ServerExitCompletion.Task; - } - - public event EventHandler ProcessExited; - - /// - /// Called when the server process has exited. - /// - /// - /// The event sender. - /// - /// - /// The event arguments. - /// - private void ServerProcess_Exit(object sender, EventArgs args) - { - Log.LogDebug("Server process has exited."); - - Process serverProcess = (Process)sender; - - int exitCode = serverProcess.ExitCode; - string errorMsg = serverProcess.StandardError.ReadToEnd(); - - OnExited(); - ProcessExited?.Invoke(this, new ProcessExitedEventArgs(exitCode, errorMsg)); - if (exitCode != 0) - { - ServerExitCompletion.TrySetException(new ProcessExitedException("Stdio server process exited unexpectedly", exitCode, errorMsg)); - } - else - { - ServerExitCompletion.TrySetResult(null); - } - ServerStartCompletion = new TaskCompletionSource(); - } - } - - public class ProcessExitedException : Exception - { - public ProcessExitedException(string message, int exitCode, string errorMessage) - : base(message) - { - ExitCode = exitCode; - ErrorMessage = errorMessage; - } - - public int ExitCode { get; init; } - - public string ErrorMessage { get; init; } - } - - public class ProcessExitedEventArgs : EventArgs - { - public ProcessExitedEventArgs(int exitCode, string errorMessage) - { - ExitCode = exitCode; - ErrorMessage = errorMessage; - } - - public int ExitCode { get; init; } - - public string ErrorMessage { get; init; } - } -} diff --git a/test/PowerShellEditorServices.Test.E2E/xunit.runner.json b/test/PowerShellEditorServices.Test.E2E/xunit.runner.json index 2719fd14a..09cc13a99 100644 --- a/test/PowerShellEditorServices.Test.E2E/xunit.runner.json +++ b/test/PowerShellEditorServices.Test.E2E/xunit.runner.json @@ -1,7 +1,8 @@ { "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", "appDomain": "denied", - "parallelizeTestCollections": false, + "parallelizeTestCollections": true, + "parallelAlgorithm": "aggressive", "methodDisplay": "method", "diagnosticMessages": true, "longRunningTestSeconds": 60