diff --git a/FitNesseRoot/PowerSlim/OriginalMode/SuiteCommon/TestScenario/content.txt b/FitNesseRoot/PowerSlim/OriginalMode/SuiteCommon/TestScenario/content.txt index bbdfc85..a4d7dc0 100644 --- a/FitNesseRoot/PowerSlim/OriginalMode/SuiteCommon/TestScenario/content.txt +++ b/FitNesseRoot/PowerSlim/OriginalMode/SuiteCommon/TestScenario/content.txt @@ -17,7 +17,7 @@ ''Test should be RED in case of missing scenario'' ''Each missing scenario increments exceptions counter'' ''We are going to run .PowerSlim.SuiteWrongFailedException.TestMissingScenario trough command line'' -''The nested run needs its own SLIM_PORT, otherwise it collides with the !-PowerSlim-! instance running this page'' +''The nested run uses a TCP port (SLIM_PORT) instead of stdin/stdout, which keeps that transport tested'' -!|script| |check|eval|java -jar fitnesse-standalone.jar -o -p 8023 -c "PowerSlim.SuiteWrongFailedException.TestMissingScenario?test&format=text&SLIM_PORT=8091"|=~/R:0.+W:0.+I:0.+E:2/| *! \ No newline at end of file diff --git a/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/ExceptionTestRunner/content.txt b/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/ExceptionTestRunner/content.txt index 5a8685b..8e9b6fd 100644 --- a/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/ExceptionTestRunner/content.txt +++ b/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/ExceptionTestRunner/content.txt @@ -27,6 +27,12 @@ Note: The test pages themselves are disabled in the suite, which makes sure they |check |eval |$page1.testResults.result.content |=~/@NEXT_EXCEPTION/ | |check |eval |$page2.testResults.result.content |=~/@PROOF_TESTING_STOPPED/ | +|scenario|To prove test page ignored after _|TESTPAGE, IGNORED, PROOF | +|$URL= |eval |'@TESTPAGE?test' | +|eval |$results = Run-Test '$URL' | +|check |eval |$results.testResults.result.content -match '@IGNORED'|True | +|check |eval |$results.testResults.result.content -match '@PROOF' |True | + ******! The following tests show how normal exceptions are now handled. The error details are returned to !-FitNesse-! and displayed as an exception - neither a success or failure - and testing continues. @@ -50,6 +56,16 @@ To abort a test or a suite the developer can issue a {{{ throw "StopTest" }}} Th |TESTPAGE |PAGETYPE|FIRST_EXCEPTION |NEXT_EXCEPTION |PROOF_TESTING_STOPPED| |PowerSlim.OriginalMode.SuiteExceptions.TestCases.AbortExceptionStopTest|test |"fail">__EXCEPTION__:ABORT_SLIM_TEST:Test aborted|"fail">ABORT_TEST_INDICATED:Test not run|"ignore">Test not run| -!| To prove suite pages aborted after failure | -|TESTPAGE |PAGETYPE|FIRST_EXCEPTION |NEXT_EXCEPTION |PROOF_TESTING_STOPPED| -|PowerSlim.OriginalMode.SuiteExceptions.TestCases.AbortExceptionStopSuiteTestPage.AbortExceptionStopSuite|suite |"fail">__EXCEPTION__:ABORT_SLIM_TEST:Suite aborted|"fail">ABORT_TEST_INDICATED:Test not run|"ignore">Test not run| +{{{ throw "StopSuite" }}} answers with ABORT_SLIM_SUITE, so !-FitNesse-! itself skips the rest of the suite (and still runs its tear down). + +!| To prove suite pages aborted after failure | +|TESTPAGE |PAGETYPE|FIRST_EXCEPTION |NEXT_EXCEPTION |PROOF_TESTING_STOPPED| +|PowerSlim.OriginalMode.SuiteExceptions.TestCases.AbortExceptionStopSuiteTestPage.AbortExceptionStopSuite|suite |"error">__EXCEPTION__:ABORT_SLIM_SUITE:Suite aborted|"error">ABORT_TEST_INDICATED:Test not run|"ignore">Test not run| + +{{{ throw "IgnoreScriptTest" }}} ignores the rest of the script table and {{{ throw "IgnoreAllTests" }}} the rest of the page. The text after a colon, as in {{{ throw "IgnoreScriptTest:reason" }}}, goes to the execution log. +''These checks return True or False: a result that contains an IGNORE tag would make !-FitNesse-! ignore this page as well'' + +!| To prove test page ignored after | +|TESTPAGE |IGNORED |PROOF | +|PowerSlim.OriginalMode.SuiteExceptions.TestCases.IgnoreScriptTest|"ignore">__EXCEPTION__:IGNORE_SCRIPT_TEST:|"ignore">Test not run[\s\S]*2\+2=4 | +|PowerSlim.OriginalMode.SuiteExceptions.TestCases.IgnoreAllTests |"ignore">__EXCEPTION__:IGNORE_ALL_TESTS: |"This table is ignored"\W+td\W+tr | diff --git a/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/ScenarioLibrary/content.txt b/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/ScenarioLibrary/content.txt index 1737ec2..57bbab6 100644 --- a/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/ScenarioLibrary/content.txt +++ b/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/ScenarioLibrary/content.txt @@ -1,9 +1,9 @@ Create the function used to run the tests. It runs the page in a nested !-FitNesse-! through the command line (no HTTP server required) and returns the XML report. -The nested run uses its own SLIM_PORT so it does not collide with the !-PowerSlim-! instance running this page. +The nested run talks to its own !-PowerSlim-! over stdin/stdout, so it never collides with the instance running this page. |script| |eval |!- function global:Run-Test ($page) { - $output = java -jar fitnesse-standalone.jar -o -p 8024 -c "$page&format=xml&SLIM_PORT=8091" | Out-String + $output = java -jar fitnesse-standalone.jar -o -p 8024 -c "$page&format=xml" | Out-String # [char]60 is the less-than sign; a literal XML declaration in this cell would be parsed as markup by FitNesse $start = $output.IndexOf([char]60 + '?xml') if ($start -lt 0) { throw "No XML report for '$page': $output" } diff --git a/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/TestCases/IgnoreAllTests/content.txt b/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/TestCases/IgnoreAllTests/content.txt new file mode 100644 index 0000000..ce62867 --- /dev/null +++ b/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/TestCases/IgnoreAllTests/content.txt @@ -0,0 +1,10 @@ +This test verifies that {{{throw 'IgnoreAllTests'}}} ignores the rest of the page. + +Note: This test page is intentionally disabled and run from the ExceptionTestRunner page. + +| script | +| eval | throw 'IgnoreAllTests:Nothing to test here' | +| show | eval | "This row is ignored" | + +| script | +| show | eval | "This table is ignored" | diff --git a/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/TestCases/IgnoreAllTests/properties.xml b/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/TestCases/IgnoreAllTests/properties.xml new file mode 100644 index 0000000..266fef9 --- /dev/null +++ b/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/TestCases/IgnoreAllTests/properties.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/TestCases/IgnoreScriptTest/content.txt b/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/TestCases/IgnoreScriptTest/content.txt new file mode 100644 index 0000000..c034729 --- /dev/null +++ b/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/TestCases/IgnoreScriptTest/content.txt @@ -0,0 +1,11 @@ +This test verifies that {{{throw 'IgnoreScriptTest'}}} ignores the rest of the script table, and that the next table runs. + +Note: This test page is intentionally disabled and run from the ExceptionTestRunner page. + +| script | +| show | eval | "This row runs" | +| eval | throw 'IgnoreScriptTest:Not on this machine' | +| show | eval | "This row is ignored" | + +| script | +| check | eval | "2+2=$(2+2)" | =~/4/ | diff --git a/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/TestCases/IgnoreScriptTest/properties.xml b/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/TestCases/IgnoreScriptTest/properties.xml new file mode 100644 index 0000000..266fef9 --- /dev/null +++ b/FitNesseRoot/PowerSlim/OriginalMode/SuiteExceptions/TestCases/IgnoreScriptTest/properties.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/FitNesseRoot/PowerSlim/OriginalMode/SuiteRemoting/SymbolSharing/TestNumeric/content.txt b/FitNesseRoot/PowerSlim/OriginalMode/SuiteRemoting/SymbolSharing/TestNumeric/content.txt index d379529..3eb8152 100644 --- a/FitNesseRoot/PowerSlim/OriginalMode/SuiteRemoting/SymbolSharing/TestNumeric/content.txt +++ b/FitNesseRoot/PowerSlim/OriginalMode/SuiteRemoting/SymbolSharing/TestNumeric/content.txt @@ -6,7 +6,7 @@ !|script|Remote|${REMOTE_SERVER}| |check|eval|$NUM.GetType()|System.Int32| -|check|eval|$DECIMAL.GetType()|System.Int32| +|check|eval|$DECIMAL.GetType()|System.Double| *! !* String should be string !|script|Remote|${REMOTE_SERVER}| diff --git a/FitNesseRoot/PowerSlim/OriginalMode/SuiteSymbols/TestQuerySymbolAssignment/content.txt b/FitNesseRoot/PowerSlim/OriginalMode/SuiteSymbols/TestQuerySymbolAssignment/content.txt new file mode 100644 index 0000000..4330f18 --- /dev/null +++ b/FitNesseRoot/PowerSlim/OriginalMode/SuiteSymbols/TestQuerySymbolAssignment/content.txt @@ -0,0 +1,8 @@ +''A query table cell can store the value it finds in a symbol (SLIM assign instruction)'' +!|Query:Local|@{Name = 'Bob'; Id = 7}| +|Name|Id | +|Bob |$QueryId=| + +!|script| +|check|eval|$QueryId|7| +|check|eval|"Id $QueryId"|Id 7| diff --git a/FitNesseRoot/PowerSlim/OriginalMode/SuiteSymbols/TestQuerySymbolAssignment/properties.xml b/FitNesseRoot/PowerSlim/OriginalMode/SuiteSymbols/TestQuerySymbolAssignment/properties.xml new file mode 100644 index 0000000..e13fdf4 --- /dev/null +++ b/FitNesseRoot/PowerSlim/OriginalMode/SuiteSymbols/TestQuerySymbolAssignment/properties.xml @@ -0,0 +1,12 @@ + + + true + true + true + true + true + true + true + true + true + diff --git a/FitNesseRoot/PowerSlim/OriginalMode/SuiteSymbols/TestSymbolValuesAreData/content.txt b/FitNesseRoot/PowerSlim/OriginalMode/SuiteSymbols/TestSymbolValuesAreData/content.txt new file mode 100644 index 0000000..d2d4f42 --- /dev/null +++ b/FitNesseRoot/PowerSlim/OriginalMode/SuiteSymbols/TestSymbolValuesAreData/content.txt @@ -0,0 +1,14 @@ +''A symbol is also a !-PowerShell-! variable. Its value is never pasted into the code: quotes, $ and ; in it stay text'' +!|script| +|$Quoted=|eval|"it's a ""quote"" and `$notAVariable; exit"| +|check|eval|$Quoted|it's a "quote" and $notAVariable; exit| +|check|eval|"[$Quoted]"|[it's a "quote" and $notAVariable; exit]| +|check|eval|'[$Quoted]'|[it's a "quote" and $notAVariable; exit]| +|check|eval|$Quoted.Length|38| + +''Symbols keep their type'' +!|script| +|$Number=|eval|41| +|check|eval|$Number + 1|42| +|$List=|eval|@(1, 2, 3)| +|check|eval|$List.Count|3| diff --git a/FitNesseRoot/PowerSlim/OriginalMode/SuiteSymbols/TestSymbolValuesAreData/properties.xml b/FitNesseRoot/PowerSlim/OriginalMode/SuiteSymbols/TestSymbolValuesAreData/properties.xml new file mode 100644 index 0000000..e13fdf4 --- /dev/null +++ b/FitNesseRoot/PowerSlim/OriginalMode/SuiteSymbols/TestSymbolValuesAreData/properties.xml @@ -0,0 +1,12 @@ + + + true + true + true + true + true + true + true + true + true + diff --git a/FitNesseRoot/PowerSlim/OriginalMode/content.txt b/FitNesseRoot/PowerSlim/OriginalMode/content.txt index db03ebf..a89a05d 100644 --- a/FitNesseRoot/PowerSlim/OriginalMode/content.txt +++ b/FitNesseRoot/PowerSlim/OriginalMode/content.txt @@ -1,5 +1,4 @@ !define TEST_SYSTEM {slim} -!define SLIM_PORT (8090) !define COMMAND_PATTERN (pwsh -NoProfile -NonInteractive -File ./slim.ps1) !define HOST_VERSION (5\.1|7\.\d+) !contents -R2 -g -p -f -h \ No newline at end of file diff --git a/FitNesseRoot/PowerSlim/SuiteREST/content.txt b/FitNesseRoot/PowerSlim/SuiteREST/content.txt index 4b5eeb9..f2b48b3 100644 --- a/FitNesseRoot/PowerSlim/SuiteREST/content.txt +++ b/FitNesseRoot/PowerSlim/SuiteREST/content.txt @@ -1,4 +1,3 @@ !define TEST_SYSTEM {slim} -!define SLIM_PORT (8090) !define COMMAND_PATTERN (pwsh -NoProfile -NonInteractive -File ./slim.ps1) !contents -R2 -g -p -f -h \ No newline at end of file diff --git a/FitNesseRoot/PowerSlim/SuiteWrongFailedException/content.txt b/FitNesseRoot/PowerSlim/SuiteWrongFailedException/content.txt index 247e9e5..7b437ba 100644 --- a/FitNesseRoot/PowerSlim/SuiteWrongFailedException/content.txt +++ b/FitNesseRoot/PowerSlim/SuiteWrongFailedException/content.txt @@ -1,5 +1,4 @@ !define TEST_SYSTEM {slim} -!define SLIM_PORT (8090) !define COMMAND_PATTERN (pwsh -NoProfile -NonInteractive -File ./slim.ps1) '''Each test in this suite should fail by purpose''' !contents -R2 -g -p -f -h \ No newline at end of file diff --git a/README.md b/README.md index b2d903a..f2ca979 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,24 @@ https://powerslim.slack.com ``` !define TEST_SYSTEM {slim} -!define SLIM_PORT (8090) !define COMMAND_PATTERN (pwsh -NoProfile -NonInteractive -File ./slim.ps1) ``` Requires PowerShell 7+ (`pwsh`) on any platform, or Windows PowerShell 5.1 (use `powershell` instead of `pwsh` in `COMMAND_PATTERN`). PowerShell 2.0–5.0 are no longer supported. +PowerSlim implements [SLIM protocol](https://fitnesse.org/FitNesse/UserGuide/WritingAcceptanceTests/SliM/SlimProtocol.html) version 0.5. FitNesse talks to it over stdin/stdout by default, so no port is needed. Defining `SLIM_PORT` (e.g. `!define SLIM_PORT (8090)`) switches to a TCP port on `127.0.0.1`, as before. + +### Symbols +A symbol (`|$name=|eval|...|`) is also a PowerShell variable, so `$name` in code gets its value with its type, and the value is never pasted into the code. FitNesse's text rules still apply where PowerShell would not expand a variable: inside single-quoted strings (`'$name'`), for a name followed by more letters (`"$name_suffix"`) and in command names (`te$name`). + +### Aborting and ignoring +`throw 'StopTest'` stops the page and `throw 'StopSuite'` the suite. `throw 'IgnoreScriptTest'` ignores the rest of the script table and `throw 'IgnoreAllTests'` the rest of the page. + ### Running the tests Requires Java 11+ and [Pester](https://pester.dev) 5+. ``` ./tests/Invoke-AcceptanceTests.ps1 # FitNesse suites (downloads the pinned FitNesse jar) ./tests/Invoke-AcceptanceTests.ps1 -Shell powershell # same, with Windows PowerShell 5.1 running slim.ps1 -Invoke-Pester ./tests # unit tests for the SLIM protocol helpers +Invoke-Pester ./tests # unit tests for slim.ps1 and client.ps1 Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 ``` CI runs all of these on Linux, macOS and Windows (PowerShell 7 and 5.1). @@ -40,7 +47,7 @@ Examples are available in the acceptance tests [FitNesseRoot/PowerSlim](https:// ### March 1, 2017 * [Fitnesse 20161106](http://fitnesse.org/.FrontPage.FitNesseDevelopment.FitNesseRelease20161106) introduced bracking change. _FitNesse and Slim can now communicate over stdin/stdout. This removes the hassle with network ports [977](https://github.com/unclebob/fitnesse/pull/977)_ - You need to set [SLIM_PORT](https://github.com/konstantinvlasenko/PowerSlim/blob/02dc82325d639123874beebbeb5229ba202f867b/FitNesseRoot/PowerSlim/OriginalMode/content.txt#L2) variable to make PowerSlim work again + PowerSlim now supports stdin/stdout, so `SLIM_PORT` is no longer needed. ### October 7, 2016 diff --git a/client.ps1 b/client.ps1 index e93014b..837e71f 100644 --- a/client.ps1 +++ b/client.ps1 @@ -4,20 +4,12 @@ # you can do whatever you want with this stuff. If we meet some day, and you # think this stuff is worth it, you can buy me a beer in return. # +# Remote tables: |script|Remote|host[:port],...| and |Query:Remote|host|code| forward their +# instructions to PowerSlim agents (slim.ps1 server) and share symbols in both directions. # the timeout for the Remote server is 1 hour $REMOTE_SERVER_READ_TIMEOUT = 60*60*1000 -function Get-RemoteSlimSymbols($inputTable) -{ - $__pattern__ = '(?scriptTable_\d+_\d+):\d{6}:callAndAssign:\d{6}:(?\w+):\d{6}:' - $inputTable | select-string $__pattern__ -allmatches | ForEach-Object {$_.matches} | ForEach-Object {@{id=$_.Groups[1].Value;name=$_.Groups[2].Value}} -} - -function Is-Numeric ($Value) { - return $Value -match "^\d+(\.\d+)?$" -} - # Port used for remote targets given without ":port". Override with $env:POWERSLIM_REMOTE_PORT, # e.g. to use an unprivileged port (ports below 1024 need root on Linux; on macOS they can only bind 0.0.0.0) function script:Get-RemoteDefaultPort { if($env:POWERSLIM_REMOTE_PORT){ [int]$env:POWERSLIM_REMOTE_PORT } else { 35 } } @@ -38,115 +30,129 @@ function script:Connect-RemoteSlim($ps_computer, $ps_port) } } } -function script:process_table_remotely($ps_table, $ps_fitnesse){ - - try { - - $originalslimbuffer = $ps_buf1 + $ps_buf2 - - $result = new-Object 'system.collections.generic.dictionary[string,object]' - - foreach($t in $targets){ - - $ps_computer, $ps_port = $t.split(':') - if($ps_computer.StartsWith('$')){ - $ps_computer = $slimsymbols[$ps_computer.Substring(1)] - } - - if($null -eq $ps_port){$ps_port = Get-RemoteDefaultPort}; - - Write-Verbose "Connecting to $ps_computer, $ps_port" - - if($slimsymbols.Count -ne 0){ - - "Connecting to $ps_computer $ps_port" | Out-Default - - $ps_sumbols_client = Connect-RemoteSlim $ps_computer $ps_port - $remoteserver = $ps_sumbols_client.GetStream() - $remoteserver.ReadTimeout = $REMOTE_SERVER_READ_TIMEOUT - - "Connected" | Out-Default - - $list = @($slimsymbols.GetEnumerator() | ForEach-Object {$_}) - $tr = "[" + (slimlen $list) + ":" - - foreach ($obj in $list){ - - $itemstr = "[" + (6).ToString("d6") + ":" +# The first instruction of a script or query table says where the table runs. Other tables +# (decision tables, scenarios) keep the current context. +function script:Update-RemoteContext($Instructions) { + if (!$Instructions.Count -or $Instructions[0] -is [hashtable] -or !$Instructions[0].Count) { return } + $first = $Instructions[0] + if ($first[0].StartsWith("scriptTable_") -or $first[0].StartsWith("queryTable_")) { + if ($first.Count -ge 5 -and "Remote" -eq $first[3]) { Set-RemoteTargets $first[4] } + else { $script:Remote = $false } + } +} - $itemstr += (slimlen 'scriptTable_0_0') + ":scriptTable_0_0:" + (slimlen 'callAndAssign') + ":callAndAssign:" - $itemstr += (slimlen $obj.Key) + ":$($obj.Key):" + (slimlen 'scriptTableActor') + ":scriptTableActor:" - $itemstr += (slimlen 'eval') + ":eval:" +# Targets are a comma-separated host[:port] list or, for anything else, an expression that returns them +function script:Set-RemoteTargets([string]$Cell) { + $script:Remote = $true + $targets = $null + if ($Cell -notmatch '^\s*[\w.\-]+(:\d+)?(\s*,\s*[\w.\-]+(:\d+)?)*\s*$') { + try { $targets = Invoke-UserCode (Expand-SlimSymbols $Cell) } + catch { Write-Verbose "Remote targets are not an expression: $($_.Exception.Message)" } + (Get-UserErrors).Clear() + } + if ($null -eq $targets) { $targets = $Cell.Split(",") | ForEach-Object { $_.Trim(", ") } | Where-Object { $_ } } + $script:targets = @($targets | ForEach-Object { "$_".Trim() }) +} - if($obj.Value -is [string]){ - $itemstr += (($obj.Value.Length + 2).ToString("d6")) + ":'$($obj.Value)':" - }elseif($obj.Value -is [int]){ - $itemstr += ((([string]$obj.Value).Length).ToString("d6")) + ":$($obj.Value):" - }else{ - $itemstr += ((3 + 2).ToString("d6")) + ":'NOT':" - } - - $itemstr += "]" - - $tr += (slimlen $itemstr) + ":" + $itemstr + ":" - } +# PowerShell code that recreates a symbol value on an agent, or $null for values that cannot be sent. +# Strings travel as base64 so no quote, $ or symbol name in them is ever read as code. +function script:ConvertTo-PowerShellLiteral($Value) { + $invariant = [Globalization.CultureInfo]::InvariantCulture + if ($Value -is [string]) { + return "[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{0}'))" -f [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Value)) + } + if ($Value -is [bool]) { if ($Value) { return '$true' } else { return '$false' } } + if ($Value -is [int] -or $Value -is [long]) { return $Value.ToString($invariant) } + if ($Value -is [double]) { return "[double]'{0}'" -f $Value.ToString('R', $invariant) } + $null +} - $tr += "]" - - $s2 = [text.encoding]::utf8.getbytes($tr).Length.ToString("d6") + ":" + $tr - $s2 = [text.encoding]::utf8.getbytes($s2) +# Remote results arrive as text; numbers become numbers again +function script:ConvertFrom-RemoteValue([string]$Text) { + $invariant = [Globalization.CultureInfo]::InvariantCulture + $int = 0; $long = 0L; $double = 0.0 + if ($Text -match '^\d+$') { + if ([int]::TryParse($Text, [Globalization.NumberStyles]::None, $invariant, [ref]$int)) { return $int } + if ([long]::TryParse($Text, [Globalization.NumberStyles]::None, $invariant, [ref]$long)) { return $long } + } + elseif ($Text -match '^\d+\.\d+$' -and [double]::TryParse($Text, [Globalization.NumberStyles]::AllowDecimalPoint, $invariant, [ref]$double)) { + return $double + } + $Text +} - $remoteserver.Write($s2, 0, $s2.Length) - get_message($remoteserver) +# Instructions that recreate our symbols on an agent, except those that came from it: it still has +# the original objects, and their text form would replace them +function script:Get-SymbolSyncMessage([string]$Origin) { + $instructions = New-Object System.Collections.Generic.List[object] + foreach ($entry in @($slimsymbols.GetEnumerator())) { + if ($script:SymbolOrigins.ContainsKey($entry.Key) -and $script:SymbolOrigins[$entry.Key] -eq $Origin) { continue } + $literal = ConvertTo-PowerShellLiteral $entry.Value + if ($null -eq $literal) { continue } + $instructions.Add([object[]]@("symbol_$($instructions.Count)", 'callAndAssign', $entry.Key, 'scriptTableActor', 'eval', $literal)) + } + if ($instructions.Count) { ConvertTo-SlimString $instructions } +} - } +# Symbols assigned on the agent (callAndAssign) are copied back from its response +function script:Receive-RemoteSymbols($Instructions, [string]$Response, [string]$Origin) { + $assigned = @{} + foreach ($ins in $Instructions) { + if ($ins -isnot [hashtable] -and $ins.Count -ge 3 -and $ins[1] -eq 'callAndAssign') { $assigned[$ins[0]] = $ins[2] } + } + if (!$assigned.Count) { return } + foreach ($item in (ConvertFrom-SlimList $Response)) { + try { $pair = ConvertFrom-SlimList $item } catch { continue } + if ($pair.Count -ne 2 -or !$assigned.ContainsKey($pair[0])) { continue } + $name = $assigned[$pair[0]] + # An agent sends nothing back for complex values; forget the symbol rather than store an empty value, + # so it is not sent over the agent's own variable later (issue #83) + if ([string]::IsNullOrEmpty($pair[1]) -or $pair[1].StartsWith($slimexception)) { + [void]$slimsymbols.Remove($name) + [void]$script:SymbolOrigins.Remove($name) + continue + } + Set-SlimSymbol $name (ConvertFrom-RemoteValue $pair[1]) -Origin $Origin + } +} - $ps_client = Connect-RemoteSlim $ps_computer $ps_port - $remoteserver = $ps_client.GetStream() - $remoteserver.ReadTimeout = $REMOTE_SERVER_READ_TIMEOUT - - $remoteserver.Write($originalslimbuffer, 0, $originalslimbuffer.Length) - $result[$ps_computer] = get_message($remoteserver) - - #$result[$ps_computer] | out-default - - #backward symbols sharing - foreach($symbol in Get-RemoteSlimSymbols([text.encoding]::utf8.getstring($originalslimbuffer, 0, $originalslimbuffer.Length))) { - $__pattern__ = "$($symbol.id):\d{6}:(?.*?):\]" - $slimsymbols[$symbol.name] = $result[$ps_computer] | select-string $__pattern__ | ForEach-Object {$_.matches} | ForEach-Object { - if(Is-Numeric $_.Groups[1].Value){ - try { [int]$_.Groups[1].Value } catch { Write-Verbose "Not an Int32, symbol dropped: $($_.Exception.Message)" } - }else{ - $_.Groups[1].Value - } - } - # remove symbol if it get's empty value from remote agent - # this will fix the issue #83 - # but in general this is incorrect as the issue with sharing back the complex objects. remote agent sends nothing - # the issue #83 would be fixed because we woudn't override a Powershell variable at the original agent. - if(!$slimsymbols[$symbol.name]){ $slimsymbols.Remove($symbol.name) } - } - - $remoteserver.Close() - $ps_client.Close() +function script:Send-RemoteMessage($Computer, $Port, [string]$Message) { + $client = Connect-RemoteSlim $Computer $Port + try { + $stream = $client.GetStream() + Write-SlimMessage $stream $Message + $reply = Read-SlimMessage $stream -FirstByteTimeout $REMOTE_SERVER_READ_TIMEOUT -Timeout $REMOTE_SERVER_READ_TIMEOUT + if ($null -eq $reply) { throw "The remote agent ${Computer}:$Port closed the connection without answering" } + $reply + } + finally { $client.Close() } +} +# Runs the table on every target and answers with the last target's response +function script:Invoke-RemoteInstructions($Instructions, [string]$Message) { + try { + $response = $null + foreach ($target in $script:targets) { + $ps_computer, $ps_port = $target.Split(':') + if ($ps_computer.StartsWith('$')) { $ps_computer = $slimsymbols[$ps_computer.Substring(1)] } + if ($null -eq $ps_port) { $ps_port = Get-RemoteDefaultPort } + $origin = "${ps_computer}:$ps_port" + + $sync = Get-SymbolSyncMessage $origin + if ($sync) { + Write-Log "Sending symbols to $origin" + [void](Send-RemoteMessage $ps_computer $ps_port $sync) } - - #if($result.Count -eq 1){ - - $res = $ps_buf1 + $ps_buf2 - $ps_fitnesse.Write($res, 0, $res.Length) - - #} - - } - catch [System.Exception] { - $send = '[000002:' + (slimlen $ps_table[0][0]) + ':' + $ps_table[0][0] + ':' + (slimlen "$slimexception$($_.Exception.Message)") + ':' + "$slimexception$($_.Exception.Message)" + ':]' - $send = (slimlen $send) + ":" + $send + ":" - $send = [text.encoding]::utf8.getbytes((pack_results $send)) - $ps_fitnesse.Write($send, 0, $send.Length) + $response = Send-RemoteMessage $ps_computer $ps_port $Message + Receive-RemoteSymbols $Instructions $response $origin } + $response + } + catch { + $id = if ($Instructions.Count -and $Instructions[0] -isnot [hashtable] -and $Instructions[0].Count) { $Instructions[0][0] } else { '' } + ConvertTo-SlimString @(, [object[]]@($id, "$slimexception$($_.Exception.Message)")) + } } function script:Test-TcpPort($ps_remotehost, $ps_port) diff --git a/slim.ps1 b/slim.ps1 index 4578dde..d9a7820 100644 --- a/slim.ps1 +++ b/slim.ps1 @@ -1,12 +1,23 @@ -###################### -# PowerSlim 20170303 # -###################### +############# +# PowerSlim # +############# #Requires -Version 5.1 +<# +.SYNOPSIS + SLIM server for FitNesse that runs PowerShell. + Protocol: https://fitnesse.org/FitNesse/UserGuide/WritingAcceptanceTests/SliM/SlimProtocol.html + +.EXAMPLE + ./slim.ps1 1 # runner on stdin/stdout (what FitNesse starts when SLIM_PORT is not defined) + ./slim.ps1 8090 # runner on a TCP port (SLIM_PORT defined) + ./slim.ps1 35 server # remote agent +#> [CmdletBinding()] Param( + # 1 means stdin/stdout, as in the Java SlimService; any other value is a TCP port [Parameter(Mandatory=$True,Position=0)] [int]$Port, - + [Parameter(Mandatory=$False, Position=1)] [string]$Mode="runner", @@ -17,167 +28,366 @@ Param( [string]$BindAddress="127.0.0.1" ) -$slimver = "Slim -- V0.3`n" -$slimnull = "000004:null:" -#$slimvoid = "/__VOID__/" +$slimver = "Slim -- V0.5`n" +$SLIM_PIPE_PORT = 1 $slimvoid = "" $slimexception = "__EXCEPTION__:" $slimsymbols = new-Object 'system.collections.generic.dictionary[string,object]' -#$slimbuffer = new-object byte[] 102400 -#$slimbuffersize = 0 -#$VerbosePreference="Continue" +# Remote agent ("host:port") each symbol was received from, so it is not sent back and overwritten there +$script:SymbolOrigins = new-Object 'system.collections.generic.dictionary[string,string]' -#Support for slow connection -#$ps_stream.ReadTimeout = 10000 #idea is that the client should send data, so the stream is in the read mode. We can wait 10 seconds or more? +# How long the rest of a message may take once its first byte arrived (and how long a remote agent waits for one) $REQUEST_READ_TIMEOUT = 10000 $script:SLIM_ABORT_TEST = $false -$script:SLIM_ABORT_SUITE = $false +$script:SLIM_ABORT_TAG = 'ABORT_SLIM_TEST' +$script:SLIM_STOP_BATCH = $false +$script:Remote = $false +$script:SlimScript = '' +$script:QueryFormat__ = '{0}' +$script:EvalFormat__ = '{0}' $script:POWERSLIM_PATH = $MyInvocation.MyCommand.Path $script:POWERSLIM_HOME = Split-Path -Parent $MyInvocation.MyCommand.Path -$script:NonTerminatingIsException = $false - -function Get-SlimTable($slimchunk){ - - $ps_exp = $slimchunk -replace "'","''" -replace "000000::","000000:blank:" -replace "(?S):\d{6}:(.*?)(?=(:\d{6}:|:\]))",',''$1''' -replace "'(\[\d{6})'", '$1' -replace ":\d{6}:", "," -replace ":\]", ")" -replace "\[\d{6},", "(" -replace "'blank'", "''" +# Test code runs in the global scope of its own runspace (see Get-UserRunspace) +$script:UserRunspace = $null +$script:UserErrors = $null +$script:QuietProgress = $false +# Runs a table cell: global scope, so variables and functions persist between instructions without +# rewriting the code, like Invoke-Expression did at the script scope. Failures come back as one object +# because a statement-terminating error (e.g. 1/0) would not otherwise stop PowerShell.Invoke(). +$script:UserCodeRunner = @' +try { Invoke-Expression $args[0] } +catch { [pscustomobject]@{ PSTypeName = 'PowerSlim.Failure'; ErrorRecord = $_ } } +'@ + +# A symbol reference as FitNesse writes it: $ followed by a letter and word characters +$script:SymbolReference = [regex]'\$(\p{L}\w*)' + +#region SLIM serialization + +function New-SlimSyntaxError([string]$Reason) { + New-Object System.FormatException "Malformed SLIM list: $Reason" +} + +# Serializes a string or a (nested) list. Lengths count UTF-16 code units, as the protocol requires. +function ConvertTo-SlimString($Value) { + if ($null -eq $Value) { return 'null' } + if ($Value -is [string]) { return $Value } + if ($Value -is [System.Collections.IList]) { + $text = New-Object System.Text.StringBuilder + [void]$text.Append('[').Append($Value.Count.ToString('d6')).Append(':') + foreach ($item in $Value) { + $s = ConvertTo-SlimString $item + [void]$text.Append($s.Length.ToString('d6')).Append(':').Append($s).Append(':') + } + [void]$text.Append(']') + return $text.ToString() + } + [string]$Value +} + +# Reads "dddddd:" at $Index (six digits or more) and moves $Index past the colon +function Read-SlimLength([string]$Text, [ref]$Index) { + $start = $Index.Value + $i = $start + while ($i -lt $Text.Length -and [int]$Text[$i] -ge 48 -and [int]$Text[$i] -le 57) { $i++ } + if ($i - $start -lt 6 -or $i -ge $Text.Length -or $Text[$i] -ne [char]':') { + throw (New-SlimSyntaxError "expected a length of six or more digits and a colon at position $start") + } + $Index.Value = $i + 1 + [int]::Parse($Text.Substring($start, $i - $start), [Globalization.CultureInfo]::InvariantCulture) +} + +# Parses one SLIM list into the strings it holds. Items are not parsed further: whether an item is a +# nested list depends on where it is used, and a code cell that happens to look like a list stays code. +function ConvertFrom-SlimList([string]$Text) { + if ([string]::IsNullOrEmpty($Text) -or $Text[0] -ne [char]'[') { throw (New-SlimSyntaxError 'a list starts with [') } + $i = 1 + $count = Read-SlimLength $Text ([ref]$i) + $items = New-Object 'System.Collections.Generic.List[string]' + for ($n = 0; $n -lt $count; $n++) { + $length = Read-SlimLength $Text ([ref]$i) + if ($i + $length -ge $Text.Length -or $Text[$i + $length] -ne [char]':') { + throw (New-SlimSyntaxError "item $n is not terminated by a colon") + } + $items.Add($Text.Substring($i, $length)) + $i += $length + 1 + } + if ($i -ne $Text.Length - 1 -or $Text[$i] -ne [char]']') { throw (New-SlimSyntaxError "expected ] after $count items") } + , $items.ToArray() +} - $script:ps_table = Invoke-Expression $ps_exp +# An instruction list: each item is one instruction, itself a list of strings +function ConvertFrom-SlimInstructions([string]$Message) { + $instructions = New-Object System.Collections.Generic.List[object] + foreach ($item in (ConvertFrom-SlimList $Message)) { + try { $instructions.Add([string[]](ConvertFrom-SlimList $item)) } + catch { $instructions.Add(@{ Malformed = $item }) } + } + , $instructions.ToArray() } +#endregion -function Test-OneRowTable($ps_table){ - !($ps_table[0] -is [array]) -} +#region Transport -function SlimException-NoClass($ps_class){ - $slimexception + "NO_CLASS " + $ps_class +function Set-ReadTimeout($Stream, [int]$Milliseconds) { + if ($Stream.CanTimeout) { $Stream.ReadTimeout = $Milliseconds } } -function SlimException-CMD_NOT_FOUND($ps_cmd){ - $slimexception + "COMMAND_NOT_FOUND " + $ps_cmd +function Read-SlimBytes($Stream, [int]$Count) { + $buffer = New-Object byte[] $Count + $offset = 0 + while ($offset -lt $Count) { + $read = $Stream.Read($buffer, $offset, $Count - $offset) + if ($read -le 0) { throw (New-Object System.IO.EndOfStreamException "Connection closed after $offset of $Count bytes") } + $offset += $read + } + , $buffer } -new-alias noclass SlimException-NoClass -new-alias nocommand SlimException-CMD_NOT_FOUND - -function Get-SlimLength($ps_obj){ - if($ps_obj -is [array]){ - $ps_obj.Count.ToString("d6") +# Reads one length-prefixed message (the length counts UTF-8 bytes). Returns $null when the peer closed +# the connection before sending anything. -FirstByteTimeout -1 waits for the next message indefinitely. +function Read-SlimMessage($Stream, [int]$FirstByteTimeout = $REQUEST_READ_TIMEOUT, [int]$Timeout = $REQUEST_READ_TIMEOUT) { + $digits = New-Object System.Text.StringBuilder + $one = New-Object byte[] 1 + Set-ReadTimeout $Stream $FirstByteTimeout + while ($true) { + if ($Stream.Read($one, 0, 1) -le 0) { + if ($digits.Length -eq 0) { return $null } + throw (New-Object System.IO.EndOfStreamException "Connection closed while reading the message length") + } + if ($digits.Length -eq 0) { Set-ReadTimeout $Stream $Timeout } + # A .NET Framework client writes a UTF-8 BOM first when its console encoding is UTF-8 + if ($digits.Length -eq 0 -and $one[0] -eq 0xEF) { + $rest = Read-SlimBytes $Stream 2 + if ($rest[0] -eq 0xBB -and $rest[1] -eq 0xBF) { continue } + throw (New-Object System.IO.InvalidDataException "Not a SLIM message: the length prefix contains byte 239") + } + if ($one[0] -eq 58) { break } # ':' + if ($one[0] -lt 48 -or $one[0] -gt 57 -or $digits.Length -ge 10) { + throw (New-Object System.IO.InvalidDataException "Not a SLIM message: the length prefix contains byte $($one[0])") + } + [void]$digits.Append([char]$one[0]) + } + if ($digits.Length -lt 6) { throw (New-Object System.IO.InvalidDataException "Not a SLIM message: the length prefix '$digits' is shorter than six digits") } + $bytes = Read-SlimBytes $Stream ([int]::Parse($digits.ToString(), [Globalization.CultureInfo]::InvariantCulture)) + [Text.Encoding]::UTF8.GetString($bytes) +} + +function Write-SlimMessage($Stream, [string]$Text) { + $body = [Text.Encoding]::UTF8.GetBytes($Text) + $head = [Text.Encoding]::ASCII.GetBytes($body.Length.ToString('d6') + ':') + $Stream.Write($head, 0, $head.Length) + $Stream.Write($body, 0, $body.Length) + $Stream.Flush() +} + +# FitNesse reads SLIM responses from our stdout, so nothing else may write there. Keep private handles to +# stdin/stdout for the protocol and point the process's standard output at stderr (FitNesse adds stderr to +# the execution log); child processes started by tests then inherit stderr as their stdout too. +function Initialize-StdioTransport { + $in = [Console]::OpenStandardInput() + $out = [Console]::OpenStandardOutput() + try { + if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { + Add-Type -Namespace PowerSlim -Name StdHandles -MemberDefinition @' +[DllImport("kernel32.dll", SetLastError = true)] public static extern System.IntPtr GetStdHandle(int nStdHandle); +[DllImport("kernel32.dll", SetLastError = true)] public static extern bool SetStdHandle(int nStdHandle, System.IntPtr handle); +'@ + [void][PowerSlim.StdHandles]::SetStdHandle(-11, [PowerSlim.StdHandles]::GetStdHandle(-12)) + try { + # Best effort: children reading stdin would otherwise take SLIM messages + $script:NullInput = [IO.File]::Open('NUL', 'Open', 'Read') + [void][PowerSlim.StdHandles]::SetStdHandle(-10, $script:NullInput.SafeFileHandle.DangerousGetHandle()) + } catch { Write-Verbose "stdin stays shared with child processes: $($_.Exception.Message)" } + } + else { + # .NET already holds duplicates of fd 0 and 1 in $in and $out, so the originals can be replaced + Add-Type -Namespace PowerSlim -Name Libc -MemberDefinition @' +[DllImport("libc", SetLastError = true)] public static extern int dup2(int oldfd, int newfd); +[DllImport("libc", SetLastError = true)] public static extern int open(string path, int flags); +'@ + [void][PowerSlim.Libc]::dup2(2, 1) + $null_fd = [PowerSlim.Libc]::open('/dev/null', 0) + if ($null_fd -ge 0) { [void][PowerSlim.Libc]::dup2($null_fd, 0) } + } } - elseif($ps_obj -is 'system.collections.generic.keyvaluepair[string,object]'){ - (1).ToString("d6") + catch { + [Console]::Error.WriteLine("PowerSlim: child processes may still write to the SLIM channel: $($_.Exception.Message)") + } + $stderr = New-Object System.IO.StreamWriter([Console]::OpenStandardError(), (New-Object System.Text.UTF8Encoding $false)) + $stderr.AutoFlush = $true + [Console]::SetOut($stderr) + # Progress bars and ANSI colors would only clutter the execution log + $global:ProgressPreference = 'SilentlyContinue' + $script:QuietProgress = $true + $style = Get-Variable PSStyle -ValueOnly -ErrorAction Ignore + if ($style) { $style.OutputRendering = 'PlainText' } + @{ In = $in; Out = $out } +} + +#endregion + +#region Symbols and user code + +# Test code gets its own runspace (sharing this process and host): its variables, functions and $Error +# are its own, so a page cannot overwrite PowerSlim's state and PowerSlim's errors never reach the page +function Get-UserRunspace { + if ($null -eq $script:UserRunspace) { + $runspace = [runspacefactory]::CreateRunspace($Host) + $runspace.ThreadOptions = 'UseCurrentThread' + $runspace.Open() + $script:UserRunspace = $runspace + $script:UserErrors = $runspace.SessionStateProxy.GetVariable('Error') + if ($script:QuietProgress) { Set-UserVariable 'ProgressPreference' 'SilentlyContinue' } + # Helpers pages may call + foreach ($name in 'Test-TcpPort', 'Wait-RemoteServer') { + $helper = Get-Command $name -CommandType Function -ErrorAction Ignore + if ($helper) { [void](Invoke-UserCode "function global:$name {$($helper.Definition)}") } + } } - else { - $ps_obj.ToString().Length.ToString("d6") + $script:UserRunspace +} + +# The test code's $Error +function Get-UserErrors { + [void](Get-UserRunspace) + , $script:UserErrors +} + +# Runs test code and writes its output. A terminating error is rethrown after the output that came before it. +function Invoke-UserCode([string]$Code, [switch]$MergeErrors) { + $runspace = Get-UserRunspace + $failure = $null + $ps = [powershell]::Create() + try { + $ps.Runspace = $runspace + [void]$ps.AddScript($script:UserCodeRunner, $false).AddArgument($Code) + if ($MergeErrors) { $ps.Commands.Commands[0].MergeMyResults('Error', 'Output') } + try { $output = $ps.Invoke() } + catch { throw $_.Exception.InnerException } # e.g. exit, or break outside a loop + foreach ($item in $output) { + if ($null -ne $item -and $item.PSObject.TypeNames -contains 'PowerSlim.Failure') { $failure = $item.ErrorRecord } + else { $item } + } } + finally { $ps.Dispose() } + if ($failure) { throw $failure } } -new-alias slimlen Get-SlimLength -function ischunk($ps_msg){ - $ps_msg.StartsWith('[') -and $ps_msg.EndsWith(']') -} +function Get-UserVariable([string]$Name) { (Get-UserRunspace).SessionStateProxy.GetVariable($Name) } +function Set-UserVariable([string]$Name, $Value) { (Get-UserRunspace).SessionStateProxy.SetVariable($Name, $Value) } -function send_slim_version($ps_stream){ - $ps_version = [text.encoding]::ascii.getbytes($slimver) - $ps_stream.Write($ps_version, 0, $ps_version.Length) +# Symbols are also PowerShell variables, so code uses their values (of any type) without any rewriting +function Set-SlimSymbol([string]$Name, $Value, [string]$Origin) { + $slimsymbols[$Name] = $Value + if ($Origin) { $script:SymbolOrigins[$Name] = $Origin } else { [void]$script:SymbolOrigins.Remove($Name) } + Set-UserVariable $Name $Value } -$ps_buf1 = $null -$ps_buf2 = $null - -function get_message_length($ps_stream){ - - $script:ps_buf1 = new-object byte[] 7 - - $null = read_message $ps_stream $script:ps_buf1 - $str = [text.encoding]::utf8.getstring($script:ps_buf1, 0, 6) - $res = $null - if([int32]::TryParse($str, [ref]$res)) - { - $res - } else - { - Write-Verbose "Bad length!" - -1 +# FitNesse resolves $SymbolPrefix_test to the symbol SymbolPrefix when no longer symbol name matches +function Find-SlimSymbol([string]$Name) { + for ($length = $Name.Length; $length -gt 0; $length--) { + $candidate = $Name.Substring(0, $length) + if ($slimsymbols.ContainsKey($candidate)) { return $candidate } } - - Write-Verbose "Length: $script:ps_buf1" + $null } -function read_message($ps_stream, $buf, $offset = 0){ - - Write-Verbose "Reading message...." - # But if the read operation completed with the zero bytes. This means that client is not going to send anything. Right? - - $ps_size = $buf.Count +# Symbol substitution in plain text (data, not code), in one pass so substituted values are never rescanned +function Expand-SlimSymbolsInText([string]$Text) { + if (!$slimsymbols.Count -or $Text.IndexOf('$') -lt 0) { return $Text } + $script:SymbolReference.Replace($Text, [System.Text.RegularExpressions.MatchEvaluator]{ + param($match) + $name = Find-SlimSymbol $match.Groups[1].Value + if ($null -eq $name) { return $match.Value } + [string]$slimsymbols[$name] + $match.Groups[1].Value.Substring($name.Length) + }) +} - while($offset -lt $ps_size){ +# An argument that is exactly one $symbol passes the stored object; anything else is text with symbols replaced +function Resolve-SlimArgument([string]$Text) { + if ($Text -match '^\$(\p{L}\w*)$' -and $slimsymbols.ContainsKey($Matches[1])) { return , $slimsymbols[$Matches[1]] } + Expand-SlimSymbolsInText $Text +} - $error.clear() - $offset += $ps_stream.Read($buf, $offset, $ps_size - $offset) +# Symbol substitution in PowerShell code. Symbol values never become code: references the PowerShell +# parser resolves itself are left to the symbol variables, and the rest is rewritten where the token is: +# 'single-quoted' the value is inserted with its quotes doubled, so it stays a string literal +# "$SymbolPrefix_x" becomes "${SymbolPrefix}_x" (FitNesse matches the longest symbol name that exists) +# te$suffix a bareword command name, which PowerShell does not expand, becomes & "te$suffix" +function Expand-SlimSymbols([string]$Code) { + if (!$slimsymbols.Count -or $Code.IndexOf('$') -lt 0) { return $Code } + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput($Code, [ref]$tokens, [ref]$parseErrors) - Write-Verbose "Offset $offset" + $commandNames = @{} + foreach ($command in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true)) { + $name = $command.CommandElements[0] + if ($command.InvocationOperator -eq 'Unknown' -and $name.StringConstantType -eq 'BareWord') { + $commandNames[$name.Extent.StartOffset] = $true + } + } - if ($error) { - Write-Verbose $error.Exception - break + $edits = New-Object System.Collections.Generic.List[object] + foreach ($token in $tokens) { + $start = $token.Extent.StartOffset + if ($token.Kind -eq 'StringLiteral' -or $token.Kind -eq 'HereStringLiteral') { + foreach ($match in $script:SymbolReference.Matches($token.Text)) { + $name = Find-SlimSymbol $match.Groups[1].Value + if ($null -eq $name) { continue } + $value = [string]$slimsymbols[$name] + if ($token.Kind -eq 'StringLiteral') { $value = $value -replace "['\u2018\u2019\u201A\u201B]", '$0$0' } + elseif ($value -match "[\r\n]['\u2018\u2019\u201A\u201B]@") { continue } # would end the here-string + $edits.Add([pscustomobject]@{ Start = $start + $match.Index; Length = $name.Length + 1; Text = $value }) + } + } + elseif ($token -is [System.Management.Automation.Language.StringExpandableToken]) { + $inner = New-Object System.Collections.Generic.List[object] + $references = 0 + $depth = 0 + foreach ($nested in $token.NestedTokens) { + switch ($nested.Kind.ToString()) { + { $_ -in 'DollarParen', 'AtParen', 'LParen' } { $depth++ } + 'RParen' { $depth-- } + 'Variable' { + # $(...) holds code; ${braced} and $scope:names are never symbols + if ($depth -gt 0 -or $nested.Text.StartsWith('${') -or $nested.Text.Contains(':')) { break } + $variable = $nested.Text.Substring(1) + $name = Find-SlimSymbol $variable + if ($null -eq $name) { break } + $references++ + if ($name.Length -lt $variable.Length) { + $inner.Add([pscustomobject]@{ Start = $nested.Extent.StartOffset; Length = $name.Length + 1; Text = '${' + $name + '}' }) + } + } } - if($offset -eq 0) - { - Write-Verbose "Offset should not be zero!" - break + } + if ($references -and $commandNames.ContainsKey($start)) { + $text = [System.Text.StringBuilder]::new([string]$token.Text) + foreach ($edit in ($inner | Sort-Object Start -Descending)) { + [void]$text.Remove($edit.Start - $start, $edit.Length).Insert($edit.Start - $start, $edit.Text) } - + $quoted = $text.ToString() -replace '["\u201C\u201D\u201E]', '`$0' + $edits.Add([pscustomobject]@{ Start = $start; Length = $token.Text.Length; Text = '& "' + $quoted + '"' }) + } + else { + foreach ($edit in $inner) { $edits.Add($edit) } + } } - - Write-Verbose "Got $buf" -} - -function get_message($ps_stream){ - - Write-Verbose "Getting Message Length ..." - - $ps_size = get_message_length($ps_stream) - - Write-Verbose "Length: $ps_size" - if($ps_size -lt 0) - { - " " - return } - $script:ps_buf2 = new-object byte[] $ps_size - $null = read_message $ps_stream $script:ps_buf2 - [text.encoding]::utf8.getstring($script:ps_buf2) - -} - -function ObjectTo-Slim($ps_obj){ - $slimview = "[000002:" + (slimlen $ps_prop) + ":" + $ps_prop+ ":" - if($null -eq $($ps_obj.$ps_prop) -or $($ps_obj.$ps_prop) -is [system.array]){ - $slimview += $slimnull + "]" - } - else{ - $slimview += (slimlen $($ps_obj.$ps_prop)) + ":" + $($ps_obj.$ps_prop).ToString() + ":]" + $result = [System.Text.StringBuilder]::new($Code) + foreach ($edit in ($edits | Sort-Object Start -Descending)) { + [void]$result.Remove($edit.Start, $edit.Length).Insert($edit.Start, $edit.Text) } - (slimlen $slimview) + ":" + $slimview + ":" + $result.ToString() } -function PropertyTo-Slim($ps_obj,$ps_prop){ - $slimview = "[000002:" + (slimlen $ps_prop) + ":" + $ps_prop+ ":" - if($null -eq $($ps_obj.$ps_prop)){ - $slimview += $slimnull + "]" - } - elseif( $($ps_obj.$ps_prop) -is [string]){ - $slimview += (slimlen $($ps_obj.$ps_prop)) + ":" + $($ps_obj.$ps_prop) + ":]" - } - elseif($($ps_obj.$ps_prop) -is [system.array] -or $($ps_obj.$ps_prop) -is [psobject]){ - $slimview += (ConvertTo-Json -Compress -InputObject $($ps_obj.$ps_prop)) |ForEach-Object {(slimlen $_) + ":" + $_.ToString() + ":]"} - } - else{ - $slimview += (slimlen $($ps_obj.$ps_prop)) + ":" + $($ps_obj.$ps_prop).ToString() + ":]" - } - (slimlen $slimview) + ":" + $slimview + ":" -} +#endregion + +#region Results function Convert-Hashtable-2Object($hashtable){ $ps_object = New-Object PSObject @@ -187,63 +397,68 @@ function Convert-Hashtable-2Object($hashtable){ } function Convert-KeyValuePair-2Object($kvp){ - $ps_obj = New-Object PSObject -Property @{ Key=$kvp.Key; Value=$kvp.Value.ToString(); SLIM_COMPUTERNAME=$env:COMPUTERNAME} - $ps_obj + New-Object PSObject -Property @{ Key=$kvp.Key; Value=$kvp.Value.ToString(); SLIM_COMPUTERNAME=$env:COMPUTERNAME} } function ConvertTo($ps_str) { - $ps_object = New-Object PSObject - Add-Member -inputObject $ps_object -memberType NoteProperty -name "Value" -value $ps_str Add-Member -inputObject $ps_object -memberType NoteProperty -name "SLIM_COMPUTERNAME" -value $env:COMPUTERNAME - $ps_object - } function ConvertTo-SimpleObject($ps_obj){ ConvertTo $ps_obj.ToString() } function GetNullObject{ ConvertTo "Null" } -function isgenericdict($list){ - $list -is [array] -and $list.Count -eq 1 -and $list[0] -is 'system.collections.generic.dictionary[string,object]' +# One query table cell: [name, value] +function PropertyTo-Slim($ps_obj,$ps_prop){ + if($null -eq $($ps_obj.$ps_prop)){ + $value = $null + } + elseif( $($ps_obj.$ps_prop) -is [string]){ + $value = $($ps_obj.$ps_prop) + } + elseif($($ps_obj.$ps_prop) -is [system.array] -or $($ps_obj.$ps_prop) -is [psobject]){ + $value = ConvertTo-Json -Compress -InputObject $($ps_obj.$ps_prop) + } + else{ + $value = $($ps_obj.$ps_prop).ToString() + } + , @($ps_prop, $value) } +# Query table result: a list of rows, each a list of [name, value] cells, already serialized function ResultTo-List($list){ if($null -eq $list){ - $slimvoid + return $slimvoid } - elseif ($list -is [array]){ - $result = "[" + (slimlen $list) + ":" - foreach ($ps_obj in $list){ - if ($ps_obj -is 'system.collections.generic.dictionary[string,object]'){ - $ps_obj = [hashtable] $ps_obj - } - if($ps_obj -is [hashtable] ){ - $ps_obj = Convert-Hashtable-2Object $ps_obj - } - if($ps_obj -is [string] -or $ps_obj -is [int]){ - $ps_obj = ConvertTo-SimpleObject $ps_obj - } - if ($ps_obj -is 'system.collections.generic.keyvaluepair[string,object]'){ - $ps_obj = Convert-KeyValuePair-2Object $ps_obj - } - if ($null -eq $ps_obj){ - $ps_obj = GetNullObject - } - $fieldscount = ($ps_obj | Get-Member -membertype Property, NoteProperty | measure-object).Count - $itemstr = "[" + $fieldscount.ToString("d6") + ":" - $ps_obj | Get-Member -membertype Property, NoteProperty | ForEach-Object {$itemstr += PropertyTo-Slim $ps_obj $_.Name } - $itemstr += "]" - - $result += (slimlen $itemstr) + ":" + $itemstr + ":" - } - $result += "]" - $result + if ($list -isnot [array]){ + return $list } - else{ - $list + $rows = New-Object System.Collections.Generic.List[object] + foreach ($ps_obj in $list){ + if ($ps_obj -is 'system.collections.generic.dictionary[string,object]'){ + $ps_obj = [hashtable] $ps_obj + } + if($ps_obj -is [hashtable] ){ + $ps_obj = Convert-Hashtable-2Object $ps_obj + } + if($ps_obj -is [string] -or $ps_obj -is [int]){ + $ps_obj = ConvertTo-SimpleObject $ps_obj + } + if ($ps_obj -is 'system.collections.generic.keyvaluepair[string,object]'){ + $ps_obj = Convert-KeyValuePair-2Object $ps_obj + } + if ($null -eq $ps_obj){ + $ps_obj = GetNullObject + } + $row = New-Object System.Collections.Generic.List[object] + foreach ($property in ($ps_obj | Get-Member -membertype Property, NoteProperty)) { + $row.Add((PropertyTo-Slim $ps_obj $property.Name)) + } + $rows.Add($row) } + ConvertTo-SlimString $rows } function ResultTo-String($res){ @@ -253,432 +468,364 @@ function ResultTo-String($res){ else{ $result = "" foreach ($ps_obj in $res){ - $result += $ps_obj.ToString() + if ($null -ne $ps_obj) { $result += $ps_obj.ToString() } $result += "," } $result.TrimEnd(",") } } -function Exec-Script( $Script, $error_to_result) { - if ( $script:SLIM_ABORT_TEST ) { return "__EXCEPTION__:ABORT_SLIM_TEST:message:<>" } - if ( $script:SLIM_ABORT_SUITE ) { return "__EXCEPTION__:ABORT_SLIM_TEST:message:<>" } - - try { - Invoke-Expression $Script - $script:matches = $matches # preserve the $matches value, if set by the expression - } catch { - if($error_to_result){ - $_.Exception.Message - $error.clear() - }else{ - $errorType = $Error[0].FullyQualifiedErrorId - if($errorType -match "^Stop(Test|Suite):?(.*)?"){ - # test or suite can be aborted by throw "StopTest" - $script:SLIM_ABORT_TEST = $true - if ($matches[1] -eq 'Suite') { - $script:SLIM_ABORT_SUITE = $true - } - "__EXCEPTION__:ABORT_SLIM_TEST:message:<<__EXCEPTION__:ABORT_SLIM_TEST:$($matches[1]) aborted. : Additional Info[ $($_.Exception.ToString()) ]>>" - "__EXCEPTION__:ABORT_SLIM_TEST:message:<<$($_.Exception.ToString())>>" - } else { - if($_.Exception -is [System.Net.WebException]){ - $script:SLIM_ABORT_TEST = $true - "__EXCEPTION__:ABORT_SLIM_TEST:message:<<$($_.Exception.Message)>>" - }else{ - "__EXCEPTION__:UnhandledException:message:<<$($_.Exception.ToString())>>" - } - } - } - } - if($null -ne $Error[0]) { Print-Error } -} - -function Print-Error { - $Error | ForEach-Object { - $details = ($_ | Out-String) - if($_.Exception) { $details += $_.Exception.ToString() } - if($script:NonTerminatingIsException){ - "__EXCEPTION__:Error:message:<<__EXCEPTION__:Error: Additional Info[ $details ]>>" +function New-SlimException([string]$Message) { "${slimexception}message:<<$Message>>" } + +#endregion + +#region Execution + +# Runs a table cell and turns failures into SLIM exceptions; non-terminating errors are appended as text +function Exec-Script([string]$Script, $error_to_result, [switch]$MergeErrors) { + # After StopTest/StopSuite the rest of the batch is answered with the same tag, so FitNesse stops the test or the suite + if ( $script:SLIM_ABORT_TEST ) { return "__EXCEPTION__:${script:SLIM_ABORT_TAG}:message:<>" } + + 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 '\r?\n', '@{' -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" + $nl +
-      "`t`t" + $nl +
-      "`t" + $nl + '
first'Bob'
' - 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() } + } } }