PowershellExpect.cs

#nullable enable

using System;
using System.Diagnostics;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading;
using System.Linq;

/*
    C# driver that provides all of the functionality to PowershellExpect
 */
public class PowershellExpectHandler
{
    // Store a handler for the spawned PowerShell process
    Process process = new Process();
    // Buffer that contains the output of the process
    private List<string> output = new List<string>();
    // Global timeout set by the spawn command
    private int? timeoutSeconds = null;
    // Whether logging has been enabled or not
    private bool loggingEnabled = false;

    // Starts the PowerShell process, attaches listeners, and initializes variables
    public System.Diagnostics.Process StartProcess(string workingDirectory, int? timeout, bool enableLogging)
    {
        // Set the color of the output to help differentiate it from normal user terminal
        Console.ForegroundColor = ConsoleColor.Blue;
        
        // If a timeout was provided, override the global timeout
        if (timeout > 0)
        {
            timeoutSeconds = timeout;
        }
        // Assuming the user has enabled logging we'll set the global variable
        loggingEnabled = enableLogging;

        // Log info message about the process startup
        if (loggingEnabled)
        {
            InfoMessage("Starting process...");
        }
        
        // Configure the process
        process.StartInfo.FileName = "pwsh.exe";
        process.StartInfo.UseShellExecute = false;
        process.StartInfo.RedirectStandardInput = true;
        process.StartInfo.RedirectStandardError = true;
        process.StartInfo.RedirectStandardOutput = true;
        process.StartInfo.CreateNoWindow = false;
        process.EnableRaisingEvents = true;
        
        // Set the working directory to the current directory of the executing assembly
        process.StartInfo.WorkingDirectory = workingDirectory;

        // Attach an asynchronous event handler to the output
        process.OutputDataReceived += ProcessOutputHandler;
        process.ErrorDataReceived += ProcessOutputHandler;

        // Start the process
        process.Start();

        // Start reading the output asynchronously
        process.BeginOutputReadLine();
        process.BeginErrorReadLine();
        
        // Return the process
        return process;
    }

    // Log a message to keep the user appraised of progress
    private void InfoMessage(string message)
    {
        Console.ForegroundColor = ConsoleColor.Green;
        Console.WriteLine(message);
        // Revert back to default color after logging info message
        Console.ForegroundColor = ConsoleColor.Blue;
    }
    
    // Event handler for output messages. Places the output into an array of strings.
    private void ProcessOutputHandler(object sender, DataReceivedEventArgs args)
    {
        void AppendOutput(string data, int maxLength)
        {
            // Log received output if logging is enabled
            if (loggingEnabled)
            {
                Console.WriteLine(data);
            }
        
            // If there are too many items in the array, truncate items starting from the oldest.
            if (output.Count > maxLength)
            {
                int removeCount = output.Count - maxLength;
                output.RemoveRange(0, removeCount);
            }

            output.Add(data);
        }
            
        if (args.Data != null)
        {
            // Set the max length of the output list to 100 items
            AppendOutput(args.Data, 100);
        }
    }
    
    // Close out the spawned process and detach event handlers
    public void Exit()
    {
        // Log info message about the process shutdown
        if (loggingEnabled)
        {
            InfoMessage("Closing process...");
        }
        
        Console.ResetColor();
        // Stop reading the process output so we can remove the event handler
        process.CancelOutputRead();
        process.CancelErrorRead();
        process.OutputDataReceived -= ProcessOutputHandler;
        process.ErrorDataReceived -= ProcessOutputHandler;

        // Assuming process has not already exited, destroy the process
        if (!process.HasExited)
        {
            process.Kill();
        }

        process.Close();
    }
    
    // Write a command to the spawned PowerShell process
    public void Send(string command, bool noNewline = false)
    {
        process.StandardInput.Write(command + (noNewline ? "" : "\n"));
    }
    
    // Write a command to the spawned PowerShell process and wait for the output buffer to be idle for a set duration
    public string SendAndWait(string command, int ignoreLines, int idleDurationSeconds, bool noNewline = false)
    {
        // Send the command
        Send(command, noNewline);

        // Capture the initial timestamp
        var startTime = DateTimeOffset.Now;

        // List to store captured output during idle time
        List<string> idleOutput = new List<string>();

        // Loop until the idle duration expires
        while (DateTimeOffset.Now < startTime.AddSeconds(idleDurationSeconds))
        {
            // We'll check for new output every 200ms. Adjust as necessary.
            Thread.Sleep(500);

            // Check if there's any new output
            if (output.Any())
            {
                // Add any new output to our idleOutput list
                idleOutput.AddRange(output);
                output.Clear(); // Clear the main output list to avoid double-capturing
            }
        }
        
        // Combine all captured lines into a single string
        var allOutput = string.Join("\n", idleOutput);

        // Split the string into lines, skip the first line, and then re-join them
        var trimmedOutput = string.Join("\n", allOutput.Split('\n').Skip(ignoreLines));

        // Return the captured output during idle time as a single string
        return trimmedOutput;
    }
    
    // Observe the output of the spawned PowerShell process and wait for a desired result to be encountered
    public string? Expect(string regexString, int? timeoutSec, bool continueOnTimeout)
    {
        // Convert incoming regex string to actual Regex
        Regex regex = new Regex(regexString);
        // Variable for storing if a match has been received
        bool matched = false;
        // Variable for storing if there is a timeout, 0 indicates no timeout
        int? timeout = 0;
        
        // If a timeout was provided specifically to this expect, override any global settings
        if (timeoutSec > 0)
        {
            timeout = timeoutSec;
        }
        //
        else if (timeoutSeconds > 0)
        {
            timeout = timeoutSeconds;
        }
        // Calculate the max timestamp we can reach before the expect times out
        long? maxTimestamp = DateTimeOffset.Now.ToUnixTimeSeconds() + timeout;
    
        // While no match is found (or no timeout occurs), continue to evaluate output until match is found
        do
        {
            // For each item found in the input, evaluate it for a match
            foreach (string item in output)
            {
                Match match = regex.Match(item);
                if (match.Success)
                {
                    // Log the match if logging is enabled
                    if (loggingEnabled)
                    {
                        InfoMessage("Match found: " + item);
                    }

                    matched = true;
                    return item;
                }
            }
            // Clear the output to keep the buffer nice and lean
            output.Clear();
            
            // If a timeout is set and we've exceeded the max time, throw timeout error and stop the loop
            if (timeout > 0 && DateTimeOffset.Now.ToUnixTimeSeconds() >= maxTimestamp)
            {
                string timeoutMessage = String.Format("Timed out waiting for: '{0}'", regexString);
                matched = true;
                if (!continueOnTimeout)
                {
                    this.Exit();
                    throw new Exception(timeoutMessage);
                }
                else
                {
                    InfoMessage(timeoutMessage);
                }
                break;
            }
            
            // TODO: Evaluate if this timeout is too much or if we should attempt to evaluate matches as they arrive.
            Thread.Sleep(500);
        } while (!matched);

        return null;
    }
}