>" }
+
+ if ([string]::IsNullOrWhiteSpace($Script)) { return }
+ $userErrors = Get-UserErrors
+ try {
+ # Collected first: when the code fails, the response is the exception alone
+ $output = Invoke-UserCode $Script -MergeErrors:$MergeErrors
+ $output
+ } catch {
+ if($error_to_result){
+ $_.Exception.Message
+ $userErrors.Clear()
+ }else{
+ # The error stays in $userErrors, so the caller still treats the result as a failure (no symbol assignment)
+ $errorType = $_.FullyQualifiedErrorId
+ if($errorType -match "^Stop(Test|Suite):?(.*)?"){
+ # test or suite can be aborted by throw "StopTest" or throw "StopSuite"
+ $script:SLIM_ABORT_TEST = $true
+ $script:SLIM_ABORT_TAG = if ($matches[1] -eq 'Suite') { 'ABORT_SLIM_SUITE' } else { 'ABORT_SLIM_TEST' }
+ $tag = $script:SLIM_ABORT_TAG
+ return "__EXCEPTION__:${tag}:message:<<__EXCEPTION__:${tag}:$($matches[1]) aborted. : Additional Info[ $($_.Exception.ToString()) ]>>"
+ } elseif($errorType -match "^Ignore(ScriptTest|AllTests):?(.*)?"){
+ # throw "IgnoreScriptTest" skips the rest of the script table, throw "IgnoreAllTests" the rest of the page
+ $script:SLIM_STOP_BATCH = $true
+ $tag = if ($matches[1] -eq 'AllTests') { 'IGNORE_ALL_TESTS' } else { 'IGNORE_SCRIPT_TEST' }
+ # FitNesse counts the row as ignored only for the bare tag, so the reason goes to the execution log
+ Write-Log "$errorType"
+ return "__EXCEPTION__:${tag}:"
+ } elseif($_.Exception -is [System.Net.WebException]){
+ $script:SLIM_ABORT_TEST = $true
+ $script:SLIM_ABORT_TAG = 'ABORT_SLIM_TEST'
+ return "__EXCEPTION__:ABORT_SLIM_TEST:message:<<$($_.Exception.Message)>>"
} else {
- $details
+ return "__EXCEPTION__:UnhandledException:message:<<$($_.Exception.ToString())>>"
}
- } | Out-String
-}
-
-function Invoke-SlimCall($fnc){
- $ps_action, $error_to_result = $fnc.split('_');
- if('eval','query','get','post','patch','put','delete' -contains $ps_action){
- $result = Exec-Script -Script $Script__ $error_to_result
- }
- else {
- if ((Table-Type) -eq "ScriptTableActor") { $result = nocommand $_ }
- else{ $result = $slimvoid }
+ }
}
- $script:matches = $matches
- $result
+ if($userErrors.Count) { Print-Error }
}
-function Set-RestScript($method, $arguments)
-{
- $uri, $body = $arguments -split ','
- if($null -ne $body) {
- $s = "Invoke-RestMethod {0} -Body '{1}' -Method {2} -ContentType 'application/json'" -f $uri,(Invoke-Expression $body | ConvertTo-JSON -Depth 3),$method
- }
- else {
- $s = "Invoke-RestMethod {0} -Method {1} -ContentType 'application/json'" -f $uri, $method
- }
- if($null -ne $headers){
- $s += ' -Headers $script:headers'
- }
- #$s = "($s) | ConvertFrom-JSON"
-
- Set-Variable -Name Script__ -Value $s -Scope Global
+function Print-Error {
+ $nonTerminatingIsException = Get-UserVariable 'NonTerminatingIsException'
+ Get-UserErrors | ForEach-Object { $_ } | ForEach-Object {
+ $details = ($_ | Out-String)
+ if($_.Exception) { $details += $_.Exception.ToString() }
+ if($nonTerminatingIsException){
+ "__EXCEPTION__:Error:message:<<__EXCEPTION__:Error: Additional Info[ $details ]>>"
+ } else {
+ $details
+ }
+ } | Out-String
}
-function Set-Script($s, $fmt){
- if(!$s){ return }
+# Turns a table cell into the code to run: FitNesse hash tables, blocks, symbols, ESXi format
+function Set-Script([string]$s, $fmt){
+ if([string]::IsNullOrEmpty($s)){ $script:SlimScript = ''; return }
# FitNesse renders hash tables with platform line endings (CRLF on Windows, LF elsewhere)
$s = $s -replace '','}' -replace '\t*\r?\n','' -replace '\t*
\r?\n','' -replace '\t*(.*) | \r?\n', '$1=' -replace '\t*(.*) | \r?\n','$1;'
if($s -match '^\s*&?')
{
- $s = $s -replace '<.?pre>' #workaround fitnesse strange behavior
+ $s = $s -replace '<.?pre>' #workaround fitnesse strange behavior
}
- if($slimsymbols.Count){$slimsymbols.Keys | Sort-Object Length -Descending | Where-Object {!($s -cmatch "\`$$_\s*=")} | Where-Object {$slimsymbols[$_] -is [string] } | ForEach-Object {$s=$s -creplace "(?&1"
- }
- $slimvoid
- #$script:decision_result
- return
- }else{
- #if($ins[3] -ne 'Result'){
- # "Not Implemented"
- #}else{
- # if($symbol){$slimsymbols[$symbol] = $script:decision_result}
- # $script:decision_result
- #}
- switch -regex ($ins[3]) {
- # Support requesting the amount of time it took to process a decision table row.
- # Newer FitNesse versions keep the dot: "Time.Seconds?" arrives as "Time.Seconds"
- '^Time\.?(?\w+)?$'
- { if ( $Matches.ContainsKey( 'comp') ) {
- switch ( $matches.comp ) {
- 'Seconds' { $script:decision_time.TotalSeconds }
- 'Days' { $script:decision_time.TotalDays }
- 'Hours' { $script:decision_time.TotalHours }
- 'Minutes' { $script:decision_time.TotalMinutes }
- 'Milliseconds' { $script:decision_time.TotalMilliseconds }
- default { 'Invalid Duration: $_' }
- }
- } else {
- $script:decision_time.TotalSeconds
- }
- break
- }
- '^Result$' { ResultTo-String ($script:decision_result)
- if ($symbol) {
- $slimsymbols[$symbol] = $script:decision_result
- }
- break
- }
- '^Result(\S+)$'
- { $prop = $Matches[1]
- $prop = $prop -replace '_','.'
- ResultTo-String (Invoke-Expression ('$script:decision_result.'+$prop))
- if ($symbol) {
- $slimsymbols[$symbol] = Invoke-Expression ('$script:decision_result.'+$prop)
- }
- break
- }
- '^(\S+)$' { $prop = $Matches[1]
- $prop = $prop -replace '_','.'
- ResultTo-String (Invoke-Expression ('$script:decision_result.'+$prop))
- if ($symbol) {
- $slimsymbols[$symbol] = Invoke-Expression ('$script:decision_result.'+$prop)
- }
- }
- default { 'Not Implemented' }
- }
- return
- }
- }
- }
+function Get-PropertyPath($Object, [string]$Path) {
+ foreach ($name in $Path.Split('.')) {
+ if ($null -eq $Object) { break }
+ $Object = $Object.$name
}
+ $Object
+}
- $ps_action = $ins[3].split('_')[0];
- if($ps_action -ne "query" -and $ps_action -ne "table"){
- if('get','post','patch','put','delete' -contains $ps_action){
- Set-RestScript $ps_action $ins[4]
- }
- else{
- Set-Script $ins[4] $EvalFormat__
- }
+# A call on a decision table: the code is the table's class name, inputs are set as variables before execute
+function Invoke-DecisionTableCall([string]$Method, [string[]]$Arguments, [string]$Symbol) {
+ if ($Method -match '^(table|beginTable|reset|endTable)$') { return "/__VOID__/" }
+ if ($Method.Length -gt 3 -and $Method.StartsWith('set', [StringComparison]::OrdinalIgnoreCase)) {
+ $name = $Method.Substring(3)
+ if ($name -notmatch '^\w+$') { return (New-SlimException "NO_METHOD_IN_CLASS $Method decisionTable") }
+ Set-UserVariable $name (Resolve-SlimArgument $Arguments[0])
+ return "/__VOID__/"
}
-
- $error.clear()
- # Measure the amount of time this step or command takes
- # Execution of the test's code occurs here. During the 'make' step this is simple
- # the execution of the 'make' procedure.
- $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
- $result = Invoke-SlimCall $ins[3]
- $script:Command_Time = $stopwatch.Elapsed
- Write-Log $Script__ : $script:Command_Time.TotalSeconds
-
-
- if($null -ne $error[0]) {
- $error.clear()
- ResultTo-String $result
- }else {
- if($symbol){$slimsymbols[$symbol] = $result}
- $error.clear()
- switch ($ps_action){
- "query" {
- if(($null -eq $result) -or ($result -is 'system.collections.generic.dictionary[string,object]' -and $result.Count -eq 0)){
- $result = ResultTo-List @()
- }
- else{
- $result = ResultTo-List @($result)
- }
- }
- "eval" { $result = ResultTo-String $result }
-
- {$_ -match '^(get|post|patch|put|delete)$'}{
- Set-Variable -Name $_ -Value ($result) -Scope Global;
- if ($result -is [String]){
- $result = ResultTo-String $result
- }else {
- $result = ResultTo-List @($result)
- }
+ if ($Method -eq 'execute') {
+ (Get-UserErrors).Clear()
+ # store the decision table test time.
+ $script:decision_time = Measure-Command {
+ $script:decision_result = Exec-Script -Script $script:SlimScript -MergeErrors
+ }
+ return $slimvoid
+ }
+ switch -regex ($Method) {
+ # Support requesting the amount of time it took to process a decision table row.
+ # Newer FitNesse versions keep the dot: "Time.Seconds?" arrives as "Time.Seconds"
+ '^Time\.?(?\w+)?$' {
+ if (!$Matches.ContainsKey('comp')) { return $script:decision_time.TotalSeconds }
+ switch ($matches.comp) {
+ 'Seconds' { return $script:decision_time.TotalSeconds }
+ 'Days' { return $script:decision_time.TotalDays }
+ 'Hours' { return $script:decision_time.TotalHours }
+ 'Minutes' { return $script:decision_time.TotalMinutes }
+ 'Milliseconds' { return $script:decision_time.TotalMilliseconds }
}
+ return "Invalid Duration: $Method"
+ }
+ '^Result$' {
+ if ($Symbol) { Set-SlimSymbol $Symbol $script:decision_result }
+ return (ResultTo-String $script:decision_result)
}
- if ($result -is [String]) {
- $result.TrimEnd("`r`n")
- } else {
- $result
+ # The properties of complex results are referenced with _ in place of . (Phone_Home is .Phone.Home)
+ '^(?:Result)?(\S+)$' {
+ $value = Get-PropertyPath $script:decision_result ($Matches[1] -replace '_','.')
+ if ($Symbol) { Set-SlimSymbol $Symbol $value }
+ return (ResultTo-String $value)
}
}
+ 'Not Implemented'
}
-function Process-Instruction($ins){
- $script:ps_row = $ins
- $result = Invoke-SlimInstruction
+function Invoke-SlimCall([string]$Instance, [string]$Method, [string[]]$Arguments, [string]$Symbol) {
+ if ($Instance.StartsWith('decisionTable')) { return (Invoke-DecisionTableCall $Method $Arguments $Symbol) }
- # Stringify scalars once so the SLIM length prefix matches the serialized text. On .NET Core,
- # Double.ToString() is round-trip precise while PowerShell string conversion uses 15 digits.
- if($null -ne $result[1] -and $result[1] -isnot [string] -and $result[1] -isnot [array]){
- $result[1] = [string]$result[1]
+ $action, $error_to_result = $Method.Split('_', 2)
+ $isRest = 'get','post','patch','put','delete' -contains $action
+ if ($action -eq 'cloneSymbol') {
+ # FitNesse copies a scenario output into a decision table symbol with cloneSymbol $output
+ $value = Resolve-SlimArgument $Arguments[0]
+ if ($Symbol) { Set-SlimSymbol $Symbol $value }
+ return (ResultTo-String $value)
}
-
- $s = '[000002:' + (slimlen $result[0]) + ':' + $result[0] + ':' + (slimlen $result[1]) + ':' + $result[1] + ':]'
- (slimlen $s) + ":" + $s + ":"
-
-}
-
-function pack_results($results){
- if($results -is [array]){
- $ps_send = "[" + (slimlen $results) + ":"
- $results | ForEach-Object {$ps_send += $_}
+ if (!$isRest -and 'eval','query' -notcontains $action) {
+ # table, beginTable, reset, endTable... on query tables; anything else is a missing method
+ if ($Instance -eq 'scriptTableActor') { return (New-SlimException "NO_METHOD_IN_CLASS $Method PowerSlim") }
+ return $slimvoid
}
- else{
- $ps_send = "[000001:$results"
- }
- $ps_send += "]"
- [text.encoding]::utf8.getbytes($ps_send).Length.ToString("d6") + ":" + $ps_send
-}
+ if ($action -eq 'eval') { Set-Script $Arguments[0] $script:EvalFormat__ }
+ elseif ($isRest) { Set-RestScript $action $Arguments[0] }
+ $userErrors = Get-UserErrors
+ $userErrors.Clear()
+ # Measure the amount of time this step or command takes
+ $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
+ $result = Exec-Script -Script $script:SlimScript $error_to_result
+ $script:Command_Time = $stopwatch.Elapsed
+ Write-Log $script:SlimScript : $script:Command_Time.TotalSeconds
-function check_remote($ps_table) {
-
- if( !(Test-OneRowTable $ps_table) ) {
-
- $ps_table = $ps_table[0]
+ if($userErrors.Count) {
+ $userErrors.Clear()
+ return (ResultTo-String $result)
}
-
- if($ps_table[0].StartsWith("scriptTable_") -or $ps_table[0].StartsWith("queryTable_")){
-
- if("Remote" -eq $ps_table[3])
- {
- set_remote_targets($ps_table[4])
- }
- else
- {
- $script:Remote = $false
- }
-
+ if($Symbol){ Set-SlimSymbol $Symbol $result }
+ if ($action -eq 'query') {
+ if(($null -eq $result) -or ($result -is 'system.collections.generic.dictionary[string,object]' -and $result.Count -eq 0)){
+ $result = ResultTo-List @()
+ }
+ else{
+ $result = ResultTo-List @($result)
+ }
}
-
+ elseif ($action -eq 'eval') { $result = ResultTo-String $result }
+ else {
+ Set-UserVariable $action $result
+ if ($result -is [String]) { $result = ResultTo-String $result }
+ else { $result = ResultTo-List @($result) }
+ }
+ if ($result -is [String]) { $result.TrimEnd([char[]]"`r`n") } else { $result }
}
-function set_remote_targets($ps_cell) {
+function Get-InstructionArguments($Instruction, [int]$From) {
+ if ($Instruction.Count -gt $From) { , [string[]]$Instruction[$From..($Instruction.Count - 1)] }
+ else { , [string[]]@() }
+}
- $script:Remote = $true
- $script:targets = $null
+function Get-MalformedInstruction($Instruction) {
+ $text = if ($Instruction -is [hashtable]) { $Instruction.Malformed } else { $Instruction -join ', ' }
+ New-SlimException "MALFORMED_INSTRUCTION [$text]"
+}
- $script:targets = Invoke-Expression $ps_cell
-
- if($null -eq $script:targets){
- $script:targets = $ps_cell.Split(",") | ForEach-Object{$_.Trim(", ")}
+function Invoke-SlimInstruction($Instruction) {
+ if ($Instruction -is [hashtable] -or $Instruction.Count -lt 2) { return (Get-MalformedInstruction $Instruction) }
+ $ins = $Instruction
+ switch -exact ($ins[1]) {
+ 'import' {
+ if ($ins.Count -ne 3) { break }
+ return (Import-SlimScript $ins[2])
+ }
+ 'make' {
+ if ($ins.Count -lt 4) { break }
+ $result = make $ins
+ # query tables keep their code in the last argument, decision tables in the class name
+ Set-Script $ins[$ins.Count - 1] $script:QueryFormat__
+ return $result
+ }
+ 'assign' {
+ if ($ins.Count -ne 4) { break }
+ Set-SlimSymbol $ins[2] $ins[3]
+ return "OK"
+ }
+ 'call' {
+ if ($ins.Count -lt 4) { break }
+ return (Invoke-SlimCall $ins[2] $ins[3] (Get-InstructionArguments $ins 4))
+ }
+ 'callAndAssign' {
+ if ($ins.Count -lt 5) { break }
+ return (Invoke-SlimCall $ins[3] $ins[4] (Get-InstructionArguments $ins 5) -Symbol $ins[2])
+ }
}
-
+ Get-MalformedInstruction $ins
}
-function process_table() {
-
- if(Test-OneRowTable $script:ps_table){ $ps_results = Process-Instruction $script:ps_table }
- else { $ps_results = $script:ps_table | ForEach-Object { Process-Instruction $_ } }
-
- $ps_results
-
+# The response value of one instruction: a string (lists are already serialized)
+function Get-SlimInstructionResult($Instruction) {
+ try { $output = @(Invoke-SlimInstruction $Instruction) }
+ catch { return (New-SlimException "$($_.Exception.Message)") }
+ if (!$output.Count -or $null -eq $output[0]) { return $slimvoid }
+ # Stringify scalars once so the SLIM length matches the serialized text. On .NET Core,
+ # Double.ToString() is round-trip precise while PowerShell string conversion uses 15 digits.
+ [string]$output[0]
}
-function process_message($ps_stream){
-
- if( ! $ps_stream.CanRead ){ return "bye" }
-
- Write-Verbose "Started processing message."
-
- $script:SLIM_ABORT_TEST = $false
- $error.clear()
- $ps_msg = get_message($ps_stream)
-
- $ps_msg
-
- if ($error) { return "bye" }
-
- if( !(ischunk $ps_msg) ){ return }
-
- $script:QueryFormat__ = $script:EvalFormat__ = "{0}"
- Get-SlimTable $ps_msg
-
- check_remote($script:ps_table)
-
- if($Remote -eq $true){
-
- Write-Verbose "Buffer1 $ps_buf1"
- Write-Verbose "Buffer2 $ps_buf2"
-
- Write-Verbose "Processing table remotelly"
-
- process_table_remotely $script:ps_table $ps_stream;
-
- Write-Verbose "Done remote call"
-
- return
+function Invoke-SlimInstructions($Instructions) {
+ $script:SLIM_STOP_BATCH = $false
+ $responses = New-Object System.Collections.Generic.List[object]
+ foreach ($instruction in $Instructions) {
+ # IgnoreScriptTest / IgnoreAllTests: FitNesse ignores the rest of the table, so do not run it
+ if ($script:SLIM_STOP_BATCH) { break }
+ $id = if ($instruction -isnot [hashtable] -and $instruction.Count) { $instruction[0] } else { '' }
+ $value = Get-SlimInstructionResult $instruction
+ $responses.Add([object[]]@($id, $value))
}
-
- $ps_results = process_table
-
- $ps_send = [text.encoding]::utf8.getbytes((pack_results $ps_results))
- $ps_stream.Write($ps_send, 0, $ps_send.Length)
-
+ ConvertTo-SlimString $responses
}
-function process_message_ignore_remote($ps_stream){
-
- Write-Verbose "Process Message & Ignore Remote"
+# Executes one instruction list and returns the serialized response list
+function Invoke-SlimMessage([string]$Message, [switch]$IgnoreRemote) {
$script:SLIM_ABORT_TEST = $false
+ $script:QueryFormat__ = $script:EvalFormat__ = '{0}'
+ try { $instructions = ConvertFrom-SlimInstructions $Message }
+ catch {
+ Write-Log "Ignoring a message that is not an instruction list: $($_.Exception.Message)"
+ return (ConvertTo-SlimString @())
+ }
+ if (!$IgnoreRemote) {
+ Update-RemoteContext $instructions
+ if ($script:Remote) { return (Invoke-RemoteInstructions $instructions $Message) }
+ }
+ Invoke-SlimInstructions $instructions
+}
- $ps_msg = get_message($ps_stream)
-
- if(ischunk($ps_msg)){
-
- $script:QueryFormat__ = $script:EvalFormat__ = "{0}"
- Get-SlimTable $ps_msg
-
- $ps_results = process_table
+#endregion
- $ps_send = [text.encoding]::utf8.getbytes((pack_results $ps_results))
- $ps_stream.Write($ps_send, 0, $ps_send.Length)
+#region Servers
+function Start-SlimRunner($Reader, $Writer) {
+ $header = [Text.Encoding]::ASCII.GetBytes($slimver)
+ $Writer.Write($header, 0, $header.Length)
+ $Writer.Flush()
+ while ($true) {
+ try {
+ $message = Read-SlimMessage $Reader -FirstByteTimeout -1
+ if ($null -eq $message -or $message -eq 'bye') { return }
+ Write-SlimMessage $Writer (Invoke-SlimMessage $message)
+ }
+ catch {
+ Write-Log "Connection to FitNesse lost: $($_.Exception.Message)"
+ return
+ }
}
}
-function Run-SlimServer($ps_server){
- $ps_fitnesse_client_task = $ps_server.AcceptTcpClientAsync()
- $ps_fitnesse_client = $ps_fitnesse_client_task.Result
-
- $ps_fitnesse_stream = $ps_fitnesse_client.GetStream()
- $ps_fitnesse_stream.ReadTimeout = $REQUEST_READ_TIMEOUT
-
- send_slim_version($ps_fitnesse_stream)
- $ps_fitnesse_client.Client.Poll(-1, [System.Net.Sockets.SelectMode]::SelectRead)
- while($ps_fitnesse_client.Connected -and "bye" -ne (process_message($ps_fitnesse_stream))){};
- $ps_fitnesse_client.Close()
+# A remote agent answers one instruction list per connection
+function Start-RemoteAgent($Listener) {
+ while ($true) {
+ Write-Log "waiting..."
+ $client = $Listener.AcceptTcpClient()
+ Write-Log "accepted!"
+ try {
+ $stream = $client.GetStream()
+ $message = Read-SlimMessage $stream
+ if ($null -ne $message) { Write-SlimMessage $stream (Invoke-SlimMessage $message -IgnoreRemote) }
+ }
+ catch { Write-Log "Dropped connection: $($_.Exception.Message)" }
+ finally { $client.Close() }
+ }
}
-function Run-RemoteServer($ps_server){
- Write-Log "waiting..."
- while($ps_fitnesse_client = $ps_server.AcceptTcpClient()){
- Write-Log "accepted!"
- $ps_fitnesse_stream = $ps_fitnesse_client.GetStream()
- $ps_fitnesse_stream.ReadTimeout = $REQUEST_READ_TIMEOUT
-
- process_message_ignore_remote($ps_fitnesse_stream)
- $ps_fitnesse_stream.Close()
- $ps_fitnesse_client.Close()
- Write-Log "waiting..."
+function Start-PowerSlim([int]$Port, [string]$Mode, [string]$BindAddress) {
+ if ($Port -eq $SLIM_PIPE_PORT) {
+ if ($Mode -ne 'runner') { throw "Port $SLIM_PIPE_PORT means stdin/stdout, which only the runner supports" }
+ $pipe = Initialize-StdioTransport
+ Write-Log "========== Starting SLIM runner on stdin/stdout =========="
+ Start-SlimRunner $pipe.In $pipe.Out
+ return
}
+
+ Write-Log "========== Starting SLIM $Mode on ${BindAddress}:$Port =========="
+ $listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Parse($BindAddress), $Port)
+ $listener.Start()
+ try {
+ if ($Mode -eq "runner") {
+ $client = $listener.AcceptTcpClient()
+ try {
+ $stream = $client.GetStream()
+ Start-SlimRunner $stream $stream
+ }
+ finally { $client.Close() }
+ }
+ else {
+ Start-RemoteAgent $listener
+ }
+ }
+ finally { $listener.Stop() }
}
function Write-Log (){
@@ -686,17 +833,7 @@ function Write-Log (){
Write-Host $timestamp"`t"$args
}
-Write-Log "========== Starting SLIM $Mode on ${BindAddress}:$Port =========="
+#endregion
-$ps_server = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Parse($BindAddress), $Port)
-$ps_server.Start()
-
-if($Mode -eq "runner"){
- $scriptPath = split-path -parent $MyInvocation.MyCommand.Definition
- . (Join-Path $scriptPath client.ps1)
- Run-SlimServer $ps_server
-}
-else{
- Run-RemoteServer $ps_server
-}
-$ps_server.Stop()
+. (Join-Path $script:POWERSLIM_HOME 'client.ps1')
+Start-PowerSlim -Port $Port -Mode $Mode -BindAddress $BindAddress
diff --git a/tests/PowerSlim.Tests.ps1 b/tests/PowerSlim.Tests.ps1
index fc9462a..757fd17 100644
--- a/tests/PowerSlim.Tests.ps1
+++ b/tests/PowerSlim.Tests.ps1
@@ -1,15 +1,18 @@
-# Unit tests for the SLIM protocol helpers in slim.ps1. Run with: Invoke-Pester ./tests
+# Unit tests for slim.ps1 and client.ps1. Run with: Invoke-Pester ./tests
BeforeAll {
- # slim.ps1 starts a TCP listener when it runs, so load only its function definitions,
- # top-level constants and aliases (via the parser) instead of executing the script.
- $path = Join-Path (Split-Path -Parent $PSScriptRoot) 'slim.ps1'
- $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
- $wanted = $ast.EndBlock.Statements | Where-Object {
- $_ -is [System.Management.Automation.Language.FunctionDefinitionAst] -or
- ($_ -is [System.Management.Automation.Language.AssignmentStatementAst] -and $_.Extent.Text -notmatch 'MyInvocation|TcpListener') -or
- ($_ -is [System.Management.Automation.Language.PipelineAst] -and $_.Extent.Text -match '^new-alias\s')
+ # slim.ps1 starts a server when it runs, so load only the function definitions and top-level
+ # constants of both scripts (via the parser) instead of executing them.
+ $root = Split-Path -Parent $PSScriptRoot
+ $definitions = foreach ($file in 'slim.ps1', 'client.ps1') {
+ $ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $root $file), [ref]$null, [ref]$null)
+ $ast.EndBlock.Statements | Where-Object {
+ $_ -is [System.Management.Automation.Language.FunctionDefinitionAst] -or
+ ($_ -is [System.Management.Automation.Language.AssignmentStatementAst] -and $_.Extent.Text -notmatch 'MyInvocation')
+ } | ForEach-Object { $_.Extent.Text }
}
- . ([scriptblock]::Create(($wanted | ForEach-Object { $_.Extent.Text }) -join "`n"))
+ . ([scriptblock]::Create($definitions -join "`n"))
+ $script:Shell = (Get-Process -Id $PID).Path
+ $script:SlimPath = Join-Path $root 'slim.ps1'
# Build a SLIM list the way FitNesse does: [count:len:item:len:item:...:]
function ConvertTo-SlimList([object[]]$items) {
@@ -21,108 +24,301 @@ BeforeAll {
}
function New-SlimStream([string]$text) {
- $stream = [System.IO.MemoryStream]::new([text.encoding]::UTF8.GetBytes($text))
- , $stream
+ , [System.IO.MemoryStream]::new([text.encoding]::UTF8.GetBytes($text))
}
-}
-Describe 'Get-SlimLength' {
- It 'pads string length to six digits' {
- Get-SlimLength 'abc' | Should -Be '000003'
+ # Runs one instruction list and returns the response as a list of [id, value] pairs
+ function Invoke-Table([object[]]$instructions) {
+ $response = Invoke-SlimMessage (ConvertTo-SlimList $instructions) -IgnoreRemote
+ foreach ($item in (ConvertFrom-SlimList $response)) { , (ConvertFrom-SlimList $item) }
}
- It 'returns the element count for arrays' {
- Get-SlimLength @('a', 'bb') | Should -Be '000002'
+
+ # The value returned for one instruction
+ function Invoke-Eval([string]$code) {
+ (Invoke-Table @(, @('id', 'call', 'scriptTableActor', 'eval', $code)))[1]
}
-}
-Describe 'ischunk' {
- It 'detects SLIM lists' {
- ischunk '[000000:]' | Should -BeTrue
- ischunk 'bye' | Should -BeFalse
+ function Reset-Slim {
+ $slimsymbols.Clear()
+ $script:SymbolOrigins.Clear()
+ $script:SLIM_ABORT_TEST = $false
}
+
+ function Write-Log { }
+ # Non-ASCII text built from code points: Windows PowerShell 5.1 reads this file as ANSI
+ $script:Emoji = [char]::ConvertFromUtf32(0x1F600)
+ $script:Cyrillic = -join [char[]](0x0414, 0x043E, 0x0431, 0x0440, 0x044B, 0x0439)
}
-Describe 'Test-OneRowTable' {
- It 'is true for a single instruction' {
- Test-OneRowTable @(1, 2, 3) | Should -BeTrue
+Describe 'SLIM lists' {
+ It 'serializes nested lists, counting UTF-16 code units' {
+ ConvertTo-SlimString @('a', @('bc', $null), $script:Emoji) |
+ Should -Be ('[000003:000001:a:000031:[000002:000002:bc:000004:null:]:000002:{0}:]' -f $script:Emoji)
}
- It 'is false for a list of instructions' {
- Test-OneRowTable @(@(1, 2, 3), 2, 3) | Should -BeFalse
+ It 'uses more than six digits for long items' {
+ $long = 'x' * 1000000
+ (ConvertTo-SlimString @(, $long)).StartsWith('[000001:1000000:xx') | Should -BeTrue
}
-}
-
-Describe 'Reading messages from the stream' {
- It 'parses the six digit length prefix' {
- get_message_length (New-SlimStream '000220:') | Should -Be 220
+ It 'parses what it serializes' {
+ $text = ConvertTo-SlimString @('a', '', 'it''s [000000:] :colon:', @('nested'))
+ $items = ConvertFrom-SlimList $text
+ $items.Count | Should -Be 4
+ $items[1] | Should -Be ''
+ $items[2] | Should -Be 'it''s [000000:] :colon:'
+ ConvertFrom-SlimList $items[3] | Should -Be 'nested'
}
- It 'returns -1 for a malformed length' {
- get_message_length (New-SlimStream 'abcdef:') | Should -Be -1
+ It 'reads lengths of more than six digits' {
+ ConvertFrom-SlimList '[0000001:0000003:abc:]' | Should -Be 'abc'
}
- It 'reads a whole message after its length' {
- $message = ConvertTo-SlimList @(, @('id_0', 'call', 'scriptTableActor', 'eval', '1+1'))
- get_message (New-SlimStream ('{0:d6}:{1}' -f $message.Length, $message)) | Should -Be $message
+ It 'rejects ' -ForEach @(
+ @{ name = 'a missing ['; text = '000001:000001:a:]' }
+ @{ name = 'a short length'; text = '[00001:000001:a:]' }
+ @{ name = 'an item without its colon'; text = '[000001:000001:ab]' }
+ @{ name = 'a wrong item count'; text = '[000002:000001:a:]' }
+ @{ name = 'trailing text'; text = '[000001:000001:a:]x' }
+ ) {
+ { ConvertFrom-SlimList $text } | Should -Throw '*Malformed SLIM list*'
+ }
+ It 'keeps a malformed instruction for its error response' {
+ $instructions = ConvertFrom-SlimInstructions (ConvertTo-SlimList @(@('id', 'call'), 'not a list'))
+ $instructions[0] | Should -Be @('id', 'call')
+ $instructions[1].Malformed | Should -Be 'not a list'
}
}
-Describe 'Get-SlimTable' {
- It 'turns a SLIM message into instructions' {
- $message = ConvertTo-SlimList @(
- @('scriptTable_0_0', 'make', 'scriptTableActor', 'script'),
- @('scriptTable_0_1', 'call', 'scriptTableActor', 'eval', "'it''s' + 1")
- )
- Get-SlimTable $message
- $script:ps_table.Count | Should -Be 2
- $script:ps_table[1][0] | Should -Be 'scriptTable_0_1'
- $script:ps_table[1][4] | Should -Be "'it''s' + 1"
+Describe 'Reading and writing messages' {
+ It 'reads a message whose length counts UTF-8 bytes' {
+ $text = "$script:Cyrillic $script:Emoji"
+ $bytes = [text.encoding]::UTF8.GetByteCount($text)
+ Read-SlimMessage (New-SlimStream ('{0:d6}:{1}' -f $bytes, $text)) | Should -Be $text
+ }
+ It 'reads a length of more than six digits' {
+ Read-SlimMessage (New-SlimStream '0000003:bye') | Should -Be 'bye'
+ }
+ It 'returns $null when the connection closes before a message' {
+ Read-SlimMessage (New-SlimStream '') | Should -BeNullOrEmpty
+ }
+ It 'skips a UTF-8 byte order mark before the length' {
+ $stream = [System.IO.MemoryStream]::new([byte[]](@(0xEF, 0xBB, 0xBF) + [text.encoding]::ASCII.GetBytes('000003:bye')))
+ Read-SlimMessage $stream | Should -Be 'bye'
+ }
+ It 'rejects ' -ForEach @(
+ @{ name = 'a length that is not a number'; text = 'abcdef:' }
+ @{ name = 'a short length'; text = '00003:bye' }
+ @{ name = 'a truncated message'; text = '000010:abc' }
+ ) {
+ { Read-SlimMessage (New-SlimStream $text) } | Should -Throw
}
- It 'keeps empty cells' {
- Get-SlimTable (ConvertTo-SlimList @(, @('id', 'call', 'actor', 'eval', '')))
- $script:ps_table[4] | Should -Be ''
+ It 'prefixes the UTF-8 byte length' {
+ $stream = [System.IO.MemoryStream]::new()
+ Write-SlimMessage $stream $script:Cyrillic
+ [text.encoding]::UTF8.GetString($stream.ToArray()) | Should -Be "000012:$script:Cyrillic"
}
}
-Describe 'pack_results' {
- It 'prefixes the UTF-8 byte length and counts the results' {
- $packed = pack_results @('000004:abcd:', '000002:ef:')
- $packed | Should -Be '000031:[000002:000004:abcd:000002:ef:]'
+Describe 'Symbol substitution' {
+ BeforeEach {
+ Reset-Slim
+ $slimsymbols['MySymbol'] = "it's `"quoted`" `$x"
+ $slimsymbols['suffix'] = 'st'
+ $slimsymbols['SymbolPrefix'] = 'test1'
+ $slimsymbols['user'] = 'Shawn'
+ }
+
+ It 'replaces symbols in text in one pass, using the longest symbol name that exists' {
+ $slimsymbols['loop'] = '$suffix'
+ Expand-SlimSymbolsInText '$SymbolPrefix_x $loop $unknown' | Should -Be 'test1_x $suffix $unknown'
+ }
+ It 'leaves variable references in code to the symbol variables' {
+ Expand-SlimSymbols '$MySymbol.Length; "$MySymbol"; function f([string]$user){ $user }' |
+ Should -Be '$MySymbol.Length; "$MySymbol"; function f([string]$user){ $user }'
+ }
+ It 'inserts values into single-quoted strings with their quotes doubled' {
+ Expand-SlimSymbols "'[`$MySymbol]'" | Should -Be "'[it''s `"quoted`" `$x]'"
+ }
+ It 'braces a symbol that is a prefix of the name in a double-quoted string' {
+ Expand-SlimSymbols '"$SymbolPrefix_test $(Get-Date $SymbolPrefix_test)"' |
+ Should -Be '"${SymbolPrefix}_test $(Get-Date $SymbolPrefix_test)"'
+ }
+ It 'calls a command whose name contains a symbol through &' {
+ Expand-SlimSymbols 'te$suffix -Name a$suffix' | Should -Be '& "te$suffix" -Name a$suffix'
+ }
+ It 'resolves an argument that is a single symbol to the stored object' {
+ $slimsymbols['list'] = @(1, 2)
+ (Resolve-SlimArgument '$list').Count | Should -Be 2
+ Resolve-SlimArgument 'a $user' | Should -Be 'a Shawn'
}
}
-Describe 'Process-Instruction' {
+Describe 'Instructions' {
+ BeforeEach { Reset-Slim }
+
+ It 'keeps variables and functions between instructions' {
+ Invoke-Eval '$a = 1; $c, $d = 3, 4; $a++; function Get-Sum { $a + $c + $d }' | Out-Null
+ Invoke-Eval 'Get-Sum' | Should -Be '9'
+ }
+ It 'keeps test variables apart from PowerSlim''s own' {
+ Invoke-Eval '$slimsymbols = "overwritten"; $Remote = $true' | Out-Null
+ $slimsymbols.GetType().Name | Should -BeLike 'Dictionary*'
+ $script:Remote | Should -BeFalse
+ }
+ It 'shows $Error to the test code' {
+ Invoke-Eval 'try { throw "Expected" } catch { $Error[0].Exception.Message }' | Should -BeLike 'Expected,*'
+ }
+ It 'stores callAndAssign results as symbols and variables' {
+ $response = Invoke-Table @(
+ @('id_0', 'callAndAssign', 'Quoted', 'scriptTableActor', 'eval', '"it''s `"x`" `$y; exit"'),
+ @('id_1', 'call', 'scriptTableActor', 'eval', '"[$Quoted]" + ''[$Quoted]'' + $Quoted.Length')
+ )
+ $response[0][1] | Should -Be 'it''s "x" $y; exit'
+ $response[1][1] | Should -Be '[it''s "x" $y; exit][it''s "x" $y; exit]17'
+ }
+ It 'assigns symbols' {
+ $response = Invoke-Table @(
+ @('id_0', 'assign', 'fromFitNesse', 'v1'),
+ @('id_1', 'call', 'scriptTableActor', 'eval', '"$fromFitNesse!"')
+ )
+ $response[0][1] | Should -Be 'OK'
+ $response[1][1] | Should -Be 'v1!'
+ }
+ It 'sets decision table inputs as variables, with symbols resolved' {
+ $slimsymbols['who'] = "O'Brien"
+ $response = Invoke-Table @(
+ @('d_0', 'make', 'decisionTable_0', '"Hello $name"'),
+ @('d_1', 'call', 'decisionTable_0', 'setName', '$who'),
+ @('d_2', 'call', 'decisionTable_0', 'execute'),
+ @('d_3', 'call', 'decisionTable_0', 'result'),
+ @('d_4', 'call', 'decisionTable_0', 'length')
+ )
+ $response[3][1] | Should -Be "Hello O'Brien"
+ $response[4][1] | Should -Be '13'
+ }
+ It 'reports ' -ForEach @(
+ @{ name = 'an unknown instruction'; instruction = @('id', 'jump', 'x'); expected = '*MALFORMED_INSTRUCTION*' }
+ @{ name = 'a missing method'; instruction = @('id', 'call', 'scriptTableActor', 'deleted1Scenario', '1'); expected = '*NO_METHOD_IN_CLASS deleted1Scenario*' }
+ @{ name = 'a missing import'; instruction = @('id', 'import', 'no-such-file.ps1'); expected = '*COULD_NOT_IMPORT*' }
+ ) {
+ @(Invoke-Table @(, $instruction))[0][1] | Should -BeLike $expected
+ }
+ It 'answers the rest of the batch after StopTest' {
+ $response = Invoke-Table @(
+ @('id_0', 'call', 'scriptTableActor', 'eval', 'throw "StopTest"'),
+ @('id_1', 'call', 'scriptTableActor', 'eval', '"not run"')
+ )
+ $response[0][1] | Should -BeLike '__EXCEPTION__:ABORT_SLIM_TEST:message:<<*Test aborted*'
+ $response[1][1] | Should -Be '__EXCEPTION__:ABORT_SLIM_TEST:message:<>'
+ }
+ It 'tags StopSuite as ABORT_SLIM_SUITE' {
+ @(Invoke-Table @(, @('id', 'call', 'scriptTableActor', 'eval', 'throw "StopSuite"')))[0][1] |
+ Should -BeLike '__EXCEPTION__:ABORT_SLIM_SUITE:*'
+ }
+ It 'stops the batch after ' -ForEach @(
+ @{ name = 'IgnoreScriptTest'; tag = '__EXCEPTION__:IGNORE_SCRIPT_TEST:' }
+ @{ name = 'IgnoreAllTests'; tag = '__EXCEPTION__:IGNORE_ALL_TESTS:' }
+ ) {
+ $response = @(Invoke-Table @(
+ @('id_0', 'call', 'scriptTableActor', 'eval', "throw '${name}:reason'"),
+ @('id_1', 'call', 'scriptTableActor', 'eval', '"not run"')
+ ))
+ $response.Count | Should -Be 1
+ $response[0][1] | Should -Be $tag
+ }
+ It 'returns the message for eval_error' {
+ Invoke-Table @(, @('id', 'call', 'scriptTableActor', 'eval_error', '1/0')) | ForEach-Object { $_[1] } |
+ Should -Be 'Attempted to divide by zero.'
+ }
It 'measures scalar results after converting them to text' {
# Regression: .NET Core formats doubles with more digits than PowerShell's string conversion
- Mock Invoke-SlimInstruction { 'id_0', 1.0060359123456789 }
- $response = Process-Instruction @('id_0')
- $inner = $response.Substring(7, $response.Length - 8)
- [int]$response.Substring(0, 6) | Should -Be $inner.Length
- $inner | Should -Match ':1\.00603591234568:\]$'
+ Mock Invoke-SlimInstruction { 1.0060359123456789 }
+ $response = Invoke-SlimInstructions @(, [string[]]@('id_0'))
+ [int]$response.Substring(1, 6) | Should -Be 1
+ $response | Should -Match ':1\.00603591234568:\]:\]$'
+ }
+ It 'returns query results as rows of [name, value] cells' {
+ $response = Invoke-Table @(
+ @('q_0', 'make', 'queryTable_0', 'Local', '@{ Name = "Bob"; Tags = @("a", "b") }'),
+ @('q_1', 'call', 'queryTable_0', 'query')
+ )
+ $rows = ConvertFrom-SlimList $response[1][1]
+ $cells = foreach ($cell in (ConvertFrom-SlimList $rows[0])) { (ConvertFrom-SlimList $cell) -join '=' }
+ $cells | Where-Object { $_ -notlike 'SLIM_COMPUTERNAME=*' } | Should -Be @('Name=Bob', 'Tags=["a","b"]')
}
}
-Describe 'Set-Script' {
- BeforeEach { $slimsymbols.Clear() }
+Describe 'Remote symbols' {
+ BeforeEach { Reset-Slim }
- It 'converts FitNesse hash tables with line endings' -ForEach @(
- @{ name = 'CRLF'; nl = "`r`n" }, @{ name = 'LF'; nl = "`n" }
+ It 'recreates on an agent' -ForEach @(
+ @{ name = 'a string with quotes and symbols'; value = "it's `$MySymbol `"x`"" }
+ @{ name = 'an int'; value = 42 }
+ @{ name = 'a long'; value = 637000000000000000L }
+ @{ name = 'a double'; value = 1.12 }
+ @{ name = 'a bool'; value = $true }
) {
- $html = '' + $nl +
- "`t" + $nl +
- "`t`t| first | " + $nl +
- "`t`t'Bob' | " + $nl +
- "`t
" + $nl + '
'
- Set-Script $html '{0}'
- $Script__ | Should -Be "@{first='Bob';}"
+ $slimsymbols['MySymbol'] = 'must not be substituted'
+ $literal = ConvertTo-PowerShellLiteral $value
+ Expand-SlimSymbols $literal | Should -Be $literal
+ $copy = & ([scriptblock]::Create($literal))
+ $copy | Should -Be $value
+ $copy.GetType() | Should -Be $value.GetType()
}
-
- It 'substitutes string symbols' {
- $slimsymbols['user'] = 'Shawn'
- Set-Script '"hello $user"' '{0}'
- $Script__ | Should -Be '"hello Shawn"'
+ It 'does not send symbols back to the agent they came from' {
+ Set-SlimSymbol 'local' 'L'
+ Set-SlimSymbol 'remote' 'R' -Origin 'host:35'
+ Set-SlimSymbol 'complex' (Get-Date)
+ $message = Get-SymbolSyncMessage 'host:35'
+ $names = ConvertFrom-SlimList $message | ForEach-Object { (ConvertFrom-SlimList $_)[2] }
+ $names | Should -Be @('local')
+ }
+ It 'copies symbols assigned on the agent from its response' {
+ Set-SlimSymbol 'gone' 'old'
+ $instructions = ConvertFrom-SlimInstructions (ConvertTo-SlimList @(
+ @('s_0', 'callAndAssign', 'num', 'scriptTableActor', 'eval', '1'),
+ @('s_1', 'callAndAssign', 'text', 'scriptTableActor', 'eval', '"1.2.3"'),
+ @('s_2', 'callAndAssign', 'gone', 'scriptTableActor', 'eval', '$null')
+ ))
+ Receive-RemoteSymbols $instructions (ConvertTo-SlimList @(@('s_0', '7'), @('s_1', '1.2.3'), @('s_2', ''))) 'host:35'
+ $slimsymbols['num'] | Should -BeOfType [int]
+ $slimsymbols['text'] | Should -Be '1.2.3'
+ $slimsymbols.ContainsKey('gone') | Should -BeFalse
+ $script:SymbolOrigins['num'] | Should -Be 'host:35'
}
+}
- It 'leaves typed variables such as parameter declarations alone' {
- $slimsymbols['user'] = 'Shawn'
- Set-Script 'function f([string]$user){ $user }' '{0}'
- $Script__ | Should -Match ([regex]::Escape('([string]$user)'))
+Describe 'stdin/stdout transport' {
+ It 'keeps the protocol channel clean of other output' {
+ $psi = [System.Diagnostics.ProcessStartInfo]::new($script:Shell, "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$script:SlimPath`" 1")
+ $psi.UseShellExecute = $false
+ $psi.RedirectStandardInput = $true
+ $psi.RedirectStandardOutput = $true
+ $psi.RedirectStandardError = $true
+ $process = [System.Diagnostics.Process]::Start($psi)
+ try {
+ $stderr = $process.StandardError.ReadToEndAsync()
+ $out = $process.StandardOutput.BaseStream
+ $header = ''
+ while (($c = $out.ReadByte()) -ne 10) { if ($c -lt 0) { throw 'no header' }; $header += [char]$c }
+ $header | Should -Be 'Slim -- V0.5'
+
+ # Host output, console output and a child process writing to its inherited stdout
+ $child = "Start-Process '$($script:Shell)' '-NoProfile -Command [Console]::Out.Write(''child'')' -NoNewWindow -Wait"
+ $message = ConvertTo-SlimList @(, @('id', 'call', 'scriptTableActor', 'eval', "Write-Host host; [Console]::WriteLine('console'); $child; 'result'"))
+ Write-SlimMessage $process.StandardInput.BaseStream $message
+ $response = Read-SlimMessage $out -FirstByteTimeout 60000 -Timeout 60000
+ if ($null -eq $response) {
+ [void]$stderr.Wait(10000)
+ throw "The runner closed stdout without answering. Its stderr:`n$($stderr.Result)"
+ }
+ (ConvertFrom-SlimList (ConvertFrom-SlimList $response)[0])[1] | Should -Be 'result'
+
+ Write-SlimMessage $process.StandardInput.BaseStream 'bye'
+ $process.WaitForExit(30000) | Should -BeTrue
+ $out.ReadByte() | Should -Be -1
+ $stderr.Result | Should -Match 'host'
+ $stderr.Result | Should -Match 'child'
+ }
+ finally {
+ if (!$process.HasExited) { $process.Kill() }
+ }
}
}