expect in PowerShell

Like the other text tools I've published here, this one is not a full analog of the Unix tool. It does only the very basic thing that is sufficient in many cases. It reads the output from a background job looking for patterns. This is a very typical thing if you want to instruct some system do some action (though WMI or such) then look at its responses or logs to make sure that the action was completed before starting a new one.

It's used like this:

 # Suppose that the job that will be sending the output $myjob has been somehow created.
$ebuf = New-ExpectBuffer $myjob $LogFile
$line = Wait-ExpectJob -Buf $ebuf -Pattern "some .* text"
...
Skip-ExpectJob -Buf $ebuf -WaitStop

New-Expect buffer creates an expect object. It takes the job to read from, and the file name to write the received data to (which can be later used to debug any unexpected issues). It can do a couple of other tricks too: If the job is $null, then it will read the input from the file instead. The reading from the file is not very smart, the file is read just once. This is intended for testing the new patterns on a results of a previous actual expect. The second trick is that this whole set of fucntions auto-detects and corrects the corruption from the Unicode mistreatment.

Wait-ExpectJob polls the output of the job until it either gets a line with the pattern or a timeout expires or the job exits. The timeout and polling frequency can be specified in the parameters. A gross simplification here is that unlike the real expect, only one job is polled at a time. It would be trivial to extend to multiple buffers and multiple patterns, it's just that in reality so far I've needed only the very basic functionality. this function returns the line that contained the pattern, so that it can be examined further.

Skip-ExpectJob's first purpose is to skip (but write into the log file) any input received so far. This allows you to skip over the repeated patterns in the output before sending the next request. This is not completely fool-proof but with the judiciously used timeouts is good enough. The second purpose for it is to wait for the job to exit, with the flag -WaitStop. In the second use it just makes sure that by the time it returns the job had exited and all its output was logged. The second use also has a timeout.

That's basically it, here is the implementation (relying on my other text tools):

 function New-ExpectBuffer
{
<#
.SYNOPSIS
Create a buffer object (returned) that would keep the data for
expecting the patterns in the job's output.
#>
    param(
        ## The job object to receive from.
        ## May be $null, then the data will be read from the file.
        $Job,
        ## Log file name to append the received data to (with a job)
        ## or read the data from (without a job).
        [parameter(Mandatory=$true)]
        $LogFile,
        ## Treat the input as Unicode corrupted by PowerShell,
        ## don't try to auto-detect.
        [switch] $Unicode
    )
    $result = @{
        job = $Job;
        logfile = $LogFile;
        buf = New-Object System.Collections.Queue;
        detect = (!$Unicode);
    }
    if (!$Job) {
        $data = (Get-Content $LogFile | ConvertFrom-Unicode -AutoDetect:$result.detect)
        if ($data) { 
            foreach ($val in $data) {
                $result.buf.enqueue($val)
            }
        }
    }
    $result
}

function Wait-ExpectJob
{
<#
.SYNOPSIS
Keep receiving output from a background job until it matches a pattern.
The output will be appended to the log file as it's received.
When a match is found, the line with it will be returned as the result.

The wait may be limited by a timeout. If the match is not received within
the timeout, throws an error (unless the option -Quiet is used, then
just returns).

If the job completes without matching the pattern, the reaction is the same
as on the timeout.
#>
    [CmdletBinding()]
    param(
        ## The buffer that keeps the job reference and the unmatched lines
        ## (as created with New-ExpectBuffer).
        [parameter(Mandatory=$true)]
        $Buf,
        ## Pattern (as for -match) to wait for.
        [parameter(Mandatory=$true)]
        $Pattern,
        ## Timeout, in fractional seconds. If $null, waits forever.
        [double] $Timeout = 10.,
        ## When the timeout expires, don't throw but just return nothing.
        [switch] $Quiet,
        ## Time in milliseconds for sleeping between the attempts.
        ## If the timeout is smaller than the step, the step will automatically
        ## be reduced to the size of timeout.
        [int] $StepMsec = 100
    )
    
    $deadline = $null
    if ($Timeout -ne $null) {
        $deadline = (Get-Date).ToFileTime();
        $deadline += [int64]($Timeout * (1000 * 1000 * 10))
    }

    while ($true) {
        while ($Buf.buf.Count -ne 0) {
            $val = $Buf.buf.Dequeue();
            if ($val -match $Pattern) {
                return $val
            }
        }
        if (!$Buf.job) {
            if ($Quiet) {
                return
            } else {
                throw "The pattern '$Pattern' was not found in the file '$($Buf.logfile)"
            }
        }
        $data = (Receive-Job $Buf.job | ConvertFrom-Unicode -AutoDetect:$Buf.detect)
        Write-Verbose "Job sent lines:`r`n$data"
        if ($data) { 
            foreach ($val in $data) {
                $Buf.buf.enqueue($val)
            }
            # Write the output to file as it's received, not as it's matched,
            # for the easier diagnostics of things that get mismatched.
            $data | Add-Content $Buf.logfile
            continue
        }

        if (!($Buf.job.State -in ("Running", "Stopping"))) {
            if ($Quiet) {
                Write-Verbose "Job found stopped"
                return
            } else {
                throw "The pattern '$Pattern' was not received until the job exited"
            }
        }

        if ($deadline -ne $null) {
            $now = (Get-Date).ToFileTime();
            if ($now -ge $deadline) {
                if ($Quiet) {
                    Write-Verbose "Job reading deadline expired"
                    return
                } else {
                    throw "The pattern '$Pattern' was not received within $Timeout seconds"
                }
            }

            $sleepmsec = ($deadline - $now) / (1000 * 10)
            if ($sleepmsec -eq 0) { $sleepmsec = 1 }
            if ($sleepmsec -gt $StepMsec) { $sleepmsec = $StepMsec }
            Sleep -Milliseconds $sleepmsec
        }
    }
}

function Skip-ExpectJob
{
<#
.SYNOPSIS
Receive whetever available output from a background job without any
pattern matching.

The output will be appended to the log file as it's received.

Optionally, may wait for the job completion first.
The wait may be limited by a timeout. If the match is not received within
the timeout, throws an error (unless the option -Quiet is used, then
just returns).
#>
    param(
        ## The buffer that keeps the job reference and the unmatched lines
        ## (as created with New-ExpectBuffer).
        [parameter(Mandatory=$true)]
        $Buf,
        ## Wait for the job to stop before skipping the output.
        ## This guarantees that all the job's output is written to the log file.
        [switch] $WaitStop,
        ## Timeout, seconds. If $null and requested to wait, waits forever.
        [int32] $Timeout = 10,
        ## When the timeout expires, don't throw but just return nothing.
        [switch] $Quiet
    )

    if ($WaitStop) {
        Wait-Job -Job $Buf.job -Timeout $Timeout
    }

    Receive-Job $Buf.job | ConvertFrom-Unicode -AutoDetect:$Buf.detect | Add-Content $Buf.logfile

    if ($WaitStop) {
        if (!($Buf.job.State -in ("Stopped", "Completed"))) {
            if ($Quiet) {
                return
            } else {
                throw "The job didn't stop within $Timeout seconds"
            }
        }
    }
}