From 6fff81cbbd9a9396741c3aba111391b2958775a5 Mon Sep 17 00:00:00 2001 From: Robert Holt Date: Thu, 29 Mar 2018 15:07:43 -0700 Subject: [PATCH] Add module restore build rule, move start script, fix script analyzer code for tests. Change test module path so it works Generalise module path resolution Add module parse error, remove test debug code Use simpler module loading API Import ScriptAnalyzer based on path precedence, not version Remove PSES installing itself, remove zip file Remove -EditorServicesVersion reference in ServerTestBase.cs Move Start-PSES script to new location Update testing Start-PSES script path Change URL of Start-PSES script in comment --- .gitignore | 3 +- PowerShellEditorServices.build.ps1 | 72 +++++++++- .../Start-EditorServices.ps1 | 43 ++---- modules.json | 12 ++ .../Analysis/AnalysisService.cs | 129 ++++++++---------- .../ServerTestsBase.cs | 24 ++-- 6 files changed, 167 insertions(+), 116 deletions(-) rename module/{ => PowerShellEditorServices}/Start-EditorServices.ps1 (90%) create mode 100644 modules.json diff --git a/.gitignore b/.gitignore index 99073998e..33a89bf3b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ docs/_site/ docs/_repo/ docs/metadata/ tools/ +*.zip # quickbuild.exe /VersionGeneratingLogs/ @@ -67,4 +68,4 @@ module/PowerShellEditorServices/Commands/en-US/*-help.xml module/PowerShellEditorServices/Third\ Party\ Notices.txt # Visual Studio for Mac generated file -*.userprefs \ No newline at end of file +*.userprefs diff --git a/PowerShellEditorServices.build.ps1 b/PowerShellEditorServices.build.ps1 index eacc30251..1029d5623 100644 --- a/PowerShellEditorServices.build.ps1 +++ b/PowerShellEditorServices.build.ps1 @@ -5,7 +5,13 @@ param( [ValidateSet("Debug", "Release")] - [string]$Configuration = "Debug" + [string]$Configuration = "Debug", + + [string]$PsesSubmodulePath = "$PSScriptRoot/module", + + [string]$ModulesJsonPath = "$PSScriptRoot/modules.json", + + [string]$DefaultModuleRepository = "PSGallery" ) #Requires -Modules @{ModuleName="InvokeBuild";ModuleVersion="3.2.1"} @@ -13,6 +19,7 @@ param( $script:IsCIBuild = $env:APPVEYOR -ne $null $script:IsUnix = $PSVersionTable.PSEdition -and $PSVersionTable.PSEdition -eq "Core" -and !$IsWindows $script:TargetFrameworksParam = "/p:TargetFrameworks=\`"$(if (!$script:IsUnix) { "net451;" })netstandard1.6\`"" +$script:SaveModuleSupportsAllowPrerelease = (Get-Command Save-Module).Parameters.ContainsKey("AllowPrerelease") if ($PSVersionTable.PSEdition -ne "Core") { Add-Type -Assembly System.IO.Compression.FileSystem @@ -179,7 +186,7 @@ task TestProtocol -If { !$script:IsUnix} { task TestHost -If { !$script:IsUnix} { Set-Location .\test\PowerShellEditorServices.Test.Host\ exec { & $script:dotnetExe build -c $Configuration -f net452 } - exec { & $script:dotnetExe xunit -configuration $Configuration -framework net452 -verbose -nobuild -x86 } + exec { & $script:dotnetExe xunit -configuration $Configuration -framework net452 -verbose -nobuild } } task CITest ?Test, { @@ -220,6 +227,67 @@ task LayoutModule -After Build { } } +task RestorePsesModules -After Build { + $submodulePath = (Resolve-Path $PsesSubmodulePath).Path + [IO.Path]::DirectorySeparatorChar + Write-Host "`nRestoring EditorServices modules..." + + # Read in the modules.json file as a hashtable so it can be splatted + $moduleInfos = @{} + + (Get-Content -Raw $ModulesJsonPath | ConvertFrom-Json).PSObject.Properties | ForEach-Object { + $name = $_.Name + $body = @{ + Name = $name + MinimumVersion = $_.Value.MinimumVersion + MaximumVersion = $_.Value.MaximumVersion + Repository = if ($_.Value.Repository) { $_.Value.Repository } else { $DefaultModuleRepository } + Path = $submodulePath + } + + if (-not $name) + { + throw "EditorServices module listed without name in '$ModulesJsonPath'" + } + + if ($script:SaveModuleSupportsAllowPrerelease) + { + $body += @{ AllowPrerelease = $_.Value.AllowPrerelease } + } + + $moduleInfos.Add($name, $body) + } + + # Save each module in the modules.json file + foreach ($moduleName in $moduleInfos.Keys) + { + if (Test-Path -Path (Join-Path -Path $submodulePath -ChildPath $moduleName)) + { + Write-Host "`tModule '${moduleName}' already detected. Skipping" + continue + } + + $moduleInstallDetails = $moduleInfos[$moduleName] + + $splatParameters = @{ + Name = $moduleName + MinimumVersion = $moduleInstallDetails.MinimumVersion + MaximumVersion = $moduleInstallDetails.MaximumVersion + Repository = if ($moduleInstallDetails.Repository) { $moduleInstallDetails.Repository } else { $DefaultModuleRepository } + Path = $submodulePath + } + + if ($script:SaveModuleSupportsAllowPrerelease) + { + $splatParameters += @{ AllowPrerelease = $moduleInstallDetails.AllowPrerelease } + } + + Write-Host "`tInstalling module: ${moduleName}" + + Save-Module @splatParameters + } + Write-Host "`n" +} + task BuildCmdletHelp { New-ExternalHelp -Path $PSScriptRoot\module\docs -OutputPath $PSScriptRoot\module\PowerShellEditorServices\Commands\en-US -Force } diff --git a/module/Start-EditorServices.ps1 b/module/PowerShellEditorServices/Start-EditorServices.ps1 similarity index 90% rename from module/Start-EditorServices.ps1 rename to module/PowerShellEditorServices/Start-EditorServices.ps1 index a1f9152ab..f94691de1 100644 --- a/module/Start-EditorServices.ps1 +++ b/module/PowerShellEditorServices/Start-EditorServices.ps1 @@ -14,14 +14,9 @@ # canonical version of this script at the PowerShell Editor # Services GitHub repository: # -# https://github.com/PowerShell/PowerShellEditorServices/blob/master/module/Start-EditorServices.ps1 +# https://github.com/PowerShell/PowerShellEditorServices/blob/master/module/PowerShellEditorServices/Start-EditorServices.ps1 param( - [Parameter(Mandatory=$true)] - [ValidateNotNullOrEmpty()] - [string] - $EditorServicesVersion, - [Parameter(Mandatory=$true)] [ValidateNotNullOrEmpty()] [string] @@ -97,6 +92,13 @@ function ExitWithError($errorString) { exit 1; } +function WriteSessionFile($sessionInfo) { + $sessionInfoJson = ConvertTo-Json -InputObject $sessionInfo -Compress + Log "Writing session file with contents:" + Log $sessionInfoJson + $sessionInfoJson | Set-Content -Force -Path "$SessionDetailsPath" -ErrorAction Stop +} + # Are we running in PowerShell 2 or earlier? if ($PSVersionTable.PSVersion.Major -le 2) { # No ConvertTo-Json on PSv2 and below, so write out the JSON manually @@ -106,12 +108,6 @@ if ($PSVersionTable.PSVersion.Major -le 2) { ExitWithError "Unsupported PowerShell version $($PSVersionTable.PSVersion), language features are disabled." } -function WriteSessionFile($sessionInfo) { - $sessionInfoJson = ConvertTo-Json -InputObject $sessionInfo -Compress - Log "Writing session file with contents:" - Log $sessionInfoJson - $sessionInfoJson | Set-Content -Force -Path "$SessionDetailsPath" -ErrorAction Stop -} if ($host.Runspace.LanguageMode -eq 'ConstrainedLanguage') { WriteSessionFile @{ @@ -244,32 +240,11 @@ if ((Test-ModuleAvailable "PowerShellGet") -eq $false) { # TODO: WRITE ERROR } -# Check if the expected version of the PowerShell Editor Services -# module is installed -$parsedVersion = New-Object System.Version @($EditorServicesVersion) -if ((Test-ModuleAvailable "PowerShellEditorServices" $parsedVersion) -eq $false) { - if ($ConfirmInstall -and $isPS5orLater) { - # TODO: Check for error and return failure if necessary - LogSection "Install PowerShellEditorServices" - Install-Module "PowerShellEditorServices" -RequiredVersion $parsedVersion -Confirm - } - else { - # Indicate to the client that the PowerShellEditorServices module - # needs to be installed - Write-Output "needs_install" - } -} - try { LogSection "Start up PowerShellEditorServices" Log "Importing PowerShellEditorServices" - if ($isPS5orLater) { - Import-Module PowerShellEditorServices -RequiredVersion $parsedVersion -ErrorAction Stop - } - else { - Import-Module PowerShellEditorServices -Version $parsedVersion -ErrorAction Stop - } + Import-Module PowerShellEditorServices -ErrorAction Stop # Locate available port numbers for services Log "Searching for available socket port for the language service" diff --git a/modules.json b/modules.json new file mode 100644 index 000000000..e4555e5de --- /dev/null +++ b/modules.json @@ -0,0 +1,12 @@ +{ + "PSScriptAnalyzer":{ + "MinimumVersion":"1.6", + "MaximumVersion":"1.99", + "AllowPrerelease":false + }, + "Plaster":{ + "MinimumVersion":"1.0", + "MaximumVersion":"1.99", + "AllowPrerelease":false + } +} diff --git a/src/PowerShellEditorServices/Analysis/AnalysisService.cs b/src/PowerShellEditorServices/Analysis/AnalysisService.cs index 80c5746ca..38b3ff6bd 100644 --- a/src/PowerShellEditorServices/Analysis/AnalysisService.cs +++ b/src/PowerShellEditorServices/Analysis/AnalysisService.cs @@ -13,6 +13,7 @@ using System.Collections.Generic; using System.Text; using System.Collections; +using System.IO; namespace Microsoft.PowerShell.EditorServices { @@ -26,17 +27,12 @@ public class AnalysisService : IDisposable private const int NumRunspaces = 1; - private ILogger logger; - private RunspacePool analysisRunspacePool; - private PSModuleInfo scriptAnalyzerModuleInfo; + private const string PSSA_MODULE_NAME = "PSScriptAnalyzer"; - private bool hasScriptAnalyzerModule - { - get - { - return scriptAnalyzerModuleInfo != null; - } - } + private ILogger _logger; + private RunspacePool _analysisRunspacePool; + + private bool _hasScriptAnalyzerModule; private string[] activeRules; private string settingsPath; @@ -108,25 +104,30 @@ public string SettingsPath /// An ILogger implementation used for writing log messages. public AnalysisService(string settingsPath, ILogger logger) { - this.logger = logger; + this._logger = logger; try { this.SettingsPath = settingsPath; - scriptAnalyzerModuleInfo = FindPSScriptAnalyzerModule(logger); - var sessionState = InitialSessionState.CreateDefault2(); - sessionState.ImportPSModulesFromPath(scriptAnalyzerModuleInfo.ModuleBase); + if (!(_hasScriptAnalyzerModule = VerifyPSScriptAnalyzerAvailable())) + { + throw new Exception("PSScriptAnalyzer module not available"); + } + + // Create a base session state with PSScriptAnalyzer loaded + InitialSessionState sessionState = InitialSessionState.CreateDefault2(); + sessionState.ImportPSModule(new [] { PSSA_MODULE_NAME }); // runspacepool takes care of queuing commands for us so we do not // need to worry about executing concurrent commands - this.analysisRunspacePool = RunspaceFactory.CreateRunspacePool(sessionState); + this._analysisRunspacePool = RunspaceFactory.CreateRunspacePool(sessionState); // having more than one runspace doesn't block code formatting if one // runspace is occupied for diagnostics - this.analysisRunspacePool.SetMaxRunspaces(NumRunspaces); - this.analysisRunspacePool.ThreadOptions = PSThreadOptions.ReuseThread; - this.analysisRunspacePool.Open(); + this._analysisRunspacePool.SetMaxRunspaces(NumRunspaces); + this._analysisRunspacePool.ThreadOptions = PSThreadOptions.ReuseThread; + this._analysisRunspacePool.Open(); ActiveRules = IncludedRules.ToArray(); EnumeratePSScriptAnalyzerCmdlets(); @@ -137,7 +138,7 @@ public AnalysisService(string settingsPath, ILogger logger) var sb = new StringBuilder(); sb.AppendLine("PSScriptAnalyzer cannot be imported, AnalysisService will be disabled."); sb.AppendLine(e.Message); - this.logger.Write(LogLevel.Warning, sb.ToString()); + this._logger.Write(LogLevel.Warning, sb.ToString()); } } @@ -234,7 +235,7 @@ public async Task GetSemanticMarkersAsync( public IEnumerable GetPSScriptAnalyzerRules() { List ruleNames = new List(); - if (hasScriptAnalyzerModule) + if (_hasScriptAnalyzerModule) { var ruleObjects = InvokePowerShell("Get-ScriptAnalyzerRule", new Dictionary()); foreach (var rule in ruleObjects) @@ -259,7 +260,7 @@ public async Task Format( int[] rangeList) { // we cannot use Range type therefore this workaround of using -1 default value - if (!hasScriptAnalyzerModule) + if (!_hasScriptAnalyzerModule) { return null; } @@ -282,11 +283,11 @@ public async Task Format( /// public void Dispose() { - if (this.analysisRunspacePool != null) + if (this._analysisRunspacePool != null) { - this.analysisRunspacePool.Close(); - this.analysisRunspacePool.Dispose(); - this.analysisRunspacePool = null; + this._analysisRunspacePool.Close(); + this._analysisRunspacePool.Dispose(); + this._analysisRunspacePool = null; } } @@ -299,7 +300,7 @@ private async Task GetSemanticMarkersAsync( string[] rules, TSettings settings) where TSettings : class { - if (hasScriptAnalyzerModule + if (_hasScriptAnalyzerModule && file.IsAnalysisEnabled) { return await GetSemanticMarkersAsync( @@ -332,44 +333,9 @@ private async Task GetSemanticMarkersAsync( } } - private static PSModuleInfo FindPSScriptAnalyzerModule(ILogger logger) - { - using (var ps = System.Management.Automation.PowerShell.Create()) - { - ps.AddCommand("Get-Module") - .AddParameter("ListAvailable") - .AddParameter("Name", "PSScriptAnalyzer"); - - ps.AddCommand("Sort-Object") - .AddParameter("Descending") - .AddParameter("Property", "Version"); - - ps.AddCommand("Select-Object") - .AddParameter("First", 1); - - var modules = ps.Invoke(); - var psModuleInfo = modules == null ? null : modules.FirstOrDefault(); - if (psModuleInfo != null) - { - logger.Write( - LogLevel.Normal, - string.Format( - "PSScriptAnalyzer found at {0}", - psModuleInfo.Path)); - - return psModuleInfo; - } - - logger.Write( - LogLevel.Normal, - "PSScriptAnalyzer module was not found."); - return null; - } - } - private void EnumeratePSScriptAnalyzerCmdlets() { - if (hasScriptAnalyzerModule) + if (_hasScriptAnalyzerModule) { var sb = new StringBuilder(); var commands = InvokePowerShell( @@ -386,13 +352,13 @@ private void EnumeratePSScriptAnalyzerCmdlets() sb.AppendLine("The following cmdlets are available in the imported PSScriptAnalyzer module:"); sb.AppendLine(String.Join(Environment.NewLine, commandNames.Select(s => " " + s))); - this.logger.Write(LogLevel.Verbose, sb.ToString()); + this._logger.Write(LogLevel.Verbose, sb.ToString()); } } private void EnumeratePSScriptAnalyzerRules() { - if (hasScriptAnalyzerModule) + if (_hasScriptAnalyzerModule) { var rules = GetPSScriptAnalyzerRules(); var sb = new StringBuilder(); @@ -402,7 +368,7 @@ private void EnumeratePSScriptAnalyzerRules() sb.AppendLine(rule); } - this.logger.Write(LogLevel.Verbose, sb.ToString()); + this._logger.Write(LogLevel.Verbose, sb.ToString()); } } @@ -421,7 +387,7 @@ private async Task GetDiagnosticRecordsAsync( return diagnosticRecords; } - if (hasScriptAnalyzerModule + if (_hasScriptAnalyzerModule && (typeof(TSettings) == typeof(string) || typeof(TSettings) == typeof(Hashtable))) { @@ -448,7 +414,7 @@ private async Task GetDiagnosticRecordsAsync( }); } - this.logger.Write( + this._logger.Write( LogLevel.Verbose, String.Format("Found {0} violations", diagnosticRecords.Count())); @@ -460,7 +426,7 @@ private PSObject[] InvokePowerShell(string command, IDictionary { using (var powerShell = System.Management.Automation.PowerShell.Create()) { - powerShell.RunspacePool = this.analysisRunspacePool; + powerShell.RunspacePool = this._analysisRunspacePool; powerShell.AddCommand(command); foreach (var kvp in paramArgMap) { @@ -472,13 +438,19 @@ private PSObject[] InvokePowerShell(string command, IDictionary { result = powerShell.Invoke()?.ToArray(); } + catch (CommandNotFoundException ex) + { + // This exception is possible if the module path loaded + // is wrong even though PSScriptAnalyzer is available as a module + this._logger.Write(LogLevel.Error, ex.Message); + } catch (CmdletInvocationException ex) { // We do not want to crash EditorServices for exceptions caused by cmdlet invocation. // Two main reasons that cause the exception are: // * PSCmdlet.WriteOutput being called from another thread than Begin/Process // * CompositionContainer.ComposeParts complaining that "...Only one batch can be composed at a time" - this.logger.Write(LogLevel.Error, ex.Message); + this._logger.Write(LogLevel.Error, ex.Message); } return result; @@ -495,6 +467,25 @@ private async Task InvokePowerShellAsync(string command, IDictionary return await task; } + private bool VerifyPSScriptAnalyzerAvailable() + { + using (var ps = System.Management.Automation.PowerShell.Create()) + { + ps.AddCommand("Get-Module") + .AddParameter("ListAvailable") + .AddParameter("Name", PSSA_MODULE_NAME); + + try + { + return ps.Invoke()?.Any() ?? false; + } + catch (Exception) + { + return false; + } + } + } + #endregion //private methods } } diff --git a/test/PowerShellEditorServices.Test.Host/ServerTestsBase.cs b/test/PowerShellEditorServices.Test.Host/ServerTestsBase.cs index 741b69abe..6ec39b431 100644 --- a/test/PowerShellEditorServices.Test.Host/ServerTestsBase.cs +++ b/test/PowerShellEditorServices.Test.Host/ServerTestsBase.cs @@ -19,7 +19,7 @@ namespace Microsoft.PowerShell.EditorServices.Test.Host { public class ServerTestsBase { - private static int sessionCounter; + private static int sessionCounter; private Process serviceProcess; protected IMessageSender messageSender; protected IMessageHandlers messageHandlers; @@ -35,7 +35,12 @@ protected async Task> LaunchService( bool waitForDebugger = false) { string modulePath = Path.GetFullPath(@"..\..\..\..\..\module"); - string scriptPath = Path.Combine(modulePath, "Start-EditorServices.ps1"); + string scriptPath = Path.GetFullPath(Path.Combine(modulePath, @"PowerShellEditorServices\Start-EditorServices.ps1")); + + if (!File.Exists(scriptPath)) + { + throw new IOException(String.Format("Bad start script path: '{0}'", scriptPath)); + } #if CoreCLR Assembly assembly = this.GetType().GetTypeInfo().Assembly; @@ -47,7 +52,7 @@ protected async Task> LaunchService( FileVersionInfo fileVersionInfo = FileVersionInfo.GetVersionInfo(assemblyPath); - string sessionPath = + string sessionPath = Path.Combine( Path.GetDirectoryName(assemblyPath), $"session-{++sessionCounter}.json"); @@ -64,9 +69,7 @@ protected async Task> LaunchService( fileVersionInfo.FileBuildPart); string scriptArgs = - string.Format( "\"" + scriptPath + "\" " + - "-EditorServicesVersion \"{0}\" " + "-HostName \\\"PowerShell Editor Services Test Host\\\" " + "-HostProfileId \"Test.PowerShellEditorServices\" " + "-HostVersion \"1.0.0\" " + @@ -75,8 +78,7 @@ protected async Task> LaunchService( "-LogPath \"" + logPath + "\" " + "-SessionDetailsPath \"" + sessionPath + "\" " + "-FeatureFlags @() " + - "-AdditionalModules @() ", - editorServicesModuleVersion); + "-AdditionalModules @() "; if (waitForDebugger) { @@ -117,6 +119,11 @@ protected async Task> LaunchService( var maxRetryAttempts = 10; while (maxRetryAttempts-- > 0) { + if (this.serviceProcess.HasExited) + { + throw new Exception(String.Format("Server host process quit unexpectedly: '{0}'", this.serviceProcess.StandardError.ReadToEnd())); + } + try { using (var stream = new FileStream(sessionPath, FileMode.Open, FileAccess.Read, FileShare.None)) @@ -126,9 +133,6 @@ protected async Task> LaunchService( break; } } - catch (FileNotFoundException) - { - } catch (Exception ex) { Debug.WriteLine($"Session details at '{sessionPath}' not available: {ex.Message}");